Skip to content

control-plane-api: capability_token grant mints masked access tokens - #3424

Open
bbartman wants to merge 6 commits into
bmb/3376-stack-6-legacy-decisionsfrom
bmb/3376-stack-7-capability-mint
Open

control-plane-api: capability_token grant mints masked access tokens#3424
bbartman wants to merge 6 commits into
bmb/3376-stack-6-legacy-decisionsfrom
bmb/3376-stack-7-capability-mint

Conversation

@bbartman

@bbartman bbartman commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Task 5 of #3376 (stacked on #3422): POST /api/v1/auth/token gains the capability_token grant, the first production path that mints masked tokens.

What this does

{"grant_type": "capability_token", "capability_mask": ["CatalogRead", "Viewer"]}

An unmasked, human bearer mints a one-hour access token that copies the caller's verified identity claims and carries capability_mask as a claim. Enforcement (landed in #3406) intersects the mask with the user's live grants at use time.

Per Gregor's revision on the issue, there is no upgrade_token parameter: a client wanting more capabilities requests the full list in a fresh mint. This removes the planned signature-only verify variant in the tokens crate, the mask-union semantics, and the expired-token re-mint rules from the original decision 4.

Decisions implemented

  • Copy-through claim set. sub, role, aud, and email (when present) are copied verbatim from the caller's claims; fresh iat; exp = iat + 1h. Aside from email and capability_mask this is exactly the SQL generate_access_token claim set (pinned by pgTAP in supabase/tests/), so the two mint paths cannot drift, and a masked token remains a fully functional Supabase/PostgREST token during the GraphQL migration: PostgREST reads sub (RLS via auth.uid()) and role (SET ROLE) and carries unknown claims inertly in request.jwt.claims.
  • The mask is stamped verbatim — bundle-name vocabulary, unknown names and duplicates included, empty list valid (identity-only token). Enforcement, not the mint, decides what a name enables; unrecognized names can never widen.
  • Rust-side signing with the control plane's existing HMAC encoding key, following the authorize_dekaf precedent. The SQL function is untouched and keeps serving the refresh_token grant and PostgREST clients.
  • Masked callers are refused with the structured unmasked_token_required 403 — a reduced token cannot widen or re-mint itself (the issue's open question).
  • Service-account callers are refused with a new structured service_account_forbidden 403 (decision 11). The internal.service_accounts lookup is shared with the refresh-token mutations via a new is_service_account predicate and is paid only at the mint, never per-request.
  • The handler extracts Authority<NoRequirement>, so a presented bearer is verified regardless of grant: a broken bearer now 401s even for the refresh_token grant, which previously ignored the Authorization header entirely. Bearer-less refresh_token requests are byte-identical. No known client sends a bearer to this endpoint (flowctl authenticates via PostgREST; the dashboard via the Supabase library).

Tests

test_capability_token_mint drives the real router end-to-end: the copy-through claim set and 1-hour expiry of a successful mint (presenting the minted token back as a bearer proves the signature through production Envelope verification); verbatim mask stamping including unknown names and duplicates; the identity-only empty mask, which is still refused as a mint caller ("masked" is claim presence, not value); an email-less caller minting an email-less token; missing-bearer 401; masked-caller and service-account-caller 403 bodies (insta-pinned); invalid-bearer 401 on the refresh_token grant; and the typed extractor's 422 for an unknown grant. Note the last: axum's Json rejects a well-formed body with an unknown grant_type as 422, not the 400 named in plan decision 6 — the tests pin actual behavior.

Closes nothing on its own; tasks 6 (guards) and 7 (no-amplification suite) follow.

Revision (2026-08-28): configurable validity window

The mint's validity window is deploy-configurable (initially opened as #3433 and folded into this PR): a new agent setting --capability-token-validity / CAPABILITY_TOKEN_VALIDITY (humantime syntax, following the controller duration settings' pattern) feeds an App field into the signer, which stays pure. The default is the shared DEFAULT_CAPABILITY_TOKEN_VALIDITY constant — one hour, matching the SQL mint — consumed by the test builders and the mint test, with a weld test in agent pinning the clap default string to the constant (which also proves the default parses before the first production boot). An override deliberately diverges from the SQL mint's fixed hour (claim-set parity is unaffected), and a longer window widens leaked-masked-token exposure — the flag docs carry both warnings. A unit test pins a non-default window at the signing seam.

… code

is_service_account is the one place that answers "is this bearer a
service-account identity" (claims alone cannot), consumed by the
refresh-token mutations' verify_not_service_account and by the
capability_token mint. Forbidden gains the service_account_forbidden
code so the mint's refusal is machine-readable in the same structured
403 envelope as its siblings.
POST /api/v1/auth/token gains the capability_token grant: an unmasked,
human bearer mints a one-hour token which copies the caller's identity
claims (sub, role, aud, and email when present) and carries the
request's capability_mask verbatim — bundle names are opaque at mint,
unknown names stay inert at use, and an empty mask is a valid
identity-only token. A masked caller is refused with the structured
unmasked_token_required 403 (a reduced token cannot widen or re-mint
itself) and a service-account caller with service_account_forbidden.

The handler now extracts Authority<NoRequirement>, so any presented
bearer is verified regardless of grant; a bearer-less refresh_token
exchange is unchanged. Signing uses the control plane's HMAC key — the
authorize_dekaf precedent — and the claim set matches the SQL
generate_access_token mint pinned by pgTAP, keeping masked tokens fully
functional against Supabase/PostgREST during the GraphQL migration.
…ordering

The mint's verbatim mask stamping means no claim assertion can catch a
token whose names enforcement doesn't recognize, so the test drives a
RequireViewer route with minted tokens: a Viewer mask clears the
requirement gate while the identity-only mask is the structured
shortfall 403. A scoped-role, email-less bearer distinguishes claim
copy-through from hardcoded values. A bearer that is both masked and a
service account pins the mask refusal ahead of the database lookup.
And the typed extractor's 422s pin that the grant structurally cannot
be spoken without a capability_mask array — null included. The
email-less mint folds into the scoped-role bearer, which carries no
email.
The predicate is an identity lookup with nothing GraphQL about it, and
grants.rs is the crate's home for identity DB helpers — both its
consumers (the refresh-token GraphQL mutations and the REST
capability_token mint) reach it as crate::grants without one surface
reaching into the other's module. The mint test's copy-through claim
assertions are one redacted insta snapshot of the full serialized
claim set, which pins the payload's exact key set — the Rust half of
the two-snapshot claim parity with the pgTAP-pinned SQL mint.
The validity window of capability_token mints is an App field fed by
the agent's --capability-token-validity / CAPABILITY_TOKEN_VALIDITY
setting (humantime syntax), following the pattern of the controller
duration settings. Its default is the shared
DEFAULT_CAPABILITY_TOKEN_VALIDITY constant — one hour, matching the SQL
generate_access_token mint — which the test builders and the mint test
consume directly, and a weld test pins the clap default string to the
constant (also proving the default parses before the first production
boot). An override diverges from the SQL mint's fixed hour
deliberately, and a longer window widens the exposure of a leaked
masked token — the flag docs say so. A unit test pins a non-default
window at the signing seam.
@bbartman
bbartman force-pushed the bmb/3376-stack-7-capability-mint branch from b3fa9c6 to 74c9cd4 Compare September 1, 2026 13:08
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.

1 participant