Skip to content

tables: capability-mask-aware user grant walk - #3397

Open
bbartman wants to merge 3 commits into
bmb/3376-stack-1-ceiling-vocabularyfrom
bmb/3376-stack-2-masked-user-walk
Open

tables: capability-mask-aware user grant walk#3397
bbartman wants to merge 3 commits into
bmb/3376-stack-1-ceiling-vocabularyfrom
bmb/3376-stack-2-masked-user-walk

Conversation

@bbartman

@bbartman bbartman commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Part of #3376; task 2 of the implementation plan in #3376 (comment). Stacked on #3396 (task 1) — review only the top commit here.

The user grant walk takes an explicit authz::CapabilityMask, applied at every node emission (edge_bits & delegatable & mask). Emission-time application makes traversal itself subject to the mask: a mask without Delegate confines the token to direct user grants, and Assume cannot re-widen a child beyond the mask. Role-to-role authorization stays unbounded (no user bearer participates), and get_user_capability masks reachability while passing legacy metadata through un-attenuated.

Zero behavior change: all 19 non-test callers (17 control-plane-api including the data-plane user token exchanges, 2 agent discovers) explicitly pass Unbounded; the signature change exists to force every future caller to choose. Real masks thread in with the Authority extractor (task 3, next PR).

One deliberate deviation from the plan text, called out for review: reachable_prefixes skips fully attenuated nodes only when the mask actually hides something (!mask.is_all()). The walk can already emit empty-bit nodes today via delegation intersections, so an unconditional skip would change unmasked authorized_prefixes output. A mask that hides nothing keeps today's output bit-for-bit; a narrower mask omits attenuated nodes so a masked token can't learn the shape of grants it can't exercise. (is_all() is a leak-prevention test only — "is this bearer masked" keys on the claim's presence, per the type's docs.)

Tests: unmasked parity (bounded(all)UNMASKED by construction), empty-mask denial, direct-only without Delegate, multi-hop clamping with Delegate, Assume containment (nb: Admin bundles Delegate but not Assume), additive multi-path composition within the mask, and attenuated-prefix omission with its Unbounded contrast.

@bbartman
bbartman force-pushed the bmb/3376-stack-2-masked-user-walk branch from 04e67d7 to d3bfb92 Compare August 25, 2026 18:18
@bbartman
bbartman force-pushed the bmb/3376-stack-2-masked-user-walk branch 2 times, most recently from c5a4f51 to dddf5c9 Compare August 26, 2026 10:52
@bbartman
bbartman requested review from GregorShear and jgraettinger and removed request for jgraettinger August 26, 2026 18:06
@bbartman
bbartman force-pushed the bmb/3376-stack-2-masked-user-walk branch 3 times, most recently from 7084155 to 0d8b4fb Compare August 27, 2026 12:22
@bbartman
bbartman force-pushed the bmb/3376-stack-2-masked-user-walk branch from 0d8b4fb to 53eaacf Compare August 28, 2026 12:17
@bbartman
bbartman force-pushed the bmb/3376-stack-2-masked-user-walk branch from 53eaacf to 3fbb0b7 Compare August 28, 2026 16:59
@bbartman
bbartman force-pushed the bmb/3376-stack-2-masked-user-walk branch from 3fbb0b7 to fa5ed25 Compare August 31, 2026 12:45
Task 2 of #3376. The user grant walk now takes an explicit
`authz::CapabilityMask`, applied at every node emission: each emitted
node is `edge_bits & delegatable & mask`. Emission-time application is
what makes traversal itself subject to the mask -- a node stripped of
Delegate/Assume is terminal, so a mask without Delegate confines the
token to direct user grants, and Assume (which makes all of an edge's
bits delegatable) cannot re-widen a child beyond the mask. Because each
parent is itself masked at emission, the clamp composes identically
across any number of hops.

`RoleGrant::reachable_nodes` stays unmasked internally: role-to-role
authorization answers questions where no user bearer participates.
`get_user_capability` masks reachability but passes a reached node's
legacy value through un-attenuated -- it's compatibility metadata, never
an authorization decision, and may legitimately read broader than the
token's effective bits.

`reachable_prefixes` omits fully attenuated nodes only when the mask
actually hides something (`!mask.is_all()`): a masked token doesn't get
to learn the shape of grants it cannot exercise. The omission is
deliberately not unconditional, because the walk can already emit
empty-bit nodes via delegation intersections and output for a mask that
hides nothing must not change.

Every existing caller (19 non-test sites: 17 in control-plane-api
including the data-plane user token exchanges, 2 in agent discovers)
explicitly passes `UNMASKED`, so this commit changes no behavior; the
signature change exists to force each future caller to choose a mask.
Threading real masks from bearer tokens lands with the Authority
extractor (task 3).

New tests cover unmasked parity, empty-mask denial, direct-only
behavior without Delegate, multi-hop clamping with Delegate, Assume
containment, additive multi-path composition within the mask, and
fully-attenuated prefix omission with its unmasked contrast.
Comment-only review pass over the stack-2 changes:

- Replace the nonexistent "public role reachability" example on the
  role-to-role walk with the real caller (prefix-to-data-plane checks).
- Fix the multi-hop test comment: SpecEdit itself is masked away, not
  just its siblings.
- Fix the omit-fully-attenuated test comment: the unmasked examples
  have non-empty bits; what's pinned is that mask-hidden prefixes
  still surface.
- CapabilityMask::apply doc: traversal terminates only when both
  Delegate and Assume are stripped, per the Assume-containment test.
- Drop forward references to artifacts of later stack PRs ("the
  mint", "upgrade token") that don't exist in the codebase yet.
Rename for intent, per review:
- CapabilityMask::UNMASKED -> ALL_CAPABILITIES, pairing with the
  predicate it feeds.
- CapabilityMask::is_all -> has_all_capabilities, so the name itself
  says it answers "does this mask attenuate nothing?" and can't be
  misread as an "is this bearer unmasked?" check (that's
  capability_mask.is_some(), a property of the claim).
Call sites in control-plane-api and agent update mechanically.

Fill two masked-walk coverage gaps:
- test_masked_walk_parent_prefix_pickup: the upward traversal mode
  (parent-prefix role-grant subjects) is clamped and Delegate-gated
  like the downward one.
- test_masked_walk_get_user_capability: a reached node's legacy value
  passes through un-attenuated (even under an empty mask, for direct
  grants) while the mask still gates which nodes are reached at all.
@bbartman
bbartman force-pushed the bmb/3376-stack-2-masked-user-walk branch from fa5ed25 to 982a843 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