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
+10
View File
@@ -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,
+3
View File
@@ -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"
}
+2
View File
@@ -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},
+120
View File
@@ -0,0 +1,120 @@
package codersdk
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
"github.com/google/uuid"
)
// UserSkillMetadata represents a user skill without its raw Markdown content.
type UserSkillMetadata struct {
ID uuid.UUID `json:"id" format:"uuid"`
Name string `json:"name"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
}
// UserSkill represents a user skill with its raw Markdown content.
type UserSkill struct {
UserSkillMetadata
Content string `json:"content"`
}
// CreateUserSkillRequest is the payload for creating a user skill.
type CreateUserSkillRequest struct {
// Content must be SKILL.md-format Markdown with YAML frontmatter. The
// frontmatter must include name, may include description, and must be
// followed by a non-empty body.
Content string `json:"content"`
}
// UpdateUserSkillRequest is the payload for updating a user skill.
type UpdateUserSkillRequest struct {
// Content must be SKILL.md-format Markdown with YAML frontmatter. The
// frontmatter must include name, may include description, and must be
// followed by a non-empty body.
Content string `json:"content"`
}
func userSkillsPath(user string) string {
return fmt.Sprintf("/api/experimental/users/%s/skills", url.PathEscape(user))
}
func userSkillPath(user string, name string) string {
return fmt.Sprintf("%s/%s", userSkillsPath(user), url.PathEscape(name))
}
// CreateUserSkill creates a user skill from raw Markdown content.
func (c *ExperimentalClient) CreateUserSkill(ctx context.Context, user string, req CreateUserSkillRequest) (UserSkill, error) {
res, err := c.Request(ctx, http.MethodPost, userSkillsPath(user), req)
if err != nil {
return UserSkill{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return UserSkill{}, ReadBodyAsError(res)
}
var skill UserSkill
return skill, json.NewDecoder(res.Body).Decode(&skill)
}
// UserSkills lists user skill metadata for the specified user.
func (c *ExperimentalClient) UserSkills(ctx context.Context, user string) ([]UserSkillMetadata, error) {
res, err := c.Request(ctx, http.MethodGet, userSkillsPath(user), nil)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, ReadBodyAsError(res)
}
var skills []UserSkillMetadata
return skills, json.NewDecoder(res.Body).Decode(&skills)
}
// UserSkillByName returns a user skill by name.
func (c *ExperimentalClient) UserSkillByName(ctx context.Context, user string, name string) (UserSkill, error) {
res, err := c.Request(ctx, http.MethodGet, userSkillPath(user, name), nil)
if err != nil {
return UserSkill{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return UserSkill{}, ReadBodyAsError(res)
}
var skill UserSkill
return skill, json.NewDecoder(res.Body).Decode(&skill)
}
// UpdateUserSkill replaces a user skill's raw Markdown content.
func (c *ExperimentalClient) UpdateUserSkill(ctx context.Context, user string, name string, req UpdateUserSkillRequest) (UserSkill, error) {
res, err := c.Request(ctx, http.MethodPatch, userSkillPath(user, name), req)
if err != nil {
return UserSkill{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return UserSkill{}, ReadBodyAsError(res)
}
var skill UserSkill
return skill, json.NewDecoder(res.Body).Decode(&skill)
}
// DeleteUserSkill deletes a user skill by name.
func (c *ExperimentalClient) DeleteUserSkill(ctx context.Context, user string, name string) error {
res, err := c.Request(ctx, http.MethodDelete, userSkillPath(user, name), nil)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}