diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 40201f7263..670fbbee31 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -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": { "get": { "produces": [ @@ -14802,6 +15023,11 @@ const docTemplate = `{ "user_secret:delete", "user_secret:read", "user_secret:update", + "user_skill:*", + "user_skill:create", + "user_skill:delete", + "user_skill:read", + "user_skill:update", "webpush_subscription:*", "webpush_subscription:create", "webpush_subscription:delete", @@ -15023,6 +15249,11 @@ const docTemplate = `{ "APIKeyScopeUserSecretDelete", "APIKeyScopeUserSecretRead", "APIKeyScopeUserSecretUpdate", + "APIKeyScopeUserSkillAll", + "APIKeyScopeUserSkillCreate", + "APIKeyScopeUserSkillDelete", + "APIKeyScopeUserSkillRead", + "APIKeyScopeUserSkillUpdate", "APIKeyScopeWebpushSubscriptionAll", "APIKeyScopeWebpushSubscriptionCreate", "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": { "type": "string", "enum": [ @@ -21537,6 +21777,7 @@ const docTemplate = `{ "usage_event", "user", "user_secret", + "user_skill", "webpush_subscription", "workspace", "workspace_agent_devcontainers", @@ -21586,6 +21827,7 @@ const docTemplate = `{ "ResourceUsageEvent", "ResourceUser", "ResourceUserSecret", + "ResourceUserSkill", "ResourceWebpushSubscription", "ResourceWorkspace", "ResourceWorkspaceAgentDevcontainers", @@ -21811,7 +22053,8 @@ const docTemplate = `{ "ai_provider_key", "group_ai_budget", "chat", - "user_secret" + "user_secret", + "user_skill" ], "x-enum-varnames": [ "ResourceTypeTemplate", @@ -21845,7 +22088,8 @@ const docTemplate = `{ "ResourceTypeAIProviderKey", "ResourceTypeGroupAIBudget", "ResourceTypeChat", - "ResourceTypeUserSecret" + "ResourceTypeUserSecret", + "ResourceTypeUserSkill" ] }, "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": { "type": "object", "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": { "type": "string", "enum": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 3074103b83..d35fbb4f82 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -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": { "get": { "produces": ["application/json"], @@ -13254,6 +13453,11 @@ "user_secret:delete", "user_secret:read", "user_secret:update", + "user_skill:*", + "user_skill:create", + "user_skill:delete", + "user_skill:read", + "user_skill:update", "webpush_subscription:*", "webpush_subscription:create", "webpush_subscription:delete", @@ -13475,6 +13679,11 @@ "APIKeyScopeUserSecretDelete", "APIKeyScopeUserSecretRead", "APIKeyScopeUserSecretUpdate", + "APIKeyScopeUserSkillAll", + "APIKeyScopeUserSkillCreate", + "APIKeyScopeUserSkillDelete", + "APIKeyScopeUserSkillRead", + "APIKeyScopeUserSkillUpdate", "APIKeyScopeWebpushSubscriptionAll", "APIKeyScopeWebpushSubscriptionCreate", "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": { "type": "string", "enum": [ @@ -19749,6 +19967,7 @@ "usage_event", "user", "user_secret", + "user_skill", "webpush_subscription", "workspace", "workspace_agent_devcontainers", @@ -19798,6 +20017,7 @@ "ResourceUsageEvent", "ResourceUser", "ResourceUserSecret", + "ResourceUserSkill", "ResourceWebpushSubscription", "ResourceWorkspace", "ResourceWorkspaceAgentDevcontainers", @@ -20013,7 +20233,8 @@ "ai_provider_key", "group_ai_budget", "chat", - "user_secret" + "user_secret", + "user_skill" ], "x-enum-varnames": [ "ResourceTypeTemplate", @@ -20047,7 +20268,8 @@ "ResourceTypeAIProviderKey", "ResourceTypeGroupAIBudget", "ResourceTypeChat", - "ResourceTypeUserSecret" + "ResourceTypeUserSecret", + "ResourceTypeUserSkill" ] }, "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": { "type": "object", "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": { "type": "string", "enum": ["active", "dormant", "suspended"], diff --git a/coderd/audit/diff.go b/coderd/audit/diff.go index b4382c79a3..a26924552b 100644 --- a/coderd/audit/diff.go +++ b/coderd/audit/diff.go @@ -38,7 +38,8 @@ type Auditable interface { database.AIProviderKey | database.Chat | database.AuditableGroupAiBudget | - database.UserSecret + database.UserSecret | + database.UserSkill } // Map is a map of changed fields in an audited resource. It maps field names to diff --git a/coderd/audit/request.go b/coderd/audit/request.go index 845265f229..0f99fe4d6a 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -153,6 +153,8 @@ func ResourceTarget[T Auditable](tgt T) string { return typed.ID.String()[:8] case database.UserSecret: return typed.Name + case database.UserSkill: + return typed.Name default: panic(fmt.Sprintf("unknown resource %T for ResourceTarget", tgt)) } @@ -229,6 +231,8 @@ func ResourceID[T Auditable](tgt T) uuid.UUID { return typed.ID case database.UserSecret: return typed.ID + case database.UserSkill: + return typed.ID default: panic(fmt.Sprintf("unknown resource %T for ResourceID", tgt)) } @@ -296,6 +300,8 @@ func ResourceType[T Auditable](tgt T) database.ResourceType { return database.ResourceTypeChat case database.UserSecret: return database.ResourceTypeUserSecret + case database.UserSkill: + return database.ResourceTypeUserSkill default: panic(fmt.Sprintf("unknown resource %T for ResourceType", typed)) } @@ -372,6 +378,9 @@ func ResourceRequiresOrgID[T Auditable]() bool { case database.UserSecret: // User secrets are global to the user across organizations. return false + case database.UserSkill: + // User skills are global to the user across organizations. + return false default: panic(fmt.Sprintf("unknown resource %T for ResourceRequiresOrgID", tgt)) } diff --git a/coderd/coderd.go b/coderd/coderd.go index 0c2c84bf93..4d51087c0f 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -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.Use( apiKeyMiddleware, diff --git a/coderd/database/check_constraint.go b/coderd/database/check_constraint.go index 993333e416..f2d49c326f 100644 --- a/coderd/database/check_constraint.go +++ b/coderd/database/check_constraint.go @@ -45,4 +45,8 @@ const ( CheckValidationMonotonicOrder CheckConstraint = "validation_monotonic_order" // template_version_parameters CheckUsageEventTypeCheck CheckConstraint = "usage_event_type_check" // usage_events 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 ) diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index e485ade9ff..0f9fcf4380 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -2046,3 +2046,37 @@ func UserSecrets(secrets []database.ListUserSecretsRow) []codersdk.UserSecret { } 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 +} diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 1ffa818dde..9367e19f5b 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2290,6 +2290,14 @@ func (q *querier) DeleteUserSecretByUserIDAndName(ctx context.Context, arg datab 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 { if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceWebpushSubscription.WithOwner(arg.UserID.String())); err != nil { return err @@ -4687,6 +4695,14 @@ func (q *querier) GetUserShellToolDisplayMode(ctx context.Context, userID uuid.U 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) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUser); err != nil { return nil, err @@ -5780,6 +5796,14 @@ func (q *querier) InsertUserLink(ctx context.Context, arg database.InsertUserLin 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) { if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceWorkspaceAgentResourceMonitor); err != nil { return database.WorkspaceAgentVolumeResourceMonitor{}, err @@ -6139,6 +6163,14 @@ func (q *querier) ListUserSecretsWithValues(ctx context.Context, userID uuid.UUI 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) { workspace, err := q.db.GetWorkspaceByID(ctx, workspaceID) if err != nil { @@ -7446,6 +7478,14 @@ func (q *querier) UpdateUserShellToolDisplayMode(ctx context.Context, arg databa 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) { fetch := func(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) { return q.db.GetUserByID(ctx, arg.ID) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index d93e735348..52bab5bc8a 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -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() { s.Run("InsertUsageEvent", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { params := database.InsertUsageEventParams{ diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 9233857fce..03f452bdf9 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -809,6 +809,14 @@ func (m queryMetricsStore) DeleteUserSecretByUserIDAndName(ctx context.Context, 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 { start := time.Now() r0 := m.s.DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx, arg) @@ -3073,6 +3081,14 @@ func (m queryMetricsStore) GetUserShellToolDisplayMode(ctx context.Context, user 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) { start := time.Now() r0, r1 := m.s.GetUserStatusCounts(ctx, arg) @@ -4097,6 +4113,14 @@ func (m queryMetricsStore) InsertUserLink(ctx context.Context, arg database.Inse 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) { start := time.Now() r0, r1 := m.s.InsertVolumeResourceMonitor(ctx, arg) @@ -4409,6 +4433,14 @@ func (m queryMetricsStore) ListUserSecretsWithValues(ctx context.Context, userID 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) { start := time.Now() r0, r1 := m.s.ListWorkspaceAgentPortShares(ctx, workspaceID) @@ -5329,6 +5361,14 @@ func (m queryMetricsStore) UpdateUserShellToolDisplayMode(ctx context.Context, a 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) { start := time.Now() r0, r1 := m.s.UpdateUserStatus(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 3ab878dc01..e108093376 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -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) } +// 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. func (m *MockStore) DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg database.DeleteWebpushSubscriptionByUserIDAndEndpointParams) error { 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) } +// 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. func (m *MockStore) GetUserStatusCounts(ctx context.Context, arg database.GetUserStatusCountsParams) ([]database.GetUserStatusCountsRow, error) { 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) } +// 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. func (m *MockStore) InsertVolumeResourceMonitor(ctx context.Context, arg database.InsertVolumeResourceMonitorParams) (database.WorkspaceAgentVolumeResourceMonitor, error) { 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) } +// 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. func (m *MockStore) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]database.WorkspaceAgentPortShare, error) { 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) } +// 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. func (m *MockStore) UpdateUserStatus(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 7b4ce53fac..89148fb938 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -243,7 +243,12 @@ CREATE TYPE api_key_scope AS ENUM ( 'ai_provider:delete', 'ai_provider:read', '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 ( @@ -553,7 +558,8 @@ CREATE TYPE resource_type AS ENUM ( 'user_secret', 'ai_provider', 'ai_provider_key', - 'group_ai_budget' + 'group_ai_budget', + 'user_skill' ); CREATE TYPE shareable_workspace_owners AS ENUM ( @@ -771,31 +777,37 @@ CREATE FUNCTION delete_deleted_user_resources() RETURNS trigger AS $$ DECLARE BEGIN - IF (NEW.deleted) THEN - -- Remove their api_keys - DELETE FROM api_keys - WHERE user_id = OLD.id; + 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_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 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; + -- 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; $$; @@ -818,6 +830,32 @@ BEGIN 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 LANGUAGE plpgsql AS $$ @@ -935,6 +973,25 @@ BEGIN 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 LANGUAGE plpgsql AS $$ @@ -3015,6 +3072,20 @@ CREATE TABLE user_secrets ( 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 ( id uuid DEFAULT gen_random_uuid() NOT NULL, user_id uuid NOT NULL, @@ -3806,6 +3877,9 @@ ALTER TABLE ONLY user_links ALTER TABLE ONLY user_secrets 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 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_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_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_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 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 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 ADD CONSTRAINT user_status_changes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index f989f19046..7bbf92d9be 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -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; 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); + 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); 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; diff --git a/coderd/database/migrations/000502_user_skills.down.sql b/coderd/database/migrations/000502_user_skills.down.sql new file mode 100644 index 0000000000..fd3c71159c --- /dev/null +++ b/coderd/database/migrations/000502_user_skills.down.sql @@ -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; diff --git a/coderd/database/migrations/000502_user_skills.up.sql b/coderd/database/migrations/000502_user_skills.up.sql new file mode 100644 index 0000000000..0a0b788991 --- /dev/null +++ b/coderd/database/migrations/000502_user_skills.up.sql @@ -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:*'; diff --git a/coderd/database/migrations/testdata/fixtures/000502_user_skills.up.sql b/coderd/database/migrations/testdata/fixtures/000502_user_skills.up.sql new file mode 100644 index 0000000000..46d911f34b --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000502_user_skills.up.sql @@ -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' +); diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index ced4716c5c..bab9759762 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -896,6 +896,10 @@ func (m WorkspaceAgentVolumeResourceMonitor) Debounce( 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 { return rbac.ResourceUserSecret.WithID(s.ID).WithOwner(s.UserID.String()) } diff --git a/coderd/database/models.go b/coderd/database/models.go index 753597e5bb..e202da2795 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -312,6 +312,11 @@ const ( ApiKeyScopeAiProviderRead APIKeyScope = "ai_provider:read" ApiKeyScopeAiProviderUpdate APIKeyScope = "ai_provider:update" 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 { @@ -567,7 +572,12 @@ func (e APIKeyScope) Valid() bool { ApiKeyScopeAiProviderDelete, ApiKeyScopeAiProviderRead, ApiKeyScopeAiProviderUpdate, - ApiKeyScopeChatShare: + ApiKeyScopeChatShare, + ApiKeyScopeUserSkillCreate, + ApiKeyScopeUserSkillRead, + ApiKeyScopeUserSkillUpdate, + ApiKeyScopeUserSkillDelete, + ApiKeyScopeUserSkill: return true } return false @@ -792,6 +802,11 @@ func AllAPIKeyScopeValues() []APIKeyScope { ApiKeyScopeAiProviderRead, ApiKeyScopeAiProviderUpdate, ApiKeyScopeChatShare, + ApiKeyScopeUserSkillCreate, + ApiKeyScopeUserSkillRead, + ApiKeyScopeUserSkillUpdate, + ApiKeyScopeUserSkillDelete, + ApiKeyScopeUserSkill, } } @@ -3322,6 +3337,7 @@ const ( ResourceTypeAiProvider ResourceType = "ai_provider" ResourceTypeAiProviderKey ResourceType = "ai_provider_key" ResourceTypeGroupAiBudget ResourceType = "group_ai_budget" + ResourceTypeUserSkill ResourceType = "user_skill" ) func (e *ResourceType) Scan(src interface{}) error { @@ -3392,7 +3408,8 @@ func (e ResourceType) Valid() bool { ResourceTypeUserSecret, ResourceTypeAiProvider, ResourceTypeAiProviderKey, - ResourceTypeGroupAiBudget: + ResourceTypeGroupAiBudget, + ResourceTypeUserSkill: return true } return false @@ -3432,6 +3449,7 @@ func AllResourceTypeValues() []ResourceType { ResourceTypeAiProvider, ResourceTypeAiProviderKey, ResourceTypeGroupAiBudget, + ResourceTypeUserSkill, } } @@ -5725,6 +5743,16 @@ type UserSecret struct { 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 type UserStatusChange struct { ID uuid.UUID `db:"id" json:"id"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 9cf04de180..aa4e556042 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -196,6 +196,7 @@ type sqlcQuerier interface { DeleteUserChatCompactionThreshold(ctx context.Context, arg DeleteUserChatCompactionThresholdParams) error DeleteUserChatProviderKey(ctx context.Context, arg DeleteUserChatProviderKeyParams) error DeleteUserSecretByUserIDAndName(ctx context.Context, arg DeleteUserSecretByUserIDAndNameParams) (UserSecret, error) + DeleteUserSkillByUserIDAndName(ctx context.Context, arg DeleteUserSkillByUserIDAndNameParams) (UserSkill, error) DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg DeleteWebpushSubscriptionByUserIDAndEndpointParams) error DeleteWebpushSubscriptions(ctx context.Context, ids []uuid.UUID) error DeleteWorkspaceACLByID(ctx context.Context, id uuid.UUID) error @@ -791,6 +792,7 @@ type sqlcQuerier interface { // values rather than interpolating between rows. GetUserSecretsTelemetrySummary(ctx context.Context) (GetUserSecretsTelemetrySummaryRow, 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. // The time range is inclusively defined by the start_time and end_time parameters. 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 InsertUserGroupsByID(ctx context.Context, arg InsertUserGroupsByIDParams) ([]uuid.UUID, 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) // Inserts or updates a webpush subscription. The (user_id, endpoint) pair // 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 // (runtime injection). 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) MarkAllInboxNotificationsAsRead(ctx context.Context, arg MarkAllInboxNotificationsAsReadParams) error OIDCClaimFieldValues(ctx context.Context, arg OIDCClaimFieldValuesParams) ([]string, error) @@ -1271,6 +1275,7 @@ type sqlcQuerier interface { UpdateUserRoles(ctx context.Context, arg UpdateUserRolesParams) (User, error) UpdateUserSecretByUserIDAndName(ctx context.Context, arg UpdateUserSecretByUserIDAndNameParams) (UserSecret, 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) UpdateUserTaskNotificationAlertDismissed(ctx context.Context, arg UpdateUserTaskNotificationAlertDismissedParams) (bool, error) UpdateUserTerminalFont(ctx context.Context, arg UpdateUserTerminalFontParams) (UserConfig, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 0e5499910a..2c49a960de 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -26894,6 +26894,177 @@ func (q *sqlQuerier) UpdateUserSecretByUserIDAndName(ctx context.Context, arg Up 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 DELETE FROM user_chat_provider_keys WHERE user_id = $1 AND chat_provider_id = $2 ` diff --git a/coderd/database/queries/user_skills.sql b/coderd/database/queries/user_skills.sql new file mode 100644 index 0000000000..a5d9a17c29 --- /dev/null +++ b/coderd/database/queries/user_skills.sql @@ -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 *; diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 0ca0283b1d..6ed84e110d 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -103,6 +103,7 @@ const ( 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); 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); 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); @@ -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); 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); + 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)); 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); diff --git a/coderd/database/user_skills_test.go b/coderd/database/user_skills_test.go new file mode 100644 index 0000000000..af010ba593 --- /dev/null +++ b/coderd/database/user_skills_test.go @@ -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) + }) + } +} diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index d5d5f821b1..340221f611 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -385,6 +385,16 @@ var ( 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 // Valid Actions // - "ActionCreate" :: create webpush subscriptions @@ -500,6 +510,7 @@ func AllResources() []Objecter { ResourceUsageEvent, ResourceUser, ResourceUserSecret, + ResourceUserSkill, ResourceWebpushSubscription, ResourceWorkspace, ResourceWorkspaceAgentDevcontainers, diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index f6c0ec4a5b..7d7a42110d 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -379,6 +379,14 @@ var RBACPermissions = map[string]PermissionDefinition{ 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": { Actions: map[Action]ActionDefinition{ ActionCreate: "create a usage event", diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 3ff972b855..cbaf49f9c0 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -301,12 +301,14 @@ func ReloadBuiltinRoles(opts *RoleOptions) { Site: append( // Workspace dormancy and workspace are omitted. // Workspace is specifically handled based on the opts.NoOwnerWorkspaceExec. - // Owners cannot access other users' secrets. - allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUserSecret, ResourceUsageEvent, ResourceBoundaryUsage, ResourceAiSeat), + // Owners can inspect and delete personal skills for operability and + // 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. Permissions(map[string][]policy.Action{ ResourceWorkspace.Type: ownerWorkspaceActions, 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. // Explicitly setting PrebuiltWorkspace permissions for clarity. // Note: even without PrebuiltWorkspace permissions, access is still granted via Workspace permissions. diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 061a061801..0170d308e0 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -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", Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate}, diff --git a/coderd/rbac/scopes_catalog.go b/coderd/rbac/scopes_catalog.go index 116f432655..dc15913faa 100644 --- a/coderd/rbac/scopes_catalog.go +++ b/coderd/rbac/scopes_catalog.go @@ -53,6 +53,13 @@ var externalLowLevel = map[ScopeName]struct{}{ "user_secret:delete": {}, "user_secret:*": {}, + // User skills + "user_skill:read": {}, + "user_skill:create": {}, + "user_skill:update": {}, + "user_skill:delete": {}, + "user_skill:*": {}, + // Tasks "task:create": {}, "task:read": {}, diff --git a/coderd/rbac/scopes_constants_gen.go b/coderd/rbac/scopes_constants_gen.go index d2021968cf..c12cba430a 100644 --- a/coderd/rbac/scopes_constants_gen.go +++ b/coderd/rbac/scopes_constants_gen.go @@ -134,6 +134,10 @@ const ( ScopeUserSecretDelete ScopeName = "user_secret:delete" ScopeUserSecretRead ScopeName = "user_secret:read" 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" ScopeWebpushSubscriptionDelete ScopeName = "webpush_subscription:delete" ScopeWebpushSubscriptionRead ScopeName = "webpush_subscription:read" @@ -307,6 +311,10 @@ func (e ScopeName) Valid() bool { ScopeUserSecretDelete, ScopeUserSecretRead, ScopeUserSecretUpdate, + ScopeUserSkillCreate, + ScopeUserSkillDelete, + ScopeUserSkillRead, + ScopeUserSkillUpdate, ScopeWebpushSubscriptionCreate, ScopeWebpushSubscriptionDelete, ScopeWebpushSubscriptionRead, @@ -481,6 +489,10 @@ func AllScopeNameValues() []ScopeName { ScopeUserSecretDelete, ScopeUserSecretRead, ScopeUserSecretUpdate, + ScopeUserSkillCreate, + ScopeUserSkillDelete, + ScopeUserSkillRead, + ScopeUserSkillUpdate, ScopeWebpushSubscriptionCreate, ScopeWebpushSubscriptionDelete, ScopeWebpushSubscriptionRead, diff --git a/coderd/userskills.go b/coderd/userskills.go new file mode 100644 index 0000000000..698f83163f --- /dev/null +++ b/coderd/userskills.go @@ -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(), + }) +} diff --git a/coderd/userskills_test.go b/coderd/userskills_test.go new file mode 100644 index 0000000000..a2c3c8e236 --- /dev/null +++ b/coderd/userskills_test.go @@ -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" +} diff --git a/coderd/x/skills/doc.go b/coderd/x/skills/doc.go index 896c18ff73..fdfe0e24d1 100644 --- a/coderd/x/skills/doc.go +++ b/coderd/x/skills/doc.go @@ -26,7 +26,7 @@ // and workspace/ for the workspace skill. One source must not silently // 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 // raw Markdown content diffs alongside the actor, target user, and relevant // metadata. diff --git a/coderd/x/skills/skills.go b/coderd/x/skills/skills.go index 9bfe102ac7..42b9d7e90d 100644 --- a/coderd/x/skills/skills.go +++ b/coderd/x/skills/skills.go @@ -18,6 +18,14 @@ const MaxPersonalSkillSizeBytes = workspacesdk.MaxSkillMetaBytes // personal skill upload. Skill names are also used in URL paths. 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. type Source string @@ -36,6 +44,8 @@ var ( ErrSkillBodyRequired = xerrors.New("skill body is required") // ErrSkillTooLarge indicates that the raw skill Markdown 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 = xerrors.New("skill not found") // ErrSkillAmbiguous indicates that a skill lookup matched multiple sources. @@ -65,8 +75,9 @@ type ResolvedSkill struct { // ParsePersonalSkillMarkdown parses raw personal skill Markdown and enforces // the personal skill contract. The raw size must not exceed // MaxPersonalSkillSizeBytes, frontmatter must contain a valid kebab-case name, -// the skill name must not exceed MaxPersonalSkillNameBytes, and the body after -// frontmatter must be non-empty. +// the skill name must not exceed MaxPersonalSkillNameBytes, the description must +// not exceed MaxPersonalSkillDescriptionBytes, and the body after frontmatter +// must be non-empty. func ParsePersonalSkillMarkdown(raw []byte) (ParsedSkill, error) { if len(raw) > MaxPersonalSkillSizeBytes { return ParsedSkill{}, xerrors.Errorf( @@ -102,6 +113,15 @@ func ParsePersonalSkillMarkdown(raw []byte) (ParsedSkill, error) { 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) == "" { return ParsedSkill{}, xerrors.Errorf( "%w: skill %q has no content after frontmatter", diff --git a/coderd/x/skills/skills_test.go b/coderd/x/skills/skills_test.go index 1c27b09187..6c40dad601 100644 --- a/coderd/x/skills/skills_test.go +++ b/coderd/x/skills/skills_test.go @@ -91,6 +91,19 @@ func TestParsePersonalSkillMarkdown(t *testing.T) { 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.Parallel() diff --git a/codersdk/apikey_scopes_gen.go b/codersdk/apikey_scopes_gen.go index d13a58c687..7bad39ccc2 100644 --- a/codersdk/apikey_scopes_gen.go +++ b/codersdk/apikey_scopes_gen.go @@ -182,6 +182,11 @@ const ( APIKeyScopeUserSecretDelete APIKeyScope = "user_secret:delete" APIKeyScopeUserSecretRead APIKeyScope = "user_secret:read" 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:*" APIKeyScopeWebpushSubscriptionCreate APIKeyScope = "webpush_subscription:create" APIKeyScopeWebpushSubscriptionDelete APIKeyScope = "webpush_subscription:delete" @@ -267,6 +272,11 @@ var PublicAPIKeyScopes = []APIKeyScope{ APIKeyScopeUserSecretDelete, APIKeyScopeUserSecretRead, APIKeyScopeUserSecretUpdate, + APIKeyScopeUserSkillAll, + APIKeyScopeUserSkillCreate, + APIKeyScopeUserSkillDelete, + APIKeyScopeUserSkillRead, + APIKeyScopeUserSkillUpdate, APIKeyScopeWorkspaceAll, APIKeyScopeWorkspaceApplicationConnect, APIKeyScopeWorkspaceCreate, diff --git a/codersdk/audit.go b/codersdk/audit.go index 253a09d99b..eceae40649 100644 --- a/codersdk/audit.go +++ b/codersdk/audit.go @@ -51,6 +51,7 @@ const ( ResourceTypeGroupAIBudget ResourceType = "group_ai_budget" ResourceTypeChat ResourceType = "chat" ResourceTypeUserSecret ResourceType = "user_secret" + ResourceTypeUserSkill ResourceType = "user_skill" ) func (r ResourceType) FriendlyString() string { @@ -121,6 +122,8 @@ func (r ResourceType) FriendlyString() string { return "chat" case ResourceTypeUserSecret: return "user secret" + case ResourceTypeUserSkill: + return "user skill" default: return "unknown" } diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index 5bf3a4f191..11b6488182 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -45,6 +45,7 @@ const ( ResourceUsageEvent RBACResource = "usage_event" ResourceUser RBACResource = "user" ResourceUserSecret RBACResource = "user_secret" + ResourceUserSkill RBACResource = "user_skill" ResourceWebpushSubscription RBACResource = "webpush_subscription" ResourceWorkspace RBACResource = "workspace" ResourceWorkspaceAgentDevcontainers RBACResource = "workspace_agent_devcontainers" @@ -120,6 +121,7 @@ var RBACResourceActions = map[RBACResource][]RBACAction{ ResourceUsageEvent: {ActionCreate, ActionRead, ActionUpdate}, ResourceUser: {ActionCreate, ActionDelete, ActionRead, ActionReadPersonal, ActionUpdate, ActionUpdatePersonal}, ResourceUserSecret: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceUserSkill: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceWebpushSubscription: {ActionCreate, ActionDelete, ActionRead}, ResourceWorkspace: {ActionApplicationConnect, ActionCreate, ActionCreateAgent, ActionDelete, ActionDeleteAgent, ActionRead, ActionShare, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate, ActionUpdateAgent}, ResourceWorkspaceAgentDevcontainers: {ActionCreate}, diff --git a/codersdk/userskills.go b/codersdk/userskills.go new file mode 100644 index 0000000000..796e9d504d --- /dev/null +++ b/codersdk/userskills.go @@ -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 +} diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 9535ca042d..712724e064 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -42,6 +42,7 @@ We track the following resources: | TemplateVersion
create, write | |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
external_auth_providersfalse
has_ai_taskfalse
has_external_agentfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
source_example_idfalse
template_idtrue
updated_atfalse
| | User
create, write, delete | |
FieldTracked
avatar_urlfalse
chat_spend_limit_microstrue
created_atfalse
deletedtrue
emailtrue
github_com_user_idfalse
hashed_one_time_passcodefalse
hashed_passwordtrue
idtrue
is_service_accounttrue
is_systemtrue
last_seen_atfalse
login_typetrue
nametrue
one_time_passcode_expires_attrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| | UserSecret
create, write, delete | |
FieldTracked
created_atfalse
descriptiontrue
env_nametrue
file_pathtrue
idtrue
nametrue
updated_atfalse
user_idtrue
valuetrue
value_key_idfalse
| +| UserSkill
create, write, delete | |
FieldTracked
contenttrue
created_atfalse
descriptiontrue
idtrue
nametrue
updated_atfalse
user_idtrue
| | WorkspaceBuild
start, stop | |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
has_ai_taskfalse
has_external_agentfalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_namefalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
reasonfalse
template_version_idtrue
template_version_preset_idfalse
transitionfalse
updated_atfalse
workspace_idfalse
| | WorkspaceProxy
| |
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
versiontrue
wildcard_hostnametrue
| | WorkspaceTable
| |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
favoritetrue
group_acltrue
idtrue
last_used_atfalse
nametrue
next_start_attrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
user_acltrue
| diff --git a/docs/reference/api/members.md b/docs/reference/api/members.md index 15097d53aa..1556ced557 100644 --- a/docs/reference/api/members.md +++ b/docs/reference/api/members.md @@ -193,10 +193,10 @@ Status Code **200** #### Enumerated Values -| 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` | -| `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` | +| 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` | +| `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). @@ -326,10 +326,10 @@ Status Code **200** #### Enumerated Values -| 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` | -| `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` | +| 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` | +| `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). @@ -459,10 +459,10 @@ Status Code **200** #### Enumerated Values -| 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` | -| `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` | +| 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` | +| `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). @@ -554,10 +554,10 @@ Status Code **200** #### Enumerated Values -| 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` | -| `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` | +| 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` | +| `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). @@ -960,9 +960,9 @@ Status Code **200** #### Enumerated Values -| 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` | -| `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` | +| 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` | +| `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). diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 15499188df..fb9221c315 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1400,9 +1400,9 @@ #### Enumerated Values -| Value(s) | -|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ai_model_price:*`, `ai_model_price:read`, `ai_model_price:update`, `ai_provider:*`, `ai_provider:create`, `ai_provider:delete`, `ai_provider:read`, `ai_provider:update`, `ai_seat:*`, `ai_seat:create`, `ai_seat:read`, `aibridge_interception:*`, `aibridge_interception:create`, `aibridge_interception:read`, `aibridge_interception:update`, `all`, `api_key:*`, `api_key:create`, `api_key:delete`, `api_key:read`, `api_key:update`, `application_connect`, `assign_org_role:*`, `assign_org_role:assign`, `assign_org_role:create`, `assign_org_role:delete`, `assign_org_role:read`, `assign_org_role:unassign`, `assign_org_role:update`, `assign_role:*`, `assign_role:assign`, `assign_role:read`, `assign_role:unassign`, `audit_log:*`, `audit_log:create`, `audit_log:read`, `boundary_usage:*`, `boundary_usage:delete`, `boundary_usage:read`, `boundary_usage:update`, `chat:*`, `chat:create`, `chat:delete`, `chat:read`, `chat:share`, `chat:update`, `coder:all`, `coder:apikeys.manage_self`, `coder:application_connect`, `coder:templates.author`, `coder:templates.build`, `coder:workspaces.access`, `coder:workspaces.create`, `coder:workspaces.delete`, `coder:workspaces.operate`, `connection_log:*`, `connection_log:read`, `connection_log:update`, `crypto_key:*`, `crypto_key:create`, `crypto_key:delete`, `crypto_key:read`, `crypto_key:update`, `debug_info:*`, `debug_info:read`, `deployment_config:*`, `deployment_config:read`, `deployment_config:update`, `deployment_stats:*`, `deployment_stats:read`, `file:*`, `file:create`, `file:read`, `group:*`, `group:create`, `group:delete`, `group:read`, `group:update`, `group_member:*`, `group_member:read`, `idpsync_settings:*`, `idpsync_settings:read`, `idpsync_settings:update`, `inbox_notification:*`, `inbox_notification:create`, `inbox_notification:read`, `inbox_notification:update`, `license:*`, `license:create`, `license:delete`, `license:read`, `notification_message:*`, `notification_message:create`, `notification_message:delete`, `notification_message:read`, `notification_message:update`, `notification_preference:*`, `notification_preference:read`, `notification_preference:update`, `notification_template:*`, `notification_template:read`, `notification_template:update`, `oauth2_app:*`, `oauth2_app:create`, `oauth2_app:delete`, `oauth2_app:read`, `oauth2_app:update`, `oauth2_app_code_token:*`, `oauth2_app_code_token:create`, `oauth2_app_code_token:delete`, `oauth2_app_code_token:read`, `oauth2_app_secret:*`, `oauth2_app_secret:create`, `oauth2_app_secret:delete`, `oauth2_app_secret:read`, `oauth2_app_secret:update`, `organization:*`, `organization:create`, `organization:delete`, `organization:read`, `organization:update`, `organization_member:*`, `organization_member:create`, `organization_member:delete`, `organization_member:read`, `organization_member:update`, `prebuilt_workspace:*`, `prebuilt_workspace:delete`, `prebuilt_workspace:update`, `provisioner_daemon:*`, `provisioner_daemon:create`, `provisioner_daemon:delete`, `provisioner_daemon:read`, `provisioner_daemon:update`, `provisioner_jobs:*`, `provisioner_jobs:create`, `provisioner_jobs:read`, `provisioner_jobs:update`, `replicas:*`, `replicas:read`, `system:*`, `system:create`, `system:delete`, `system:read`, `system:update`, `tailnet_coordinator:*`, `tailnet_coordinator:create`, `tailnet_coordinator:delete`, `tailnet_coordinator:read`, `tailnet_coordinator:update`, `task:*`, `task:create`, `task:delete`, `task:read`, `task:update`, `template:*`, `template:create`, `template:delete`, `template:read`, `template:update`, `template:use`, `template:view_insights`, `usage_event:*`, `usage_event:create`, `usage_event:read`, `usage_event:update`, `user:*`, `user:create`, `user:delete`, `user:read`, `user:read_personal`, `user:update`, `user:update_personal`, `user_secret:*`, `user_secret:create`, `user_secret:delete`, `user_secret:read`, `user_secret:update`, `webpush_subscription:*`, `webpush_subscription:create`, `webpush_subscription:delete`, `webpush_subscription:read`, `workspace:*`, `workspace:application_connect`, `workspace:create`, `workspace:create_agent`, `workspace:delete`, `workspace:delete_agent`, `workspace:read`, `workspace:share`, `workspace:ssh`, `workspace:start`, `workspace:stop`, `workspace:update`, `workspace:update_agent`, `workspace_agent_devcontainers:*`, `workspace_agent_devcontainers:create`, `workspace_agent_resource_monitor:*`, `workspace_agent_resource_monitor:create`, `workspace_agent_resource_monitor:read`, `workspace_agent_resource_monitor:update`, `workspace_dormant:*`, `workspace_dormant:application_connect`, `workspace_dormant:create`, `workspace_dormant:create_agent`, `workspace_dormant:delete`, `workspace_dormant:delete_agent`, `workspace_dormant:read`, `workspace_dormant:share`, `workspace_dormant:ssh`, `workspace_dormant:start`, `workspace_dormant:stop`, `workspace_dormant:update`, `workspace_dormant:update_agent`, `workspace_proxy:*`, `workspace_proxy:create`, `workspace_proxy:delete`, `workspace_proxy:read`, `workspace_proxy:update` | +| Value(s) | +|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ai_model_price:*`, `ai_model_price:read`, `ai_model_price:update`, `ai_provider:*`, `ai_provider:create`, `ai_provider:delete`, `ai_provider:read`, `ai_provider:update`, `ai_seat:*`, `ai_seat:create`, `ai_seat:read`, `aibridge_interception:*`, `aibridge_interception:create`, `aibridge_interception:read`, `aibridge_interception:update`, `all`, `api_key:*`, `api_key:create`, `api_key:delete`, `api_key:read`, `api_key:update`, `application_connect`, `assign_org_role:*`, `assign_org_role:assign`, `assign_org_role:create`, `assign_org_role:delete`, `assign_org_role:read`, `assign_org_role:unassign`, `assign_org_role:update`, `assign_role:*`, `assign_role:assign`, `assign_role:read`, `assign_role:unassign`, `audit_log:*`, `audit_log:create`, `audit_log:read`, `boundary_usage:*`, `boundary_usage:delete`, `boundary_usage:read`, `boundary_usage:update`, `chat:*`, `chat:create`, `chat:delete`, `chat:read`, `chat:share`, `chat:update`, `coder:all`, `coder:apikeys.manage_self`, `coder:application_connect`, `coder:templates.author`, `coder:templates.build`, `coder:workspaces.access`, `coder:workspaces.create`, `coder:workspaces.delete`, `coder:workspaces.operate`, `connection_log:*`, `connection_log:read`, `connection_log:update`, `crypto_key:*`, `crypto_key:create`, `crypto_key:delete`, `crypto_key:read`, `crypto_key:update`, `debug_info:*`, `debug_info:read`, `deployment_config:*`, `deployment_config:read`, `deployment_config:update`, `deployment_stats:*`, `deployment_stats:read`, `file:*`, `file:create`, `file:read`, `group:*`, `group:create`, `group:delete`, `group:read`, `group:update`, `group_member:*`, `group_member:read`, `idpsync_settings:*`, `idpsync_settings:read`, `idpsync_settings:update`, `inbox_notification:*`, `inbox_notification:create`, `inbox_notification:read`, `inbox_notification:update`, `license:*`, `license:create`, `license:delete`, `license:read`, `notification_message:*`, `notification_message:create`, `notification_message:delete`, `notification_message:read`, `notification_message:update`, `notification_preference:*`, `notification_preference:read`, `notification_preference:update`, `notification_template:*`, `notification_template:read`, `notification_template:update`, `oauth2_app:*`, `oauth2_app:create`, `oauth2_app:delete`, `oauth2_app:read`, `oauth2_app:update`, `oauth2_app_code_token:*`, `oauth2_app_code_token:create`, `oauth2_app_code_token:delete`, `oauth2_app_code_token:read`, `oauth2_app_secret:*`, `oauth2_app_secret:create`, `oauth2_app_secret:delete`, `oauth2_app_secret:read`, `oauth2_app_secret:update`, `organization:*`, `organization:create`, `organization:delete`, `organization:read`, `organization:update`, `organization_member:*`, `organization_member:create`, `organization_member:delete`, `organization_member:read`, `organization_member:update`, `prebuilt_workspace:*`, `prebuilt_workspace:delete`, `prebuilt_workspace:update`, `provisioner_daemon:*`, `provisioner_daemon:create`, `provisioner_daemon:delete`, `provisioner_daemon:read`, `provisioner_daemon:update`, `provisioner_jobs:*`, `provisioner_jobs:create`, `provisioner_jobs:read`, `provisioner_jobs:update`, `replicas:*`, `replicas:read`, `system:*`, `system:create`, `system:delete`, `system:read`, `system:update`, `tailnet_coordinator:*`, `tailnet_coordinator:create`, `tailnet_coordinator:delete`, `tailnet_coordinator:read`, `tailnet_coordinator:update`, `task:*`, `task:create`, `task:delete`, `task:read`, `task:update`, `template:*`, `template:create`, `template:delete`, `template:read`, `template:update`, `template:use`, `template:view_insights`, `usage_event:*`, `usage_event:create`, `usage_event:read`, `usage_event:update`, `user:*`, `user:create`, `user:delete`, `user:read`, `user:read_personal`, `user:update`, `user:update_personal`, `user_secret:*`, `user_secret:create`, `user_secret:delete`, `user_secret:read`, `user_secret:update`, `user_skill:*`, `user_skill:create`, `user_skill:delete`, `user_skill:read`, `user_skill:update`, `webpush_subscription:*`, `webpush_subscription:create`, `webpush_subscription:delete`, `webpush_subscription:read`, `workspace:*`, `workspace:application_connect`, `workspace:create`, `workspace:create_agent`, `workspace:delete`, `workspace:delete_agent`, `workspace:read`, `workspace:share`, `workspace:ssh`, `workspace:start`, `workspace:stop`, `workspace:update`, `workspace:update_agent`, `workspace_agent_devcontainers:*`, `workspace_agent_devcontainers:create`, `workspace_agent_resource_monitor:*`, `workspace_agent_resource_monitor:create`, `workspace_agent_resource_monitor:read`, `workspace_agent_resource_monitor:update`, `workspace_dormant:*`, `workspace_dormant:application_connect`, `workspace_dormant:create`, `workspace_dormant:create_agent`, `workspace_dormant:delete`, `workspace_dormant:delete_agent`, `workspace_dormant:read`, `workspace_dormant:share`, `workspace_dormant:ssh`, `workspace_dormant:start`, `workspace_dormant:stop`, `workspace_dormant:update`, `workspace_dormant:update_agent`, `workspace_proxy:*`, `workspace_proxy:create`, `workspace_proxy:delete`, `workspace_proxy:read`, `workspace_proxy:update` | ## codersdk.AddLicenseRequest @@ -4827,6 +4827,20 @@ This is required on creation to enable a user-flow of validating a template work | `name` | string | false | | | | `value` | string | false | | | +## codersdk.CreateUserSkillRequest + +```json +{ + "content": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-----------|--------|----------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `content` | string | false | | 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. | + ## codersdk.CreateWorkspaceBuildReason ```json @@ -10501,9 +10515,9 @@ Only certain features set these fields: - FeatureManagedAgentLimit| #### Enumerated Values -| Value(s) | -|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `*`, `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` | +| Value(s) | +|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `*`, `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` | ## codersdk.RateLimitConfig @@ -10721,9 +10735,9 @@ Only certain features set these fields: - FeatureManagedAgentLimit| #### Enumerated Values -| Value(s) | -|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ai_provider`, `ai_provider_key`, `ai_seat`, `api_key`, `chat`, `convert_login`, `custom_role`, `git_ssh_key`, `group`, `group_ai_budget`, `health_settings`, `idp_sync_settings_group`, `idp_sync_settings_organization`, `idp_sync_settings_role`, `license`, `notification_template`, `notifications_settings`, `oauth2_provider_app`, `oauth2_provider_app_secret`, `organization`, `organization_member`, `prebuilds_settings`, `task`, `template`, `template_version`, `user`, `user_secret`, `workspace`, `workspace_agent`, `workspace_app`, `workspace_build`, `workspace_proxy` | +| Value(s) | +|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ai_provider`, `ai_provider_key`, `ai_seat`, `api_key`, `chat`, `convert_login`, `custom_role`, `git_ssh_key`, `group`, `group_ai_budget`, `health_settings`, `idp_sync_settings_group`, `idp_sync_settings_organization`, `idp_sync_settings_role`, `license`, `notification_template`, `notifications_settings`, `oauth2_provider_app`, `oauth2_provider_app_secret`, `organization`, `organization_member`, `prebuilds_settings`, `task`, `template`, `template_version`, `user`, `user_secret`, `user_skill`, `workspace`, `workspace_agent`, `workspace_app`, `workspace_build`, `workspace_proxy` | ## codersdk.Response @@ -13067,6 +13081,20 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `file_path` | string | false | | | | `value` | string | false | | | +## codersdk.UpdateUserSkillRequest + +```json +{ + "content": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-----------|--------|----------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `content` | string | false | | 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. | + ## codersdk.UpdateWorkspaceACL ```json @@ -13693,6 +13721,52 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `name` | string | false | | | | `updated_at` | string | false | | | +## codersdk.UserSkill + +```json +{ + "content": "string", + "created_at": "2019-08-24T14:15:22Z", + "description": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "updated_at": "2019-08-24T14:15:22Z" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------------|--------|----------|--------------|-------------| +| `content` | string | false | | | +| `created_at` | string | false | | | +| `description` | string | false | | | +| `id` | string | false | | | +| `name` | string | false | | | +| `updated_at` | string | false | | | + +## codersdk.UserSkillMetadata + +```json +{ + "created_at": "2019-08-24T14:15:22Z", + "description": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "updated_at": "2019-08-24T14:15:22Z" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------------|--------|----------|--------------|-------------| +| `created_at` | string | false | | | +| `description` | string | false | | | +| `id` | string | false | | | +| `name` | string | false | | | +| `updated_at` | string | false | | | + ## codersdk.UserStatus ```json diff --git a/docs/reference/api/users.md b/docs/reference/api/users.md index 4d4e39c4a6..376a415031 100644 --- a/docs/reference/api/users.md +++ b/docs/reference/api/users.md @@ -865,11 +865,11 @@ Status Code **200** #### Enumerated Values -| 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` | -| `login_type` | `github`, `oidc`, `password`, `token` | -| `scope` | `all`, `application_connect` | +| 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`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| `login_type` | `github`, `oidc`, `password`, `token` | +| `scope` | `all`, `application_connect` | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/enterprise/audit/diff_internal_test.go b/enterprise/audit/diff_internal_test.go index 5e8e30492d..33566d64d1 100644 --- a/enterprise/audit/diff_internal_test.go +++ b/enterprise/audit/diff_internal_test.go @@ -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{ { name: "Create", diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 9475f6e118..a1dbee6f73 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -34,6 +34,7 @@ var AuditActionMap = map[string][]codersdk.AuditAction{ "AuditableGroupAiBudget": {codersdk.AuditActionWrite, codersdk.AuditActionDelete}, "Chat": {codersdk.AuditActionCreate, codersdk.AuditActionWrite}, // chats get 'archived' by users, not deleted. "UserSecret": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, + "UserSkill": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, } 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. "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{}: { "id": ActionTrack, "user_id": ActionTrack, diff --git a/site/src/api/rbacresourcesGenerated.ts b/site/src/api/rbacresourcesGenerated.ts index 1ee44290d6..23bd95350c 100644 --- a/site/src/api/rbacresourcesGenerated.ts +++ b/site/src/api/rbacresourcesGenerated.ts @@ -215,6 +215,12 @@ export const RBACResourceActions: Partial< read: "read 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: { create: "create webpush subscriptions", delete: "delete webpush subscriptions", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 4ce4e9945b..f4b245220e 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -653,6 +653,11 @@ export type APIKeyScope = | "user_secret:delete" | "user_secret:read" | "user_secret:update" + | "user_skill:*" + | "user_skill:create" + | "user_skill:delete" + | "user_skill:read" + | "user_skill:update" | "user:update" | "user:update_personal" | "webpush_subscription:*" @@ -874,6 +879,11 @@ export const APIKeyScopes: APIKeyScope[] = [ "user_secret:delete", "user_secret:read", "user_secret:update", + "user_skill:*", + "user_skill:create", + "user_skill:delete", + "user_skill:read", + "user_skill:update", "user:update", "user:update_personal", "webpush_subscription:*", @@ -3581,6 +3591,19 @@ export interface CreateUserSecretRequest { 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 export type CreateWorkspaceBuildReason = | "cli" @@ -6718,6 +6741,7 @@ export type RBACResource = | "usage_event" | "user" | "user_secret" + | "user_skill" | "webpush_subscription" | "*" | "workspace" @@ -6767,6 +6791,7 @@ export const RBACResources: RBACResource[] = [ "usage_event", "user", "user_secret", + "user_skill", "webpush_subscription", "*", "workspace", @@ -6914,6 +6939,7 @@ export type ResourceType = | "template_version" | "user" | "user_secret" + | "user_skill" | "workspace" | "workspace_agent" | "workspace_app" @@ -6948,6 +6974,7 @@ export const ResourceTypes: ResourceType[] = [ "template_version", "user", "user_secret", + "user_skill", "workspace", "workspace_agent", "workspace_app", @@ -8818,6 +8845,19 @@ export interface UpdateUserSecretRequest { 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 export interface UpdateWorkspaceACL { /** @@ -9235,6 +9275,26 @@ export interface UserSecret { 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 export type UserStatus = "active" | "dormant" | "suspended";