Files
coder/coderd/rbac/scopes_catalog.go
T
Michael Suchacz 5a8d0016a5 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`
2026-05-20 00:09:09 +02:00

130 lines
3.3 KiB
Go

package rbac
import (
"sort"
"strings"
)
// externalLowLevel is the curated set of low-level scope names exposed to users.
// Any valid resource:action pair not in this set is considered internal-only
// and must not be user-requestable.
var externalLowLevel = map[ScopeName]struct{}{
// Workspaces
"workspace:read": {},
"workspace:create": {},
"workspace:update": {},
"workspace:delete": {},
"workspace:ssh": {},
"workspace:start": {},
"workspace:stop": {},
"workspace:application_connect": {},
"workspace:*": {},
// Templates
"template:read": {},
"template:create": {},
"template:update": {},
"template:delete": {},
"template:use": {},
"template:*": {},
// API keys (self-management)
"api_key:read": {},
"api_key:create": {},
"api_key:update": {},
"api_key:delete": {},
"api_key:*": {},
// Files
"file:read": {},
"file:create": {},
"file:*": {},
// Users
"user:read": {},
"user:read_personal": {},
"user:update_personal": {},
"user.*": {},
// User secrets
"user_secret:read": {},
"user_secret:create": {},
"user_secret:update": {},
"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": {},
"task:update": {},
"task:delete": {},
"task:*": {},
// Organizations
"organization:read": {},
"organization:update": {},
"organization:delete": {},
"organization:*": {},
}
// Public composite coder:* scopes exposed to users.
var externalComposite = map[ScopeName]struct{}{
"coder:workspaces.create": {},
"coder:workspaces.operate": {},
"coder:workspaces.delete": {},
"coder:workspaces.access": {},
"coder:templates.build": {},
"coder:templates.author": {},
"coder:apikeys.manage_self": {},
}
// IsExternalScope returns true if the scope is public, including the
// `all` and `application_connect` special scopes and the curated
// low-level resource:action scopes.
func IsExternalScope(name ScopeName) bool {
switch name {
// Include `all` and `application_connect` for backward compatibility.
case "all", ScopeAll, "application_connect", ScopeApplicationConnect:
return true
}
if _, ok := externalLowLevel[name]; ok {
return true
}
if _, ok := externalComposite[name]; ok {
return true
}
return false
}
// ExternalScopeNames returns a sorted list of all public scopes, which
// includes the `all` and `application_connect` special scopes, curated
// low-level resource:action names, and curated composite coder:* scopes.
func ExternalScopeNames() []string {
names := make([]string, 0, len(externalLowLevel)+len(externalComposite)+2)
names = append(names, string(ScopeAll))
names = append(names, string(ScopeApplicationConnect))
// curated low-level names, filtered for validity
for name := range externalLowLevel {
if _, _, ok := parseLowLevelScope(name); ok {
names = append(names, string(name))
}
}
// curated composite names
for name := range externalComposite {
names = append(names, string(name))
}
sort.Slice(names, func(i, j int) bool { return strings.Compare(names[i], names[j]) < 0 })
return names
}