Skip to content

control-plane-api: live capability-ceiling threading (#3376 task 3c) - #3406

Open
bbartman wants to merge 10 commits into
bmb/3376-stack-4-authority-migrationfrom
bmb/3376-stack-5-live-ceiling
Open

control-plane-api: live capability-ceiling threading (#3376 task 3c)#3406
bbartman wants to merge 10 commits into
bmb/3376-stack-4-authority-migrationfrom
bmb/3376-stack-5-live-ceiling

Conversation

@bbartman

@bbartman bbartman commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Task 3c of the #3376 capability-mask plan (implementation plan), stacked on #3405: live ceiling threading. The Authority extractor's capability mask now reaches every request-path authorization walk, so enforcement of masked tokens goes live — while no production path mints masked tokens yet.

What changed

Definitive vs. retriable denials become a typed distinction. AuthZResult's error side is now AuthZError { Retriable(tonic::Status), Definitive(Forbidden) }. A walk denial stays retriable — a fresh Snapshot may reveal a just-committed grant, so authorization_outcome holds it for refresh as before. A mask shortfall is a pure function of the bearer's verified claims which no Snapshot can change: authorization_outcome returns it immediately as the new ApiError::Forbidden, which renders the structured 403 body (error: "missing_capabilities", missing_capabilities: [...]) on REST and a GraphQL error carrying the same fields in extensions. An MCP client parses the missing names identically on both surfaces and drives the upgrade_token re-mint.

The mask-shortfall pre-check lives inside the shared policy functionsevaluate_names_authorization, the three authorize_user_* evaluate_authorization helpers, and storage-mappings' check_authorization — computed as required − mask.apply(required) before the walk, so no call site can forget it and the error discloses nothing about the user's actual grants (no existence or grant oracle in the definitive/retriable distinction).

Route-level REQUIRED declarations are used where the capability is statically known. /api/v1/catalog/status and /api/v1/metrics/{*prefix} take Authority<RequireViewer>, which declares CapabilityBundle::Viewer — the bits legacy Read requires — in the same bundle vocabulary the capability_mask claim speaks, rejecting mask shortfalls at extraction before the handler or any DB access runs. A parity test pins the bundle's bits to bits_for_legacy(Read) so the declaration and the handler's walk requirement cannot drift. Everywhere a const structurally can't reach — GraphQL's single route, the body-dependent /authorize/user/* endpoints — the identical runtime pre-check applies.

Every request-path walk runs under the bearer's mask (~17 sites task 2 pinned to ALL_CAPABILITIES):

  • REST: /authorize/user/{collection,task,prefix} (including the estuary_support/ secondary Admin walks — the mask applies uniformly to every walk a request performs), /api/v1/catalog/status (both the gate and the connected filter), /api/v1/metrics.
  • GraphQL hard gates: verify_authorization (22 call sites), storage-mappings' check_authorization, and the direct evaluate_names_authorization callers (alerts, alert_configs, live_spec_refs).
  • GraphQL filters and metadata: may_access, authorized_prefixes / filtered_authorized_prefixes and their consumers (prefixes, invite_links, alert_configs, storage_mappings, service_accounts), attach_user_capabilities (live_spec_refs, live_specs, data_planes), the data-planes list filter, and storage-mappings' attached user_capability — filter and metadata are computed under the same mask, so masked listings narrow coherently rather than erroring.

Plumbing follows the agreed split: ctx-taking GraphQL helpers (may_access) read the mask from context internally; &Envelope/claims-taking helpers take an explicit mask: CapabilityMask parameter, threaded from a resolver's single *ctx.data::<models::authz::CapabilityMask>()? pull or a REST handler's Authority { envelope, mask, .. } binding. No new context objects.

Deliberately not threaded (mask-evaporation class): the two agent/src/discovers.rs walks and live_specs::partition_by_authorization (consumed only by the discovers/publications executors) stay explicitly unmasked with comments — they run after the requesting JWT and its mask are gone, authorizing by user identity. Whether and how the mask should propagate across the async discover boundary is a new dedicated task on the #3376 plan (spike + implementation).

Decisions made in review of this task

  1. No blanket route consts. REQUIRED consts only where the required capability is a static fact of the route (status, metrics); runtime pre-checks with the identical error shape everywhere else. Considered and rejected: dropping the const mechanism entirely (loses free extraction-time fail-fast), and consts-only (structurally impossible for GraphQL and body-dependent routes).
  2. AuthZError enum over alternatives (option A): pre-check helpers at every call site risk omission; smuggling the structured body through tonic::Status would send definitive denials through the snapshot-retry limbo and force clients to parse JSON out of a message string.
  3. The estuary_support/ secondary check is masked like every other walk — the invariant is that a masked token's authority is a subset of the same bearer's unmasked authority, with no special-cased walks.
  4. Legacy user_capability metadata under a mask keeps task 2's semantics: the mask gates node reachability; a reached node's legacy value passes through un-attenuated as informational compatibility metadata.
  5. Discovers mask propagation split out as its own task: a spike first — what could a mask-limited discovery expose or wrongly withhold, and should the mask propagate across the async boundary at all — then implementation only if the spike says yes.

Tests (written first, TDD)

  • envelope.rs: definitive denials bypass snapshot-retry (stale and fresh snapshots); retriable denials keep their existing provisional/terminal behavior.
  • server/mod.rs: the chokepoint pre-check — mask shortfall is definitive and names exactly the missing bits, evaluated before the walk; covering-mask and unmasked walks are ordinary.
  • public/mod.rs: oneshot-router tests of the two RequireViewer routes — structured 403 at extraction with no DB touched; covering and unmasked bearers pass extraction identically; plus the const↔bits_for_legacy(Read) parity pin.
  • authorize_user_collection.rs (class representative for /authorize/user/*): masked shortfall, empty-mask identity-only denial, covering-mask parity with the unmasked outcome, and the masked estuary_support/ Admin walk.
  • graphql/mod.rs (DB-backed, class representative for GraphQL hard gates): masked denial with the full extensions shape pinned, empty-mask denial, covering-mask/unmasked parity.
  • prefixes.rs (class representative for GraphQL filters): a mask without Delegate confines the listing to direct grants; adding Delegate restores role-graph traversal; an empty mask lists nothing — never an error.
  • storage_mappings.rs: unit coverage of check_authorization under masks, plus the list-coherence case — row filter and attached user_capability computed under one mask.
  • Every pre-existing test passes unchanged (all fixtures mint unmasked tokens), which is the unmasked-parity assertion for the whole surface.

@bbartman
bbartman force-pushed the bmb/3376-stack-5-live-ceiling branch from 3ffc30f to 3cdd00b Compare August 27, 2026 10:50
@bbartman
bbartman force-pushed the bmb/3376-stack-5-live-ceiling branch 2 times, most recently from 21922f8 to cb085f4 Compare August 27, 2026 12:22
@bbartman
bbartman force-pushed the bmb/3376-stack-5-live-ceiling branch from cb085f4 to d728724 Compare August 28, 2026 12:17
@bbartman
bbartman force-pushed the bmb/3376-stack-5-live-ceiling branch from d728724 to 7ee4659 Compare August 28, 2026 16:59
@bbartman
bbartman force-pushed the bmb/3376-stack-5-live-ceiling branch from 7ee4659 to 6323d88 Compare August 31, 2026 12:45
…ials

AuthZResult's error side becomes AuthZError { Retriable(tonic::Status),
Definitive(Forbidden) }. A walk denial stays provisional -- a fresh
Snapshot may reveal a just-committed grant -- but a capability-mask
shortfall is a pure function of the bearer's verified claims which no
refresh can change: authorization_outcome now returns it immediately as
the new ApiError::Forbidden, rendering the structured 403 body on REST
and carrying error / missing_capabilities in GraphQL error extensions.
evaluate_names_authorization and the three authorize_user_* policy
functions take the bearer's mask, pre-checking required - mask.apply(
required) before the walk so a shortfall is a definitive 403 naming the
missing capabilities without consulting (or disclosing anything about)
the user's grants. Every walk of a request runs under the mask,
including the estuary_support/ secondary Admin checks.

/api/v1/catalog/status and /api/v1/metrics declare the requirement as
a route const -- Authority<RequireViewer> rejects a shortfall at
extraction, before the handler or any DB access runs -- with a parity
test pinning the const to bits_for_legacy(Read) so the two statements
of the requirement cannot drift.
verify_authorization (22 call sites) and storage_mappings'
check_authorization gate under the bearer's mask with the same
definitive-shortfall shape as REST; may_access, authorized_prefixes /
filtered_authorized_prefixes, attach_user_capabilities consumers, the
prefixes / data-planes / service-accounts listings, and the attached
user_capability metadata all consume the mask from the GraphQL context,
so masked listings narrow coherently rather than erroring. Resolvers
pull the mask once via *ctx.data()? and pass it as an explicit
parameter to the pure helpers.
The discover executor's SpecEdit / data-plane walks and
live_specs::partition_by_authorization (consumed only by discovers and
publication initialization) authorize by user identity after the
requesting JWT and its capability mask are gone. They stay explicitly
unmasked; whether and how the mask should propagate across the async
boundary is the dedicated discovers follow-up task of the #3376 plan.
Review feedback: the untyped *ctx.data()? relied on inference from the
callee's parameter, which read ambiguously at call sites. Every pull is
now the explicit *ctx.data::<models::authz::CapabilityMask>()?.
Comment-review pass: may_access, authorization_outcome,
authorized_prefixes, and attach_user_capabilities now document that they
evaluate under the bearer's capability mask (their contracts changed but
their docs hadn't); a test comment loses its 'pre-existing' relative-time
phrasing; and the storage-mappings coherence comment scopes its
never-errors claim to what the shared masked reachability actually
guarantees.
…pers

- server/mod.rs test module qualifies authz types per the repo standard
  instead of importing them bare.
- check_authorization's masked-denial test pins the full
  missing_capabilities set with an insta snapshot; the contains-based
  assertions it replaces had missed that JournalRead was in the set.
- The Retriable/Definitive -> Outcome::Err mapping copied across five
  handler test modules collapses into a shared, test-only
  AuthZError::into_status_message.
- The ~40 GraphQL context pulls of the capability mask go through one
  named accessor, graphql::bearer_mask, keeping call sites explicit
  about what they read without each repeating the type.
The identical required - mask.apply(required) block and its near-verbatim
rationale comment appeared in five policy functions. It collapses into
Forbidden::require_mask_covers(mask, required), which carries the
rationale once: a definitive denial, evaluated before the grant walk,
disclosing nothing about the user's grants.
With Requirement::REQUIRED now a bundle slice, the enum_set! literal
spelling (a workaround for capabilities() not being const) collapses to
naming CapabilityBundle::Viewer directly. The parity test still pins the
bundle's bits to bits_for_legacy(Read).
@bbartman
bbartman force-pushed the bmb/3376-stack-5-live-ceiling branch from 6323d88 to 7677256 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