mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -0,0 +1,43 @@
|
||||
-- Enum additions to resource_type and api_key_scope are intentionally not
|
||||
-- reverted because Postgres cannot drop enum values safely.
|
||||
DROP TRIGGER IF EXISTS trigger_upsert_user_skills ON user_skills;
|
||||
DROP FUNCTION IF EXISTS insert_user_skill_fail_if_user_deleted;
|
||||
|
||||
-- Restore the previous body of delete_deleted_user_resources() from
|
||||
-- migration 000492 (without the user_skills cleanup).
|
||||
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;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trigger_user_skills_per_user_limit ON user_skills;
|
||||
DROP FUNCTION IF EXISTS enforce_user_skills_per_user_limit();
|
||||
DROP TABLE user_skills;
|
||||
@@ -0,0 +1,138 @@
|
||||
-- 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:*';
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Inserts a user skill fixture so migration coverage includes the table.
|
||||
INSERT INTO user_skills (
|
||||
id,
|
||||
user_id,
|
||||
name,
|
||||
description,
|
||||
content,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
'7f070eb2-991e-4f7f-b780-40c4e0f49001',
|
||||
'30095c71-380b-457a-8995-97b8ee6e5307',
|
||||
'example-skill',
|
||||
'Example skill fixture.',
|
||||
'Example content.',
|
||||
'2026-05-07 00:00:00+00',
|
||||
'2026-05-07 00:00:00+00'
|
||||
);
|
||||
Reference in New Issue
Block a user