Skip to content

models: capability-mask vocabulary and lenient claim - #3396

Open
bbartman wants to merge 3 commits into
masterfrom
bmb/3376-stack-1-ceiling-vocabulary
Open

models: capability-mask vocabulary and lenient claim#3396
bbartman wants to merge 3 commits into
masterfrom
bmb/3376-stack-1-ceiling-vocabulary

Conversation

@bbartman

@bbartman bbartman commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

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::Capability gains a stable PascalCase name() mapping. The names are minted into tokens which outlive a deploy, so the mapping is written out rather than derived from Debug: renaming a variant must not silently be a wire-format change.
  • authz::CapabilityBundle is the claim's vocabulary. It gains a stable PascalCase name()from_name wire mapping (distinct from its snake_case serde / Postgres spelling), and a same-named single-capability bundle for every individual Capability, each mapping directly to its bit through capabilities(). So the names of a missing_capabilities denial 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 Postgres capability_bundle enum and never appear on grant rows. from_name returns None for 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::CapabilityMask is the request-scoped ceiling computed from those names: a newtype over CapabilitySet with intersection-only (enable/disable) semantics — naming a capability the user doesn't hold conveys nothing; omitting one they hold disables it. An unmasked bearer carries CapabilityMask::UNMASKED (the full set, so intersection is the identity); a present claim builds CapabilityMask::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 no Default/From. It answers what may be exercised, never whether the bearer is masked — fail-closed surfaces key on the claim's presence, never is_all().
  • ControlClaims carries capability_mask as an opaque Option<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_capabilities vocabulary (and GraphQL's CapabilityBit spelling) 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.

@bbartman
bbartman force-pushed the bmb/3376-stack-1-ceiling-vocabulary branch 6 times, most recently from 432f372 to adbdda0 Compare August 28, 2026 12:17
@bbartman
bbartman requested a review from jgraettinger August 28, 2026 13:36
@bbartman
bbartman force-pushed the bmb/3376-stack-1-ceiling-vocabulary branch 2 times, most recently from 573656b to 5a86804 Compare August 31, 2026 11:09
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
bbartman force-pushed the bmb/3376-stack-1-ceiling-vocabulary branch from 5a86804 to a1483c2 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