models: capability-mask vocabulary and lenient claim - #3396
Open
bbartman wants to merge 3 commits into
Open
Conversation
This was referenced Aug 25, 2026
bbartman
force-pushed
the
bmb/3376-stack-1-ceiling-vocabulary
branch
6 times, most recently
from
August 28, 2026 12:17
432f372 to
adbdda0
Compare
bbartman
force-pushed
the
bmb/3376-stack-1-ceiling-vocabulary
branch
2 times, most recently
from
August 31, 2026 11:09
573656b to
5a86804
Compare
Groundwork for capability-masked access tokens (#3376). Two pieces, both inert until the grant walk and the token mint consume them: `authz::Capability` gains a PascalCase name<->bit mapping. The names are minted into tokens which outlive a deploy and are read by instances of differing versions, so the mapping is written out rather than derived from `Debug`: renaming a variant should not silently be a wire change. `from_name` returns `None` for names this binary doesn't know, so a token minted by a newer control plane still authenticates against an older one and the capability it can't enforce is simply inert. `authz::CapabilityMask` is the request-scoped ceiling computed from those names: `Unbounded` for a token with no mask (which is every token we mint today), or `Bounded` for one with a mask, including an empty mask -- "no mask" and "an empty mask" are distinct, and the latter is an identity-only token. `apply` is the attenuation the grant walk will perform at each node emission. `ControlClaims` carries `capability_mask` as an opaque `Option<Vec<String>>` so the shared claim doesn't structurally depend on the newest capability variant. The claim's shape is still strict -- an array, absent, or null -- because we are its only minter; only unrecognized *names* are lenient. A test asserts the claim vocabulary and GraphQL's `CapabilityBit` vocabulary agree, so "you need capability X" reads identically on both surfaces. It compiles only with the `async-graphql` feature, which a workspace-wide test run enables.
Close the review gaps in the task-1 test coverage: - An explicit `"capability_mask": null` parses as no mask, same as an absent claim, and both are distinct from `[]` -- which bounds the token to nothing. The empty-vs-absent distinction is now a hard assertion in addition to the snapshots. - Any other claim shape (string, number, boolean, object, nested array, mixed-type array, [null]) fails deserialization outright: leniency is over names only, and refusing a malformed token is the fail-safe outcome. - A populated mask round-trips its names verbatim, including names this binary doesn't recognize. Carry-through is load-bearing for the upgrade-token re-mint: unrecognized names must survive whichever instance happens to re-sign them.
The capability_mask claim now names CapabilityBundles rather than individual Capability bits: CapabilityMask::from_claim parses PascalCase bundle names and unions their capabilities() bit sets into the mask. Every individual capability gains a same-named single-capability bundle with a direct mapping to its bit, so the names of a missing_capabilities denial remain valid claim vocabulary, while composite bundles (Viewer, TeamAdmin, ...) let one name enable a coarse set. The single-capability bundles are claim vocabulary only: they are not values of the Postgres capability_bundle enum and never appear on grant rows.
bbartman
force-pushed
the
bmb/3376-stack-1-ceiling-vocabulary
branch
from
September 1, 2026 13:08
5a86804 to
a1483c2
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.
Part of #3376; task 1 of the implementation plan in #3376 (comment). First PR of the stack — everything here is inert until the grant walk (next PR) and the token mint consume it.
authz::Capabilitygains a stable PascalCasename()mapping. The names are minted into tokens which outlive a deploy, so the mapping is written out rather than derived fromDebug: renaming a variant must not silently be a wire-format change.authz::CapabilityBundleis the claim's vocabulary. It gains a stable PascalCasename()↔from_namewire mapping (distinct from its snake_case serde / Postgres spelling), and a same-named single-capability bundle for every individualCapability, each mapping directly to its bit throughcapabilities(). So the names of amissing_capabilitiesdenial are always valid claim vocabulary, while composite bundles (Viewer,TeamAdmin, …) enable a coarse set with one name. The single-capability bundles exist for the claim vocabulary only: they are not values of the Postgrescapability_bundleenum and never appear on grant rows.from_namereturnsNonefor unrecognized names — a token minted by a newer control plane still authenticates against an older one, and a name the older binary can't enforce is simply inert (never widening).authz::CapabilityMaskis the request-scoped ceiling computed from those names: a newtype overCapabilitySetwith intersection-only (enable/disable) semantics — naming a capability the user doesn't hold conveys nothing; omitting one they hold disables it. An unmasked bearer carriesCapabilityMask::UNMASKED(the full set, so intersection is the identity); a present claim buildsCapabilityMask::bounded(..)as the union of the capability bits of its recognized bundle names, and an empty mask is valid and distinct from no mask: it mints an identity-only token. It's a newtype (not a bare set) so "capabilities requested" and "ceiling enforced" can't be swapped silently at call sites, and it deliberately has noDefault/From. It answers what may be exercised, never whether the bearer is masked — fail-closed surfaces key on the claim's presence, neveris_all().ControlClaimscarriescapability_maskas an opaqueOption<Vec<String>>, so the shared claim doesn't structurally depend on the newest capability or bundle variant. Leniency is over names only — the claim's shape is strict (array, absent, or null), because the control plane is its sole minter and refusing a malformed token is the fail-safe outcome.Tests pin: the claim (bundle) vocabulary snapshot and its name round-trip; that every capability is a same-named single-capability bundle enabling exactly its bit, holding the
missing_capabilitiesvocabulary (and GraphQL'sCapabilityBitspelling) to the claim vocabulary; absent / null / empty / recognized / composite-bundle / mixed / unknown-only claim forms; malformed-shape rejection; and verbatim carry-through of unrecognized names across a re-serialization, which the upgrade-token re-mint depends on.