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]>
This commit is contained in:
Tomas Dvorak committed 2026-10-08 14:31:29 +02:00
1 parent f9ae5b962c
commit e881747388
17 files changed
+1141 -30

No files matched your search

+27
View File
@@ -90,3 +90,30 @@ backend restart.
once), copy the prefilled install command, revoke tokens when done. Agents
register over `POST /api/agents/register` and heartbeat via
`POST /api/agents/heartbeat`.
## Personal access tokens
**Settings → Personal Access Tokens**: issue `cnp_…` bearer tokens for the
CLI, MCP server, and scripts — the same privileges as your account, bounded
by scope. The raw token is shown once at creation; only its SHA-256 hash is
stored.
Scopes:
- `read` — `GET`/`HEAD`/`OPTIONS` only; mutations return `403 SCOPE_INSUFFICIENT`.
- `write` — everything except admin routes.
- `admin` — full access; requires a platform admin account, and the token is
still capped at the owner's privileges.
A token can never outrank its owner: non-admin scopes report
`is_admin = false` even on admin accounts.
```
GET /api/v1/user/tokens list (metadata only, never hashes)
POST /api/v1/user/tokens {name, scope?, expires_in_days?} → raw token once
DELETE /api/v1/user/tokens/:id revoke (owner-scoped)
```
Use as `Authorization: Bearer cnp_…`, or `containr auth login cnp_…`.
Revocation takes effect on the next request; `last_used_at` updates on each
authenticated call.