mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00: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()
|
||||
|
||||
|
||||
@@ -182,6 +182,11 @@ const (
|
||||
APIKeyScopeUserSecretDelete APIKeyScope = "user_secret:delete"
|
||||
APIKeyScopeUserSecretRead APIKeyScope = "user_secret:read"
|
||||
APIKeyScopeUserSecretUpdate APIKeyScope = "user_secret:update"
|
||||
APIKeyScopeUserSkillAll APIKeyScope = "user_skill:*"
|
||||
APIKeyScopeUserSkillCreate APIKeyScope = "user_skill:create"
|
||||
APIKeyScopeUserSkillDelete APIKeyScope = "user_skill:delete"
|
||||
APIKeyScopeUserSkillRead APIKeyScope = "user_skill:read"
|
||||
APIKeyScopeUserSkillUpdate APIKeyScope = "user_skill:update"
|
||||
APIKeyScopeWebpushSubscriptionAll APIKeyScope = "webpush_subscription:*"
|
||||
APIKeyScopeWebpushSubscriptionCreate APIKeyScope = "webpush_subscription:create"
|
||||
APIKeyScopeWebpushSubscriptionDelete APIKeyScope = "webpush_subscription:delete"
|
||||
@@ -267,6 +272,11 @@ var PublicAPIKeyScopes = []APIKeyScope{
|
||||
APIKeyScopeUserSecretDelete,
|
||||
APIKeyScopeUserSecretRead,
|
||||
APIKeyScopeUserSecretUpdate,
|
||||
APIKeyScopeUserSkillAll,
|
||||
APIKeyScopeUserSkillCreate,
|
||||
APIKeyScopeUserSkillDelete,
|
||||
APIKeyScopeUserSkillRead,
|
||||
APIKeyScopeUserSkillUpdate,
|
||||
APIKeyScopeWorkspaceAll,
|
||||
APIKeyScopeWorkspaceApplicationConnect,
|
||||
APIKeyScopeWorkspaceCreate,
|
||||
|
||||
@@ -51,6 +51,7 @@ const (
|
||||
ResourceTypeGroupAIBudget ResourceType = "group_ai_budget"
|
||||
ResourceTypeChat ResourceType = "chat"
|
||||
ResourceTypeUserSecret ResourceType = "user_secret"
|
||||
ResourceTypeUserSkill ResourceType = "user_skill"
|
||||
)
|
||||
|
||||
func (r ResourceType) FriendlyString() string {
|
||||
@@ -121,6 +122,8 @@ func (r ResourceType) FriendlyString() string {
|
||||
return "chat"
|
||||
case ResourceTypeUserSecret:
|
||||
return "user secret"
|
||||
case ResourceTypeUserSkill:
|
||||
return "user skill"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ const (
|
||||
ResourceUsageEvent RBACResource = "usage_event"
|
||||
ResourceUser RBACResource = "user"
|
||||
ResourceUserSecret RBACResource = "user_secret"
|
||||
ResourceUserSkill RBACResource = "user_skill"
|
||||
ResourceWebpushSubscription RBACResource = "webpush_subscription"
|
||||
ResourceWorkspace RBACResource = "workspace"
|
||||
ResourceWorkspaceAgentDevcontainers RBACResource = "workspace_agent_devcontainers"
|
||||
@@ -120,6 +121,7 @@ var RBACResourceActions = map[RBACResource][]RBACAction{
|
||||
ResourceUsageEvent: {ActionCreate, ActionRead, ActionUpdate},
|
||||
ResourceUser: {ActionCreate, ActionDelete, ActionRead, ActionReadPersonal, ActionUpdate, ActionUpdatePersonal},
|
||||
ResourceUserSecret: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
|
||||
ResourceUserSkill: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
|
||||
ResourceWebpushSubscription: {ActionCreate, ActionDelete, ActionRead},
|
||||
ResourceWorkspace: {ActionApplicationConnect, ActionCreate, ActionCreateAgent, ActionDelete, ActionDeleteAgent, ActionRead, ActionShare, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate, ActionUpdateAgent},
|
||||
ResourceWorkspaceAgentDevcontainers: {ActionCreate},
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package codersdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// UserSkillMetadata represents a user skill without its raw Markdown content.
|
||||
type UserSkillMetadata struct {
|
||||
ID uuid.UUID `json:"id" format:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt time.Time `json:"created_at" format:"date-time"`
|
||||
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
|
||||
}
|
||||
|
||||
// UserSkill represents a user skill with its raw Markdown content.
|
||||
type UserSkill struct {
|
||||
UserSkillMetadata
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// CreateUserSkillRequest is the payload for creating a user skill.
|
||||
type CreateUserSkillRequest struct {
|
||||
// Content must be SKILL.md-format Markdown with YAML frontmatter. The
|
||||
// frontmatter must include name, may include description, and must be
|
||||
// followed by a non-empty body.
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// UpdateUserSkillRequest is the payload for updating a user skill.
|
||||
type UpdateUserSkillRequest struct {
|
||||
// Content must be SKILL.md-format Markdown with YAML frontmatter. The
|
||||
// frontmatter must include name, may include description, and must be
|
||||
// followed by a non-empty body.
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func userSkillsPath(user string) string {
|
||||
return fmt.Sprintf("/api/experimental/users/%s/skills", url.PathEscape(user))
|
||||
}
|
||||
|
||||
func userSkillPath(user string, name string) string {
|
||||
return fmt.Sprintf("%s/%s", userSkillsPath(user), url.PathEscape(name))
|
||||
}
|
||||
|
||||
// CreateUserSkill creates a user skill from raw Markdown content.
|
||||
func (c *ExperimentalClient) CreateUserSkill(ctx context.Context, user string, req CreateUserSkillRequest) (UserSkill, error) {
|
||||
res, err := c.Request(ctx, http.MethodPost, userSkillsPath(user), req)
|
||||
if err != nil {
|
||||
return UserSkill{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusCreated {
|
||||
return UserSkill{}, ReadBodyAsError(res)
|
||||
}
|
||||
var skill UserSkill
|
||||
return skill, json.NewDecoder(res.Body).Decode(&skill)
|
||||
}
|
||||
|
||||
// UserSkills lists user skill metadata for the specified user.
|
||||
func (c *ExperimentalClient) UserSkills(ctx context.Context, user string) ([]UserSkillMetadata, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, userSkillsPath(user), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, ReadBodyAsError(res)
|
||||
}
|
||||
var skills []UserSkillMetadata
|
||||
return skills, json.NewDecoder(res.Body).Decode(&skills)
|
||||
}
|
||||
|
||||
// UserSkillByName returns a user skill by name.
|
||||
func (c *ExperimentalClient) UserSkillByName(ctx context.Context, user string, name string) (UserSkill, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, userSkillPath(user, name), nil)
|
||||
if err != nil {
|
||||
return UserSkill{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return UserSkill{}, ReadBodyAsError(res)
|
||||
}
|
||||
var skill UserSkill
|
||||
return skill, json.NewDecoder(res.Body).Decode(&skill)
|
||||
}
|
||||
|
||||
// UpdateUserSkill replaces a user skill's raw Markdown content.
|
||||
func (c *ExperimentalClient) UpdateUserSkill(ctx context.Context, user string, name string, req UpdateUserSkillRequest) (UserSkill, error) {
|
||||
res, err := c.Request(ctx, http.MethodPatch, userSkillPath(user, name), req)
|
||||
if err != nil {
|
||||
return UserSkill{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return UserSkill{}, ReadBodyAsError(res)
|
||||
}
|
||||
var skill UserSkill
|
||||
return skill, json.NewDecoder(res.Body).Decode(&skill)
|
||||
}
|
||||
|
||||
// DeleteUserSkill deletes a user skill by name.
|
||||
func (c *ExperimentalClient) DeleteUserSkill(ctx context.Context, user string, name string) error {
|
||||
res, err := c.Request(ctx, http.MethodDelete, userSkillPath(user, name), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusNoContent {
|
||||
return ReadBodyAsError(res)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -42,6 +42,7 @@ We track the following resources:
|
||||
| TemplateVersion<br><i>create, write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>archived</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_name</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>external_auth_providers</td><td>false</td></tr><tr><td>has_ai_task</td><td>false</td></tr><tr><td>has_external_agent</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>message</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>readme</td><td>true</td></tr><tr><td>source_example_id</td><td>false</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
|
||||
| User<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>avatar_url</td><td>false</td></tr><tr><td>chat_spend_limit_micros</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>email</td><td>true</td></tr><tr><td>github_com_user_id</td><td>false</td></tr><tr><td>hashed_one_time_passcode</td><td>false</td></tr><tr><td>hashed_password</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>is_service_account</td><td>true</td></tr><tr><td>is_system</td><td>true</td></tr><tr><td>last_seen_at</td><td>false</td></tr><tr><td>login_type</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>one_time_passcode_expires_at</td><td>true</td></tr><tr><td>quiet_hours_schedule</td><td>true</td></tr><tr><td>rbac_roles</td><td>true</td></tr><tr><td>status</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
|
||||
| UserSecret<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>description</td><td>true</td></tr><tr><td>env_name</td><td>true</td></tr><tr><td>file_path</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr><tr><td>value</td><td>true</td></tr><tr><td>value_key_id</td><td>false</td></tr></tbody></table> |
|
||||
| UserSkill<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>content</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>description</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
|
||||
| WorkspaceBuild<br><i>start, stop</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>build_number</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>daily_cost</td><td>false</td></tr><tr><td>deadline</td><td>false</td></tr><tr><td>has_ai_task</td><td>false</td></tr><tr><td>has_external_agent</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>initiator_by_avatar_url</td><td>false</td></tr><tr><td>initiator_by_name</td><td>false</td></tr><tr><td>initiator_by_username</td><td>false</td></tr><tr><td>initiator_id</td><td>false</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>max_deadline</td><td>false</td></tr><tr><td>reason</td><td>false</td></tr><tr><td>template_version_id</td><td>true</td></tr><tr><td>template_version_preset_id</td><td>false</td></tr><tr><td>transition</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>workspace_id</td><td>false</td></tr></tbody></table> |
|
||||
| WorkspaceProxy<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>derp_enabled</td><td>true</td></tr><tr><td>derp_only</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>region_id</td><td>true</td></tr><tr><td>token_hashed_secret</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>url</td><td>true</td></tr><tr><td>version</td><td>true</td></tr><tr><td>wildcard_hostname</td><td>true</td></tr></tbody></table> |
|
||||
| WorkspaceTable<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>automatic_updates</td><td>true</td></tr><tr><td>autostart_schedule</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>deleting_at</td><td>true</td></tr><tr><td>dormant_at</td><td>true</td></tr><tr><td>favorite</td><td>true</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_used_at</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>next_start_at</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>ttl</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_acl</td><td>true</td></tr></tbody></table> |
|
||||
|
||||
Generated
+20
-20
@@ -193,10 +193,10 @@ Status Code **200**
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Property | Value(s) |
|
||||
|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
|
||||
| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` |
|
||||
| Property | Value(s) |
|
||||
|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
|
||||
| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
@@ -326,10 +326,10 @@ Status Code **200**
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Property | Value(s) |
|
||||
|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
|
||||
| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` |
|
||||
| Property | Value(s) |
|
||||
|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
|
||||
| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
@@ -459,10 +459,10 @@ Status Code **200**
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Property | Value(s) |
|
||||
|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
|
||||
| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` |
|
||||
| Property | Value(s) |
|
||||
|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
|
||||
| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
@@ -554,10 +554,10 @@ Status Code **200**
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Property | Value(s) |
|
||||
|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
|
||||
| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` |
|
||||
| Property | Value(s) |
|
||||
|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
|
||||
| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
@@ -960,9 +960,9 @@ Status Code **200**
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Property | Value(s) |
|
||||
|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
|
||||
| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` |
|
||||
| Property | Value(s) |
|
||||
|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
|
||||
| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
Generated
+83
-9
File diff suppressed because one or more lines are too long
Generated
+5
-5
@@ -865,11 +865,11 @@ Status Code **200**
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Property | Value(s) |
|
||||
|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` |
|
||||
| `login_type` | `github`, `oidc`, `password`, `token` |
|
||||
| `scope` | `all`, `application_connect` |
|
||||
| Property | Value(s) |
|
||||
|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` |
|
||||
| `login_type` | `github`, `oidc`, `password`, `token` |
|
||||
| `scope` | `all`, `application_connect` |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
|
||||
@@ -390,6 +390,29 @@ func Test_diff(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
runDiffTests(t, []diffTest{
|
||||
{
|
||||
// User skill content is user-authored instruction text, not secret
|
||||
// material, so audit diffs can include the content change.
|
||||
name: "UserSkillContentTracked",
|
||||
left: audit.Empty[database.UserSkill](),
|
||||
right: database.UserSkill{
|
||||
ID: uuid.UUID{1},
|
||||
UserID: uuid.UUID{2},
|
||||
Name: "review-guidance",
|
||||
Description: "How to review private projects",
|
||||
Content: "review markdown",
|
||||
},
|
||||
exp: audit.Map{
|
||||
"id": audit.OldNew{Old: "", New: uuid.UUID{1}.String()},
|
||||
"user_id": audit.OldNew{Old: "", New: uuid.UUID{2}.String()},
|
||||
"name": audit.OldNew{Old: "", New: "review-guidance"},
|
||||
"description": audit.OldNew{Old: "", New: "How to review private projects"},
|
||||
"content": audit.OldNew{Old: "", New: "review markdown"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
runDiffTests(t, []diffTest{
|
||||
{
|
||||
name: "Create",
|
||||
|
||||
@@ -34,6 +34,7 @@ var AuditActionMap = map[string][]codersdk.AuditAction{
|
||||
"AuditableGroupAiBudget": {codersdk.AuditActionWrite, codersdk.AuditActionDelete},
|
||||
"Chat": {codersdk.AuditActionCreate, codersdk.AuditActionWrite}, // chats get 'archived' by users, not deleted.
|
||||
"UserSecret": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete},
|
||||
"UserSkill": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete},
|
||||
}
|
||||
|
||||
type Action string
|
||||
@@ -446,6 +447,15 @@ var auditableResourcesTypes = map[any]map[string]Action{
|
||||
"plan_mode": ActionIgnore, // Can flip back and forth during a session.
|
||||
"client_type": ActionIgnore, // Set at creation.
|
||||
},
|
||||
&database.UserSkill{}: {
|
||||
"id": ActionTrack,
|
||||
"user_id": ActionTrack,
|
||||
"name": ActionTrack,
|
||||
"description": ActionTrack,
|
||||
"content": ActionTrack,
|
||||
"created_at": ActionIgnore,
|
||||
"updated_at": ActionIgnore,
|
||||
},
|
||||
&database.UserSecret{}: {
|
||||
"id": ActionTrack,
|
||||
"user_id": ActionTrack,
|
||||
|
||||
@@ -215,6 +215,12 @@ export const RBACResourceActions: Partial<
|
||||
read: "read user secret metadata and value",
|
||||
update: "update user secret metadata and value",
|
||||
},
|
||||
user_skill: {
|
||||
create: "create a user skill",
|
||||
delete: "delete a user skill",
|
||||
read: "read user skill metadata and content",
|
||||
update: "update user skill metadata and content",
|
||||
},
|
||||
webpush_subscription: {
|
||||
create: "create webpush subscriptions",
|
||||
delete: "delete webpush subscriptions",
|
||||
|
||||
Generated
+60
@@ -653,6 +653,11 @@ export type APIKeyScope =
|
||||
| "user_secret:delete"
|
||||
| "user_secret:read"
|
||||
| "user_secret:update"
|
||||
| "user_skill:*"
|
||||
| "user_skill:create"
|
||||
| "user_skill:delete"
|
||||
| "user_skill:read"
|
||||
| "user_skill:update"
|
||||
| "user:update"
|
||||
| "user:update_personal"
|
||||
| "webpush_subscription:*"
|
||||
@@ -874,6 +879,11 @@ export const APIKeyScopes: APIKeyScope[] = [
|
||||
"user_secret:delete",
|
||||
"user_secret:read",
|
||||
"user_secret:update",
|
||||
"user_skill:*",
|
||||
"user_skill:create",
|
||||
"user_skill:delete",
|
||||
"user_skill:read",
|
||||
"user_skill:update",
|
||||
"user:update",
|
||||
"user:update_personal",
|
||||
"webpush_subscription:*",
|
||||
@@ -3581,6 +3591,19 @@ export interface CreateUserSecretRequest {
|
||||
readonly file_path?: string;
|
||||
}
|
||||
|
||||
// From codersdk/userskills.go
|
||||
/**
|
||||
* CreateUserSkillRequest is the payload for creating a user skill.
|
||||
*/
|
||||
export interface CreateUserSkillRequest {
|
||||
/**
|
||||
* Content must be SKILL.md-format Markdown with YAML frontmatter. The
|
||||
* frontmatter must include name, may include description, and must be
|
||||
* followed by a non-empty body.
|
||||
*/
|
||||
readonly content: string;
|
||||
}
|
||||
|
||||
// From codersdk/workspaces.go
|
||||
export type CreateWorkspaceBuildReason =
|
||||
| "cli"
|
||||
@@ -6718,6 +6741,7 @@ export type RBACResource =
|
||||
| "usage_event"
|
||||
| "user"
|
||||
| "user_secret"
|
||||
| "user_skill"
|
||||
| "webpush_subscription"
|
||||
| "*"
|
||||
| "workspace"
|
||||
@@ -6767,6 +6791,7 @@ export const RBACResources: RBACResource[] = [
|
||||
"usage_event",
|
||||
"user",
|
||||
"user_secret",
|
||||
"user_skill",
|
||||
"webpush_subscription",
|
||||
"*",
|
||||
"workspace",
|
||||
@@ -6914,6 +6939,7 @@ export type ResourceType =
|
||||
| "template_version"
|
||||
| "user"
|
||||
| "user_secret"
|
||||
| "user_skill"
|
||||
| "workspace"
|
||||
| "workspace_agent"
|
||||
| "workspace_app"
|
||||
@@ -6948,6 +6974,7 @@ export const ResourceTypes: ResourceType[] = [
|
||||
"template_version",
|
||||
"user",
|
||||
"user_secret",
|
||||
"user_skill",
|
||||
"workspace",
|
||||
"workspace_agent",
|
||||
"workspace_app",
|
||||
@@ -8818,6 +8845,19 @@ export interface UpdateUserSecretRequest {
|
||||
readonly file_path?: string;
|
||||
}
|
||||
|
||||
// From codersdk/userskills.go
|
||||
/**
|
||||
* UpdateUserSkillRequest is the payload for updating a user skill.
|
||||
*/
|
||||
export interface UpdateUserSkillRequest {
|
||||
/**
|
||||
* Content must be SKILL.md-format Markdown with YAML frontmatter. The
|
||||
* frontmatter must include name, may include description, and must be
|
||||
* followed by a non-empty body.
|
||||
*/
|
||||
readonly content: string;
|
||||
}
|
||||
|
||||
// From codersdk/workspaces.go
|
||||
export interface UpdateWorkspaceACL {
|
||||
/**
|
||||
@@ -9235,6 +9275,26 @@ export interface UserSecret {
|
||||
readonly updated_at: string;
|
||||
}
|
||||
|
||||
// From codersdk/userskills.go
|
||||
/**
|
||||
* UserSkill represents a user skill with its raw Markdown content.
|
||||
*/
|
||||
export interface UserSkill extends UserSkillMetadata {
|
||||
readonly content: string;
|
||||
}
|
||||
|
||||
// From codersdk/userskills.go
|
||||
/**
|
||||
* UserSkillMetadata represents a user skill without its raw Markdown content.
|
||||
*/
|
||||
export interface UserSkillMetadata {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly created_at: string;
|
||||
readonly updated_at: string;
|
||||
}
|
||||
|
||||
// From codersdk/users.go
|
||||
export type UserStatus = "active" | "dormant" | "suspended";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user