Personal API tokens (#75) - #112
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_tokenspermission) mints aflp_-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
api_tokenstable, hashed-secret pattern, provenance FK)cc6b585manage_api_tokens, Users & Roles, global; Administrator-only)066b87cflp_prefix,get_current_userbranch, revoked/expired 401,last_used_at, rate limits inherited via the resolved holder)75295a9usersmodule +/meself-service) & Events (api_token.created/revoked+ trigger catalogs)4105b22userssection, per the follow-up answers)a9b5a8fuse-api-tokenshooks, Admin → Users panel withEntityCombobox+ one-time reveal +useConfirmrevoke,/profilecard)31fd051cfb13819dcaceeAll five open questions were answered on the issue and are implemented as answered: self-service view/revoke on
/profile, singleAuthorization: Bearerheader 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_idisSET NULLrather thanCASCADE(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
cd backend && .venv/bin/pytest(471 passed; migration also verified against a real Postgres 16)cd frontend && npm run test && npx tsc --noEmit && npx eslint .(159 passed, tsc + eslint clean)competition_idscoping (n/a — site-wide identity),require_permission, one hook per domain, design tokens)YYYY-MM-DD_<revid>_<desc>.py(2026-07-30_6b7c8d9eafb0_api_tokens.py)🤖 Generated with Claude Code
https://claude.ai/code/session_01RsKBXTpu4k8hSiR1TmLBbD
Generated by Claude Code