feat: add personal skill storage, API, and SDK (#25363)

> Mux updated this PR on behalf of Mike.

## Stack Context

This PR is the storage, permissions, API, and SDK layer for experimental
personal skills. #25362 has landed on `main`, so this branch is
restacked directly on `main`.

Stack order:
1. #25363 storage, permissions, API, and SDK
2. #25365 API test coverage
3. #25366 chattool and chatd integration
4. #25066 settings UI and docs
5. #25386 personal skills slash menu

## What?

Adds the `user_skills` database table, generated queries, RBAC resources
and scopes, audit resource handling, experimental user-scoped CRUD
endpoints, SDK types, and generated API/site types.

Follow-up review and restack fixes:
- Enforce a bounded personal skill description in parser and database
constraints.
- Return `403 Forbidden` for unauthorized create and update attempts.
- Return explicit conflict responses when soft-deleted users are
targeted.
- Keep user admins out of personal skills, while site owners can read
and delete but not create or update.
- Document trigger-raised constraint names and keep schema constants
covered by tests.
- Reuse `UserSkillMetadata` in the full `UserSkill` SDK response type.
- Generate user skill IDs in Go instead of relying on a database
default.
- Rebase on latest `main` and renumber the user skills migration to
`000502_user_skills`.

## Why?

Personal skills need durable user-owned storage with owner
authorization, limited site-owner moderation, and a hidden API surface
before chatd can consume them.

## Validation

- `make gen`
- `go test ./coderd/database -run '^TestUserSkillSchemaConstants$'
-count=1`
- `go test ./coderd/database/dbauthz -run
'^TestMethodTestSuite/TestUserSkills$' -count=1`
- `go test ./coderd -run '^TestPatchUserSkill$' -count=1`
- `go test ./codersdk ./coderd/database/db2sdk`
- `make lint`
- pre-commit hook on `97fd58108d`
This commit is contained in:
Michael Suchacz
2026-05-20 00:09:09 +02:00
committed by GitHub
parent 3c9c8c708d
commit 5a8d0016a5
46 changed files with 2315 additions and 70 deletions
+304 -2
View File
@@ -877,6 +877,227 @@ const docTemplate = `{
] ]
} }
}, },
"/api/experimental/users/{user}/skills": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "List user skills",
"operationId": "list-user-skills",
"parameters": [
{
"type": "string",
"description": "User ID, username, or me",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.UserSkillMetadata"
}
}
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
},
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Create a user skill",
"operationId": "create-a-user-skill",
"parameters": [
{
"type": "string",
"description": "User ID, username, or me",
"name": "user",
"in": "path",
"required": true
},
{
"description": "Create user skill request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.CreateUserSkillRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/codersdk.UserSkill"
}
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
}
},
"/api/experimental/users/{user}/skills/{skillName}": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Get a user skill by name",
"operationId": "get-a-user-skill-by-name",
"parameters": [
{
"type": "string",
"description": "User ID, username, or me",
"name": "user",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Skill name",
"name": "skillName",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.UserSkill"
}
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
},
"delete": {
"tags": [
"Users"
],
"summary": "Delete a user skill",
"operationId": "delete-a-user-skill",
"parameters": [
{
"type": "string",
"description": "User ID, username, or me",
"name": "user",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Skill name",
"name": "skillName",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
},
"patch": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Update a user skill",
"operationId": "update-a-user-skill",
"parameters": [
{
"type": "string",
"description": "User ID, username, or me",
"name": "user",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Skill name",
"name": "skillName",
"in": "path",
"required": true
},
{
"description": "Update user skill request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpdateUserSkillRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.UserSkill"
}
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
}
},
"/api/experimental/watch-all-workspacebuilds": { "/api/experimental/watch-all-workspacebuilds": {
"get": { "get": {
"produces": [ "produces": [
@@ -14802,6 +15023,11 @@ const docTemplate = `{
"user_secret:delete", "user_secret:delete",
"user_secret:read", "user_secret:read",
"user_secret:update", "user_secret:update",
"user_skill:*",
"user_skill:create",
"user_skill:delete",
"user_skill:read",
"user_skill:update",
"webpush_subscription:*", "webpush_subscription:*",
"webpush_subscription:create", "webpush_subscription:create",
"webpush_subscription:delete", "webpush_subscription:delete",
@@ -15023,6 +15249,11 @@ const docTemplate = `{
"APIKeyScopeUserSecretDelete", "APIKeyScopeUserSecretDelete",
"APIKeyScopeUserSecretRead", "APIKeyScopeUserSecretRead",
"APIKeyScopeUserSecretUpdate", "APIKeyScopeUserSecretUpdate",
"APIKeyScopeUserSkillAll",
"APIKeyScopeUserSkillCreate",
"APIKeyScopeUserSkillDelete",
"APIKeyScopeUserSkillRead",
"APIKeyScopeUserSkillUpdate",
"APIKeyScopeWebpushSubscriptionAll", "APIKeyScopeWebpushSubscriptionAll",
"APIKeyScopeWebpushSubscriptionCreate", "APIKeyScopeWebpushSubscriptionCreate",
"APIKeyScopeWebpushSubscriptionDelete", "APIKeyScopeWebpushSubscriptionDelete",
@@ -17400,6 +17631,15 @@ const docTemplate = `{
} }
} }
}, },
"codersdk.CreateUserSkillRequest": {
"type": "object",
"properties": {
"content": {
"description": "Content must be SKILL.md-format Markdown with YAML frontmatter. The\nfrontmatter must include name, may include description, and must be\nfollowed by a non-empty body.",
"type": "string"
}
}
},
"codersdk.CreateWorkspaceBuildReason": { "codersdk.CreateWorkspaceBuildReason": {
"type": "string", "type": "string",
"enum": [ "enum": [
@@ -21537,6 +21777,7 @@ const docTemplate = `{
"usage_event", "usage_event",
"user", "user",
"user_secret", "user_secret",
"user_skill",
"webpush_subscription", "webpush_subscription",
"workspace", "workspace",
"workspace_agent_devcontainers", "workspace_agent_devcontainers",
@@ -21586,6 +21827,7 @@ const docTemplate = `{
"ResourceUsageEvent", "ResourceUsageEvent",
"ResourceUser", "ResourceUser",
"ResourceUserSecret", "ResourceUserSecret",
"ResourceUserSkill",
"ResourceWebpushSubscription", "ResourceWebpushSubscription",
"ResourceWorkspace", "ResourceWorkspace",
"ResourceWorkspaceAgentDevcontainers", "ResourceWorkspaceAgentDevcontainers",
@@ -21811,7 +22053,8 @@ const docTemplate = `{
"ai_provider_key", "ai_provider_key",
"group_ai_budget", "group_ai_budget",
"chat", "chat",
"user_secret" "user_secret",
"user_skill"
], ],
"x-enum-varnames": [ "x-enum-varnames": [
"ResourceTypeTemplate", "ResourceTypeTemplate",
@@ -21845,7 +22088,8 @@ const docTemplate = `{
"ResourceTypeAIProviderKey", "ResourceTypeAIProviderKey",
"ResourceTypeGroupAIBudget", "ResourceTypeGroupAIBudget",
"ResourceTypeChat", "ResourceTypeChat",
"ResourceTypeUserSecret" "ResourceTypeUserSecret",
"ResourceTypeUserSkill"
] ]
}, },
"codersdk.Response": { "codersdk.Response": {
@@ -23781,6 +24025,15 @@ const docTemplate = `{
} }
} }
}, },
"codersdk.UpdateUserSkillRequest": {
"type": "object",
"properties": {
"content": {
"description": "Content must be SKILL.md-format Markdown with YAML frontmatter. The\nfrontmatter must include name, may include description, and must be\nfollowed by a non-empty body.",
"type": "string"
}
}
},
"codersdk.UpdateWorkspaceACL": { "codersdk.UpdateWorkspaceACL": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -24307,6 +24560,55 @@ const docTemplate = `{
} }
} }
}, },
"codersdk.UserSkill": {
"type": "object",
"properties": {
"content": {
"type": "string"
},
"created_at": {
"type": "string",
"format": "date-time"
},
"description": {
"type": "string"
},
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"updated_at": {
"type": "string",
"format": "date-time"
}
}
},
"codersdk.UserSkillMetadata": {
"type": "object",
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"description": {
"type": "string"
},
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"updated_at": {
"type": "string",
"format": "date-time"
}
}
},
"codersdk.UserStatus": { "codersdk.UserStatus": {
"type": "string", "type": "string",
"enum": [ "enum": [
+282 -2
View File
@@ -774,6 +774,205 @@
] ]
} }
}, },
"/api/experimental/users/{user}/skills": {
"get": {
"produces": ["application/json"],
"tags": ["Users"],
"summary": "List user skills",
"operationId": "list-user-skills",
"parameters": [
{
"type": "string",
"description": "User ID, username, or me",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.UserSkillMetadata"
}
}
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
},
"post": {
"consumes": ["application/json"],
"produces": ["application/json"],
"tags": ["Users"],
"summary": "Create a user skill",
"operationId": "create-a-user-skill",
"parameters": [
{
"type": "string",
"description": "User ID, username, or me",
"name": "user",
"in": "path",
"required": true
},
{
"description": "Create user skill request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.CreateUserSkillRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/codersdk.UserSkill"
}
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
}
},
"/api/experimental/users/{user}/skills/{skillName}": {
"get": {
"produces": ["application/json"],
"tags": ["Users"],
"summary": "Get a user skill by name",
"operationId": "get-a-user-skill-by-name",
"parameters": [
{
"type": "string",
"description": "User ID, username, or me",
"name": "user",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Skill name",
"name": "skillName",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.UserSkill"
}
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
},
"delete": {
"tags": ["Users"],
"summary": "Delete a user skill",
"operationId": "delete-a-user-skill",
"parameters": [
{
"type": "string",
"description": "User ID, username, or me",
"name": "user",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Skill name",
"name": "skillName",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
},
"patch": {
"consumes": ["application/json"],
"produces": ["application/json"],
"tags": ["Users"],
"summary": "Update a user skill",
"operationId": "update-a-user-skill",
"parameters": [
{
"type": "string",
"description": "User ID, username, or me",
"name": "user",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Skill name",
"name": "skillName",
"in": "path",
"required": true
},
{
"description": "Update user skill request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpdateUserSkillRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.UserSkill"
}
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
}
},
"/api/experimental/watch-all-workspacebuilds": { "/api/experimental/watch-all-workspacebuilds": {
"get": { "get": {
"produces": ["application/json"], "produces": ["application/json"],
@@ -13254,6 +13453,11 @@
"user_secret:delete", "user_secret:delete",
"user_secret:read", "user_secret:read",
"user_secret:update", "user_secret:update",
"user_skill:*",
"user_skill:create",
"user_skill:delete",
"user_skill:read",
"user_skill:update",
"webpush_subscription:*", "webpush_subscription:*",
"webpush_subscription:create", "webpush_subscription:create",
"webpush_subscription:delete", "webpush_subscription:delete",
@@ -13475,6 +13679,11 @@
"APIKeyScopeUserSecretDelete", "APIKeyScopeUserSecretDelete",
"APIKeyScopeUserSecretRead", "APIKeyScopeUserSecretRead",
"APIKeyScopeUserSecretUpdate", "APIKeyScopeUserSecretUpdate",
"APIKeyScopeUserSkillAll",
"APIKeyScopeUserSkillCreate",
"APIKeyScopeUserSkillDelete",
"APIKeyScopeUserSkillRead",
"APIKeyScopeUserSkillUpdate",
"APIKeyScopeWebpushSubscriptionAll", "APIKeyScopeWebpushSubscriptionAll",
"APIKeyScopeWebpushSubscriptionCreate", "APIKeyScopeWebpushSubscriptionCreate",
"APIKeyScopeWebpushSubscriptionDelete", "APIKeyScopeWebpushSubscriptionDelete",
@@ -15752,6 +15961,15 @@
} }
} }
}, },
"codersdk.CreateUserSkillRequest": {
"type": "object",
"properties": {
"content": {
"description": "Content must be SKILL.md-format Markdown with YAML frontmatter. The\nfrontmatter must include name, may include description, and must be\nfollowed by a non-empty body.",
"type": "string"
}
}
},
"codersdk.CreateWorkspaceBuildReason": { "codersdk.CreateWorkspaceBuildReason": {
"type": "string", "type": "string",
"enum": [ "enum": [
@@ -19749,6 +19967,7 @@
"usage_event", "usage_event",
"user", "user",
"user_secret", "user_secret",
"user_skill",
"webpush_subscription", "webpush_subscription",
"workspace", "workspace",
"workspace_agent_devcontainers", "workspace_agent_devcontainers",
@@ -19798,6 +20017,7 @@
"ResourceUsageEvent", "ResourceUsageEvent",
"ResourceUser", "ResourceUser",
"ResourceUserSecret", "ResourceUserSecret",
"ResourceUserSkill",
"ResourceWebpushSubscription", "ResourceWebpushSubscription",
"ResourceWorkspace", "ResourceWorkspace",
"ResourceWorkspaceAgentDevcontainers", "ResourceWorkspaceAgentDevcontainers",
@@ -20013,7 +20233,8 @@
"ai_provider_key", "ai_provider_key",
"group_ai_budget", "group_ai_budget",
"chat", "chat",
"user_secret" "user_secret",
"user_skill"
], ],
"x-enum-varnames": [ "x-enum-varnames": [
"ResourceTypeTemplate", "ResourceTypeTemplate",
@@ -20047,7 +20268,8 @@
"ResourceTypeAIProviderKey", "ResourceTypeAIProviderKey",
"ResourceTypeGroupAIBudget", "ResourceTypeGroupAIBudget",
"ResourceTypeChat", "ResourceTypeChat",
"ResourceTypeUserSecret" "ResourceTypeUserSecret",
"ResourceTypeUserSkill"
] ]
}, },
"codersdk.Response": { "codersdk.Response": {
@@ -21887,6 +22109,15 @@
} }
} }
}, },
"codersdk.UpdateUserSkillRequest": {
"type": "object",
"properties": {
"content": {
"description": "Content must be SKILL.md-format Markdown with YAML frontmatter. The\nfrontmatter must include name, may include description, and must be\nfollowed by a non-empty body.",
"type": "string"
}
}
},
"codersdk.UpdateWorkspaceACL": { "codersdk.UpdateWorkspaceACL": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -22388,6 +22619,55 @@
} }
} }
}, },
"codersdk.UserSkill": {
"type": "object",
"properties": {
"content": {
"type": "string"
},
"created_at": {
"type": "string",
"format": "date-time"
},
"description": {
"type": "string"
},
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"updated_at": {
"type": "string",
"format": "date-time"
}
}
},
"codersdk.UserSkillMetadata": {
"type": "object",
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"description": {
"type": "string"
},
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"updated_at": {
"type": "string",
"format": "date-time"
}
}
},
"codersdk.UserStatus": { "codersdk.UserStatus": {
"type": "string", "type": "string",
"enum": ["active", "dormant", "suspended"], "enum": ["active", "dormant", "suspended"],
+2 -1
View File
@@ -38,7 +38,8 @@ type Auditable interface {
database.AIProviderKey | database.AIProviderKey |
database.Chat | database.Chat |
database.AuditableGroupAiBudget | database.AuditableGroupAiBudget |
database.UserSecret database.UserSecret |
database.UserSkill
} }
// Map is a map of changed fields in an audited resource. It maps field names to // Map is a map of changed fields in an audited resource. It maps field names to
+9
View File
@@ -153,6 +153,8 @@ func ResourceTarget[T Auditable](tgt T) string {
return typed.ID.String()[:8] return typed.ID.String()[:8]
case database.UserSecret: case database.UserSecret:
return typed.Name return typed.Name
case database.UserSkill:
return typed.Name
default: default:
panic(fmt.Sprintf("unknown resource %T for ResourceTarget", tgt)) panic(fmt.Sprintf("unknown resource %T for ResourceTarget", tgt))
} }
@@ -229,6 +231,8 @@ func ResourceID[T Auditable](tgt T) uuid.UUID {
return typed.ID return typed.ID
case database.UserSecret: case database.UserSecret:
return typed.ID return typed.ID
case database.UserSkill:
return typed.ID
default: default:
panic(fmt.Sprintf("unknown resource %T for ResourceID", tgt)) panic(fmt.Sprintf("unknown resource %T for ResourceID", tgt))
} }
@@ -296,6 +300,8 @@ func ResourceType[T Auditable](tgt T) database.ResourceType {
return database.ResourceTypeChat return database.ResourceTypeChat
case database.UserSecret: case database.UserSecret:
return database.ResourceTypeUserSecret return database.ResourceTypeUserSecret
case database.UserSkill:
return database.ResourceTypeUserSkill
default: default:
panic(fmt.Sprintf("unknown resource %T for ResourceType", typed)) panic(fmt.Sprintf("unknown resource %T for ResourceType", typed))
} }
@@ -372,6 +378,9 @@ func ResourceRequiresOrgID[T Auditable]() bool {
case database.UserSecret: case database.UserSecret:
// User secrets are global to the user across organizations. // User secrets are global to the user across organizations.
return false return false
case database.UserSkill:
// User skills are global to the user across organizations.
return false
default: default:
panic(fmt.Sprintf("unknown resource %T for ResourceRequiresOrgID", tgt)) panic(fmt.Sprintf("unknown resource %T for ResourceRequiresOrgID", tgt))
} }
+13
View File
@@ -1184,6 +1184,19 @@ func New(options *Options) *API {
}) })
}) })
}) })
r.Route("/users/{user}/skills", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
httpmw.ExtractUserParam(options.Database),
)
r.Post("/", api.postUserSkill)
r.Get("/", api.getUserSkills)
r.Route("/{skillName}", func(r chi.Router) {
r.Get("/", api.getUserSkill)
r.Patch("/", api.patchUserSkill)
r.Delete("/", api.deleteUserSkill)
})
})
r.Route("/chats", func(r chi.Router) { r.Route("/chats", func(r chi.Router) {
r.Use( r.Use(
apiKeyMiddleware, apiKeyMiddleware,
+4
View File
@@ -45,4 +45,8 @@ const (
CheckValidationMonotonicOrder CheckConstraint = "validation_monotonic_order" // template_version_parameters CheckValidationMonotonicOrder CheckConstraint = "validation_monotonic_order" // template_version_parameters
CheckUsageEventTypeCheck CheckConstraint = "usage_event_type_check" // usage_events CheckUsageEventTypeCheck CheckConstraint = "usage_event_type_check" // usage_events
CheckUserChatProviderKeysAPIKeyCheck CheckConstraint = "user_chat_provider_keys_api_key_check" // user_chat_provider_keys CheckUserChatProviderKeysAPIKeyCheck CheckConstraint = "user_chat_provider_keys_api_key_check" // user_chat_provider_keys
CheckUserSkillsContentSize CheckConstraint = "user_skills_content_size" // user_skills
CheckUserSkillsDescriptionSize CheckConstraint = "user_skills_description_size" // user_skills
CheckUserSkillsNameFormat CheckConstraint = "user_skills_name_format" // user_skills
CheckUserSkillsNameSize CheckConstraint = "user_skills_name_size" // user_skills
) )
+34
View File
@@ -2046,3 +2046,37 @@ func UserSecrets(secrets []database.ListUserSecretsRow) []codersdk.UserSecret {
} }
return result return result
} }
// UserSkill converts a database UserSkill to an SDK UserSkill.
func UserSkill(skill database.UserSkill) codersdk.UserSkill {
return codersdk.UserSkill{
UserSkillMetadata: codersdk.UserSkillMetadata{
ID: skill.ID,
Name: skill.Name,
Description: skill.Description,
CreatedAt: skill.CreatedAt,
UpdatedAt: skill.UpdatedAt,
},
Content: skill.Content,
}
}
// UserSkillMetadata converts database user skill metadata to an SDK UserSkillMetadata.
func UserSkillMetadata(skill database.ListUserSkillMetadataByUserIDRow) codersdk.UserSkillMetadata {
return codersdk.UserSkillMetadata{
ID: skill.ID,
Name: skill.Name,
Description: skill.Description,
CreatedAt: skill.CreatedAt,
UpdatedAt: skill.UpdatedAt,
}
}
// UserSkillMetadataList converts database user skill metadata rows to SDK values.
func UserSkillMetadataList(rows []database.ListUserSkillMetadataByUserIDRow) []codersdk.UserSkillMetadata {
metadata := make([]codersdk.UserSkillMetadata, 0, len(rows))
for _, row := range rows {
metadata = append(metadata, UserSkillMetadata(row))
}
return metadata
}
+40
View File
@@ -2290,6 +2290,14 @@ func (q *querier) DeleteUserSecretByUserIDAndName(ctx context.Context, arg datab
return q.db.DeleteUserSecretByUserIDAndName(ctx, arg) return q.db.DeleteUserSecretByUserIDAndName(ctx, arg)
} }
func (q *querier) DeleteUserSkillByUserIDAndName(ctx context.Context, arg database.DeleteUserSkillByUserIDAndNameParams) (database.UserSkill, error) {
obj := rbac.ResourceUserSkill.WithOwner(arg.UserID.String())
if err := q.authorizeContext(ctx, policy.ActionDelete, obj); err != nil {
return database.UserSkill{}, err
}
return q.db.DeleteUserSkillByUserIDAndName(ctx, arg)
}
func (q *querier) DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg database.DeleteWebpushSubscriptionByUserIDAndEndpointParams) error { func (q *querier) DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg database.DeleteWebpushSubscriptionByUserIDAndEndpointParams) error {
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceWebpushSubscription.WithOwner(arg.UserID.String())); err != nil { if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceWebpushSubscription.WithOwner(arg.UserID.String())); err != nil {
return err return err
@@ -4687,6 +4695,14 @@ func (q *querier) GetUserShellToolDisplayMode(ctx context.Context, userID uuid.U
return q.db.GetUserShellToolDisplayMode(ctx, userID) return q.db.GetUserShellToolDisplayMode(ctx, userID)
} }
func (q *querier) GetUserSkillByUserIDAndName(ctx context.Context, arg database.GetUserSkillByUserIDAndNameParams) (database.UserSkill, error) {
obj := rbac.ResourceUserSkill.WithOwner(arg.UserID.String())
if err := q.authorizeContext(ctx, policy.ActionRead, obj); err != nil {
return database.UserSkill{}, err
}
return q.db.GetUserSkillByUserIDAndName(ctx, arg)
}
func (q *querier) GetUserStatusCounts(ctx context.Context, arg database.GetUserStatusCountsParams) ([]database.GetUserStatusCountsRow, error) { func (q *querier) GetUserStatusCounts(ctx context.Context, arg database.GetUserStatusCountsParams) ([]database.GetUserStatusCountsRow, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUser); err != nil { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUser); err != nil {
return nil, err return nil, err
@@ -5780,6 +5796,14 @@ func (q *querier) InsertUserLink(ctx context.Context, arg database.InsertUserLin
return q.db.InsertUserLink(ctx, arg) return q.db.InsertUserLink(ctx, arg)
} }
func (q *querier) InsertUserSkill(ctx context.Context, arg database.InsertUserSkillParams) (database.UserSkill, error) {
obj := rbac.ResourceUserSkill.WithOwner(arg.UserID.String())
if err := q.authorizeContext(ctx, policy.ActionCreate, obj); err != nil {
return database.UserSkill{}, err
}
return q.db.InsertUserSkill(ctx, arg)
}
func (q *querier) InsertVolumeResourceMonitor(ctx context.Context, arg database.InsertVolumeResourceMonitorParams) (database.WorkspaceAgentVolumeResourceMonitor, error) { func (q *querier) InsertVolumeResourceMonitor(ctx context.Context, arg database.InsertVolumeResourceMonitorParams) (database.WorkspaceAgentVolumeResourceMonitor, error) {
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceWorkspaceAgentResourceMonitor); err != nil { if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceWorkspaceAgentResourceMonitor); err != nil {
return database.WorkspaceAgentVolumeResourceMonitor{}, err return database.WorkspaceAgentVolumeResourceMonitor{}, err
@@ -6139,6 +6163,14 @@ func (q *querier) ListUserSecretsWithValues(ctx context.Context, userID uuid.UUI
return q.db.ListUserSecretsWithValues(ctx, userID) return q.db.ListUserSecretsWithValues(ctx, userID)
} }
func (q *querier) ListUserSkillMetadataByUserID(ctx context.Context, userID uuid.UUID) ([]database.ListUserSkillMetadataByUserIDRow, error) {
obj := rbac.ResourceUserSkill.WithOwner(userID.String())
if err := q.authorizeContext(ctx, policy.ActionRead, obj); err != nil {
return nil, err
}
return q.db.ListUserSkillMetadataByUserID(ctx, userID)
}
func (q *querier) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]database.WorkspaceAgentPortShare, error) { func (q *querier) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]database.WorkspaceAgentPortShare, error) {
workspace, err := q.db.GetWorkspaceByID(ctx, workspaceID) workspace, err := q.db.GetWorkspaceByID(ctx, workspaceID)
if err != nil { if err != nil {
@@ -7446,6 +7478,14 @@ func (q *querier) UpdateUserShellToolDisplayMode(ctx context.Context, arg databa
return q.db.UpdateUserShellToolDisplayMode(ctx, arg) return q.db.UpdateUserShellToolDisplayMode(ctx, arg)
} }
func (q *querier) UpdateUserSkillByUserIDAndName(ctx context.Context, arg database.UpdateUserSkillByUserIDAndNameParams) (database.UserSkill, error) {
obj := rbac.ResourceUserSkill.WithOwner(arg.UserID.String())
if err := q.authorizeContext(ctx, policy.ActionUpdate, obj); err != nil {
return database.UserSkill{}, err
}
return q.db.UpdateUserSkillByUserIDAndName(ctx, arg)
}
func (q *querier) UpdateUserStatus(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) { func (q *querier) UpdateUserStatus(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) {
fetch := func(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) { fetch := func(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) {
return q.db.GetUserByID(ctx, arg.ID) return q.db.GetUserByID(ctx, arg.ID)
+55
View File
@@ -6046,6 +6046,61 @@ func (s *MethodTestSuite) TestUserSecrets() {
})) }))
} }
func (s *MethodTestSuite) TestUserSkills() {
s.Run("GetUserSkillByUserIDAndName", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
user := testutil.Fake(s.T(), faker, database.User{})
skill := testutil.Fake(s.T(), faker, database.UserSkill{UserID: user.ID})
arg := database.GetUserSkillByUserIDAndNameParams{UserID: user.ID, Name: skill.Name}
dbm.EXPECT().GetUserSkillByUserIDAndName(gomock.Any(), arg).Return(skill, nil).AnyTimes()
check.Args(arg).
Asserts(rbac.ResourceUserSkill.WithOwner(user.ID.String()), policy.ActionRead).
Returns(skill)
}))
s.Run("ListUserSkillMetadataByUserID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
user := testutil.Fake(s.T(), faker, database.User{})
row := testutil.Fake(s.T(), faker, database.ListUserSkillMetadataByUserIDRow{UserID: user.ID})
dbm.EXPECT().ListUserSkillMetadataByUserID(gomock.Any(), user.ID).Return([]database.ListUserSkillMetadataByUserIDRow{row}, nil).AnyTimes()
check.Args(user.ID).
Asserts(rbac.ResourceUserSkill.WithOwner(user.ID.String()), policy.ActionRead).
Returns([]database.ListUserSkillMetadataByUserIDRow{row})
}))
s.Run("InsertUserSkill", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
user := testutil.Fake(s.T(), faker, database.User{})
arg := database.InsertUserSkillParams{
ID: uuid.New(),
UserID: user.ID,
Name: "test",
}
ret := testutil.Fake(s.T(), faker, database.UserSkill{
ID: arg.ID,
UserID: user.ID,
Name: arg.Name,
})
dbm.EXPECT().InsertUserSkill(gomock.Any(), arg).Return(ret, nil).AnyTimes()
check.Args(arg).
Asserts(rbac.ResourceUserSkill.WithOwner(user.ID.String()), policy.ActionCreate).
Returns(ret)
}))
s.Run("UpdateUserSkillByUserIDAndName", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
user := testutil.Fake(s.T(), faker, database.User{})
arg := database.UpdateUserSkillByUserIDAndNameParams{UserID: user.ID, Name: "test"}
updated := testutil.Fake(s.T(), faker, database.UserSkill{UserID: user.ID, Name: arg.Name})
dbm.EXPECT().UpdateUserSkillByUserIDAndName(gomock.Any(), arg).Return(updated, nil).AnyTimes()
check.Args(arg).
Asserts(rbac.ResourceUserSkill.WithOwner(user.ID.String()), policy.ActionUpdate).
Returns(updated)
}))
s.Run("DeleteUserSkillByUserIDAndName", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
user := testutil.Fake(s.T(), faker, database.User{})
arg := database.DeleteUserSkillByUserIDAndNameParams{UserID: user.ID, Name: "test"}
deleted := testutil.Fake(s.T(), faker, database.UserSkill{UserID: user.ID, Name: arg.Name})
dbm.EXPECT().DeleteUserSkillByUserIDAndName(gomock.Any(), arg).Return(deleted, nil).AnyTimes()
check.Args(arg).
Asserts(rbac.ResourceUserSkill.WithOwner(user.ID.String()), policy.ActionDelete).
Returns(deleted)
}))
}
func (s *MethodTestSuite) TestUsageEvents() { func (s *MethodTestSuite) TestUsageEvents() {
s.Run("InsertUsageEvent", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { s.Run("InsertUsageEvent", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
params := database.InsertUsageEventParams{ params := database.InsertUsageEventParams{
+40
View File
@@ -809,6 +809,14 @@ func (m queryMetricsStore) DeleteUserSecretByUserIDAndName(ctx context.Context,
return r0, r1 return r0, r1
} }
func (m queryMetricsStore) DeleteUserSkillByUserIDAndName(ctx context.Context, arg database.DeleteUserSkillByUserIDAndNameParams) (database.UserSkill, error) {
start := time.Now()
r0, r1 := m.s.DeleteUserSkillByUserIDAndName(ctx, arg)
m.queryLatencies.WithLabelValues("DeleteUserSkillByUserIDAndName").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteUserSkillByUserIDAndName").Inc()
return r0, r1
}
func (m queryMetricsStore) DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg database.DeleteWebpushSubscriptionByUserIDAndEndpointParams) error { func (m queryMetricsStore) DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg database.DeleteWebpushSubscriptionByUserIDAndEndpointParams) error {
start := time.Now() start := time.Now()
r0 := m.s.DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx, arg) r0 := m.s.DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx, arg)
@@ -3073,6 +3081,14 @@ func (m queryMetricsStore) GetUserShellToolDisplayMode(ctx context.Context, user
return r0, r1 return r0, r1
} }
func (m queryMetricsStore) GetUserSkillByUserIDAndName(ctx context.Context, arg database.GetUserSkillByUserIDAndNameParams) (database.UserSkill, error) {
start := time.Now()
r0, r1 := m.s.GetUserSkillByUserIDAndName(ctx, arg)
m.queryLatencies.WithLabelValues("GetUserSkillByUserIDAndName").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetUserSkillByUserIDAndName").Inc()
return r0, r1
}
func (m queryMetricsStore) GetUserStatusCounts(ctx context.Context, arg database.GetUserStatusCountsParams) ([]database.GetUserStatusCountsRow, error) { func (m queryMetricsStore) GetUserStatusCounts(ctx context.Context, arg database.GetUserStatusCountsParams) ([]database.GetUserStatusCountsRow, error) {
start := time.Now() start := time.Now()
r0, r1 := m.s.GetUserStatusCounts(ctx, arg) r0, r1 := m.s.GetUserStatusCounts(ctx, arg)
@@ -4097,6 +4113,14 @@ func (m queryMetricsStore) InsertUserLink(ctx context.Context, arg database.Inse
return r0, r1 return r0, r1
} }
func (m queryMetricsStore) InsertUserSkill(ctx context.Context, arg database.InsertUserSkillParams) (database.UserSkill, error) {
start := time.Now()
r0, r1 := m.s.InsertUserSkill(ctx, arg)
m.queryLatencies.WithLabelValues("InsertUserSkill").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "InsertUserSkill").Inc()
return r0, r1
}
func (m queryMetricsStore) InsertVolumeResourceMonitor(ctx context.Context, arg database.InsertVolumeResourceMonitorParams) (database.WorkspaceAgentVolumeResourceMonitor, error) { func (m queryMetricsStore) InsertVolumeResourceMonitor(ctx context.Context, arg database.InsertVolumeResourceMonitorParams) (database.WorkspaceAgentVolumeResourceMonitor, error) {
start := time.Now() start := time.Now()
r0, r1 := m.s.InsertVolumeResourceMonitor(ctx, arg) r0, r1 := m.s.InsertVolumeResourceMonitor(ctx, arg)
@@ -4409,6 +4433,14 @@ func (m queryMetricsStore) ListUserSecretsWithValues(ctx context.Context, userID
return r0, r1 return r0, r1
} }
func (m queryMetricsStore) ListUserSkillMetadataByUserID(ctx context.Context, userID uuid.UUID) ([]database.ListUserSkillMetadataByUserIDRow, error) {
start := time.Now()
r0, r1 := m.s.ListUserSkillMetadataByUserID(ctx, userID)
m.queryLatencies.WithLabelValues("ListUserSkillMetadataByUserID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ListUserSkillMetadataByUserID").Inc()
return r0, r1
}
func (m queryMetricsStore) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]database.WorkspaceAgentPortShare, error) { func (m queryMetricsStore) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]database.WorkspaceAgentPortShare, error) {
start := time.Now() start := time.Now()
r0, r1 := m.s.ListWorkspaceAgentPortShares(ctx, workspaceID) r0, r1 := m.s.ListWorkspaceAgentPortShares(ctx, workspaceID)
@@ -5329,6 +5361,14 @@ func (m queryMetricsStore) UpdateUserShellToolDisplayMode(ctx context.Context, a
return r0, r1 return r0, r1
} }
func (m queryMetricsStore) UpdateUserSkillByUserIDAndName(ctx context.Context, arg database.UpdateUserSkillByUserIDAndNameParams) (database.UserSkill, error) {
start := time.Now()
r0, r1 := m.s.UpdateUserSkillByUserIDAndName(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateUserSkillByUserIDAndName").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateUserSkillByUserIDAndName").Inc()
return r0, r1
}
func (m queryMetricsStore) UpdateUserStatus(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) { func (m queryMetricsStore) UpdateUserStatus(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) {
start := time.Now() start := time.Now()
r0, r1 := m.s.UpdateUserStatus(ctx, arg) r0, r1 := m.s.UpdateUserStatus(ctx, arg)
+75
View File
@@ -1377,6 +1377,21 @@ func (mr *MockStoreMockRecorder) DeleteUserSecretByUserIDAndName(ctx, arg any) *
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUserSecretByUserIDAndName", reflect.TypeOf((*MockStore)(nil).DeleteUserSecretByUserIDAndName), ctx, arg) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUserSecretByUserIDAndName", reflect.TypeOf((*MockStore)(nil).DeleteUserSecretByUserIDAndName), ctx, arg)
} }
// DeleteUserSkillByUserIDAndName mocks base method.
func (m *MockStore) DeleteUserSkillByUserIDAndName(ctx context.Context, arg database.DeleteUserSkillByUserIDAndNameParams) (database.UserSkill, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteUserSkillByUserIDAndName", ctx, arg)
ret0, _ := ret[0].(database.UserSkill)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// DeleteUserSkillByUserIDAndName indicates an expected call of DeleteUserSkillByUserIDAndName.
func (mr *MockStoreMockRecorder) DeleteUserSkillByUserIDAndName(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUserSkillByUserIDAndName", reflect.TypeOf((*MockStore)(nil).DeleteUserSkillByUserIDAndName), ctx, arg)
}
// DeleteWebpushSubscriptionByUserIDAndEndpoint mocks base method. // DeleteWebpushSubscriptionByUserIDAndEndpoint mocks base method.
func (m *MockStore) DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg database.DeleteWebpushSubscriptionByUserIDAndEndpointParams) error { func (m *MockStore) DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg database.DeleteWebpushSubscriptionByUserIDAndEndpointParams) error {
m.ctrl.T.Helper() m.ctrl.T.Helper()
@@ -5761,6 +5776,21 @@ func (mr *MockStoreMockRecorder) GetUserShellToolDisplayMode(ctx, userID any) *g
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserShellToolDisplayMode", reflect.TypeOf((*MockStore)(nil).GetUserShellToolDisplayMode), ctx, userID) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserShellToolDisplayMode", reflect.TypeOf((*MockStore)(nil).GetUserShellToolDisplayMode), ctx, userID)
} }
// GetUserSkillByUserIDAndName mocks base method.
func (m *MockStore) GetUserSkillByUserIDAndName(ctx context.Context, arg database.GetUserSkillByUserIDAndNameParams) (database.UserSkill, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetUserSkillByUserIDAndName", ctx, arg)
ret0, _ := ret[0].(database.UserSkill)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetUserSkillByUserIDAndName indicates an expected call of GetUserSkillByUserIDAndName.
func (mr *MockStoreMockRecorder) GetUserSkillByUserIDAndName(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserSkillByUserIDAndName", reflect.TypeOf((*MockStore)(nil).GetUserSkillByUserIDAndName), ctx, arg)
}
// GetUserStatusCounts mocks base method. // GetUserStatusCounts mocks base method.
func (m *MockStore) GetUserStatusCounts(ctx context.Context, arg database.GetUserStatusCountsParams) ([]database.GetUserStatusCountsRow, error) { func (m *MockStore) GetUserStatusCounts(ctx context.Context, arg database.GetUserStatusCountsParams) ([]database.GetUserStatusCountsRow, error) {
m.ctrl.T.Helper() m.ctrl.T.Helper()
@@ -7685,6 +7715,21 @@ func (mr *MockStoreMockRecorder) InsertUserLink(ctx, arg any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertUserLink", reflect.TypeOf((*MockStore)(nil).InsertUserLink), ctx, arg) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertUserLink", reflect.TypeOf((*MockStore)(nil).InsertUserLink), ctx, arg)
} }
// InsertUserSkill mocks base method.
func (m *MockStore) InsertUserSkill(ctx context.Context, arg database.InsertUserSkillParams) (database.UserSkill, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "InsertUserSkill", ctx, arg)
ret0, _ := ret[0].(database.UserSkill)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// InsertUserSkill indicates an expected call of InsertUserSkill.
func (mr *MockStoreMockRecorder) InsertUserSkill(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertUserSkill", reflect.TypeOf((*MockStore)(nil).InsertUserSkill), ctx, arg)
}
// InsertVolumeResourceMonitor mocks base method. // InsertVolumeResourceMonitor mocks base method.
func (m *MockStore) InsertVolumeResourceMonitor(ctx context.Context, arg database.InsertVolumeResourceMonitorParams) (database.WorkspaceAgentVolumeResourceMonitor, error) { func (m *MockStore) InsertVolumeResourceMonitor(ctx context.Context, arg database.InsertVolumeResourceMonitorParams) (database.WorkspaceAgentVolumeResourceMonitor, error) {
m.ctrl.T.Helper() m.ctrl.T.Helper()
@@ -8340,6 +8385,21 @@ func (mr *MockStoreMockRecorder) ListUserSecretsWithValues(ctx, userID any) *gom
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListUserSecretsWithValues", reflect.TypeOf((*MockStore)(nil).ListUserSecretsWithValues), ctx, userID) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListUserSecretsWithValues", reflect.TypeOf((*MockStore)(nil).ListUserSecretsWithValues), ctx, userID)
} }
// ListUserSkillMetadataByUserID mocks base method.
func (m *MockStore) ListUserSkillMetadataByUserID(ctx context.Context, userID uuid.UUID) ([]database.ListUserSkillMetadataByUserIDRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListUserSkillMetadataByUserID", ctx, userID)
ret0, _ := ret[0].([]database.ListUserSkillMetadataByUserIDRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListUserSkillMetadataByUserID indicates an expected call of ListUserSkillMetadataByUserID.
func (mr *MockStoreMockRecorder) ListUserSkillMetadataByUserID(ctx, userID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListUserSkillMetadataByUserID", reflect.TypeOf((*MockStore)(nil).ListUserSkillMetadataByUserID), ctx, userID)
}
// ListWorkspaceAgentPortShares mocks base method. // ListWorkspaceAgentPortShares mocks base method.
func (m *MockStore) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]database.WorkspaceAgentPortShare, error) { func (m *MockStore) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]database.WorkspaceAgentPortShare, error) {
m.ctrl.T.Helper() m.ctrl.T.Helper()
@@ -10048,6 +10108,21 @@ func (mr *MockStoreMockRecorder) UpdateUserShellToolDisplayMode(ctx, arg any) *g
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserShellToolDisplayMode", reflect.TypeOf((*MockStore)(nil).UpdateUserShellToolDisplayMode), ctx, arg) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserShellToolDisplayMode", reflect.TypeOf((*MockStore)(nil).UpdateUserShellToolDisplayMode), ctx, arg)
} }
// UpdateUserSkillByUserIDAndName mocks base method.
func (m *MockStore) UpdateUserSkillByUserIDAndName(ctx context.Context, arg database.UpdateUserSkillByUserIDAndNameParams) (database.UserSkill, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateUserSkillByUserIDAndName", ctx, arg)
ret0, _ := ret[0].(database.UserSkill)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateUserSkillByUserIDAndName indicates an expected call of UpdateUserSkillByUserIDAndName.
func (mr *MockStoreMockRecorder) UpdateUserSkillByUserIDAndName(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserSkillByUserIDAndName", reflect.TypeOf((*MockStore)(nil).UpdateUserSkillByUserIDAndName), ctx, arg)
}
// UpdateUserStatus mocks base method. // UpdateUserStatus mocks base method.
func (m *MockStore) UpdateUserStatus(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) { func (m *MockStore) UpdateUserStatus(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) {
m.ctrl.T.Helper() m.ctrl.T.Helper()
+107 -24
View File
@@ -243,7 +243,12 @@ CREATE TYPE api_key_scope AS ENUM (
'ai_provider:delete', 'ai_provider:delete',
'ai_provider:read', 'ai_provider:read',
'ai_provider:update', 'ai_provider:update',
'chat:share' 'chat:share',
'user_skill:create',
'user_skill:read',
'user_skill:update',
'user_skill:delete',
'user_skill:*'
); );
CREATE TYPE app_sharing_level AS ENUM ( CREATE TYPE app_sharing_level AS ENUM (
@@ -553,7 +558,8 @@ CREATE TYPE resource_type AS ENUM (
'user_secret', 'user_secret',
'ai_provider', 'ai_provider',
'ai_provider_key', 'ai_provider_key',
'group_ai_budget' 'group_ai_budget',
'user_skill'
); );
CREATE TYPE shareable_workspace_owners AS ENUM ( CREATE TYPE shareable_workspace_owners AS ENUM (
@@ -771,31 +777,37 @@ CREATE FUNCTION delete_deleted_user_resources() RETURNS trigger
AS $$ AS $$
DECLARE DECLARE
BEGIN BEGIN
IF (NEW.deleted) THEN IF (NEW.deleted) THEN
-- Remove their api_keys -- Remove their api_keys.
DELETE FROM api_keys DELETE FROM api_keys
WHERE user_id = OLD.id; WHERE user_id = OLD.id;
-- Remove their user_links -- Remove their user_links.
-- Their login_type is preserved in the users table. -- Their login_type is preserved in the users table.
-- Matching this user back to the link can still be done by their -- Matching this user back to the link can still be done by their
-- email if the account is undeleted. Although that is not a guarantee. -- email if the account is undeleted. Although that is not a guarantee.
DELETE FROM user_links DELETE FROM user_links
WHERE user_id = OLD.id; WHERE user_id = OLD.id;
-- Remove their user_secrets. -- Remove their user_secrets.
-- user_secrets.user_id has ON DELETE CASCADE, but soft-delete -- user_secrets.user_id has ON DELETE CASCADE, but soft-delete
-- does not remove the users row so the FK cascade never fires. -- does not remove the users row so the FK cascade never fires.
DELETE FROM user_secrets DELETE FROM user_secrets
WHERE user_id = OLD.id; WHERE user_id = OLD.id;
-- Remove their organization memberships. -- Remove their organization memberships.
-- This also triggers group membership cleanup via -- This also triggers group membership cleanup via
-- trigger_delete_group_members_on_org_member_delete. -- trigger_delete_group_members_on_org_member_delete.
DELETE FROM organization_members DELETE FROM organization_members
WHERE user_id = OLD.id; WHERE user_id = OLD.id;
END IF;
RETURN NEW; -- Remove their user_skills.
-- user_skills.user_id has ON DELETE CASCADE, but soft-delete
-- does not remove the users row so the FK cascade never fires.
DELETE FROM user_skills
WHERE user_id = OLD.id;
END IF;
RETURN NEW;
END; END;
$$; $$;
@@ -818,6 +830,32 @@ BEGIN
END; END;
$$; $$;
CREATE FUNCTION enforce_user_skills_per_user_limit() RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
skill_count int;
skill_limit constant int := 100;
BEGIN
-- Serialize skill-cap checks per user so concurrent inserts cannot all
-- observe the same pre-insert count and exceed the hard limit.
PERFORM 1
FROM users
WHERE id = NEW.user_id
FOR UPDATE;
SELECT count(*) INTO skill_count
FROM user_skills
WHERE user_id = NEW.user_id;
IF skill_count >= skill_limit THEN
RAISE EXCEPTION 'user has reached the personal skill limit'
USING ERRCODE = 'check_violation',
CONSTRAINT = 'user_skills_per_user_limit';
END IF;
RETURN NEW;
END;
$$;
CREATE FUNCTION inhibit_enqueue_if_disabled() RETURNS trigger CREATE FUNCTION inhibit_enqueue_if_disabled() RETURNS trigger
LANGUAGE plpgsql LANGUAGE plpgsql
AS $$ AS $$
@@ -935,6 +973,25 @@ BEGIN
END; END;
$$; $$;
CREATE FUNCTION insert_user_skill_fail_if_user_deleted() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM 1
FROM users
WHERE id = NEW.user_id
AND deleted = true
LIMIT 1;
IF FOUND THEN
RAISE EXCEPTION 'Cannot create user_skill for deleted user'
USING ERRCODE = 'check_violation',
CONSTRAINT = 'user_skill_user_deleted';
END IF;
RETURN NEW;
END;
$$;
CREATE FUNCTION nullify_next_start_at_on_workspace_autostart_modification() RETURNS trigger CREATE FUNCTION nullify_next_start_at_on_workspace_autostart_modification() RETURNS trigger
LANGUAGE plpgsql LANGUAGE plpgsql
AS $$ AS $$
@@ -3015,6 +3072,20 @@ CREATE TABLE user_secrets (
value_key_id text value_key_id text
); );
CREATE TABLE user_skills (
id uuid NOT NULL,
user_id uuid NOT NULL,
name text NOT NULL,
description text DEFAULT ''::text NOT NULL,
content text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT user_skills_content_size CHECK ((octet_length(content) <= 65536)),
CONSTRAINT user_skills_description_size CHECK ((octet_length(description) <= 4096)),
CONSTRAINT user_skills_name_format CHECK ((name ~ '^[a-z0-9]+(-[a-z0-9]+)*$'::text)),
CONSTRAINT user_skills_name_size CHECK ((octet_length(name) <= 256))
);
CREATE TABLE user_status_changes ( CREATE TABLE user_status_changes (
id uuid DEFAULT gen_random_uuid() NOT NULL, id uuid DEFAULT gen_random_uuid() NOT NULL,
user_id uuid NOT NULL, user_id uuid NOT NULL,
@@ -3806,6 +3877,9 @@ ALTER TABLE ONLY user_links
ALTER TABLE ONLY user_secrets ALTER TABLE ONLY user_secrets
ADD CONSTRAINT user_secrets_pkey PRIMARY KEY (id); ADD CONSTRAINT user_secrets_pkey PRIMARY KEY (id);
ALTER TABLE ONLY user_skills
ADD CONSTRAINT user_skills_pkey PRIMARY KEY (id);
ALTER TABLE ONLY user_status_changes ALTER TABLE ONLY user_status_changes
ADD CONSTRAINT user_status_changes_pkey PRIMARY KEY (id); ADD CONSTRAINT user_status_changes_pkey PRIMARY KEY (id);
@@ -4142,6 +4216,8 @@ CREATE UNIQUE INDEX user_secrets_user_file_path_idx ON user_secrets USING btree
CREATE UNIQUE INDEX user_secrets_user_name_idx ON user_secrets USING btree (user_id, name); CREATE UNIQUE INDEX user_secrets_user_name_idx ON user_secrets USING btree (user_id, name);
CREATE UNIQUE INDEX user_skills_user_id_name_idx ON user_skills USING btree (user_id, name);
CREATE UNIQUE INDEX users_email_lower_idx ON users USING btree (lower(email)) WHERE ((deleted = false) AND (email <> ''::text)); CREATE UNIQUE INDEX users_email_lower_idx ON users USING btree (lower(email)) WHERE ((deleted = false) AND (email <> ''::text));
CREATE UNIQUE INDEX users_username_lower_idx ON users USING btree (lower(username)) WHERE (deleted = false); CREATE UNIQUE INDEX users_username_lower_idx ON users USING btree (lower(username)) WHERE (deleted = false);
@@ -4266,6 +4342,10 @@ CREATE TRIGGER trigger_upsert_user_links BEFORE INSERT OR UPDATE ON user_links F
CREATE TRIGGER trigger_upsert_user_secrets BEFORE INSERT OR UPDATE ON user_secrets FOR EACH ROW EXECUTE FUNCTION insert_user_secret_fail_if_user_deleted(); CREATE TRIGGER trigger_upsert_user_secrets BEFORE INSERT OR UPDATE ON user_secrets FOR EACH ROW EXECUTE FUNCTION insert_user_secret_fail_if_user_deleted();
CREATE TRIGGER trigger_upsert_user_skills BEFORE INSERT OR UPDATE ON user_skills FOR EACH ROW EXECUTE FUNCTION insert_user_skill_fail_if_user_deleted();
CREATE TRIGGER trigger_user_skills_per_user_limit BEFORE INSERT ON user_skills FOR EACH ROW EXECUTE FUNCTION enforce_user_skills_per_user_limit();
CREATE TRIGGER update_notification_message_dedupe_hash BEFORE INSERT OR UPDATE ON notification_messages FOR EACH ROW EXECUTE FUNCTION compute_notification_message_dedupe_hash(); CREATE TRIGGER update_notification_message_dedupe_hash BEFORE INSERT OR UPDATE ON notification_messages FOR EACH ROW EXECUTE FUNCTION compute_notification_message_dedupe_hash();
CREATE TRIGGER user_status_change_trigger AFTER INSERT OR UPDATE ON users FOR EACH ROW EXECUTE FUNCTION record_user_status_change(); CREATE TRIGGER user_status_change_trigger AFTER INSERT OR UPDATE ON users FOR EACH ROW EXECUTE FUNCTION record_user_status_change();
@@ -4591,6 +4671,9 @@ ALTER TABLE ONLY user_secrets
ALTER TABLE ONLY user_secrets ALTER TABLE ONLY user_secrets
ADD CONSTRAINT user_secrets_value_key_id_fkey FOREIGN KEY (value_key_id) REFERENCES dbcrypt_keys(active_key_digest); ADD CONSTRAINT user_secrets_value_key_id_fkey FOREIGN KEY (value_key_id) REFERENCES dbcrypt_keys(active_key_digest);
ALTER TABLE ONLY user_skills
ADD CONSTRAINT user_skills_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE ONLY user_status_changes ALTER TABLE ONLY user_status_changes
ADD CONSTRAINT user_status_changes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); ADD CONSTRAINT user_status_changes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id);
@@ -111,6 +111,7 @@ const (
ForeignKeyUserLinksUserID ForeignKeyConstraint = "user_links_user_id_fkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyUserLinksUserID ForeignKeyConstraint = "user_links_user_id_fkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyUserSecretsUserID ForeignKeyConstraint = "user_secrets_user_id_fkey" // ALTER TABLE ONLY user_secrets ADD CONSTRAINT user_secrets_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyUserSecretsUserID ForeignKeyConstraint = "user_secrets_user_id_fkey" // ALTER TABLE ONLY user_secrets ADD CONSTRAINT user_secrets_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyUserSecretsValueKeyID ForeignKeyConstraint = "user_secrets_value_key_id_fkey" // ALTER TABLE ONLY user_secrets ADD CONSTRAINT user_secrets_value_key_id_fkey FOREIGN KEY (value_key_id) REFERENCES dbcrypt_keys(active_key_digest); ForeignKeyUserSecretsValueKeyID ForeignKeyConstraint = "user_secrets_value_key_id_fkey" // ALTER TABLE ONLY user_secrets ADD CONSTRAINT user_secrets_value_key_id_fkey FOREIGN KEY (value_key_id) REFERENCES dbcrypt_keys(active_key_digest);
ForeignKeyUserSkillsUserID ForeignKeyConstraint = "user_skills_user_id_fkey" // ALTER TABLE ONLY user_skills ADD CONSTRAINT user_skills_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyUserStatusChangesUserID ForeignKeyConstraint = "user_status_changes_user_id_fkey" // ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); ForeignKeyUserStatusChangesUserID ForeignKeyConstraint = "user_status_changes_user_id_fkey" // ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id);
ForeignKeyWebpushSubscriptionsUserID ForeignKeyConstraint = "webpush_subscriptions_user_id_fkey" // ALTER TABLE ONLY webpush_subscriptions ADD CONSTRAINT webpush_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyWebpushSubscriptionsUserID ForeignKeyConstraint = "webpush_subscriptions_user_id_fkey" // ALTER TABLE ONLY webpush_subscriptions ADD CONSTRAINT webpush_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyWorkspaceAgentDevcontainersSubagentID ForeignKeyConstraint = "workspace_agent_devcontainers_subagent_id_fkey" // ALTER TABLE ONLY workspace_agent_devcontainers ADD CONSTRAINT workspace_agent_devcontainers_subagent_id_fkey FOREIGN KEY (subagent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; ForeignKeyWorkspaceAgentDevcontainersSubagentID ForeignKeyConstraint = "workspace_agent_devcontainers_subagent_id_fkey" // ALTER TABLE ONLY workspace_agent_devcontainers ADD CONSTRAINT workspace_agent_devcontainers_subagent_id_fkey FOREIGN KEY (subagent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE;
@@ -0,0 +1,43 @@
-- Enum additions to resource_type and api_key_scope are intentionally not
-- reverted because Postgres cannot drop enum values safely.
DROP TRIGGER IF EXISTS trigger_upsert_user_skills ON user_skills;
DROP FUNCTION IF EXISTS insert_user_skill_fail_if_user_deleted;
-- Restore the previous body of delete_deleted_user_resources() from
-- migration 000492 (without the user_skills cleanup).
CREATE OR REPLACE FUNCTION delete_deleted_user_resources() RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
BEGIN
IF (NEW.deleted) THEN
-- Remove their api_keys.
DELETE FROM api_keys
WHERE user_id = OLD.id;
-- Remove their user_links.
-- Their login_type is preserved in the users table.
-- Matching this user back to the link can still be done by their
-- email if the account is undeleted. Although that is not a guarantee.
DELETE FROM user_links
WHERE user_id = OLD.id;
-- Remove their user_secrets.
-- user_secrets.user_id has ON DELETE CASCADE, but soft-delete
-- does not remove the users row so the FK cascade never fires.
DELETE FROM user_secrets
WHERE user_id = OLD.id;
-- Remove their organization memberships.
-- This also triggers group membership cleanup via
-- trigger_delete_group_members_on_org_member_delete.
DELETE FROM organization_members
WHERE user_id = OLD.id;
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trigger_user_skills_per_user_limit ON user_skills;
DROP FUNCTION IF EXISTS enforce_user_skills_per_user_limit();
DROP TABLE user_skills;
@@ -0,0 +1,138 @@
-- Creates the user_skills table and indexes.
CREATE TABLE user_skills (
id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name text NOT NULL,
description text NOT NULL DEFAULT '',
content text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT user_skills_name_size CHECK (octet_length(name) <= 256),
CONSTRAINT user_skills_name_format CHECK (name ~ '^[a-z0-9]+(-[a-z0-9]+)*$'),
CONSTRAINT user_skills_description_size CHECK (octet_length(description) <= 4096),
CONSTRAINT user_skills_content_size CHECK (octet_length(content) <= 65536)
);
CREATE UNIQUE INDEX user_skills_user_id_name_idx ON user_skills (user_id, name);
-- Enforces the per-user personal-skill cap at the schema level so the
-- invariant survives any future refactor of InsertUserSkill. The cap
-- value must stay in sync with skills.MaxPersonalSkillsPerUser in Go.
CREATE FUNCTION enforce_user_skills_per_user_limit() RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
skill_count int;
skill_limit constant int := 100;
BEGIN
-- Serialize skill-cap checks per user so concurrent inserts cannot all
-- observe the same pre-insert count and exceed the hard limit.
PERFORM 1
FROM users
WHERE id = NEW.user_id
FOR UPDATE;
SELECT count(*) INTO skill_count
FROM user_skills
WHERE user_id = NEW.user_id;
IF skill_count >= skill_limit THEN
RAISE EXCEPTION 'user has reached the personal skill limit'
USING ERRCODE = 'check_violation',
CONSTRAINT = 'user_skills_per_user_limit';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trigger_user_skills_per_user_limit
BEFORE INSERT ON user_skills
FOR EACH ROW
EXECUTE PROCEDURE enforce_user_skills_per_user_limit();
-- Extend the soft-delete cleanup trigger to also wipe user_skills.
-- user_skills.user_id has ON DELETE CASCADE, but Coder soft-deletes
-- users by flipping users.deleted instead of removing the row, so the
-- FK cascade never fires and skills would otherwise survive deletion.
DELETE FROM
user_skills
WHERE
user_id
IN (
SELECT id FROM users WHERE deleted
);
CREATE OR REPLACE FUNCTION delete_deleted_user_resources() RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
BEGIN
IF (NEW.deleted) THEN
-- Remove their api_keys.
DELETE FROM api_keys
WHERE user_id = OLD.id;
-- Remove their user_links.
-- Their login_type is preserved in the users table.
-- Matching this user back to the link can still be done by their
-- email if the account is undeleted. Although that is not a guarantee.
DELETE FROM user_links
WHERE user_id = OLD.id;
-- Remove their user_secrets.
-- user_secrets.user_id has ON DELETE CASCADE, but soft-delete
-- does not remove the users row so the FK cascade never fires.
DELETE FROM user_secrets
WHERE user_id = OLD.id;
-- Remove their organization memberships.
-- This also triggers group membership cleanup via
-- trigger_delete_group_members_on_org_member_delete.
DELETE FROM organization_members
WHERE user_id = OLD.id;
-- Remove their user_skills.
-- user_skills.user_id has ON DELETE CASCADE, but soft-delete
-- does not remove the users row so the FK cascade never fires.
DELETE FROM user_skills
WHERE user_id = OLD.id;
END IF;
RETURN NEW;
END;
$$;
-- Prevent adding new user_skills for soft-deleted users.
-- Closes the window between an in-flight CreateUserSkill request and
-- the soft-delete UPDATE committing.
CREATE FUNCTION insert_user_skill_fail_if_user_deleted() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM 1
FROM users
WHERE id = NEW.user_id
AND deleted = true
LIMIT 1;
IF FOUND THEN
RAISE EXCEPTION 'Cannot create user_skill for deleted user'
USING ERRCODE = 'check_violation',
CONSTRAINT = 'user_skill_user_deleted';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trigger_upsert_user_skills
BEFORE INSERT OR UPDATE ON user_skills
FOR EACH ROW
EXECUTE PROCEDURE insert_user_skill_fail_if_user_deleted();
-- Adds the user skill audit resource type.
ALTER TYPE resource_type ADD VALUE IF NOT EXISTS 'user_skill';
-- Adds API key scopes for managing user skills.
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'user_skill:create';
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'user_skill:read';
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'user_skill:update';
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'user_skill:delete';
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'user_skill:*';
@@ -0,0 +1,18 @@
-- Inserts a user skill fixture so migration coverage includes the table.
INSERT INTO user_skills (
id,
user_id,
name,
description,
content,
created_at,
updated_at
) VALUES (
'7f070eb2-991e-4f7f-b780-40c4e0f49001',
'30095c71-380b-457a-8995-97b8ee6e5307',
'example-skill',
'Example skill fixture.',
'Example content.',
'2026-05-07 00:00:00+00',
'2026-05-07 00:00:00+00'
);
+4
View File
@@ -896,6 +896,10 @@ func (m WorkspaceAgentVolumeResourceMonitor) Debounce(
return m.DebouncedUntil, false return m.DebouncedUntil, false
} }
func (s UserSkill) RBACObject() rbac.Object {
return rbac.ResourceUserSkill.WithID(s.ID).WithOwner(s.UserID.String())
}
func (s UserSecret) RBACObject() rbac.Object { func (s UserSecret) RBACObject() rbac.Object {
return rbac.ResourceUserSecret.WithID(s.ID).WithOwner(s.UserID.String()) return rbac.ResourceUserSecret.WithID(s.ID).WithOwner(s.UserID.String())
} }
+30 -2
View File
@@ -312,6 +312,11 @@ const (
ApiKeyScopeAiProviderRead APIKeyScope = "ai_provider:read" ApiKeyScopeAiProviderRead APIKeyScope = "ai_provider:read"
ApiKeyScopeAiProviderUpdate APIKeyScope = "ai_provider:update" ApiKeyScopeAiProviderUpdate APIKeyScope = "ai_provider:update"
ApiKeyScopeChatShare APIKeyScope = "chat:share" ApiKeyScopeChatShare APIKeyScope = "chat:share"
ApiKeyScopeUserSkillCreate APIKeyScope = "user_skill:create"
ApiKeyScopeUserSkillRead APIKeyScope = "user_skill:read"
ApiKeyScopeUserSkillUpdate APIKeyScope = "user_skill:update"
ApiKeyScopeUserSkillDelete APIKeyScope = "user_skill:delete"
ApiKeyScopeUserSkill APIKeyScope = "user_skill:*"
) )
func (e *APIKeyScope) Scan(src interface{}) error { func (e *APIKeyScope) Scan(src interface{}) error {
@@ -567,7 +572,12 @@ func (e APIKeyScope) Valid() bool {
ApiKeyScopeAiProviderDelete, ApiKeyScopeAiProviderDelete,
ApiKeyScopeAiProviderRead, ApiKeyScopeAiProviderRead,
ApiKeyScopeAiProviderUpdate, ApiKeyScopeAiProviderUpdate,
ApiKeyScopeChatShare: ApiKeyScopeChatShare,
ApiKeyScopeUserSkillCreate,
ApiKeyScopeUserSkillRead,
ApiKeyScopeUserSkillUpdate,
ApiKeyScopeUserSkillDelete,
ApiKeyScopeUserSkill:
return true return true
} }
return false return false
@@ -792,6 +802,11 @@ func AllAPIKeyScopeValues() []APIKeyScope {
ApiKeyScopeAiProviderRead, ApiKeyScopeAiProviderRead,
ApiKeyScopeAiProviderUpdate, ApiKeyScopeAiProviderUpdate,
ApiKeyScopeChatShare, ApiKeyScopeChatShare,
ApiKeyScopeUserSkillCreate,
ApiKeyScopeUserSkillRead,
ApiKeyScopeUserSkillUpdate,
ApiKeyScopeUserSkillDelete,
ApiKeyScopeUserSkill,
} }
} }
@@ -3322,6 +3337,7 @@ const (
ResourceTypeAiProvider ResourceType = "ai_provider" ResourceTypeAiProvider ResourceType = "ai_provider"
ResourceTypeAiProviderKey ResourceType = "ai_provider_key" ResourceTypeAiProviderKey ResourceType = "ai_provider_key"
ResourceTypeGroupAiBudget ResourceType = "group_ai_budget" ResourceTypeGroupAiBudget ResourceType = "group_ai_budget"
ResourceTypeUserSkill ResourceType = "user_skill"
) )
func (e *ResourceType) Scan(src interface{}) error { func (e *ResourceType) Scan(src interface{}) error {
@@ -3392,7 +3408,8 @@ func (e ResourceType) Valid() bool {
ResourceTypeUserSecret, ResourceTypeUserSecret,
ResourceTypeAiProvider, ResourceTypeAiProvider,
ResourceTypeAiProviderKey, ResourceTypeAiProviderKey,
ResourceTypeGroupAiBudget: ResourceTypeGroupAiBudget,
ResourceTypeUserSkill:
return true return true
} }
return false return false
@@ -3432,6 +3449,7 @@ func AllResourceTypeValues() []ResourceType {
ResourceTypeAiProvider, ResourceTypeAiProvider,
ResourceTypeAiProviderKey, ResourceTypeAiProviderKey,
ResourceTypeGroupAiBudget, ResourceTypeGroupAiBudget,
ResourceTypeUserSkill,
} }
} }
@@ -5725,6 +5743,16 @@ type UserSecret struct {
ValueKeyID sql.NullString `db:"value_key_id" json:"value_key_id"` ValueKeyID sql.NullString `db:"value_key_id" json:"value_key_id"`
} }
type UserSkill struct {
ID uuid.UUID `db:"id" json:"id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"`
Description string `db:"description" json:"description"`
Content string `db:"content" json:"content"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// Tracks the history of user status changes // Tracks the history of user status changes
type UserStatusChange struct { type UserStatusChange struct {
ID uuid.UUID `db:"id" json:"id"` ID uuid.UUID `db:"id" json:"id"`
+5
View File
@@ -196,6 +196,7 @@ type sqlcQuerier interface {
DeleteUserChatCompactionThreshold(ctx context.Context, arg DeleteUserChatCompactionThresholdParams) error DeleteUserChatCompactionThreshold(ctx context.Context, arg DeleteUserChatCompactionThresholdParams) error
DeleteUserChatProviderKey(ctx context.Context, arg DeleteUserChatProviderKeyParams) error DeleteUserChatProviderKey(ctx context.Context, arg DeleteUserChatProviderKeyParams) error
DeleteUserSecretByUserIDAndName(ctx context.Context, arg DeleteUserSecretByUserIDAndNameParams) (UserSecret, error) DeleteUserSecretByUserIDAndName(ctx context.Context, arg DeleteUserSecretByUserIDAndNameParams) (UserSecret, error)
DeleteUserSkillByUserIDAndName(ctx context.Context, arg DeleteUserSkillByUserIDAndNameParams) (UserSkill, error)
DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg DeleteWebpushSubscriptionByUserIDAndEndpointParams) error DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg DeleteWebpushSubscriptionByUserIDAndEndpointParams) error
DeleteWebpushSubscriptions(ctx context.Context, ids []uuid.UUID) error DeleteWebpushSubscriptions(ctx context.Context, ids []uuid.UUID) error
DeleteWorkspaceACLByID(ctx context.Context, id uuid.UUID) error DeleteWorkspaceACLByID(ctx context.Context, id uuid.UUID) error
@@ -791,6 +792,7 @@ type sqlcQuerier interface {
// values rather than interpolating between rows. // values rather than interpolating between rows.
GetUserSecretsTelemetrySummary(ctx context.Context) (GetUserSecretsTelemetrySummaryRow, error) GetUserSecretsTelemetrySummary(ctx context.Context) (GetUserSecretsTelemetrySummaryRow, error)
GetUserShellToolDisplayMode(ctx context.Context, userID uuid.UUID) (string, error) GetUserShellToolDisplayMode(ctx context.Context, userID uuid.UUID) (string, error)
GetUserSkillByUserIDAndName(ctx context.Context, arg GetUserSkillByUserIDAndNameParams) (UserSkill, error)
// GetUserStatusCounts returns the count of users in each status over time. // GetUserStatusCounts returns the count of users in each status over time.
// The time range is inclusively defined by the start_time and end_time parameters. // The time range is inclusively defined by the start_time and end_time parameters.
GetUserStatusCounts(ctx context.Context, arg GetUserStatusCountsParams) ([]GetUserStatusCountsRow, error) GetUserStatusCounts(ctx context.Context, arg GetUserStatusCountsParams) ([]GetUserStatusCountsRow, error)
@@ -966,6 +968,7 @@ type sqlcQuerier interface {
// If there is a conflict, the user is already a member // If there is a conflict, the user is already a member
InsertUserGroupsByID(ctx context.Context, arg InsertUserGroupsByIDParams) ([]uuid.UUID, error) InsertUserGroupsByID(ctx context.Context, arg InsertUserGroupsByIDParams) ([]uuid.UUID, error)
InsertUserLink(ctx context.Context, arg InsertUserLinkParams) (UserLink, error) InsertUserLink(ctx context.Context, arg InsertUserLinkParams) (UserLink, error)
InsertUserSkill(ctx context.Context, arg InsertUserSkillParams) (UserSkill, error)
InsertVolumeResourceMonitor(ctx context.Context, arg InsertVolumeResourceMonitorParams) (WorkspaceAgentVolumeResourceMonitor, error) InsertVolumeResourceMonitor(ctx context.Context, arg InsertVolumeResourceMonitorParams) (WorkspaceAgentVolumeResourceMonitor, error)
// Inserts or updates a webpush subscription. The (user_id, endpoint) pair // Inserts or updates a webpush subscription. The (user_id, endpoint) pair
// is unique; re-subscribing the same endpoint replaces the keys instead of // is unique; re-subscribing the same endpoint replaces the keys instead of
@@ -1033,6 +1036,7 @@ type sqlcQuerier interface {
// provisioner (build-time injection) and the agent manifest // provisioner (build-time injection) and the agent manifest
// (runtime injection). // (runtime injection).
ListUserSecretsWithValues(ctx context.Context, userID uuid.UUID) ([]UserSecret, error) ListUserSecretsWithValues(ctx context.Context, userID uuid.UUID) ([]UserSecret, error)
ListUserSkillMetadataByUserID(ctx context.Context, userID uuid.UUID) ([]ListUserSkillMetadataByUserIDRow, error)
ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceAgentPortShare, error) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceAgentPortShare, error)
MarkAllInboxNotificationsAsRead(ctx context.Context, arg MarkAllInboxNotificationsAsReadParams) error MarkAllInboxNotificationsAsRead(ctx context.Context, arg MarkAllInboxNotificationsAsReadParams) error
OIDCClaimFieldValues(ctx context.Context, arg OIDCClaimFieldValuesParams) ([]string, error) OIDCClaimFieldValues(ctx context.Context, arg OIDCClaimFieldValuesParams) ([]string, error)
@@ -1271,6 +1275,7 @@ type sqlcQuerier interface {
UpdateUserRoles(ctx context.Context, arg UpdateUserRolesParams) (User, error) UpdateUserRoles(ctx context.Context, arg UpdateUserRolesParams) (User, error)
UpdateUserSecretByUserIDAndName(ctx context.Context, arg UpdateUserSecretByUserIDAndNameParams) (UserSecret, error) UpdateUserSecretByUserIDAndName(ctx context.Context, arg UpdateUserSecretByUserIDAndNameParams) (UserSecret, error)
UpdateUserShellToolDisplayMode(ctx context.Context, arg UpdateUserShellToolDisplayModeParams) (string, error) UpdateUserShellToolDisplayMode(ctx context.Context, arg UpdateUserShellToolDisplayModeParams) (string, error)
UpdateUserSkillByUserIDAndName(ctx context.Context, arg UpdateUserSkillByUserIDAndNameParams) (UserSkill, error)
UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) (User, error) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) (User, error)
UpdateUserTaskNotificationAlertDismissed(ctx context.Context, arg UpdateUserTaskNotificationAlertDismissedParams) (bool, error) UpdateUserTaskNotificationAlertDismissed(ctx context.Context, arg UpdateUserTaskNotificationAlertDismissedParams) (bool, error)
UpdateUserTerminalFont(ctx context.Context, arg UpdateUserTerminalFontParams) (UserConfig, error) UpdateUserTerminalFont(ctx context.Context, arg UpdateUserTerminalFontParams) (UserConfig, error)
+171
View File
@@ -26894,6 +26894,177 @@ func (q *sqlQuerier) UpdateUserSecretByUserIDAndName(ctx context.Context, arg Up
return i, err return i, err
} }
const deleteUserSkillByUserIDAndName = `-- name: DeleteUserSkillByUserIDAndName :one
DELETE FROM user_skills
WHERE user_id = $1 AND name = $2
RETURNING id, user_id, name, description, content, created_at, updated_at
`
type DeleteUserSkillByUserIDAndNameParams struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"`
}
func (q *sqlQuerier) DeleteUserSkillByUserIDAndName(ctx context.Context, arg DeleteUserSkillByUserIDAndNameParams) (UserSkill, error) {
row := q.db.QueryRowContext(ctx, deleteUserSkillByUserIDAndName, arg.UserID, arg.Name)
var i UserSkill
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.Content,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getUserSkillByUserIDAndName = `-- name: GetUserSkillByUserIDAndName :one
SELECT id, user_id, name, description, content, created_at, updated_at
FROM user_skills
WHERE user_id = $1 AND name = $2
`
type GetUserSkillByUserIDAndNameParams struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"`
}
func (q *sqlQuerier) GetUserSkillByUserIDAndName(ctx context.Context, arg GetUserSkillByUserIDAndNameParams) (UserSkill, error) {
row := q.db.QueryRowContext(ctx, getUserSkillByUserIDAndName, arg.UserID, arg.Name)
var i UserSkill
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.Content,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const insertUserSkill = `-- name: InsertUserSkill :one
INSERT INTO user_skills (id, user_id, name, description, content)
VALUES ($1::uuid, $2::uuid, $3::text, $4::text, $5::text)
RETURNING id, user_id, name, description, content, created_at, updated_at
`
type InsertUserSkillParams struct {
ID uuid.UUID `db:"id" json:"id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"`
Description string `db:"description" json:"description"`
Content string `db:"content" json:"content"`
}
func (q *sqlQuerier) InsertUserSkill(ctx context.Context, arg InsertUserSkillParams) (UserSkill, error) {
row := q.db.QueryRowContext(ctx, insertUserSkill,
arg.ID,
arg.UserID,
arg.Name,
arg.Description,
arg.Content,
)
var i UserSkill
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.Content,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listUserSkillMetadataByUserID = `-- name: ListUserSkillMetadataByUserID :many
SELECT
id, user_id, name, description, created_at, updated_at
FROM user_skills
WHERE user_id = $1
ORDER BY name ASC
`
type ListUserSkillMetadataByUserIDRow struct {
ID uuid.UUID `db:"id" json:"id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"`
Description string `db:"description" json:"description"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
func (q *sqlQuerier) ListUserSkillMetadataByUserID(ctx context.Context, userID uuid.UUID) ([]ListUserSkillMetadataByUserIDRow, error) {
rows, err := q.db.QueryContext(ctx, listUserSkillMetadataByUserID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListUserSkillMetadataByUserIDRow
for rows.Next() {
var i ListUserSkillMetadataByUserIDRow
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateUserSkillByUserIDAndName = `-- name: UpdateUserSkillByUserIDAndName :one
UPDATE user_skills
SET
description = $1,
content = $2,
updated_at = now()
WHERE user_id = $3 AND name = $4
RETURNING id, user_id, name, description, content, created_at, updated_at
`
type UpdateUserSkillByUserIDAndNameParams struct {
Description string `db:"description" json:"description"`
Content string `db:"content" json:"content"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"`
}
func (q *sqlQuerier) UpdateUserSkillByUserIDAndName(ctx context.Context, arg UpdateUserSkillByUserIDAndNameParams) (UserSkill, error) {
row := q.db.QueryRowContext(ctx, updateUserSkillByUserIDAndName,
arg.Description,
arg.Content,
arg.UserID,
arg.Name,
)
var i UserSkill
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.Content,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deleteUserChatProviderKey = `-- name: DeleteUserChatProviderKey :exec const deleteUserChatProviderKey = `-- name: DeleteUserChatProviderKey :exec
DELETE FROM user_chat_provider_keys WHERE user_id = $1 AND chat_provider_id = $2 DELETE FROM user_chat_provider_keys WHERE user_id = $1 AND chat_provider_id = $2
` `
+30
View File
@@ -0,0 +1,30 @@
-- name: InsertUserSkill :one
INSERT INTO user_skills (id, user_id, name, description, content)
VALUES (@id::uuid, @user_id::uuid, @name::text, @description::text, @content::text)
RETURNING *;
-- name: GetUserSkillByUserIDAndName :one
SELECT *
FROM user_skills
WHERE user_id = @user_id AND name = @name;
-- name: ListUserSkillMetadataByUserID :many
SELECT
id, user_id, name, description, created_at, updated_at
FROM user_skills
WHERE user_id = @user_id
ORDER BY name ASC;
-- name: UpdateUserSkillByUserIDAndName :one
UPDATE user_skills
SET
description = @description,
content = @content,
updated_at = now()
WHERE user_id = @user_id AND name = @name
RETURNING *;
-- name: DeleteUserSkillByUserIDAndName :one
DELETE FROM user_skills
WHERE user_id = @user_id AND name = @name
RETURNING *;
+2
View File
@@ -103,6 +103,7 @@ const (
UniqueUserDeletedPkey UniqueConstraint = "user_deleted_pkey" // ALTER TABLE ONLY user_deleted ADD CONSTRAINT user_deleted_pkey PRIMARY KEY (id); UniqueUserDeletedPkey UniqueConstraint = "user_deleted_pkey" // ALTER TABLE ONLY user_deleted ADD CONSTRAINT user_deleted_pkey PRIMARY KEY (id);
UniqueUserLinksPkey UniqueConstraint = "user_links_pkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_pkey PRIMARY KEY (user_id, login_type); UniqueUserLinksPkey UniqueConstraint = "user_links_pkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_pkey PRIMARY KEY (user_id, login_type);
UniqueUserSecretsPkey UniqueConstraint = "user_secrets_pkey" // ALTER TABLE ONLY user_secrets ADD CONSTRAINT user_secrets_pkey PRIMARY KEY (id); UniqueUserSecretsPkey UniqueConstraint = "user_secrets_pkey" // ALTER TABLE ONLY user_secrets ADD CONSTRAINT user_secrets_pkey PRIMARY KEY (id);
UniqueUserSkillsPkey UniqueConstraint = "user_skills_pkey" // ALTER TABLE ONLY user_skills ADD CONSTRAINT user_skills_pkey PRIMARY KEY (id);
UniqueUserStatusChangesPkey UniqueConstraint = "user_status_changes_pkey" // ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_pkey PRIMARY KEY (id); UniqueUserStatusChangesPkey UniqueConstraint = "user_status_changes_pkey" // ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_pkey PRIMARY KEY (id);
UniqueUsersPkey UniqueConstraint = "users_pkey" // ALTER TABLE ONLY users ADD CONSTRAINT users_pkey PRIMARY KEY (id); UniqueUsersPkey UniqueConstraint = "users_pkey" // ALTER TABLE ONLY users ADD CONSTRAINT users_pkey PRIMARY KEY (id);
UniqueWebpushSubscriptionsPkey UniqueConstraint = "webpush_subscriptions_pkey" // ALTER TABLE ONLY webpush_subscriptions ADD CONSTRAINT webpush_subscriptions_pkey PRIMARY KEY (id); UniqueWebpushSubscriptionsPkey UniqueConstraint = "webpush_subscriptions_pkey" // ALTER TABLE ONLY webpush_subscriptions ADD CONSTRAINT webpush_subscriptions_pkey PRIMARY KEY (id);
@@ -156,6 +157,7 @@ const (
UniqueUserSecretsUserEnvNameIndex UniqueConstraint = "user_secrets_user_env_name_idx" // CREATE UNIQUE INDEX user_secrets_user_env_name_idx ON user_secrets USING btree (user_id, env_name) WHERE (env_name <> ''::text); UniqueUserSecretsUserEnvNameIndex UniqueConstraint = "user_secrets_user_env_name_idx" // CREATE UNIQUE INDEX user_secrets_user_env_name_idx ON user_secrets USING btree (user_id, env_name) WHERE (env_name <> ''::text);
UniqueUserSecretsUserFilePathIndex UniqueConstraint = "user_secrets_user_file_path_idx" // CREATE UNIQUE INDEX user_secrets_user_file_path_idx ON user_secrets USING btree (user_id, file_path) WHERE (file_path <> ''::text); UniqueUserSecretsUserFilePathIndex UniqueConstraint = "user_secrets_user_file_path_idx" // CREATE UNIQUE INDEX user_secrets_user_file_path_idx ON user_secrets USING btree (user_id, file_path) WHERE (file_path <> ''::text);
UniqueUserSecretsUserNameIndex UniqueConstraint = "user_secrets_user_name_idx" // CREATE UNIQUE INDEX user_secrets_user_name_idx ON user_secrets USING btree (user_id, name); UniqueUserSecretsUserNameIndex UniqueConstraint = "user_secrets_user_name_idx" // CREATE UNIQUE INDEX user_secrets_user_name_idx ON user_secrets USING btree (user_id, name);
UniqueUserSkillsUserIDNameIndex UniqueConstraint = "user_skills_user_id_name_idx" // CREATE UNIQUE INDEX user_skills_user_id_name_idx ON user_skills USING btree (user_id, name);
UniqueUsersEmailLowerIndex UniqueConstraint = "users_email_lower_idx" // CREATE UNIQUE INDEX users_email_lower_idx ON users USING btree (lower(email)) WHERE ((deleted = false) AND (email <> ''::text)); UniqueUsersEmailLowerIndex UniqueConstraint = "users_email_lower_idx" // CREATE UNIQUE INDEX users_email_lower_idx ON users USING btree (lower(email)) WHERE ((deleted = false) AND (email <> ''::text));
UniqueUsersUsernameLowerIndex UniqueConstraint = "users_username_lower_idx" // CREATE UNIQUE INDEX users_username_lower_idx ON users USING btree (lower(username)) WHERE (deleted = false); UniqueUsersUsernameLowerIndex UniqueConstraint = "users_username_lower_idx" // CREATE UNIQUE INDEX users_username_lower_idx ON users USING btree (lower(username)) WHERE (deleted = false);
UniqueWebpushSubscriptionsUserIDEndpointIndex UniqueConstraint = "webpush_subscriptions_user_id_endpoint_idx" // CREATE UNIQUE INDEX webpush_subscriptions_user_id_endpoint_idx ON webpush_subscriptions USING btree (user_id, endpoint); UniqueWebpushSubscriptionsUserIDEndpointIndex UniqueConstraint = "webpush_subscriptions_user_id_endpoint_idx" // CREATE UNIQUE INDEX webpush_subscriptions_user_id_endpoint_idx ON webpush_subscriptions USING btree (user_id, endpoint);
+62
View File
@@ -0,0 +1,62 @@
package database_test
import (
"fmt"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/x/skills"
"github.com/coder/coder/v2/testutil"
)
func TestUserSkillSchemaConstants(t *testing.T) {
t.Parallel()
if testing.Short() {
t.SkipNow()
}
ctx := testutil.Context(t, testutil.WaitMedium)
_, _, sqlDB := dbtestutil.NewDBWithSQLDB(t)
var triggerDef string
err := sqlDB.QueryRowContext(ctx,
`SELECT pg_get_functiondef('enforce_user_skills_per_user_limit'::regproc)`,
).Scan(&triggerDef)
require.NoError(t, err)
require.Contains(t, triggerDef, fmt.Sprintf(
"skill_limit constant int := %d",
skills.MaxPersonalSkillsPerUser,
))
constraints := map[database.CheckConstraint]string{
database.CheckUserSkillsNameSize: fmt.Sprintf(
"octet_length(name) <= %d",
skills.MaxPersonalSkillNameBytes,
),
database.CheckUserSkillsNameFormat: "name ~ '^[a-z0-9]+(-[a-z0-9]+)*$'::text",
database.CheckUserSkillsDescriptionSize: fmt.Sprintf(
"octet_length(description) <= %d",
skills.MaxPersonalSkillDescriptionBytes,
),
database.CheckUserSkillsContentSize: fmt.Sprintf(
"octet_length(content) <= %d",
skills.MaxPersonalSkillSizeBytes,
),
}
for constraint, expected := range constraints {
t.Run(string(constraint), func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
var constraintDef string
err := sqlDB.QueryRowContext(ctx,
`SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname = $1`,
constraint,
).Scan(&constraintDef)
require.NoError(t, err)
require.Contains(t, constraintDef, expected)
})
}
}
+11
View File
@@ -385,6 +385,16 @@ var (
Type: "user_secret", Type: "user_secret",
} }
// ResourceUserSkill
// Valid Actions
// - "ActionCreate" :: create a user skill
// - "ActionDelete" :: delete a user skill
// - "ActionRead" :: read user skill metadata and content
// - "ActionUpdate" :: update user skill metadata and content
ResourceUserSkill = Object{
Type: "user_skill",
}
// ResourceWebpushSubscription // ResourceWebpushSubscription
// Valid Actions // Valid Actions
// - "ActionCreate" :: create webpush subscriptions // - "ActionCreate" :: create webpush subscriptions
@@ -500,6 +510,7 @@ func AllResources() []Objecter {
ResourceUsageEvent, ResourceUsageEvent,
ResourceUser, ResourceUser,
ResourceUserSecret, ResourceUserSecret,
ResourceUserSkill,
ResourceWebpushSubscription, ResourceWebpushSubscription,
ResourceWorkspace, ResourceWorkspace,
ResourceWorkspaceAgentDevcontainers, ResourceWorkspaceAgentDevcontainers,
+8
View File
@@ -379,6 +379,14 @@ var RBACPermissions = map[string]PermissionDefinition{
ActionDelete: "delete a user secret", ActionDelete: "delete a user secret",
}, },
}, },
"user_skill": {
Actions: map[Action]ActionDefinition{
ActionCreate: "create a user skill",
ActionRead: "read user skill metadata and content",
ActionUpdate: "update user skill metadata and content",
ActionDelete: "delete a user skill",
},
},
"usage_event": { "usage_event": {
Actions: map[Action]ActionDefinition{ Actions: map[Action]ActionDefinition{
ActionCreate: "create a usage event", ActionCreate: "create a usage event",
+4 -2
View File
@@ -301,12 +301,14 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
Site: append( Site: append(
// Workspace dormancy and workspace are omitted. // Workspace dormancy and workspace are omitted.
// Workspace is specifically handled based on the opts.NoOwnerWorkspaceExec. // Workspace is specifically handled based on the opts.NoOwnerWorkspaceExec.
// Owners cannot access other users' secrets. // Owners can inspect and delete personal skills for operability and
allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUserSecret, ResourceUsageEvent, ResourceBoundaryUsage, ResourceAiSeat), // abuse handling, but cannot create or edit user-authored instructions.
allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUserSecret, ResourceUserSkill, ResourceUsageEvent, ResourceBoundaryUsage, ResourceAiSeat),
// This adds back in the Workspace permissions. // This adds back in the Workspace permissions.
Permissions(map[string][]policy.Action{ Permissions(map[string][]policy.Action{
ResourceWorkspace.Type: ownerWorkspaceActions, ResourceWorkspace.Type: ownerWorkspaceActions,
ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop, policy.ActionCreateAgent, policy.ActionDeleteAgent, policy.ActionUpdateAgent}, ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop, policy.ActionCreateAgent, policy.ActionDeleteAgent, policy.ActionUpdateAgent},
ResourceUserSkill.Type: {policy.ActionRead, policy.ActionDelete},
// PrebuiltWorkspaces are a subset of Workspaces. // PrebuiltWorkspaces are a subset of Workspaces.
// Explicitly setting PrebuiltWorkspace permissions for clarity. // Explicitly setting PrebuiltWorkspace permissions for clarity.
// Note: even without PrebuiltWorkspace permissions, access is still granted via Workspace permissions. // Note: even without PrebuiltWorkspace permissions, access is still granted via Workspace permissions.
+28
View File
@@ -1110,6 +1110,34 @@ func TestRolePermissions(t *testing.T) {
}, },
}, },
}, },
// Skills are user-authored instructions, not secrets. Owners can inspect
// and delete them, but only the user can create or update them.
{
Name: "UserSkillsReadDelete",
Actions: []policy.Action{policy.ActionRead, policy.ActionDelete},
Resource: rbac.ResourceUserSkill.WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, memberMe, agentsAccessUser},
false: {
orgAdmin,
otherOrgAdmin, orgAuditor, orgUserAdmin, orgTemplateAdmin,
templateAdmin, userAdmin, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin,
},
},
},
{
Name: "UserSkillsCreateUpdate",
Actions: []policy.Action{policy.ActionCreate, policy.ActionUpdate},
Resource: rbac.ResourceUserSkill.WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {memberMe, agentsAccessUser},
false: {
owner, orgAdmin,
otherOrgAdmin, orgAuditor, orgUserAdmin, orgTemplateAdmin,
templateAdmin, userAdmin, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin,
},
},
},
{ {
Name: "UsageEvents", Name: "UsageEvents",
Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate}, Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate},
+7
View File
@@ -53,6 +53,13 @@ var externalLowLevel = map[ScopeName]struct{}{
"user_secret:delete": {}, "user_secret:delete": {},
"user_secret:*": {}, "user_secret:*": {},
// User skills
"user_skill:read": {},
"user_skill:create": {},
"user_skill:update": {},
"user_skill:delete": {},
"user_skill:*": {},
// Tasks // Tasks
"task:create": {}, "task:create": {},
"task:read": {}, "task:read": {},
+12
View File
@@ -134,6 +134,10 @@ const (
ScopeUserSecretDelete ScopeName = "user_secret:delete" ScopeUserSecretDelete ScopeName = "user_secret:delete"
ScopeUserSecretRead ScopeName = "user_secret:read" ScopeUserSecretRead ScopeName = "user_secret:read"
ScopeUserSecretUpdate ScopeName = "user_secret:update" ScopeUserSecretUpdate ScopeName = "user_secret:update"
ScopeUserSkillCreate ScopeName = "user_skill:create"
ScopeUserSkillDelete ScopeName = "user_skill:delete"
ScopeUserSkillRead ScopeName = "user_skill:read"
ScopeUserSkillUpdate ScopeName = "user_skill:update"
ScopeWebpushSubscriptionCreate ScopeName = "webpush_subscription:create" ScopeWebpushSubscriptionCreate ScopeName = "webpush_subscription:create"
ScopeWebpushSubscriptionDelete ScopeName = "webpush_subscription:delete" ScopeWebpushSubscriptionDelete ScopeName = "webpush_subscription:delete"
ScopeWebpushSubscriptionRead ScopeName = "webpush_subscription:read" ScopeWebpushSubscriptionRead ScopeName = "webpush_subscription:read"
@@ -307,6 +311,10 @@ func (e ScopeName) Valid() bool {
ScopeUserSecretDelete, ScopeUserSecretDelete,
ScopeUserSecretRead, ScopeUserSecretRead,
ScopeUserSecretUpdate, ScopeUserSecretUpdate,
ScopeUserSkillCreate,
ScopeUserSkillDelete,
ScopeUserSkillRead,
ScopeUserSkillUpdate,
ScopeWebpushSubscriptionCreate, ScopeWebpushSubscriptionCreate,
ScopeWebpushSubscriptionDelete, ScopeWebpushSubscriptionDelete,
ScopeWebpushSubscriptionRead, ScopeWebpushSubscriptionRead,
@@ -481,6 +489,10 @@ func AllScopeNameValues() []ScopeName {
ScopeUserSecretDelete, ScopeUserSecretDelete,
ScopeUserSecretRead, ScopeUserSecretRead,
ScopeUserSecretUpdate, ScopeUserSecretUpdate,
ScopeUserSkillCreate,
ScopeUserSkillDelete,
ScopeUserSkillRead,
ScopeUserSkillUpdate,
ScopeWebpushSubscriptionCreate, ScopeWebpushSubscriptionCreate,
ScopeWebpushSubscriptionDelete, ScopeWebpushSubscriptionDelete,
ScopeWebpushSubscriptionRead, ScopeWebpushSubscriptionRead,
+354
View File
@@ -0,0 +1,354 @@
package coderd
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/x/skills"
"github.com/coder/coder/v2/codersdk"
)
const (
// personalSkillJSONEscapeExpansion is the maximum expansion for one byte in a JSON string.
personalSkillJSONEscapeExpansion = 6
// personalSkillRequestEnvelopeBytes leaves room for the surrounding JSON object.
personalSkillRequestEnvelopeBytes = 1024
// maxPersonalSkillRequestBytes allows worst-case JSON string escaping for
// otherwise valid raw skill content.
maxPersonalSkillRequestBytes = skills.MaxPersonalSkillSizeBytes*personalSkillJSONEscapeExpansion + personalSkillRequestEnvelopeBytes
// These names are raised by trigger functions with USING CONSTRAINT.
// They are not table CHECK constraints, so dbgen does not emit them in
// check_constraint.go.
userSkillsPerUserLimitConstraint database.CheckConstraint = "user_skills_per_user_limit"
userSkillUserDeletedConstraint database.CheckConstraint = "user_skill_user_deleted"
)
// @Summary Create a user skill
// @ID create-a-user-skill
// @Security CoderSessionToken
// @Accept json
// @Produce json
// @Tags Users
// @Param user path string true "User ID, username, or me"
// @Param request body codersdk.CreateUserSkillRequest true "Create user skill request"
// @Success 201 {object} codersdk.UserSkill
// @Router /api/experimental/users/{user}/skills [post]
// @x-apidocgen {"skip": true}
func (api *API) postUserSkill(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
user = httpmw.UserParam(r)
auditor = api.Auditor.Load()
aReq, commitAudit = audit.InitRequest[database.UserSkill](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionCreate,
})
)
defer commitAudit()
r.Body = http.MaxBytesReader(rw, r.Body, maxPersonalSkillRequestBytes)
var req codersdk.CreateUserSkillRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
parsedSkill, err := skills.ParsePersonalSkillMarkdown([]byte(req.Content))
if err != nil {
writeInvalidUserSkillContent(ctx, rw, err)
return
}
params := database.InsertUserSkillParams{
ID: uuid.New(),
UserID: user.ID,
Name: parsedSkill.Name,
Description: parsedSkill.Description,
Content: req.Content,
}
skill, err := api.Database.InsertUserSkill(ctx, params)
if err != nil {
if httpapi.IsUnauthorizedError(err) {
httpapi.Forbidden(rw)
return
}
if database.IsCheckViolation(err, userSkillUserDeletedConstraint) {
writeCannotCreateUserSkillForDeletedUser(ctx, rw)
return
}
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
if database.IsCheckViolation(err, userSkillsPerUserLimitConstraint) {
writeUserSkillLimitReached(ctx, rw)
return
}
if database.IsUniqueViolation(err, database.UniqueUserSkillsUserIDNameIndex) {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "A skill with that name already exists.",
Detail: err.Error(),
})
return
}
httpapi.InternalServerError(rw, err)
return
}
aReq.New = skill
httpapi.Write(ctx, rw, http.StatusCreated, db2sdk.UserSkill(skill))
}
// @Summary List user skills
// @ID list-user-skills
// @Security CoderSessionToken
// @Produce json
// @Tags Users
// @Param user path string true "User ID, username, or me"
// @Success 200 {array} codersdk.UserSkillMetadata
// @Router /api/experimental/users/{user}/skills [get]
// @x-apidocgen {"skip": true}
func (api *API) getUserSkills(rw http.ResponseWriter, r *http.Request) { //nolint:revive // Method name matches route.
ctx := r.Context()
user := httpmw.UserParam(r)
rows, err := api.Database.ListUserSkillMetadataByUserID(ctx, user.ID)
if err != nil {
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.InternalServerError(rw, err)
return
}
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.UserSkillMetadataList(rows))
}
// @Summary Get a user skill by name
// @ID get-a-user-skill-by-name
// @Security CoderSessionToken
// @Produce json
// @Tags Users
// @Param user path string true "User ID, username, or me"
// @Param skillName path string true "Skill name"
// @Success 200 {object} codersdk.UserSkill
// @Router /api/experimental/users/{user}/skills/{skillName} [get]
// @x-apidocgen {"skip": true}
func (api *API) getUserSkill(rw http.ResponseWriter, r *http.Request) { //nolint:revive // Method name matches route.
ctx := r.Context()
user := httpmw.UserParam(r)
name := chi.URLParam(r, "skillName")
skill, err := api.Database.GetUserSkillByUserIDAndName(ctx, database.GetUserSkillByUserIDAndNameParams{
UserID: user.ID,
Name: name,
})
if err != nil {
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.InternalServerError(rw, err)
return
}
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.UserSkill(skill))
}
// @Summary Update a user skill
// @ID update-a-user-skill
// @Security CoderSessionToken
// @Accept json
// @Produce json
// @Tags Users
// @Param user path string true "User ID, username, or me"
// @Param skillName path string true "Skill name"
// @Param request body codersdk.UpdateUserSkillRequest true "Update user skill request"
// @Success 200 {object} codersdk.UserSkill
// @Router /api/experimental/users/{user}/skills/{skillName} [patch]
// @x-apidocgen {"skip": true}
func (api *API) patchUserSkill(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
user = httpmw.UserParam(r)
name = chi.URLParam(r, "skillName")
auditor = api.Auditor.Load()
aReq, commitAudit = audit.InitRequest[database.UserSkill](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionWrite,
})
)
defer commitAudit()
r.Body = http.MaxBytesReader(rw, r.Body, maxPersonalSkillRequestBytes)
var req codersdk.UpdateUserSkillRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
parsedSkill, err := skills.ParsePersonalSkillMarkdown([]byte(req.Content))
if err != nil {
writeInvalidUserSkillContent(ctx, rw, err)
return
}
if parsedSkill.Name != name {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Skill name in path does not match frontmatter name.",
Detail: fmt.Sprintf("path has %q, frontmatter has %q", name, parsedSkill.Name),
})
return
}
params := database.UpdateUserSkillByUserIDAndNameParams{
UserID: user.ID,
Name: name,
Description: parsedSkill.Description,
Content: req.Content,
}
var (
skill database.UserSkill
oldSkill database.UserSkill
)
err = api.Database.InTx(func(tx database.Store) error {
fetched, err := tx.GetUserSkillByUserIDAndName(ctx, database.GetUserSkillByUserIDAndNameParams{
UserID: user.ID,
Name: name,
})
if err != nil {
return xerrors.Errorf("fetch user skill: %w", err)
}
updated, err := tx.UpdateUserSkillByUserIDAndName(ctx, params)
if err != nil {
return xerrors.Errorf("update user skill: %w", err)
}
oldSkill = fetched
skill = updated
return nil
}, nil)
if err != nil {
if httpapi.IsUnauthorizedError(err) {
httpapi.Forbidden(rw)
return
}
if database.IsCheckViolation(err, userSkillUserDeletedConstraint) {
writeCannotModifyUserSkillForDeletedUser(ctx, rw)
return
}
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.InternalServerError(rw, err)
return
}
// Assign audit state after InTx returns so the audit log can never
// claim a rolled-back update was committed.
aReq.Old = oldSkill
aReq.New = skill
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.UserSkill(skill))
}
// @Summary Delete a user skill
// @ID delete-a-user-skill
// @Security CoderSessionToken
// @Tags Users
// @Param user path string true "User ID, username, or me"
// @Param skillName path string true "Skill name"
// @Success 204
// @Router /api/experimental/users/{user}/skills/{skillName} [delete]
// @x-apidocgen {"skip": true}
func (api *API) deleteUserSkill(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
user = httpmw.UserParam(r)
name = chi.URLParam(r, "skillName")
auditor = api.Auditor.Load()
aReq, commitAudit = audit.InitRequest[database.UserSkill](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionDelete,
})
)
defer commitAudit()
deleted, err := api.Database.DeleteUserSkillByUserIDAndName(ctx, database.DeleteUserSkillByUserIDAndNameParams{
UserID: user.ID,
Name: name,
})
if err != nil {
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.InternalServerError(rw, err)
return
}
aReq.Old = deleted
rw.WriteHeader(http.StatusNoContent)
}
func writeCannotCreateUserSkillForDeletedUser(ctx context.Context, rw http.ResponseWriter) {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Cannot create skills for deleted users.",
Detail: "This user has been deleted and cannot be modified.",
})
}
func writeCannotModifyUserSkillForDeletedUser(ctx context.Context, rw http.ResponseWriter) {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Cannot modify skills for deleted users.",
Detail: "This user has been deleted and cannot be modified.",
})
}
func writeUserSkillLimitReached(ctx context.Context, rw http.ResponseWriter) {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Personal skill limit reached.",
Detail: fmt.Sprintf(
"Each user can have at most %d personal skills.",
skills.MaxPersonalSkillsPerUser,
),
})
}
func writeInvalidUserSkillContent(ctx context.Context, rw http.ResponseWriter, err error) {
message := "Invalid skill content."
switch {
case errors.Is(err, skills.ErrInvalidSkillName):
message = "Invalid skill name."
case errors.Is(err, skills.ErrSkillBodyRequired):
message = "Skill body is required."
case errors.Is(err, skills.ErrSkillTooLarge):
message = "Skill content is too large."
case errors.Is(err, skills.ErrSkillDescriptionTooLarge):
message = "Skill description is too large."
}
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: message,
Detail: err.Error(),
})
}
+43
View File
@@ -0,0 +1,43 @@
package coderd_test
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
func TestPatchUserSkill(t *testing.T) {
t.Parallel()
ownerRawClient := coderdtest.New(t, nil)
firstUser := coderdtest.CreateFirstUser(t, ownerRawClient)
memberRawClient, member := coderdtest.CreateAnotherUser(t, ownerRawClient, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberRawClient)
auditorRawClient, _ := coderdtest.CreateAnotherUser(t, ownerRawClient, firstUser.OrganizationID, rbac.RoleAuditor())
auditorClient := codersdk.NewExperimentalClient(auditorRawClient)
ctx := testutil.Context(t, testutil.WaitMedium)
_, err := memberClient.CreateUserSkill(ctx, codersdk.Me, codersdk.CreateUserSkillRequest{
Content: userSkillTestContent("forbidden-skill", "Original body."),
})
require.NoError(t, err)
_, err = auditorClient.UpdateUserSkill(ctx, member.ID.String(), "forbidden-skill", codersdk.UpdateUserSkillRequest{
Content: userSkillTestContent("forbidden-skill", "Updated body."),
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
assert.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
}
func userSkillTestContent(name string, body string) string {
return "---\nname: " + name + "\ndescription: Test skill\n---\n" + body + "\n"
}
+1 -1
View File
@@ -26,7 +26,7 @@
// and workspace/<name> for the workspace skill. One source must not silently // and workspace/<name> for the workspace skill. One source must not silently
// override the other. // override the other.
// //
// Site admins can read and modify personal skill content. Personal skills are // Site admins can read and delete personal skill content. Personal skills are
// user-authored instructions, not secret material. Audit records can include // user-authored instructions, not secret material. Audit records can include
// raw Markdown content diffs alongside the actor, target user, and relevant // raw Markdown content diffs alongside the actor, target user, and relevant
// metadata. // metadata.
+22 -2
View File
@@ -18,6 +18,14 @@ const MaxPersonalSkillSizeBytes = workspacesdk.MaxSkillMetaBytes
// personal skill upload. Skill names are also used in URL paths. // personal skill upload. Skill names are also used in URL paths.
const MaxPersonalSkillNameBytes = 256 const MaxPersonalSkillNameBytes = 256
// MaxPersonalSkillDescriptionBytes is the maximum frontmatter description size
// accepted for a personal skill upload.
const MaxPersonalSkillDescriptionBytes = 4096
// MaxPersonalSkillsPerUser is the maximum number of personal skills a user may
// create.
const MaxPersonalSkillsPerUser = 100
// Source identifies where a skill came from. // Source identifies where a skill came from.
type Source string type Source string
@@ -36,6 +44,8 @@ var (
ErrSkillBodyRequired = xerrors.New("skill body is required") ErrSkillBodyRequired = xerrors.New("skill body is required")
// ErrSkillTooLarge indicates that the raw skill Markdown is too large. // ErrSkillTooLarge indicates that the raw skill Markdown is too large.
ErrSkillTooLarge = xerrors.New("skill is too large") ErrSkillTooLarge = xerrors.New("skill is too large")
// ErrSkillDescriptionTooLarge indicates that the description is too large.
ErrSkillDescriptionTooLarge = xerrors.New("skill description is too large")
// ErrSkillNotFound indicates that a skill lookup did not match any alias. // ErrSkillNotFound indicates that a skill lookup did not match any alias.
ErrSkillNotFound = xerrors.New("skill not found") ErrSkillNotFound = xerrors.New("skill not found")
// ErrSkillAmbiguous indicates that a skill lookup matched multiple sources. // ErrSkillAmbiguous indicates that a skill lookup matched multiple sources.
@@ -65,8 +75,9 @@ type ResolvedSkill struct {
// ParsePersonalSkillMarkdown parses raw personal skill Markdown and enforces // ParsePersonalSkillMarkdown parses raw personal skill Markdown and enforces
// the personal skill contract. The raw size must not exceed // the personal skill contract. The raw size must not exceed
// MaxPersonalSkillSizeBytes, frontmatter must contain a valid kebab-case name, // MaxPersonalSkillSizeBytes, frontmatter must contain a valid kebab-case name,
// the skill name must not exceed MaxPersonalSkillNameBytes, and the body after // the skill name must not exceed MaxPersonalSkillNameBytes, the description must
// frontmatter must be non-empty. // not exceed MaxPersonalSkillDescriptionBytes, and the body after frontmatter
// must be non-empty.
func ParsePersonalSkillMarkdown(raw []byte) (ParsedSkill, error) { func ParsePersonalSkillMarkdown(raw []byte) (ParsedSkill, error) {
if len(raw) > MaxPersonalSkillSizeBytes { if len(raw) > MaxPersonalSkillSizeBytes {
return ParsedSkill{}, xerrors.Errorf( return ParsedSkill{}, xerrors.Errorf(
@@ -102,6 +113,15 @@ func ParsePersonalSkillMarkdown(raw []byte) (ParsedSkill, error) {
MaxPersonalSkillNameBytes, MaxPersonalSkillNameBytes,
) )
} }
descriptionBytes := len(description)
if descriptionBytes > MaxPersonalSkillDescriptionBytes {
return ParsedSkill{}, xerrors.Errorf(
"%w: got %d bytes, maximum is %d bytes",
ErrSkillDescriptionTooLarge,
descriptionBytes,
MaxPersonalSkillDescriptionBytes,
)
}
if strings.TrimSpace(body) == "" { if strings.TrimSpace(body) == "" {
return ParsedSkill{}, xerrors.Errorf( return ParsedSkill{}, xerrors.Errorf(
"%w: skill %q has no content after frontmatter", "%w: skill %q has no content after frontmatter",
+13
View File
@@ -91,6 +91,19 @@ func TestParsePersonalSkillMarkdown(t *testing.T) {
require.ErrorContains(t, err, "maximum is 256 bytes") require.ErrorContains(t, err, "maximum is 256 bytes")
}) })
t.Run("DescriptionTooLong", func(t *testing.T) {
t.Parallel()
_, err := skills.ParsePersonalSkillMarkdown([]byte(personalSkillMarkdownForTest(
"my-skill",
strings.Repeat("a", skills.MaxPersonalSkillDescriptionBytes+1),
"Body.",
)))
require.ErrorIs(t, err, skills.ErrSkillDescriptionTooLarge)
require.ErrorContains(t, err, "maximum is 4096 bytes")
})
t.Run("EmptyBody", func(t *testing.T) { t.Run("EmptyBody", func(t *testing.T) {
t.Parallel() t.Parallel()
+10
View File
@@ -182,6 +182,11 @@ const (
APIKeyScopeUserSecretDelete APIKeyScope = "user_secret:delete" APIKeyScopeUserSecretDelete APIKeyScope = "user_secret:delete"
APIKeyScopeUserSecretRead APIKeyScope = "user_secret:read" APIKeyScopeUserSecretRead APIKeyScope = "user_secret:read"
APIKeyScopeUserSecretUpdate APIKeyScope = "user_secret:update" APIKeyScopeUserSecretUpdate APIKeyScope = "user_secret:update"
APIKeyScopeUserSkillAll APIKeyScope = "user_skill:*"
APIKeyScopeUserSkillCreate APIKeyScope = "user_skill:create"
APIKeyScopeUserSkillDelete APIKeyScope = "user_skill:delete"
APIKeyScopeUserSkillRead APIKeyScope = "user_skill:read"
APIKeyScopeUserSkillUpdate APIKeyScope = "user_skill:update"
APIKeyScopeWebpushSubscriptionAll APIKeyScope = "webpush_subscription:*" APIKeyScopeWebpushSubscriptionAll APIKeyScope = "webpush_subscription:*"
APIKeyScopeWebpushSubscriptionCreate APIKeyScope = "webpush_subscription:create" APIKeyScopeWebpushSubscriptionCreate APIKeyScope = "webpush_subscription:create"
APIKeyScopeWebpushSubscriptionDelete APIKeyScope = "webpush_subscription:delete" APIKeyScopeWebpushSubscriptionDelete APIKeyScope = "webpush_subscription:delete"
@@ -267,6 +272,11 @@ var PublicAPIKeyScopes = []APIKeyScope{
APIKeyScopeUserSecretDelete, APIKeyScopeUserSecretDelete,
APIKeyScopeUserSecretRead, APIKeyScopeUserSecretRead,
APIKeyScopeUserSecretUpdate, APIKeyScopeUserSecretUpdate,
APIKeyScopeUserSkillAll,
APIKeyScopeUserSkillCreate,
APIKeyScopeUserSkillDelete,
APIKeyScopeUserSkillRead,
APIKeyScopeUserSkillUpdate,
APIKeyScopeWorkspaceAll, APIKeyScopeWorkspaceAll,
APIKeyScopeWorkspaceApplicationConnect, APIKeyScopeWorkspaceApplicationConnect,
APIKeyScopeWorkspaceCreate, APIKeyScopeWorkspaceCreate,
+3
View File
@@ -51,6 +51,7 @@ const (
ResourceTypeGroupAIBudget ResourceType = "group_ai_budget" ResourceTypeGroupAIBudget ResourceType = "group_ai_budget"
ResourceTypeChat ResourceType = "chat" ResourceTypeChat ResourceType = "chat"
ResourceTypeUserSecret ResourceType = "user_secret" ResourceTypeUserSecret ResourceType = "user_secret"
ResourceTypeUserSkill ResourceType = "user_skill"
) )
func (r ResourceType) FriendlyString() string { func (r ResourceType) FriendlyString() string {
@@ -121,6 +122,8 @@ func (r ResourceType) FriendlyString() string {
return "chat" return "chat"
case ResourceTypeUserSecret: case ResourceTypeUserSecret:
return "user secret" return "user secret"
case ResourceTypeUserSkill:
return "user skill"
default: default:
return "unknown" return "unknown"
} }
+2
View File
@@ -45,6 +45,7 @@ const (
ResourceUsageEvent RBACResource = "usage_event" ResourceUsageEvent RBACResource = "usage_event"
ResourceUser RBACResource = "user" ResourceUser RBACResource = "user"
ResourceUserSecret RBACResource = "user_secret" ResourceUserSecret RBACResource = "user_secret"
ResourceUserSkill RBACResource = "user_skill"
ResourceWebpushSubscription RBACResource = "webpush_subscription" ResourceWebpushSubscription RBACResource = "webpush_subscription"
ResourceWorkspace RBACResource = "workspace" ResourceWorkspace RBACResource = "workspace"
ResourceWorkspaceAgentDevcontainers RBACResource = "workspace_agent_devcontainers" ResourceWorkspaceAgentDevcontainers RBACResource = "workspace_agent_devcontainers"
@@ -120,6 +121,7 @@ var RBACResourceActions = map[RBACResource][]RBACAction{
ResourceUsageEvent: {ActionCreate, ActionRead, ActionUpdate}, ResourceUsageEvent: {ActionCreate, ActionRead, ActionUpdate},
ResourceUser: {ActionCreate, ActionDelete, ActionRead, ActionReadPersonal, ActionUpdate, ActionUpdatePersonal}, ResourceUser: {ActionCreate, ActionDelete, ActionRead, ActionReadPersonal, ActionUpdate, ActionUpdatePersonal},
ResourceUserSecret: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceUserSecret: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
ResourceUserSkill: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
ResourceWebpushSubscription: {ActionCreate, ActionDelete, ActionRead}, ResourceWebpushSubscription: {ActionCreate, ActionDelete, ActionRead},
ResourceWorkspace: {ActionApplicationConnect, ActionCreate, ActionCreateAgent, ActionDelete, ActionDeleteAgent, ActionRead, ActionShare, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate, ActionUpdateAgent}, ResourceWorkspace: {ActionApplicationConnect, ActionCreate, ActionCreateAgent, ActionDelete, ActionDeleteAgent, ActionRead, ActionShare, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate, ActionUpdateAgent},
ResourceWorkspaceAgentDevcontainers: {ActionCreate}, ResourceWorkspaceAgentDevcontainers: {ActionCreate},
+120
View File
@@ -0,0 +1,120 @@
package codersdk
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
"github.com/google/uuid"
)
// UserSkillMetadata represents a user skill without its raw Markdown content.
type UserSkillMetadata struct {
ID uuid.UUID `json:"id" format:"uuid"`
Name string `json:"name"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
}
// UserSkill represents a user skill with its raw Markdown content.
type UserSkill struct {
UserSkillMetadata
Content string `json:"content"`
}
// CreateUserSkillRequest is the payload for creating a user skill.
type CreateUserSkillRequest struct {
// Content must be SKILL.md-format Markdown with YAML frontmatter. The
// frontmatter must include name, may include description, and must be
// followed by a non-empty body.
Content string `json:"content"`
}
// UpdateUserSkillRequest is the payload for updating a user skill.
type UpdateUserSkillRequest struct {
// Content must be SKILL.md-format Markdown with YAML frontmatter. The
// frontmatter must include name, may include description, and must be
// followed by a non-empty body.
Content string `json:"content"`
}
func userSkillsPath(user string) string {
return fmt.Sprintf("/api/experimental/users/%s/skills", url.PathEscape(user))
}
func userSkillPath(user string, name string) string {
return fmt.Sprintf("%s/%s", userSkillsPath(user), url.PathEscape(name))
}
// CreateUserSkill creates a user skill from raw Markdown content.
func (c *ExperimentalClient) CreateUserSkill(ctx context.Context, user string, req CreateUserSkillRequest) (UserSkill, error) {
res, err := c.Request(ctx, http.MethodPost, userSkillsPath(user), req)
if err != nil {
return UserSkill{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return UserSkill{}, ReadBodyAsError(res)
}
var skill UserSkill
return skill, json.NewDecoder(res.Body).Decode(&skill)
}
// UserSkills lists user skill metadata for the specified user.
func (c *ExperimentalClient) UserSkills(ctx context.Context, user string) ([]UserSkillMetadata, error) {
res, err := c.Request(ctx, http.MethodGet, userSkillsPath(user), nil)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, ReadBodyAsError(res)
}
var skills []UserSkillMetadata
return skills, json.NewDecoder(res.Body).Decode(&skills)
}
// UserSkillByName returns a user skill by name.
func (c *ExperimentalClient) UserSkillByName(ctx context.Context, user string, name string) (UserSkill, error) {
res, err := c.Request(ctx, http.MethodGet, userSkillPath(user, name), nil)
if err != nil {
return UserSkill{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return UserSkill{}, ReadBodyAsError(res)
}
var skill UserSkill
return skill, json.NewDecoder(res.Body).Decode(&skill)
}
// UpdateUserSkill replaces a user skill's raw Markdown content.
func (c *ExperimentalClient) UpdateUserSkill(ctx context.Context, user string, name string, req UpdateUserSkillRequest) (UserSkill, error) {
res, err := c.Request(ctx, http.MethodPatch, userSkillPath(user, name), req)
if err != nil {
return UserSkill{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return UserSkill{}, ReadBodyAsError(res)
}
var skill UserSkill
return skill, json.NewDecoder(res.Body).Decode(&skill)
}
// DeleteUserSkill deletes a user skill by name.
func (c *ExperimentalClient) DeleteUserSkill(ctx context.Context, user string, name string) error {
res, err := c.Request(ctx, http.MethodDelete, userSkillPath(user, name), nil)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}
+1
View File
@@ -42,6 +42,7 @@ We track the following resources:
| TemplateVersion<br><i>create, write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>archived</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_name</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>external_auth_providers</td><td>false</td></tr><tr><td>has_ai_task</td><td>false</td></tr><tr><td>has_external_agent</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>message</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>readme</td><td>true</td></tr><tr><td>source_example_id</td><td>false</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> | | TemplateVersion<br><i>create, write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>archived</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_name</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>external_auth_providers</td><td>false</td></tr><tr><td>has_ai_task</td><td>false</td></tr><tr><td>has_external_agent</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>message</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>readme</td><td>true</td></tr><tr><td>source_example_id</td><td>false</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
| User<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>avatar_url</td><td>false</td></tr><tr><td>chat_spend_limit_micros</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>email</td><td>true</td></tr><tr><td>github_com_user_id</td><td>false</td></tr><tr><td>hashed_one_time_passcode</td><td>false</td></tr><tr><td>hashed_password</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>is_service_account</td><td>true</td></tr><tr><td>is_system</td><td>true</td></tr><tr><td>last_seen_at</td><td>false</td></tr><tr><td>login_type</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>one_time_passcode_expires_at</td><td>true</td></tr><tr><td>quiet_hours_schedule</td><td>true</td></tr><tr><td>rbac_roles</td><td>true</td></tr><tr><td>status</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> | | User<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>avatar_url</td><td>false</td></tr><tr><td>chat_spend_limit_micros</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>email</td><td>true</td></tr><tr><td>github_com_user_id</td><td>false</td></tr><tr><td>hashed_one_time_passcode</td><td>false</td></tr><tr><td>hashed_password</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>is_service_account</td><td>true</td></tr><tr><td>is_system</td><td>true</td></tr><tr><td>last_seen_at</td><td>false</td></tr><tr><td>login_type</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>one_time_passcode_expires_at</td><td>true</td></tr><tr><td>quiet_hours_schedule</td><td>true</td></tr><tr><td>rbac_roles</td><td>true</td></tr><tr><td>status</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
| UserSecret<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>description</td><td>true</td></tr><tr><td>env_name</td><td>true</td></tr><tr><td>file_path</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr><tr><td>value</td><td>true</td></tr><tr><td>value_key_id</td><td>false</td></tr></tbody></table> | | UserSecret<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>description</td><td>true</td></tr><tr><td>env_name</td><td>true</td></tr><tr><td>file_path</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr><tr><td>value</td><td>true</td></tr><tr><td>value_key_id</td><td>false</td></tr></tbody></table> |
| UserSkill<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>content</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>description</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| WorkspaceBuild<br><i>start, stop</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>build_number</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>daily_cost</td><td>false</td></tr><tr><td>deadline</td><td>false</td></tr><tr><td>has_ai_task</td><td>false</td></tr><tr><td>has_external_agent</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>initiator_by_avatar_url</td><td>false</td></tr><tr><td>initiator_by_name</td><td>false</td></tr><tr><td>initiator_by_username</td><td>false</td></tr><tr><td>initiator_id</td><td>false</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>max_deadline</td><td>false</td></tr><tr><td>reason</td><td>false</td></tr><tr><td>template_version_id</td><td>true</td></tr><tr><td>template_version_preset_id</td><td>false</td></tr><tr><td>transition</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>workspace_id</td><td>false</td></tr></tbody></table> | | WorkspaceBuild<br><i>start, stop</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>build_number</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>daily_cost</td><td>false</td></tr><tr><td>deadline</td><td>false</td></tr><tr><td>has_ai_task</td><td>false</td></tr><tr><td>has_external_agent</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>initiator_by_avatar_url</td><td>false</td></tr><tr><td>initiator_by_name</td><td>false</td></tr><tr><td>initiator_by_username</td><td>false</td></tr><tr><td>initiator_id</td><td>false</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>max_deadline</td><td>false</td></tr><tr><td>reason</td><td>false</td></tr><tr><td>template_version_id</td><td>true</td></tr><tr><td>template_version_preset_id</td><td>false</td></tr><tr><td>transition</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>workspace_id</td><td>false</td></tr></tbody></table> |
| WorkspaceProxy<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>derp_enabled</td><td>true</td></tr><tr><td>derp_only</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>region_id</td><td>true</td></tr><tr><td>token_hashed_secret</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>url</td><td>true</td></tr><tr><td>version</td><td>true</td></tr><tr><td>wildcard_hostname</td><td>true</td></tr></tbody></table> | | WorkspaceProxy<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>derp_enabled</td><td>true</td></tr><tr><td>derp_only</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>region_id</td><td>true</td></tr><tr><td>token_hashed_secret</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>url</td><td>true</td></tr><tr><td>version</td><td>true</td></tr><tr><td>wildcard_hostname</td><td>true</td></tr></tbody></table> |
| WorkspaceTable<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>automatic_updates</td><td>true</td></tr><tr><td>autostart_schedule</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>deleting_at</td><td>true</td></tr><tr><td>dormant_at</td><td>true</td></tr><tr><td>favorite</td><td>true</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_used_at</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>next_start_at</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>ttl</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_acl</td><td>true</td></tr></tbody></table> | | WorkspaceTable<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>automatic_updates</td><td>true</td></tr><tr><td>autostart_schedule</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>deleting_at</td><td>true</td></tr><tr><td>dormant_at</td><td>true</td></tr><tr><td>favorite</td><td>true</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_used_at</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>next_start_at</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>ttl</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_acl</td><td>true</td></tr></tbody></table> |
+20 -20
View File
@@ -193,10 +193,10 @@ Status Code **200**
#### Enumerated Values #### Enumerated Values
| Property | Value(s) | | Property | Value(s) |
|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| |-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | | `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | | `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` |
To perform this operation, you must be authenticated. [Learn more](authentication.md). To perform this operation, you must be authenticated. [Learn more](authentication.md).
@@ -326,10 +326,10 @@ Status Code **200**
#### Enumerated Values #### Enumerated Values
| Property | Value(s) | | Property | Value(s) |
|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| |-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | | `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | | `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` |
To perform this operation, you must be authenticated. [Learn more](authentication.md). To perform this operation, you must be authenticated. [Learn more](authentication.md).
@@ -459,10 +459,10 @@ Status Code **200**
#### Enumerated Values #### Enumerated Values
| Property | Value(s) | | Property | Value(s) |
|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| |-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | | `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | | `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` |
To perform this operation, you must be authenticated. [Learn more](authentication.md). To perform this operation, you must be authenticated. [Learn more](authentication.md).
@@ -554,10 +554,10 @@ Status Code **200**
#### Enumerated Values #### Enumerated Values
| Property | Value(s) | | Property | Value(s) |
|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| |-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | | `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | | `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` |
To perform this operation, you must be authenticated. [Learn more](authentication.md). To perform this operation, you must be authenticated. [Learn more](authentication.md).
@@ -960,9 +960,9 @@ Status Code **200**
#### Enumerated Values #### Enumerated Values
| Property | Value(s) | | Property | Value(s) |
|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| |-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | | `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | | `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` |
To perform this operation, you must be authenticated. [Learn more](authentication.md). To perform this operation, you must be authenticated. [Learn more](authentication.md).
+83 -9
View File
File diff suppressed because one or more lines are too long
+5 -5
View File
@@ -865,11 +865,11 @@ Status Code **200**
#### Enumerated Values #### Enumerated Values
| Property | Value(s) | | Property | Value(s) |
|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| |--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | | `type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` |
| `login_type` | `github`, `oidc`, `password`, `token` | | `login_type` | `github`, `oidc`, `password`, `token` |
| `scope` | `all`, `application_connect` | | `scope` | `all`, `application_connect` |
To perform this operation, you must be authenticated. [Learn more](authentication.md). To perform this operation, you must be authenticated. [Learn more](authentication.md).
+23
View File
@@ -390,6 +390,29 @@ func Test_diff(t *testing.T) {
}, },
}) })
runDiffTests(t, []diffTest{
{
// User skill content is user-authored instruction text, not secret
// material, so audit diffs can include the content change.
name: "UserSkillContentTracked",
left: audit.Empty[database.UserSkill](),
right: database.UserSkill{
ID: uuid.UUID{1},
UserID: uuid.UUID{2},
Name: "review-guidance",
Description: "How to review private projects",
Content: "review markdown",
},
exp: audit.Map{
"id": audit.OldNew{Old: "", New: uuid.UUID{1}.String()},
"user_id": audit.OldNew{Old: "", New: uuid.UUID{2}.String()},
"name": audit.OldNew{Old: "", New: "review-guidance"},
"description": audit.OldNew{Old: "", New: "How to review private projects"},
"content": audit.OldNew{Old: "", New: "review markdown"},
},
},
})
runDiffTests(t, []diffTest{ runDiffTests(t, []diffTest{
{ {
name: "Create", name: "Create",
+10
View File
@@ -34,6 +34,7 @@ var AuditActionMap = map[string][]codersdk.AuditAction{
"AuditableGroupAiBudget": {codersdk.AuditActionWrite, codersdk.AuditActionDelete}, "AuditableGroupAiBudget": {codersdk.AuditActionWrite, codersdk.AuditActionDelete},
"Chat": {codersdk.AuditActionCreate, codersdk.AuditActionWrite}, // chats get 'archived' by users, not deleted. "Chat": {codersdk.AuditActionCreate, codersdk.AuditActionWrite}, // chats get 'archived' by users, not deleted.
"UserSecret": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, "UserSecret": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete},
"UserSkill": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete},
} }
type Action string type Action string
@@ -446,6 +447,15 @@ var auditableResourcesTypes = map[any]map[string]Action{
"plan_mode": ActionIgnore, // Can flip back and forth during a session. "plan_mode": ActionIgnore, // Can flip back and forth during a session.
"client_type": ActionIgnore, // Set at creation. "client_type": ActionIgnore, // Set at creation.
}, },
&database.UserSkill{}: {
"id": ActionTrack,
"user_id": ActionTrack,
"name": ActionTrack,
"description": ActionTrack,
"content": ActionTrack,
"created_at": ActionIgnore,
"updated_at": ActionIgnore,
},
&database.UserSecret{}: { &database.UserSecret{}: {
"id": ActionTrack, "id": ActionTrack,
"user_id": ActionTrack, "user_id": ActionTrack,
+6
View File
@@ -215,6 +215,12 @@ export const RBACResourceActions: Partial<
read: "read user secret metadata and value", read: "read user secret metadata and value",
update: "update user secret metadata and value", update: "update user secret metadata and value",
}, },
user_skill: {
create: "create a user skill",
delete: "delete a user skill",
read: "read user skill metadata and content",
update: "update user skill metadata and content",
},
webpush_subscription: { webpush_subscription: {
create: "create webpush subscriptions", create: "create webpush subscriptions",
delete: "delete webpush subscriptions", delete: "delete webpush subscriptions",
+60
View File
@@ -653,6 +653,11 @@ export type APIKeyScope =
| "user_secret:delete" | "user_secret:delete"
| "user_secret:read" | "user_secret:read"
| "user_secret:update" | "user_secret:update"
| "user_skill:*"
| "user_skill:create"
| "user_skill:delete"
| "user_skill:read"
| "user_skill:update"
| "user:update" | "user:update"
| "user:update_personal" | "user:update_personal"
| "webpush_subscription:*" | "webpush_subscription:*"
@@ -874,6 +879,11 @@ export const APIKeyScopes: APIKeyScope[] = [
"user_secret:delete", "user_secret:delete",
"user_secret:read", "user_secret:read",
"user_secret:update", "user_secret:update",
"user_skill:*",
"user_skill:create",
"user_skill:delete",
"user_skill:read",
"user_skill:update",
"user:update", "user:update",
"user:update_personal", "user:update_personal",
"webpush_subscription:*", "webpush_subscription:*",
@@ -3581,6 +3591,19 @@ export interface CreateUserSecretRequest {
readonly file_path?: string; readonly file_path?: string;
} }
// From codersdk/userskills.go
/**
* CreateUserSkillRequest is the payload for creating a user skill.
*/
export interface CreateUserSkillRequest {
/**
* Content must be SKILL.md-format Markdown with YAML frontmatter. The
* frontmatter must include name, may include description, and must be
* followed by a non-empty body.
*/
readonly content: string;
}
// From codersdk/workspaces.go // From codersdk/workspaces.go
export type CreateWorkspaceBuildReason = export type CreateWorkspaceBuildReason =
| "cli" | "cli"
@@ -6718,6 +6741,7 @@ export type RBACResource =
| "usage_event" | "usage_event"
| "user" | "user"
| "user_secret" | "user_secret"
| "user_skill"
| "webpush_subscription" | "webpush_subscription"
| "*" | "*"
| "workspace" | "workspace"
@@ -6767,6 +6791,7 @@ export const RBACResources: RBACResource[] = [
"usage_event", "usage_event",
"user", "user",
"user_secret", "user_secret",
"user_skill",
"webpush_subscription", "webpush_subscription",
"*", "*",
"workspace", "workspace",
@@ -6914,6 +6939,7 @@ export type ResourceType =
| "template_version" | "template_version"
| "user" | "user"
| "user_secret" | "user_secret"
| "user_skill"
| "workspace" | "workspace"
| "workspace_agent" | "workspace_agent"
| "workspace_app" | "workspace_app"
@@ -6948,6 +6974,7 @@ export const ResourceTypes: ResourceType[] = [
"template_version", "template_version",
"user", "user",
"user_secret", "user_secret",
"user_skill",
"workspace", "workspace",
"workspace_agent", "workspace_agent",
"workspace_app", "workspace_app",
@@ -8818,6 +8845,19 @@ export interface UpdateUserSecretRequest {
readonly file_path?: string; readonly file_path?: string;
} }
// From codersdk/userskills.go
/**
* UpdateUserSkillRequest is the payload for updating a user skill.
*/
export interface UpdateUserSkillRequest {
/**
* Content must be SKILL.md-format Markdown with YAML frontmatter. The
* frontmatter must include name, may include description, and must be
* followed by a non-empty body.
*/
readonly content: string;
}
// From codersdk/workspaces.go // From codersdk/workspaces.go
export interface UpdateWorkspaceACL { export interface UpdateWorkspaceACL {
/** /**
@@ -9235,6 +9275,26 @@ export interface UserSecret {
readonly updated_at: string; readonly updated_at: string;
} }
// From codersdk/userskills.go
/**
* UserSkill represents a user skill with its raw Markdown content.
*/
export interface UserSkill extends UserSkillMetadata {
readonly content: string;
}
// From codersdk/userskills.go
/**
* UserSkillMetadata represents a user skill without its raw Markdown content.
*/
export interface UserSkillMetadata {
readonly id: string;
readonly name: string;
readonly description: string;
readonly created_at: string;
readonly updated_at: string;
}
// From codersdk/users.go // From codersdk/users.go
export type UserStatus = "active" | "dormant" | "suspended"; export type UserStatus = "active" | "dormant" | "suspended";