Skip to content

Personal API tokens (#75) - #112

Merged
tbcsec merged 10 commits into
mainfrom
claude/issue-75
Jul 30, 2026
Merged

Personal API tokens (#75)#112
tbcsec merged 10 commits into
mainfrom
claude/issue-75

Conversation

@tbcsec

@tbcsec tbcsec commented Jul 30, 2026

Copy link
Copy Markdown
Owner

What & why

Administrator-minted personal API tokens as a first-class authentication mechanism beside the browser JWT flow, so scripts and integrations can call the REST API without capturing an access token. An admin (new global manage_api_tokens permission) mints a flp_-prefixed opaque token for a chosen user; requests bearing it authenticate as that holder with their full effective permission set. Only the SHA-256 is stored; the raw value is shown once at mint time. A holder can view/revoke (not create) their own tokens from /profile.

Fixes #75

Plan → commit map

Plan section Commit
Data model / migration (api_tokens table, hashed-secret pattern, provenance FK) cc6b585
Permission (manage_api_tokens, Users & Roles, global; Administrator-only) 066b87c
Token format & auth (flp_ prefix, get_current_user branch, revoked/expired 401, last_used_at, rate limits inherited via the resolved holder) 75295a9
Router (mint/list/revoke on the users module + /me self-service) & Events (api_token.created/revoked + trigger catalogs) 4105b22
Backup (tokens travel with the users section, per the follow-up answers) a9b5a8f
Frontend (use-api-tokens hooks, Admin → Users panel with EntityCombobox + one-time reveal + useConfirm revoke, /profile card) 31fd051
Testing (backend pytest per the Test Strategy + backup round-trip; frontend vitest) cfb1381
Docs (ARCHITECTURE §7.1 catalog + §7.7 auth exception) 9dcacee

All five open questions were answered on the issue and are implemented as answered: self-service view/revoke on /profile, single Authorization: Bearer header disambiguated by prefix, tokens included in the platform backup, no expiry ceiling (any admin-chosen duration), REST only — no WebSocket auth.

Deviations from the plan: none of substance. Two small judgment calls within its bounds: created_by_user_id is SET NULL rather than CASCADE (deleting the minting admin shouldn't kill the holder's still-valid token), and the plan's "expiry select" is a free number-of-days input since the owner declined an upper bound.

Checklist

  • Backend tests pass — cd backend && .venv/bin/pytest (471 passed; migration also verified against a real Postgres 16)
  • Frontend checks pass — cd frontend && npm run test && npx tsc --noEmit && npx eslint . (159 passed, tsc + eslint clean)
  • If UI-observable, I ran it in the browser and confirmed it works — not run; UI is exercised only via unit tests and type/lint checks in this environment
  • Follows the architectural rules in CONTRIBUTING.md (events, competition_id scoping (n/a — site-wide identity), require_permission, one hook per domain, design tokens)
  • One migration for this PR if the schema changed, named YYYY-MM-DD_<revid>_<desc>.py (2026-07-30_6b7c8d9eafb0_api_tokens.py)
  • This is not a security fix (those follow SECURITY.md)

🤖 Generated with Claude Code

https://claude.ai/code/session_01RsKBXTpu4k8hSiR1TmLBbD


Generated by Claude Code

claude and others added 10 commits July 30, 2026 21:37
Site-wide identity table following the RefreshSession/PasswordResetToken
hashed-secret pattern: SHA-256 token_hash only, holder FK (CASCADE),
minting-admin provenance FK (SET NULL), required description + expiry,
best-effort last_used_at, soft revoked_at. Verified against Postgres 16.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RsKBXTpu4k8hSiR1TmLBbD
Global-scoped, in Users & Roles. Administrator picks it up automatically
(ADMINISTRATOR_PERMISSIONS derives from the catalog + startup role
re-sync); deliberately not added to Judge/Participant — minting is
admin-only per the issue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RsKBXTpu4k8hSiR1TmLBbD
…75)

generate_api_token() mints flp_ + token_urlsafe(40) — unambiguous vs a
JWT (three dot-separated segments), greppable if leaked. get_current_user
branches on the prefix: hash lookup, 401 on missing/revoked/expired,
resolves the holder (inactive rejected same as JWT), best-effort
last_used_at bump. Identity resolution stays centralized here, so every
require_permission check and per-user rate limiter keys off the resolved
holder unchanged — same rate limits as browser sessions for free.
REST only; not wired into the WebSocket handshake.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RsKBXTpu4k8hSiR1TmLBbD
/api/api-tokens mounted by the users required-core module: mint (raw
token returned once)/list/revoke gated on manage_api_tokens, plus /me
self-service list + revoke-own (ownership-checked, no catalog perm —
owner call). Emits api_token.created/revoked (id/user refs only, never
the token or hash); events added to the §3.2 catalog and the automation
trigger catalogs (fields + manage_api_tokens trigger permission).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RsKBXTpu4k8hSiR1TmLBbD
Owner call: unlike refresh_sessions (excluded point-in-time session
state), API tokens are durable account configuration and round-trip with
the ADR-0016 platform backup. Natural key = token_hash (the raw value
never round-trips), holder FK required, provenance FK optional-nulled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RsKBXTpu4k8hSiR1TmLBbD
use-api-tokens hook module (§8) over a new apiTokensApi. Admin → Users
gains an API tokens section (manage_api_tokens): inventory table
(holder/description/created-by/expiry/last-used/status), create dialog
(EntityCombobox holder picker, description, expiry days), one-time
copy-only reveal dialog, useConfirm-gated revoke. /profile gains a
self-service card (own tokens, view/revoke only; hidden when empty).
Status derivation shared via lib/api-token-status.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RsKBXTpu4k8hSiR1TmLBbD
Backend: mint/list/revoke gated on manage_api_tokens; minted token
authenticates as its holder with the holder's (not the minter's)
permissions; revoked/expired/garbage flp_ tokens 401; JWT path
untouched; self-service list/revoke own-only (cross-user 404). Backup
round-trip: api_tokens export with users, restore after delete,
additive-idempotent re-import. Frontend: apiTokenStatus derivation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RsKBXTpu4k8hSiR1TmLBbD
manage_api_tokens joins the Users & Roles permission category; §7.7
documents the deliberate exception to one-token-type-across-transports:
flp_-prefixed, admin-minted, full holder permissions, hash-at-rest,
REST only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RsKBXTpu4k8hSiR1TmLBbD
…75)

Tokens were administrator-minted for a chosen user, which made
manage_api_tokens an impersonation primitive: a custom global role holding
only that permission could mint a token for an Administrator and act as them.
Verified against a running instance before the change — judge (a non-admin)
minted an admin token and used it to read /api/users and export the platform
backup.

Minting is now self-only. POST /api/api-tokens takes no user id at all, so a
token always belongs to the caller. That is a property of the route's shape
rather than a check that could be bypassed or forgotten — there is no way to
express "a token for someone else".

manage_api_tokens becomes an oversight grant: list every token, revoke any of
them, never mint. Revocation only removes access, so the permission can no
longer be used to gain any, while a leaked token can still be killed by
somebody other than its holder.

Also folded in, since these files were being rewritten anyway:

- created_by_user_id dropped (holder == creator now). The migration is amended
  rather than followed by a second one, since it has not been applied anywhere
  outside this branch.
- The migration's token_hash index now matches what Base.metadata builds (a
  single unique index, not a constraint plus a plain index) — the suite never
  runs migrations, so that divergence was invisible in CI.
- The oversight list resolves holder names with a join instead of a per-row
  lookup (it spans every account, so the N+1 scaled with the install).
- Revocation is shared between the self and oversight routes, and the event
  carries actor_user_id, so an audit reader can tell a holder revoking their
  own token from an admin doing it for them.
- api_token.created/revoked added to ARCHITECTURE.md §3.2, which the original
  change updated elsewhere but not there.
- The profile card distinguishes a failed fetch from "no tokens" instead of
  rendering nothing, which previously hid the revoke UI exactly when a user
  was trying to kill a leaked token.

Backend suite 473 passed; the escalation probe now returns a judge-owned token
that 403s on both admin surfaces, and oversight still lists.

Co-Authored-By: Claude <noreply@anthropic.com>
…tle the hot path (#75)

Closes the four remaining review findings.

Revocation on the compromise paths. Tokens previously outlived every
intervention meant to contain them:

- Ban now *revokes* rather than relying on the is_active check to suspend, so
  unbanning no longer silently re-arms every credential the account held. This
  was the sharpest one: an admin bans a compromised account, treats it as
  contained, later restores it, and the attacker's token starts working again.
- A password *reset* (recovering without knowing the old password) and an
  administrator setting someone's password both revoke, matching the
  compromise assumption those paths already make about refresh sessions.
- A voluntary change-password deliberately does NOT revoke. Proving the current
  password is routine hygiene, and killing a CI credential on every rotation
  would be a surprise rather than a safeguard — GitHub and GitLab draw the line
  in the same place. Documented in the docstring so the asymmetry is a decision
  rather than an oversight.

Shared in utils/api_tokens.py so these paths cannot drift from the router's own
revoke, and each revocation emits api_token.revoked with actor_user_id.

Tokens no longer travel in a platform backup, joining refresh_sessions in the
exclusion list. Only the SHA-256 is stored, but that hash is precisely what
authentication compares against, so exporting it would let the original raw
token be re-armed on whatever install the document is imported into — bound by
natural key to whichever local account happened to match. Import is additive
and cross-install by design (ADR-0016), so credentials must not ride along.

The last_used_at write is now throttled to one per five minutes and guarded.
It was an UPDATE plus a commit on the hottest path in the app, producing a dead
row version per request and serialising concurrent requests that share a token
on the same row — and despite the "best-effort" comment it was unguarded, so a
lock timeout turned a valid token into a 500 on a pure read. Verified: eight
authenticated requests now produce one write.

Backend suite 477 passed. Live-verified all four against a running instance.

Co-Authored-By: Claude <noreply@anthropic.com>
@tbcsec
tbcsec marked this pull request as ready for review July 30, 2026 22:38
@tbcsec
tbcsec merged commit ddbf22d into main Jul 30, 2026
4 checks passed
@tbcsec
tbcsec deleted the claude/issue-75 branch July 30, 2026 22:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: personal API tokens

2 participants