control-plane-api: capability_token grant mints masked access tokens - #3424
Open
bbartman wants to merge 6 commits into
Open
control-plane-api: capability_token grant mints masked access tokens#3424bbartman wants to merge 6 commits into
bbartman wants to merge 6 commits into
Conversation
This was referenced Aug 27, 2026
bbartman
force-pushed
the
bmb/3376-stack-7-capability-mint
branch
from
August 28, 2026 12:17
8d84b94 to
533ddf1
Compare
bbartman
force-pushed
the
bmb/3376-stack-7-capability-mint
branch
2 times, most recently
from
August 28, 2026 16:59
5dec927 to
bf6e870
Compare
bbartman
force-pushed
the
bmb/3376-stack-7-capability-mint
branch
from
August 31, 2026 12:45
bf6e870 to
b3fa9c6
Compare
… 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
force-pushed
the
bmb/3376-stack-7-capability-mint
branch
from
September 1, 2026 13:08
b3fa9c6 to
74c9cd4
Compare
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.
Task 5 of #3376 (stacked on #3422):
POST /api/v1/auth/tokengains thecapability_tokengrant, 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_maskas 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_tokenparameter: a client wanting more capabilities requests the full list in a fresh mint. This removes the planned signature-only verify variant in thetokenscrate, the mask-union semantics, and the expired-token re-mint rules from the original decision 4.Decisions implemented
sub,role,aud, andemail(when present) are copied verbatim from the caller's claims; freshiat;exp = iat + 1h. Aside fromemailandcapability_maskthis is exactly the SQLgenerate_access_tokenclaim set (pinned by pgTAP insupabase/tests/), so the two mint paths cannot drift, and a masked token remains a fully functional Supabase/PostgREST token during the GraphQL migration: PostgREST readssub(RLS viaauth.uid()) androle(SET ROLE) and carries unknown claims inertly inrequest.jwt.claims.authorize_dekafprecedent. The SQL function is untouched and keeps serving therefresh_tokengrant and PostgREST clients.unmasked_token_required403 — a reduced token cannot widen or re-mint itself (the issue's open question).service_account_forbidden403 (decision 11). Theinternal.service_accountslookup is shared with the refresh-token mutations via a newis_service_accountpredicate and is paid only at the mint, never per-request.Authority<NoRequirement>, so a presented bearer is verified regardless of grant: a broken bearer now 401s even for therefresh_tokengrant, which previously ignored the Authorization header entirely. Bearer-lessrefresh_tokenrequests 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_mintdrives 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 therefresh_tokengrant; and the typed extractor's 422 for an unknown grant. Note the last: axum'sJsonrejects a well-formed body with an unknowngrant_typeas 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 anAppfield into the signer, which stays pure. The default is the sharedDEFAULT_CAPABILITY_TOKEN_VALIDITYconstant — one hour, matching the SQL mint — consumed by the test builders and the mint test, with a weld test inagentpinning 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.