Files
coder/coderd/database/migrations/000502_user_skills.up.sql
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

139 lines
4.7 KiB
PL/PgSQL

-- 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:*';