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

> Mux updated this PR on behalf of Mike.

## Stack Context

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

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

## What?

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

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

## Why?

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

## Validation

- `make gen`
- `go test ./coderd/database -run '^TestUserSkillSchemaConstants$'
-count=1`
- `go test ./coderd/database/dbauthz -run
'^TestMethodTestSuite/TestUserSkills$' -count=1`
- `go test ./coderd -run '^TestPatchUserSkill$' -count=1`
- `go test ./codersdk ./coderd/database/db2sdk`
- `make lint`
- pre-commit hook on `97fd58108d`
This commit is contained in:
Michael Suchacz
2026-05-20 00:09:09 +02:00
committed by GitHub
parent 3c9c8c708d
commit 5a8d0016a5
46 changed files with 2315 additions and 70 deletions
+11
View File
@@ -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,
+8
View File
@@ -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",
+4 -2
View File
@@ -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.
+28
View File
@@ -1110,6 +1110,34 @@ func TestRolePermissions(t *testing.T) {
},
},
},
// Skills are user-authored instructions, not secrets. Owners can inspect
// and delete them, but only the user can create or update them.
{
Name: "UserSkillsReadDelete",
Actions: []policy.Action{policy.ActionRead, policy.ActionDelete},
Resource: rbac.ResourceUserSkill.WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, memberMe, agentsAccessUser},
false: {
orgAdmin,
otherOrgAdmin, orgAuditor, orgUserAdmin, orgTemplateAdmin,
templateAdmin, userAdmin, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin,
},
},
},
{
Name: "UserSkillsCreateUpdate",
Actions: []policy.Action{policy.ActionCreate, policy.ActionUpdate},
Resource: rbac.ResourceUserSkill.WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {memberMe, agentsAccessUser},
false: {
owner, orgAdmin,
otherOrgAdmin, orgAuditor, orgUserAdmin, orgTemplateAdmin,
templateAdmin, userAdmin, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin,
},
},
},
{
Name: "UsageEvents",
Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate},
+7
View File
@@ -53,6 +53,13 @@ var externalLowLevel = map[ScopeName]struct{}{
"user_secret:delete": {},
"user_secret:*": {},
// User skills
"user_skill:read": {},
"user_skill:create": {},
"user_skill:update": {},
"user_skill:delete": {},
"user_skill:*": {},
// Tasks
"task:create": {},
"task:read": {},
+12
View File
@@ -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,