Skip to content

control-plane-api, tables: legacy decisions migrate to effective capability bits - #3422

Open
bbartman wants to merge 10 commits into
bmb/3376-stack-5-live-ceilingfrom
bmb/3376-stack-6-legacy-decisions
Open

control-plane-api, tables: legacy decisions migrate to effective capability bits#3422
bbartman wants to merge 10 commits into
bmb/3376-stack-5-live-ceilingfrom
bmb/3376-stack-6-legacy-decisions

Conversation

@bbartman

Copy link
Copy Markdown
Contributor

Task 4 of #3376 (stacked on #3406): every remaining request-path authorization and visibility decision that read the legacy read/write/admin label now keys on mask-attenuated effective capability bits. The legacy label is reporting metadata only, per decision 3 of the implementation plan.

Closes #3232.

What changes

  • tables::UserGrant::get_user_authorization is a new single-name walk reduction returning both halves of a name's authority: effective bits accumulated additively across covering nodes (the decision input), and the max un-attenuated legacy label (compatibility metadata, None under bundles-only coverage). It supersedes get_user_capability, which is removed; the label semantics are pinned by the converted tests.
  • LiveSpecRef: the six gated sub-resolvers (liveSpec, activeAlerts, alertHistory, status, lastPublication, publicationHistory) and the writtenBy/readBy minimum-capability pagination filter (previously an Ord comparison on the legacy enum) decide on effective bits. userCapability is null iff the bits fall short of Viewer; otherwise it reports the literal legacy column, with none standing in for bundles-only coverage.
  • storageMappings no longer errors the whole query when a row's covering grants carry no legacy label (the storageMappings query fails for bundle-only grants (legacy-capability mismatch) #3232 bug); the row serves with userCapability: none.
  • dataPlanes no longer panics on the same shape (.expect("capability guaranteed by pre-filter") trusted the legacy label where the pre-filter guarantees bits).
  • Vocabulary normalization: alertConfigs and inviteLinks spell their filtered_authorized_prefixes thresholds as CapabilityBundle::Viewer / CapabilityBundle::Admin — bit-identical to the legacy enums they replace.
  • The hardcoded Admin literals in createStorageMapping/updateStorageMapping responses stay: the field is informational, the mutations require admin-level effective bits, and no consumer reads it (the only userCapability consumer anywhere is the dashboard's auth-roles store on PrefixRef, untouched here).

Behavior changes

Thresholds are unchanged everywhere — only the decision input moves. The one deliberate behavior change beyond mask-correctness: bundles-only grants (capability = 'none', non-empty bundles) now work through liveSpecs, storageMappings, and dataPlanes listings, consistent with the walk-based checks that already admit them. PrefixRef already reports these as none; LiveSpecRef/StorageMapping/DataPlane now do the same.

Explicitly excluded per the plan: live_specs/db.rs's SQL-computed user_capability and get_connected_live_specs (async-executor-only; task 9 territory), and per-endpoint re-vocabulary (e.g. CreateInviteLink for invite links).

Tests

  • tables: the combined reduction (bundles-only, masked attenuation, un-attenuated label, additive multi-path composition), plus the converted legacy-label pins.
  • liveSpecs: a bundles-only viewer lists and reads specs (labeled none); writtenBy/readBy admit bundles-only-covered names and withhold unauthorized names; a sub-Viewer mask is refused at the root with the structured missing-capabilities body, so legacy labels are never a side-channel around the mask.
  • storageMappings / dataPlanes: bundles-only listings serve with none where they previously errored/panicked.
  • Unmasked parity: all existing snapshots unchanged.

Note

The 2026-08-26 Daily Sync recorded an open follow-up ("Discuss Legacy Bits", Brian with Dave and Greg) on exactly this gate-on-bits question. The written record — #3232, #2936's design comment defining capability: none for bundles-only grants, and the PrefixRef doc — all point the way this PR implements it; that follow-up is the venue to veto before merge.

@bbartman

Copy link
Copy Markdown
Contributor Author

I need someone with more knowledge of the change from models::Capability::Read, to models::authz::CapabilityBundle::Viewer, because I don't know if these are the same thing or not. Claude seems to think they are.

…abel

A single walk reduction returning both halves of a node's authority for
one name: mask-attenuated effective bits accumulated additively across
covering nodes (the decision input), and the max un-attenuated legacy
label (compatibility metadata, None under bundles-only coverage).
Request-path consumers that today gate on get_user_capability's legacy
label migrate onto the bits half.
attach_user_capabilities hands its closures both halves of
get_user_authorization: mask-attenuated effective bits (the decision
input) and the un-attenuated legacy label (reporting metadata).
LiveSpecRef construction derives userCapability from the bits — null
when they fall short of Viewer, otherwise the literal legacy label with
none standing in for bundles-only coverage — so the field gates and the
writtenBy/readBy minimum-capability filter (now a CapabilitySet superset
check, spelled in the bundle vocabulary) no longer consult the legacy
label for any decision. A bundles-only viewer grant now lists and reads
specs exactly like a legacy read grant.
The listing's rows are authorized by the effective-bits prefix
pre-filter, so a row whose covering grants carry no legacy label — a
bundles-only grant — is a legitimate outcome, not an error: its
userCapability reports the literal column value, none.

Closes #3232.
The listing pre-filters rows on effective bits, so a covering grant
with no legacy label is a legitimate outcome: userCapability reports
the literal column value, none, where the absent label previously
tripped the resolver's expect.
writtenBy/readBy admit bundles-only-covered names on effective bits
(labeled none) while withholding unauthorized names entirely, and a
mask below the Viewer threshold is refused at the liveSpecs root with
the structured missing-capabilities body — reached grants' legacy
labels are never a side-channel around the mask.
get_user_capability's callers all consume get_user_authorization now,
whose legacy half carries the identical semantics: the label of a
reached node reflects storage, un-attenuated, and never authorizes.
Its tests pin that half of the combined reduction.
alertConfigs and inviteLinks spell their filtered_authorized_prefixes
minimums as CapabilityBundle::Viewer and CapabilityBundle::Admin —
bit-identical to the legacy enums they replace, and the vocabulary the
rest of the mask stack speaks. The storage-mapping mutation responses'
Admin literals gain the comment stating they are informational
metadata backed by the mutations' admin-level requirement.
UserAuthorization pairs the effective bits with the legacy label as a
named type, with legacy_label() as the single owner of the reporting
rule that none stands in for bundles-only coverage — the shape the
listing resolvers each restated in comments. storageMappings' edge
collection drops its Result plumbing: the closure is infallible.
writesTo refs are un-filtered, so one query exercises both outcomes of
the effective-bits gate: an accessible bundles-only referent serves its
gated fields under the none label, and a referent outside the caller's
grants presents with userCapability null and every gated field null —
the coupling LiveSpecRef's field docs promise.
The userCapability docstring gained legacy-metadata semantics in this
branch, but the committed SDL was not regenerated to match.
@bbartman
bbartman force-pushed the bmb/3376-stack-6-legacy-decisions branch from 2f44eed to e87c6d1 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.

storageMappings query fails for bundle-only grants (legacy-capability mismatch)

1 participant