mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add personal skill storage, API, and SDK (#25363)
> Mux updated this PR on behalf of Mike. ## Stack Context This PR is the storage, permissions, API, and SDK layer for experimental personal skills. #25362 has landed on `main`, so this branch is restacked directly on `main`. Stack order: 1. #25363 storage, permissions, API, and SDK 2. #25365 API test coverage 3. #25366 chattool and chatd integration 4. #25066 settings UI and docs 5. #25386 personal skills slash menu ## What? Adds the `user_skills` database table, generated queries, RBAC resources and scopes, audit resource handling, experimental user-scoped CRUD endpoints, SDK types, and generated API/site types. Follow-up review and restack fixes: - Enforce a bounded personal skill description in parser and database constraints. - Return `403 Forbidden` for unauthorized create and update attempts. - Return explicit conflict responses when soft-deleted users are targeted. - Keep user admins out of personal skills, while site owners can read and delete but not create or update. - Document trigger-raised constraint names and keep schema constants covered by tests. - Reuse `UserSkillMetadata` in the full `UserSkill` SDK response type. - Generate user skill IDs in Go instead of relying on a database default. - Rebase on latest `main` and renumber the user skills migration to `000502_user_skills`. ## Why? Personal skills need durable user-owned storage with owner authorization, limited site-owner moderation, and a hidden API surface before chatd can consume them. ## Validation - `make gen` - `go test ./coderd/database -run '^TestUserSkillSchemaConstants$' -count=1` - `go test ./coderd/database/dbauthz -run '^TestMethodTestSuite/TestUserSkills$' -count=1` - `go test ./coderd -run '^TestPatchUserSkill$' -count=1` - `go test ./codersdk ./coderd/database/db2sdk` - `make lint` - pre-commit hook on `97fd58108d`
This commit is contained in:
Generated
+304
-2
@@ -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": [
|
||||
|
||||
Generated
+282
-2
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Generated
+107
-24
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Enum additions to resource_type and api_key_scope are intentionally not
|
||||
-- reverted because Postgres cannot drop enum values safely.
|
||||
DROP TRIGGER IF EXISTS trigger_upsert_user_skills ON user_skills;
|
||||
DROP FUNCTION IF EXISTS insert_user_skill_fail_if_user_deleted;
|
||||
|
||||
-- Restore the previous body of delete_deleted_user_resources() from
|
||||
-- migration 000492 (without the user_skills cleanup).
|
||||
CREATE OR REPLACE FUNCTION delete_deleted_user_resources() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
BEGIN
|
||||
IF (NEW.deleted) THEN
|
||||
-- Remove their api_keys.
|
||||
DELETE FROM api_keys
|
||||
WHERE user_id = OLD.id;
|
||||
|
||||
-- Remove their user_links.
|
||||
-- Their login_type is preserved in the users table.
|
||||
-- Matching this user back to the link can still be done by their
|
||||
-- email if the account is undeleted. Although that is not a guarantee.
|
||||
DELETE FROM user_links
|
||||
WHERE user_id = OLD.id;
|
||||
|
||||
-- Remove their user_secrets.
|
||||
-- user_secrets.user_id has ON DELETE CASCADE, but soft-delete
|
||||
-- does not remove the users row so the FK cascade never fires.
|
||||
DELETE FROM user_secrets
|
||||
WHERE user_id = OLD.id;
|
||||
|
||||
-- Remove their organization memberships.
|
||||
-- This also triggers group membership cleanup via
|
||||
-- trigger_delete_group_members_on_org_member_delete.
|
||||
DELETE FROM organization_members
|
||||
WHERE user_id = OLD.id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trigger_user_skills_per_user_limit ON user_skills;
|
||||
DROP FUNCTION IF EXISTS enforce_user_skills_per_user_limit();
|
||||
DROP TABLE user_skills;
|
||||
@@ -0,0 +1,138 @@
|
||||
-- Creates the user_skills table and indexes.
|
||||
CREATE TABLE user_skills (
|
||||
id uuid PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
content text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT user_skills_name_size CHECK (octet_length(name) <= 256),
|
||||
CONSTRAINT user_skills_name_format CHECK (name ~ '^[a-z0-9]+(-[a-z0-9]+)*$'),
|
||||
CONSTRAINT user_skills_description_size CHECK (octet_length(description) <= 4096),
|
||||
CONSTRAINT user_skills_content_size CHECK (octet_length(content) <= 65536)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX user_skills_user_id_name_idx ON user_skills (user_id, name);
|
||||
|
||||
-- Enforces the per-user personal-skill cap at the schema level so the
|
||||
-- invariant survives any future refactor of InsertUserSkill. The cap
|
||||
-- value must stay in sync with skills.MaxPersonalSkillsPerUser in Go.
|
||||
CREATE FUNCTION enforce_user_skills_per_user_limit() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
skill_count int;
|
||||
skill_limit constant int := 100;
|
||||
BEGIN
|
||||
-- Serialize skill-cap checks per user so concurrent inserts cannot all
|
||||
-- observe the same pre-insert count and exceed the hard limit.
|
||||
PERFORM 1
|
||||
FROM users
|
||||
WHERE id = NEW.user_id
|
||||
FOR UPDATE;
|
||||
|
||||
SELECT count(*) INTO skill_count
|
||||
FROM user_skills
|
||||
WHERE user_id = NEW.user_id;
|
||||
IF skill_count >= skill_limit THEN
|
||||
RAISE EXCEPTION 'user has reached the personal skill limit'
|
||||
USING ERRCODE = 'check_violation',
|
||||
CONSTRAINT = 'user_skills_per_user_limit';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trigger_user_skills_per_user_limit
|
||||
BEFORE INSERT ON user_skills
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE enforce_user_skills_per_user_limit();
|
||||
|
||||
-- Extend the soft-delete cleanup trigger to also wipe user_skills.
|
||||
-- user_skills.user_id has ON DELETE CASCADE, but Coder soft-deletes
|
||||
-- users by flipping users.deleted instead of removing the row, so the
|
||||
-- FK cascade never fires and skills would otherwise survive deletion.
|
||||
DELETE FROM
|
||||
user_skills
|
||||
WHERE
|
||||
user_id
|
||||
IN (
|
||||
SELECT id FROM users WHERE deleted
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION delete_deleted_user_resources() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
BEGIN
|
||||
IF (NEW.deleted) THEN
|
||||
-- Remove their api_keys.
|
||||
DELETE FROM api_keys
|
||||
WHERE user_id = OLD.id;
|
||||
|
||||
-- Remove their user_links.
|
||||
-- Their login_type is preserved in the users table.
|
||||
-- Matching this user back to the link can still be done by their
|
||||
-- email if the account is undeleted. Although that is not a guarantee.
|
||||
DELETE FROM user_links
|
||||
WHERE user_id = OLD.id;
|
||||
|
||||
-- Remove their user_secrets.
|
||||
-- user_secrets.user_id has ON DELETE CASCADE, but soft-delete
|
||||
-- does not remove the users row so the FK cascade never fires.
|
||||
DELETE FROM user_secrets
|
||||
WHERE user_id = OLD.id;
|
||||
|
||||
-- Remove their organization memberships.
|
||||
-- This also triggers group membership cleanup via
|
||||
-- trigger_delete_group_members_on_org_member_delete.
|
||||
DELETE FROM organization_members
|
||||
WHERE user_id = OLD.id;
|
||||
|
||||
-- Remove their user_skills.
|
||||
-- user_skills.user_id has ON DELETE CASCADE, but soft-delete
|
||||
-- does not remove the users row so the FK cascade never fires.
|
||||
DELETE FROM user_skills
|
||||
WHERE user_id = OLD.id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Prevent adding new user_skills for soft-deleted users.
|
||||
-- Closes the window between an in-flight CreateUserSkill request and
|
||||
-- the soft-delete UPDATE committing.
|
||||
CREATE FUNCTION insert_user_skill_fail_if_user_deleted() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
|
||||
BEGIN
|
||||
PERFORM 1
|
||||
FROM users
|
||||
WHERE id = NEW.user_id
|
||||
AND deleted = true
|
||||
LIMIT 1;
|
||||
IF FOUND THEN
|
||||
RAISE EXCEPTION 'Cannot create user_skill for deleted user'
|
||||
USING ERRCODE = 'check_violation',
|
||||
CONSTRAINT = 'user_skill_user_deleted';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trigger_upsert_user_skills
|
||||
BEFORE INSERT OR UPDATE ON user_skills
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE insert_user_skill_fail_if_user_deleted();
|
||||
|
||||
-- Adds the user skill audit resource type.
|
||||
ALTER TYPE resource_type ADD VALUE IF NOT EXISTS 'user_skill';
|
||||
|
||||
-- Adds API key scopes for managing user skills.
|
||||
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'user_skill:create';
|
||||
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'user_skill:read';
|
||||
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'user_skill:update';
|
||||
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'user_skill:delete';
|
||||
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'user_skill:*';
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Inserts a user skill fixture so migration coverage includes the table.
|
||||
INSERT INTO user_skills (
|
||||
id,
|
||||
user_id,
|
||||
name,
|
||||
description,
|
||||
content,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
'7f070eb2-991e-4f7f-b780-40c4e0f49001',
|
||||
'30095c71-380b-457a-8995-97b8ee6e5307',
|
||||
'example-skill',
|
||||
'Example skill fixture.',
|
||||
'Example content.',
|
||||
'2026-05-07 00:00:00+00',
|
||||
'2026-05-07 00:00:00+00'
|
||||
);
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
`
|
||||
|
||||
@@ -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 *;
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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": {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -26,7 +26,7 @@
|
||||
// and workspace/<name> 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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user