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
+107 -24
View File
@@ -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);