Files
Containr/app/backend/migrations_goose/20261008000000_user_tokens.sql
T
Tomas Dvorak e881747388 feat: personal access tokens with scoped bearer auth
Add cnp_ personal access tokens so the CLI, MCP server, and external
agents authenticate without a browser session — the agent-first spine
from ROADMAP2 phase A. Modeled on agent_auth_tokens: raw token shown
once, only SHA-256 hash stored.

- user_tokens table (goose migration + sqlc), name/prefix/scope/expiry,
  owner-scoped revoke, last_used_at stamping
- middleware: cnp_ bearer resolution, read scope gates mutations with
  403 SCOPE_INSUFFICIENT, non-admin scopes cap is_admin even for admin
  owners — a token never outranks its scope or its owner
- GET/POST/DELETE /api/v1/user/tokens + OpenAPI + generated types
- Settings UI: create with scope/expiry pickers, one-time token display
  with copy, list with last-used/expiry, confirm-revoke
- docs: OPERATIONS.md section, README feature line

Verified end-to-end against live Postgres: create/list/revoke, read
scope blocks PUT (403), write PAT on admin owner blocked from admin
routes (403), admin scope works, revoked token returns 401.

Authored By: TDvorak <[email protected]>
2026-10-08 14:31:29 +02:00

26 lines
1.2 KiB
SQL

-- +goose Up
-- Personal access tokens for CLI/MCP/agent authentication.
-- Only sha256 hashes are stored — raw tokens (cnp_…) are shown once at
-- creation and never persisted. Scope gates what the token may do:
-- read = GET/HEAD only, write = all non-admin routes, admin = everything
-- (admin still requires the owning user to be a platform admin).
CREATE TABLE IF NOT EXISTS public.user_tokens (
id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
user_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
name character varying(255) NOT NULL,
key_prefix character varying(16) NOT NULL,
token_hash text NOT NULL UNIQUE,
scope character varying(20) NOT NULL DEFAULT 'write'
CHECK (scope IN ('read', 'write', 'admin')),
expires_at timestamp with time zone,
last_used_at timestamp with time zone,
revoked_at timestamp with time zone,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_user_tokens_user_id ON public.user_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_user_tokens_token_hash ON public.user_tokens(token_hash);
-- +goose Down
DROP TABLE IF EXISTS public.user_tokens;