control-plane-api: live capability-ceiling threading (#3376 task 3c) - #3406
Open
bbartman wants to merge 10 commits into
Open
control-plane-api: live capability-ceiling threading (#3376 task 3c)#3406bbartman wants to merge 10 commits into
bbartman wants to merge 10 commits into
Conversation
bbartman
force-pushed
the
bmb/3376-stack-5-live-ceiling
branch
from
August 27, 2026 10:50
3ffc30f to
3cdd00b
Compare
bbartman
force-pushed
the
bmb/3376-stack-5-live-ceiling
branch
2 times, most recently
from
August 27, 2026 12:22
21922f8 to
cb085f4
Compare
This was referenced Aug 27, 2026
bbartman
force-pushed
the
bmb/3376-stack-5-live-ceiling
branch
from
August 28, 2026 12:17
cb085f4 to
d728724
Compare
bbartman
force-pushed
the
bmb/3376-stack-5-live-ceiling
branch
from
August 28, 2026 16:59
d728724 to
7ee4659
Compare
bbartman
force-pushed
the
bmb/3376-stack-5-live-ceiling
branch
from
August 31, 2026 12:45
7ee4659 to
6323d88
Compare
…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
force-pushed
the
bmb/3376-stack-5-live-ceiling
branch
from
September 1, 2026 13:08
6323d88 to
7677256
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 3c of the #3376 capability-mask plan (implementation plan), stacked on #3405: live ceiling threading. The
Authorityextractor'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 nowAuthZError { Retriable(tonic::Status), Definitive(Forbidden) }. A walk denial stays retriable — a fresh Snapshot may reveal a just-committed grant, soauthorization_outcomeholds it for refresh as before. A mask shortfall is a pure function of the bearer's verified claims which no Snapshot can change:authorization_outcomereturns it immediately as the newApiError::Forbidden, which renders the structured 403 body (error: "missing_capabilities",missing_capabilities: [...]) on REST and a GraphQL error carrying the same fields inextensions. An MCP client parses the missing names identically on both surfaces and drives theupgrade_tokenre-mint.The mask-shortfall pre-check lives inside the shared policy functions —
evaluate_names_authorization, the threeauthorize_user_*evaluate_authorizationhelpers, and storage-mappings'check_authorization— computed asrequired − 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
REQUIREDdeclarations are used where the capability is statically known./api/v1/catalog/statusand/api/v1/metrics/{*prefix}takeAuthority<RequireViewer>, which declaresCapabilityBundle::Viewer— the bits legacyReadrequires — in the same bundle vocabulary thecapability_maskclaim speaks, rejecting mask shortfalls at extraction before the handler or any DB access runs. A parity test pins the bundle's bits tobits_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):/authorize/user/{collection,task,prefix}(including theestuary_support/secondary Admin walks — the mask applies uniformly to every walk a request performs),/api/v1/catalog/status(both the gate and theconnectedfilter),/api/v1/metrics.verify_authorization(22 call sites), storage-mappings'check_authorization, and the directevaluate_names_authorizationcallers (alerts, alert_configs, live_spec_refs).may_access,authorized_prefixes/filtered_authorized_prefixesand 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' attacheduser_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 explicitmask: CapabilityMaskparameter, threaded from a resolver's single*ctx.data::<models::authz::CapabilityMask>()?pull or a REST handler'sAuthority { envelope, mask, .. }binding. No new context objects.Deliberately not threaded (mask-evaporation class): the two
agent/src/discovers.rswalks andlive_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
REQUIREDconsts 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).AuthZErrorenum over alternatives (option A): pre-check helpers at every call site risk omission; smuggling the structured body throughtonic::Statuswould send definitive denials through the snapshot-retry limbo and force clients to parse JSON out of a message string.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.user_capabilitymetadata 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.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 twoRequireViewerroutes — 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 maskedestuary_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 withoutDelegateconfines the listing to direct grants; addingDelegaterestores role-graph traversal; an empty mask lists nothing — never an error.storage_mappings.rs: unit coverage ofcheck_authorizationunder masks, plus the list-coherence case — row filter and attacheduser_capabilitycomputed under one mask.