From 74966a5895aa537bc8c5e8187ebd015a61e8c8eb Mon Sep 17 00:00:00 2001 From: Brian Bartman Date: Tue, 25 Aug 2026 17:08:40 +0000 Subject: [PATCH 1/3] models: capability-mask vocabulary and lenient claim 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>` 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. --- crates/agent/src/integration_tests/harness.rs | 1 + .../src/server/authorize_dekaf.rs | 3 + .../src/server/authorize_user_collection.rs | 1 + .../src/server/authorize_user_prefix.rs | 1 + .../src/server/authorize_user_task.rs | 1 + crates/control-plane-api/src/test_server.rs | 1 + crates/models/src/authorizations.rs | 164 +++++++++++ crates/models/src/authz.rs | 268 ++++++++++++++++++ 8 files changed, 440 insertions(+) diff --git a/crates/agent/src/integration_tests/harness.rs b/crates/agent/src/integration_tests/harness.rs index 8d346ea39be..b02c69d7720 100644 --- a/crates/agent/src/integration_tests/harness.rs +++ b/crates/agent/src/integration_tests/harness.rs @@ -1683,6 +1683,7 @@ impl TestHarness { exp: (req_start + chrono::Duration::hours(1)).timestamp() as u64, role: "authenticated".to_string(), email: Some("user@example.com".to_string()), + capability_mask: None, }; let token = tokens::jwt::sign(&claims, &app.control_plane_jwt_encode_key) diff --git a/crates/control-plane-api/src/server/authorize_dekaf.rs b/crates/control-plane-api/src/server/authorize_dekaf.rs index c043fced266..c22da994ad9 100644 --- a/crates/control-plane-api/src/server/authorize_dekaf.rs +++ b/crates/control-plane-api/src/server/authorize_dekaf.rs @@ -94,6 +94,9 @@ pub async fn authorize_dekaf( sub: uuid::Uuid::nil(), role: DEKAF_ROLE.to_string(), email: None, + // This token authorizes the `dekaf` role rather than a user (`sub` + // is nil), so there is no user authority for a mask to attenuate. + capability_mask: None, }; // Only return a token if we are not redirecting diff --git a/crates/control-plane-api/src/server/authorize_user_collection.rs b/crates/control-plane-api/src/server/authorize_user_collection.rs index a02267bab29..a75a43c100a 100644 --- a/crates/control-plane-api/src/server/authorize_user_collection.rs +++ b/crates/control-plane-api/src/server/authorize_user_collection.rs @@ -371,6 +371,7 @@ mod tests { sub: user_id, role: "authenticated".to_string(), email, + capability_mask: None, }; match evaluate_authorization(&snapshot, &claims, &collection, capability) { diff --git a/crates/control-plane-api/src/server/authorize_user_prefix.rs b/crates/control-plane-api/src/server/authorize_user_prefix.rs index dec72c990a2..3c484c288c4 100644 --- a/crates/control-plane-api/src/server/authorize_user_prefix.rs +++ b/crates/control-plane-api/src/server/authorize_user_prefix.rs @@ -551,6 +551,7 @@ mod tests { sub: user_id, role: "authenticated".to_string(), email, + capability_mask: None, }; match evaluate_authorization(&snapshot, &claims, &prefix, &data_plane, capability) { diff --git a/crates/control-plane-api/src/server/authorize_user_task.rs b/crates/control-plane-api/src/server/authorize_user_task.rs index 493c6060ed6..b78427eed2f 100644 --- a/crates/control-plane-api/src/server/authorize_user_task.rs +++ b/crates/control-plane-api/src/server/authorize_user_task.rs @@ -498,6 +498,7 @@ mod tests { sub: user_id, role: "authenticated".to_string(), email, + capability_mask: None, }; match evaluate_authorization(&snapshot, &claims, &task, capability) { diff --git a/crates/control-plane-api/src/test_server.rs b/crates/control-plane-api/src/test_server.rs index 7a3ca8f7cc6..e5cd21475a7 100644 --- a/crates/control-plane-api/src/test_server.rs +++ b/crates/control-plane-api/src/test_server.rs @@ -170,6 +170,7 @@ impl TestServer { role: "authenticated".to_string(), aud: "authenticated".to_string(), email: email.map(String::from), + capability_mask: None, }; jsonwebtoken::encode( diff --git a/crates/models/src/authorizations.rs b/crates/models/src/authorizations.rs index 432dc089823..e2e259a7aa4 100644 --- a/crates/models/src/authorizations.rs +++ b/crates/models/src/authorizations.rs @@ -20,6 +20,27 @@ pub struct ControlClaims { // Authorized user email, if known. #[serde(default, skip_serializing_if = "Option::is_none")] pub email: Option, + // Capability names to which this token's authority is masked. + // + // `None` is an unmasked token — today that's every token we mint, and a + // full-authority credential stays unmasked once masking exists. `Some` + // is a masked token whose authority is its user's live grants + // intersected with the recognized names herein, and an empty list is + // valid: it mints an identity-only token. + // + // Deliberately an opaque list of strings rather than + // `authz::CapabilitySet`, so that this shared claim doesn't structurally + // depend on the newest capability variant. A name an instance doesn't + // recognize must parse and then be inert — it can never widen authority — + // which is what makes mixed-version fleets and future capability names + // safe by construction. See `authz::CapabilityMask::from_claim`. + // + // Leniency is over names only. The claim's shape stays strict — an array, + // absent, or null — because the control plane is its sole minter, so a + // differently-shaped claim is a corrupt or forged token and failing + // verification is the fail-safe outcome. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub capability_mask: Option>, } impl ControlClaims { @@ -250,3 +271,146 @@ pub struct DekafAuthResponse { const fn capability_read() -> crate::Capability { crate::Capability::Read } + +#[cfg(test)] +mod test { + use super::ControlClaims; + use crate::authz::CapabilityMask; + + #[test] + fn test_capability_mask_claim_forms() { + // Every form of the claim parses. A token which fails to parse is a + // token which fails to authenticate, so an unrecognized name must + // never be a deserialization error: it's carried through and is then + // inert when the mask is built. + let base = serde_json::json!({ + "aud": "authenticated", + "iat": 1000, + "exp": 2000, + "sub": "11111111-1111-1111-1111-111111111111", + "role": "authenticated", + }); + let cases = [ + // Absent: every token minted before capability masks existed. + serde_json::Value::Null, + serde_json::json!(["CatalogRead", "JournalRead", "Delegate"]), + serde_json::json!([]), + serde_json::json!(["SpecEdit", "FutureCapability"]), + serde_json::json!(["FutureCapability"]), + ]; + + let outcomes: Vec<(Option>, CapabilityMask)> = cases + .into_iter() + .map(|mask| { + let mut claims = base.clone(); + if !mask.is_null() { + claims["capability_mask"] = mask; + } + let claims: ControlClaims = serde_json::from_value(claims).unwrap(); + let mask = CapabilityMask::from_claim(claims.capability_mask.as_deref()); + (claims.capability_mask, mask) + }) + .collect(); + + // Claim-less forms map to the unmasked (full) set. Asserted rather + // than snapshotted so this test doesn't churn when a capability + // variant is added. + for (claim, mask) in &outcomes { + if claim.is_none() { + assert_eq!(*mask, CapabilityMask::UNMASKED); + } + } + let bounded: Vec<_> = outcomes + .into_iter() + .filter(|(claim, _)| claim.is_some()) + .collect(); + + insta::assert_debug_snapshot!(bounded, @r#" + [ + ( + Some( + [ + "CatalogRead", + "JournalRead", + "Delegate", + ], + ), + CapabilityMask( + EnumSet(CatalogRead | JournalRead | Delegate), + ), + ), + ( + Some( + [], + ), + CapabilityMask( + EnumSet(), + ), + ), + ( + Some( + [ + "SpecEdit", + "FutureCapability", + ], + ), + CapabilityMask( + EnumSet(SpecEdit), + ), + ), + ( + Some( + [ + "FutureCapability", + ], + ), + CapabilityMask( + EnumSet(), + ), + ), + ] + "#); + } + + #[test] + fn test_capability_mask_claim_round_trip() { + let claims = ControlClaims { + aud: "authenticated".to_string(), + iat: 1000, + exp: 2000, + sub: uuid::Uuid::nil(), + role: "authenticated".to_string(), + email: None, + capability_mask: None, + }; + + // An unmasked token doesn't carry the claim at all, so tokens we + // mint today are unchanged on the wire. + insta::assert_json_snapshot!(claims, @r#" + { + "aud": "authenticated", + "iat": 1000, + "exp": 2000, + "sub": "00000000-0000-0000-0000-000000000000", + "role": "authenticated" + } + "#); + + // An empty mask is distinct from an absent one on the wire, and + // survives a round trip as such. + let masked = ControlClaims { + capability_mask: Some(Vec::new()), + ..claims + }; + insta::assert_json_snapshot!(masked, @r#" + { + "aud": "authenticated", + "iat": 1000, + "exp": 2000, + "sub": "00000000-0000-0000-0000-000000000000", + "role": "authenticated", + "capability_mask": [] + } + "#); + } +} diff --git a/crates/models/src/authz.rs b/crates/models/src/authz.rs index 18ce2d4625e..9906e2ef071 100644 --- a/crates/models/src/authz.rs +++ b/crates/models/src/authz.rs @@ -45,6 +45,137 @@ impl std::fmt::Display for Capability { } } +impl Capability { + /// PascalCase wire name of this capability. + /// + /// One vocabulary is shared by the `capability_mask` token claim and by + /// GraphQL's `CapabilityBit` enum (which derives the same spelling from + /// these variant identifiers), so that "you need capability X" reads + /// identically wherever it's said. Names are minted into tokens which + /// outlive a deploy and are interpreted by instances of differing + /// versions, so they must remain stable: the mapping is written out + /// rather than derived from `Debug` precisely so that renaming a variant + /// is not silently a wire-format change — `test_graphql_names_match_claim_names` + /// holds this mapping and GraphQL's derived spelling together. + pub const fn name(&self) -> &'static str { + match self { + Self::CatalogRead => "CatalogRead", + Self::JournalRead => "JournalRead", + Self::JournalAppend => "JournalAppend", + Self::SpecEdit => "SpecEdit", + Self::CreateGrant => "CreateGrant", + Self::DeleteGrant => "DeleteGrant", + Self::CreateInviteLink => "CreateInviteLink", + Self::ViewDataPlanePrivateNetworking => "ViewDataPlanePrivateNetworking", + Self::ModifyDataPlanePrivateNetworking => "ModifyDataPlanePrivateNetworking", + Self::ViewBilling => "ViewBilling", + Self::EditBilling => "EditBilling", + Self::QueryServiceAccounts => "QueryServiceAccounts", + Self::CreateServiceAccount => "CreateServiceAccount", + Self::CreateApiKey => "CreateApiKey", + Self::RevokeApiKey => "RevokeApiKey", + Self::Delegate => "Delegate", + Self::Assume => "Assume", + } + } + + /// Parse a PascalCase capability name, or `None` if this binary doesn't + /// recognize it. + /// + /// An unrecognized name is inert rather than an error: a token minted by + /// a newer control plane must still authenticate against an older one, + /// and a capability we cannot enforce must never widen what we allow. + pub fn from_name(name: &str) -> Option { + // Linear over the variants, which keeps `name()` the mapping's + // single source of truth. + CapabilitySet::all().iter().find(|c| c.name() == name) + } +} + +/// The capability mask carried by a request: a ceiling on the capabilities it +/// may exercise, independent of the grants held by its user. It's computed +/// from the authenticating token's `capability_mask` claim, and callers apply +/// it wherever authority is derived from grants — so that a masked token can +/// only ever attenuate its user's live authority, never amplify it. +/// +/// The mask is an enable/disable filter, never a grant: `apply` is pure +/// intersection, so naming a capability the user doesn't hold conveys +/// nothing, while omitting one they do hold disables it. An unmasked bearer +/// simply carries the full set ([`Self::UNMASKED`]) and intersects as the +/// identity. +/// +/// This is a newtype over [`CapabilitySet`] rather than a bare set because +/// authorization call sites take both "the capabilities requested" and "the +/// ceiling enforced" — often side by side. As bare sets those swap silently +/// and compile; as distinct types the swap is a compile error. +/// +/// This type answers *what may be exercised*, never *whether the bearer is +/// masked*. A token whose mask happens to enable everything is still a +/// deliberately-reduced credential, and surfaces that fail closed for masked +/// bearers (the `/admin` endpoints, the mint) must key on the claim's +/// presence — `capability_mask.is_some()` — and never on [`Self::is_all`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CapabilityMask(CapabilitySet); + +impl CapabilityMask { + /// The mask of an unmasked bearer: every capability this binary knows, + /// so intersection is the identity. Adding a future `Capability` + /// variant automatically widens this, which is correct — an unmasked + /// token's authority is bounded only by grants. + /// + /// There is deliberately no `Default` and no `From`: + /// every caller must name its mask, and constructing an unrestricted + /// one must be a visible, greppable choice. + pub const UNMASKED: Self = Self(CapabilitySet::all()); + + /// A mask enabling exactly `set`. An empty set is valid and yields a + /// token which authenticates an identity but authorizes nothing. + pub fn bounded(set: CapabilitySet) -> Self { + Self(set) + } + + /// Build a mask from a token's verified `capability_mask` claim. + /// + /// An absent claim is [`Self::UNMASKED`]; a present claim enables its + /// recognized names, and that includes an empty list — "no mask" and + /// "an empty mask" are distinct on the wire and the difference is + /// load-bearing. Unrecognized names contribute nothing, so a claim + /// naming only names we don't know bounds the token to nothing at all; + /// see [`Capability::from_name`]. + pub fn from_claim(mask: Option<&[String]>) -> Self { + let Some(mask) = mask else { + return Self::UNMASKED; + }; + Self( + mask.iter() + .filter_map(|name| Capability::from_name(name)) + .collect(), + ) + } + + /// Attenuate `capabilities` to this mask. + /// + /// Apply this at each node emission of the user grant walk, never to the + /// walk's result: the mask has to gate `Delegate` itself, so that a mask + /// without it confines the token to direct user grants, and it must not + /// be re-widened by `Assume`, which makes all of an edge's bits + /// delegatable as it passes through. + pub fn apply(self, capabilities: CapabilitySet) -> CapabilitySet { + capabilities & self.0 + } + + /// True when this mask attenuates nothing this binary can enforce. + /// + /// This exists for *leak-prevention* decisions only — e.g. whether + /// `reachable_prefixes` may keep emitting fully-attenuated legacy nodes, + /// which is safe when the mask hides nothing. It must NEVER stand in + /// for "is this bearer unmasked?": that is a property of the claim + /// (`capability_mask.is_some()`), not of this value. + pub fn is_all(self) -> bool { + self.0 == CapabilitySet::all() + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] #[cfg_attr( @@ -150,3 +281,140 @@ impl From for CapabilitySet { bits_for_legacy(capability) } } + +#[cfg(test)] +mod test { + use super::{Capability, CapabilityBundle, CapabilityMask, CapabilitySet}; + + #[test] + fn test_capability_names_round_trip() { + // Every capability has a name which parses back to itself, and the + // set of names is the vocabulary of the `capability_mask` claim. + let names: Vec<&str> = CapabilitySet::all().iter().map(|c| c.name()).collect(); + + for capability in CapabilitySet::all() { + assert_eq!(Capability::from_name(capability.name()), Some(capability)); + } + // Names a binary doesn't know about are not errors, they're absences. + assert_eq!(Capability::from_name("NotACapability"), None); + assert_eq!(Capability::from_name("catalogRead"), None); + assert_eq!(Capability::from_name(""), None); + + insta::assert_debug_snapshot!(names, @r#" + [ + "CatalogRead", + "JournalRead", + "JournalAppend", + "SpecEdit", + "CreateGrant", + "DeleteGrant", + "CreateInviteLink", + "ViewDataPlanePrivateNetworking", + "ModifyDataPlanePrivateNetworking", + "ViewBilling", + "EditBilling", + "QueryServiceAccounts", + "CreateServiceAccount", + "CreateApiKey", + "RevokeApiKey", + "Delegate", + "Assume", + ] + "#); + } + + #[test] + fn test_capability_mask_from_claim() { + // An absent claim is an unmasked token: the full set. + assert_eq!(CapabilityMask::from_claim(None), CapabilityMask::UNMASKED); + + let cases = [ + // Recognized names enable exactly what they name. + Some(vec!["CatalogRead".to_string(), "Delegate".to_string()]), + // An empty mask is valid, and authorizes nothing. + Some(vec![]), + // Unknown names are inert alongside known ones... + Some(vec![ + "SpecEdit".to_string(), + "FutureCapability".to_string(), + "catalog_read".to_string(), + ]), + // ...and a claim of only unknown names bounds the token to + // nothing, never leaving it unmasked. + Some(vec!["FutureCapability".to_string()]), + // Duplicates and ordering are immaterial to a set. + Some(vec![ + "Delegate".to_string(), + "CatalogRead".to_string(), + "CatalogRead".to_string(), + ]), + ]; + let masks: Vec = cases + .iter() + .map(|claim| CapabilityMask::from_claim(claim.as_deref())) + .collect(); + + insta::assert_debug_snapshot!(masks, @r" + [ + CapabilityMask( + EnumSet(CatalogRead | Delegate), + ), + CapabilityMask( + EnumSet(), + ), + CapabilityMask( + EnumSet(SpecEdit), + ), + CapabilityMask( + EnumSet(), + ), + CapabilityMask( + EnumSet(CatalogRead | Delegate), + ), + ] + "); + } + + #[test] + fn test_capability_mask_apply() { + let editor = CapabilityBundle::Editor.capabilities(); + + // The unmasked mask is the identity, and enabling every capability + // is the same thing by construction. + assert_eq!(CapabilityMask::UNMASKED.apply(editor), editor); + assert_eq!( + CapabilityMask::bounded(CapabilitySet::all()), + CapabilityMask::UNMASKED, + ); + + // A mask intersects: it can only ever disable bits, and bits it + // enables which the grant doesn't hold stay absent. + assert_eq!( + CapabilityMask::bounded(Capability::CatalogRead | Capability::EditBilling) + .apply(editor), + CapabilitySet::from(Capability::CatalogRead), + ); + assert_eq!( + CapabilityMask::bounded(CapabilitySet::empty()).apply(editor), + CapabilitySet::empty(), + ); + assert!(!CapabilityMask::bounded(editor).is_all()); + assert!(CapabilityMask::UNMASKED.is_all()); + } + + // The claim vocabulary and GraphQL's `CapabilityBit` vocabulary are one + // and the same, and must not drift: an agent told it needs `SpecEdit` + // must be able to name `SpecEdit` in a mask request. Reading the derive's + // own item list catches a variant rename, where GraphQL's spelling moves + // and `name()`'s hard-coded string doesn't. + // + // Requires the `async-graphql` feature, so `cargo test -p models` alone + // skips this; a workspace-wide run enables it by feature unification. + #[cfg(feature = "async-graphql")] + #[test] + fn test_graphql_names_match_claim_names() { + for item in ::items() { + assert_eq!(item.name, item.value.name()); + } + } +} From 0f7dc00a9a3a86ecccef14a3d83ba27aebf14972 Mon Sep 17 00:00:00 2001 From: Brian Bartman Date: Tue, 25 Aug 2026 17:25:17 +0000 Subject: [PATCH 2/3] models: pin capability_mask claim edge cases 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. --- crates/models/src/authorizations.rs | 96 +++++++++++++++++++++++++++-- crates/models/src/authz.rs | 10 +++ 2 files changed, 100 insertions(+), 6 deletions(-) diff --git a/crates/models/src/authorizations.rs b/crates/models/src/authorizations.rs index e2e259a7aa4..ecc2b3458c4 100644 --- a/crates/models/src/authorizations.rs +++ b/crates/models/src/authorizations.rs @@ -290,20 +290,30 @@ mod test { "sub": "11111111-1111-1111-1111-111111111111", "role": "authenticated", }); + // `None` leaves the field absent; `Some` sets it, including to an + // explicit JSON null. Absent and null both mean "no mask", while an + // empty array is a mask enabling nothing — and the difference + // is load-bearing: `[]` attenuates authority to nothing, absence + // doesn't attenuate at all. let cases = [ // Absent: every token minted before capability masks existed. - serde_json::Value::Null, - serde_json::json!(["CatalogRead", "JournalRead", "Delegate"]), - serde_json::json!([]), - serde_json::json!(["SpecEdit", "FutureCapability"]), - serde_json::json!(["FutureCapability"]), + None, + Some(serde_json::Value::Null), + Some(serde_json::json!([ + "CatalogRead", + "JournalRead", + "Delegate" + ])), + Some(serde_json::json!([])), + Some(serde_json::json!(["SpecEdit", "FutureCapability"])), + Some(serde_json::json!(["FutureCapability"])), ]; let outcomes: Vec<(Option>, CapabilityMask)> = cases .into_iter() .map(|mask| { let mut claims = base.clone(); - if !mask.is_null() { + if let Some(mask) = mask { claims["capability_mask"] = mask; } let claims: ControlClaims = serde_json::from_value(claims).unwrap(); @@ -372,6 +382,53 @@ mod test { "#); } + #[test] + fn test_capability_mask_claim_rejects_malformed_shapes() { + // Leniency is over names only. The claim must be an array of + // strings, absent, or null; any other shape fails deserialization, + // and a token whose claims fail to parse fails to authenticate. + // The control plane is the claim's sole minter, so a + // differently-shaped claim is corrupt or forged, and refusing the + // token is the fail-safe outcome. + let base = serde_json::json!({ + "aud": "authenticated", + "iat": 1000, + "exp": 2000, + "sub": "11111111-1111-1111-1111-111111111111", + "role": "authenticated", + }); + let errors: Vec = [ + serde_json::json!("CatalogRead"), + serde_json::json!(42), + serde_json::json!(true), + serde_json::json!({"names": ["CatalogRead"]}), + serde_json::json!([["CatalogRead"]]), + serde_json::json!(["CatalogRead", 42]), + serde_json::json!([null]), + ] + .into_iter() + .map(|mask| { + let mut claims = base.clone(); + claims["capability_mask"] = mask; + serde_json::from_value::(claims) + .unwrap_err() + .to_string() + }) + .collect(); + + insta::assert_debug_snapshot!(errors, @r#" + [ + "invalid type: string \"CatalogRead\", expected a sequence", + "invalid type: integer `42`, expected a sequence", + "invalid type: boolean `true`, expected a sequence", + "invalid type: map, expected a sequence", + "invalid type: sequence, expected a string", + "invalid type: integer `42`, expected a string", + "invalid type: null, expected a string", + ] + "#); + } + #[test] fn test_capability_mask_claim_round_trip() { let claims = ControlClaims { @@ -412,5 +469,32 @@ mod test { "capability_mask": [] } "#); + + // A populated mask serializes its names verbatim — including names + // this binary doesn't recognize — and they survive a round trip + // intact. Carry-through is load-bearing: an upgrade token's + // unrecognized names must re-mint unchanged rather than being + // dropped by whichever instance happens to re-sign it. + let masked = ControlClaims { + capability_mask: Some(vec!["SpecEdit".to_string(), "FutureCapability".to_string()]), + ..masked + }; + let round_tripped: ControlClaims = + serde_json::from_value(serde_json::to_value(&masked).unwrap()).unwrap(); + assert_eq!(round_tripped.capability_mask, masked.capability_mask); + + insta::assert_json_snapshot!(masked, @r#" + { + "aud": "authenticated", + "iat": 1000, + "exp": 2000, + "sub": "00000000-0000-0000-0000-000000000000", + "role": "authenticated", + "capability_mask": [ + "SpecEdit", + "FutureCapability" + ] + } + "#); } } diff --git a/crates/models/src/authz.rs b/crates/models/src/authz.rs index 9906e2ef071..100772abdda 100644 --- a/crates/models/src/authz.rs +++ b/crates/models/src/authz.rs @@ -354,6 +354,16 @@ mod test { .map(|claim| CapabilityMask::from_claim(claim.as_deref())) .collect(); + // The empty claim bounds the token to nothing; it is not "no mask". + assert_eq!( + CapabilityMask::from_claim(Some(&[])), + CapabilityMask::bounded(CapabilitySet::empty()), + ); + assert_ne!( + CapabilityMask::from_claim(Some(&[])), + CapabilityMask::UNMASKED, + ); + insta::assert_debug_snapshot!(masks, @r" [ CapabilityMask( From a1483c2c976f8608169f398c05b03e1a57ae1d30 Mon Sep 17 00:00:00 2001 From: Brian Bartman Date: Thu, 27 Aug 2026 12:01:20 +0000 Subject: [PATCH 3/3] models: capability_mask claim speaks the bundle vocabulary 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. --- crates/models/src/authorizations.rs | 19 ++- crates/models/src/authz.rs | 255 +++++++++++++++++++++++----- 2 files changed, 226 insertions(+), 48 deletions(-) diff --git a/crates/models/src/authorizations.rs b/crates/models/src/authorizations.rs index ecc2b3458c4..8cbfa263e4a 100644 --- a/crates/models/src/authorizations.rs +++ b/crates/models/src/authorizations.rs @@ -20,13 +20,15 @@ pub struct ControlClaims { // Authorized user email, if known. #[serde(default, skip_serializing_if = "Option::is_none")] pub email: Option, - // Capability names to which this token's authority is masked. + // Capability-bundle names to which this token's authority is masked. // // `None` is an unmasked token — today that's every token we mint, and a // full-authority credential stays unmasked once masking exists. `Some` // is a masked token whose authority is its user's live grants - // intersected with the recognized names herein, and an empty list is - // valid: it mints an identity-only token. + // intersected with the capability bits of the recognized bundle names + // herein, and an empty list is valid: it mints an identity-only token. + // Every individual capability is itself a same-named bundle, so the + // vocabulary spans coarse bundles and single capability bits alike. // // Deliberately an opaque list of strings rather than // `authz::CapabilitySet`, so that this shared claim doesn't structurally @@ -304,6 +306,7 @@ mod test { "JournalRead", "Delegate" ])), + Some(serde_json::json!(["Viewer"])), Some(serde_json::json!([])), Some(serde_json::json!(["SpecEdit", "FutureCapability"])), Some(serde_json::json!(["FutureCapability"])), @@ -349,6 +352,16 @@ mod test { EnumSet(CatalogRead | JournalRead | Delegate), ), ), + ( + Some( + [ + "Viewer", + ], + ), + CapabilityMask( + EnumSet(CatalogRead | JournalRead | ViewDataPlanePrivateNetworking), + ), + ), ( Some( [], diff --git a/crates/models/src/authz.rs b/crates/models/src/authz.rs index 100772abdda..f3852948b59 100644 --- a/crates/models/src/authz.rs +++ b/crates/models/src/authz.rs @@ -48,15 +48,18 @@ impl std::fmt::Display for Capability { impl Capability { /// PascalCase wire name of this capability. /// - /// One vocabulary is shared by the `capability_mask` token claim and by - /// GraphQL's `CapabilityBit` enum (which derives the same spelling from - /// these variant identifiers), so that "you need capability X" reads - /// identically wherever it's said. Names are minted into tokens which - /// outlive a deploy and are interpreted by instances of differing + /// This spelling is shared by GraphQL's `CapabilityBit` enum (which + /// derives it from these variant identifiers) and — because every + /// capability is also a same-named single-capability [`CapabilityBundle`] — + /// by the `capability_mask` token claim, so that "you need capability X" + /// reads identically wherever it's said. Names are minted into tokens + /// which outlive a deploy and are interpreted by instances of differing /// versions, so they must remain stable: the mapping is written out /// rather than derived from `Debug` precisely so that renaming a variant /// is not silently a wire-format change — `test_graphql_names_match_claim_names` - /// holds this mapping and GraphQL's derived spelling together. + /// holds this mapping and GraphQL's derived spelling together, and + /// `test_capabilities_are_single_capability_bundles` holds it to the + /// claim vocabulary. pub const fn name(&self) -> &'static str { match self { Self::CatalogRead => "CatalogRead", @@ -78,18 +81,6 @@ impl Capability { Self::Assume => "Assume", } } - - /// Parse a PascalCase capability name, or `None` if this binary doesn't - /// recognize it. - /// - /// An unrecognized name is inert rather than an error: a token minted by - /// a newer control plane must still authenticate against an older one, - /// and a capability we cannot enforce must never widen what we allow. - pub fn from_name(name: &str) -> Option { - // Linear over the variants, which keeps `name()` the mapping's - // single source of truth. - CapabilitySet::all().iter().find(|c| c.name() == name) - } } /// The capability mask carried by a request: a ceiling on the capabilities it @@ -136,20 +127,22 @@ impl CapabilityMask { /// Build a mask from a token's verified `capability_mask` claim. /// - /// An absent claim is [`Self::UNMASKED`]; a present claim enables its - /// recognized names, and that includes an empty list — "no mask" and - /// "an empty mask" are distinct on the wire and the difference is - /// load-bearing. Unrecognized names contribute nothing, so a claim - /// naming only names we don't know bounds the token to nothing at all; - /// see [`Capability::from_name`]. + /// An absent claim is [`Self::UNMASKED`]; a present claim enables the + /// union of the capability bits of its recognized [`CapabilityBundle`] + /// names, and that includes an empty list — "no mask" and "an empty + /// mask" are distinct on the wire and the difference is load-bearing. + /// Unrecognized names contribute nothing, so a claim naming only names + /// we don't know bounds the token to nothing at all; see + /// [`CapabilityBundle::from_name`]. pub fn from_claim(mask: Option<&[String]>) -> Self { let Some(mask) = mask else { return Self::UNMASKED; }; Self( mask.iter() - .filter_map(|name| Capability::from_name(name)) - .collect(), + .filter_map(|name| CapabilityBundle::from_name(name)) + .map(|bundle| bundle.capabilities()) + .fold(CapabilitySet::empty(), |set, bits| set | bits), ) } @@ -194,6 +187,31 @@ pub enum CapabilityBundle { ManageDataPlane, Delegate, Assume, + // The variants below are single-capability bundles: each maps directly + // to the one `Capability` bit of the same name, so that any individual + // capability — in particular one named by a `missing_capabilities` + // denial — is expressible in the bundle vocabulary of the + // `capability_mask` claim. + // + // Unlike the grantable bundles above, these are not values of the + // Postgres `capability_bundle` enum and never appear on grant rows: + // they exist for the claim vocabulary. Encoding one into SQL is a + // runtime error, and nothing does. + CatalogRead, + JournalRead, + JournalAppend, + SpecEdit, + CreateGrant, + DeleteGrant, + CreateInviteLink, + ViewDataPlanePrivateNetworking, + ModifyDataPlanePrivateNetworking, + ViewBilling, + EditBilling, + QueryServiceAccounts, + CreateServiceAccount, + CreateApiKey, + RevokeApiKey, } impl CapabilityBundle { @@ -263,8 +281,115 @@ impl CapabilityBundle { } Self::Delegate => Delegate.into(), Self::Assume => Assume.into(), + // Single-capability bundles map directly to their bit. + Self::CatalogRead => CatalogRead.into(), + Self::JournalRead => JournalRead.into(), + Self::JournalAppend => JournalAppend.into(), + Self::SpecEdit => SpecEdit.into(), + Self::CreateGrant => CreateGrant.into(), + Self::DeleteGrant => DeleteGrant.into(), + Self::CreateInviteLink => CreateInviteLink.into(), + Self::ViewDataPlanePrivateNetworking => ViewDataPlanePrivateNetworking.into(), + Self::ModifyDataPlanePrivateNetworking => ModifyDataPlanePrivateNetworking.into(), + Self::ViewBilling => ViewBilling.into(), + Self::EditBilling => EditBilling.into(), + Self::QueryServiceAccounts => QueryServiceAccounts.into(), + Self::CreateServiceAccount => CreateServiceAccount.into(), + Self::CreateApiKey => CreateApiKey.into(), + Self::RevokeApiKey => RevokeApiKey.into(), + } + } + + /// Every bundle, in declaration order: the vocabulary of the + /// `capability_mask` claim. [`Self::from_name`] searches this, keeping + /// [`Self::name`] the mapping's single source of truth. + pub const ALL: [Self; 25] = [ + Self::Viewer, + Self::Writer, + Self::Editor, + Self::Admin, + Self::Billing, + Self::TeamAdmin, + Self::ManageServiceAccounts, + Self::ManageDataPlane, + Self::Delegate, + Self::Assume, + Self::CatalogRead, + Self::JournalRead, + Self::JournalAppend, + Self::SpecEdit, + Self::CreateGrant, + Self::DeleteGrant, + Self::CreateInviteLink, + Self::ViewDataPlanePrivateNetworking, + Self::ModifyDataPlanePrivateNetworking, + Self::ViewBilling, + Self::EditBilling, + Self::QueryServiceAccounts, + Self::CreateServiceAccount, + Self::CreateApiKey, + Self::RevokeApiKey, + ]; + + /// PascalCase wire name of this bundle: the vocabulary of the + /// `capability_mask` token claim. + /// + /// This is distinct from the snake_case serde / Postgres spelling, which + /// is a storage concern. Like [`Capability::name`], these names are + /// minted into tokens which outlive a deploy, so they must remain + /// stable; single-capability bundles share their capability's spelling + /// by construction. + pub const fn name(&self) -> &'static str { + match self { + Self::Viewer => "Viewer", + Self::Writer => "Writer", + Self::Editor => "Editor", + Self::Admin => "Admin", + Self::Billing => "Billing", + Self::TeamAdmin => "TeamAdmin", + Self::ManageServiceAccounts => "ManageServiceAccounts", + Self::ManageDataPlane => "ManageDataPlane", + Self::Delegate => Capability::Delegate.name(), + Self::Assume => Capability::Assume.name(), + Self::CatalogRead => Capability::CatalogRead.name(), + Self::JournalRead => Capability::JournalRead.name(), + Self::JournalAppend => Capability::JournalAppend.name(), + Self::SpecEdit => Capability::SpecEdit.name(), + Self::CreateGrant => Capability::CreateGrant.name(), + Self::DeleteGrant => Capability::DeleteGrant.name(), + Self::CreateInviteLink => Capability::CreateInviteLink.name(), + Self::ViewDataPlanePrivateNetworking => { + Capability::ViewDataPlanePrivateNetworking.name() + } + Self::ModifyDataPlanePrivateNetworking => { + Capability::ModifyDataPlanePrivateNetworking.name() + } + Self::ViewBilling => Capability::ViewBilling.name(), + Self::EditBilling => Capability::EditBilling.name(), + Self::QueryServiceAccounts => Capability::QueryServiceAccounts.name(), + Self::CreateServiceAccount => Capability::CreateServiceAccount.name(), + Self::CreateApiKey => Capability::CreateApiKey.name(), + Self::RevokeApiKey => Capability::RevokeApiKey.name(), } } + + /// Parse a PascalCase bundle name, or `None` if this binary doesn't + /// recognize it. + /// + /// An unrecognized name is inert rather than an error: a token minted by + /// a newer control plane must still authenticate against an older one, + /// and a capability we cannot enforce must never widen what we allow. + pub fn from_name(name: &str) -> Option { + // Linear over the variants, which keeps `name()` the mapping's + // single source of truth. + Self::ALL.into_iter().find(|b| b.name() == name) + } +} + +impl From for CapabilitySet { + fn from(bundle: CapabilityBundle) -> Self { + bundle.capabilities() + } } pub fn bits_for_legacy(capability: super::Capability) -> CapabilitySet { @@ -287,21 +412,35 @@ mod test { use super::{Capability, CapabilityBundle, CapabilityMask, CapabilitySet}; #[test] - fn test_capability_names_round_trip() { - // Every capability has a name which parses back to itself, and the - // set of names is the vocabulary of the `capability_mask` claim. - let names: Vec<&str> = CapabilitySet::all().iter().map(|c| c.name()).collect(); + fn test_bundle_names_round_trip() { + // Every bundle has a name which parses back to itself, and the set + // of names is the vocabulary of the `capability_mask` claim. + let names: Vec<&str> = CapabilityBundle::ALL.iter().map(|b| b.name()).collect(); - for capability in CapabilitySet::all() { - assert_eq!(Capability::from_name(capability.name()), Some(capability)); + for bundle in CapabilityBundle::ALL { + assert_eq!(CapabilityBundle::from_name(bundle.name()), Some(bundle)); } - // Names a binary doesn't know about are not errors, they're absences. - assert_eq!(Capability::from_name("NotACapability"), None); - assert_eq!(Capability::from_name("catalogRead"), None); - assert_eq!(Capability::from_name(""), None); + // Names a binary doesn't know about are not errors, they're + // absences — and the snake_case serde / Postgres spelling is not + // the claim vocabulary. + assert_eq!(CapabilityBundle::from_name("NotABundle"), None); + assert_eq!(CapabilityBundle::from_name("viewer"), None); + assert_eq!(CapabilityBundle::from_name("team_admin"), None); + assert_eq!(CapabilityBundle::from_name("catalogRead"), None); + assert_eq!(CapabilityBundle::from_name(""), None); insta::assert_debug_snapshot!(names, @r#" [ + "Viewer", + "Writer", + "Editor", + "Admin", + "Billing", + "TeamAdmin", + "ManageServiceAccounts", + "ManageDataPlane", + "Delegate", + "Assume", "CatalogRead", "JournalRead", "JournalAppend", @@ -317,23 +456,41 @@ mod test { "CreateServiceAccount", "CreateApiKey", "RevokeApiKey", - "Delegate", - "Assume", ] "#); } + #[test] + fn test_capabilities_are_single_capability_bundles() { + // Every capability is expressible in the claim vocabulary under its + // own spelling: a `missing_capabilities` denial names `Capability` + // bits, and an agent must be able to hand those names straight back + // in a mask request. Each such name parses as a bundle enabling + // exactly its bit. + for capability in CapabilitySet::all() { + let bundle = CapabilityBundle::from_name(capability.name()) + .expect("every capability name is a bundle name"); + assert_eq!(bundle.capabilities(), CapabilitySet::only(capability)); + } + } + #[test] fn test_capability_mask_from_claim() { // An absent claim is an unmasked token: the full set. assert_eq!(CapabilityMask::from_claim(None), CapabilityMask::UNMASKED); let cases = [ - // Recognized names enable exactly what they name. + // Single-capability bundle names enable exactly the bit they + // name. Some(vec!["CatalogRead".to_string(), "Delegate".to_string()]), + // A composite bundle name enables all of its capability bits... + Some(vec!["Viewer".to_string()]), + // ...and bundles and single capabilities union freely. + Some(vec!["Viewer".to_string(), "SpecEdit".to_string()]), // An empty mask is valid, and authorizes nothing. Some(vec![]), - // Unknown names are inert alongside known ones... + // Unknown names are inert alongside known ones — including the + // snake_case Postgres spelling, which is not this vocabulary... Some(vec![ "SpecEdit".to_string(), "FutureCapability".to_string(), @@ -369,6 +526,12 @@ mod test { CapabilityMask( EnumSet(CatalogRead | Delegate), ), + CapabilityMask( + EnumSet(CatalogRead | JournalRead | ViewDataPlanePrivateNetworking), + ), + CapabilityMask( + EnumSet(CatalogRead | JournalRead | SpecEdit | ViewDataPlanePrivateNetworking), + ), CapabilityMask( EnumSet(), ), @@ -412,11 +575,13 @@ mod test { assert!(CapabilityMask::UNMASKED.is_all()); } - // The claim vocabulary and GraphQL's `CapabilityBit` vocabulary are one - // and the same, and must not drift: an agent told it needs `SpecEdit` - // must be able to name `SpecEdit` in a mask request. Reading the derive's - // own item list catches a variant rename, where GraphQL's spelling moves - // and `name()`'s hard-coded string doesn't. + // GraphQL's `CapabilityBit` vocabulary must stay a subset of the claim + // vocabulary and must not drift: an agent told it needs `SpecEdit` must + // be able to name `SpecEdit` in a mask request. This test holds + // `Capability::name` to GraphQL's derived spelling (catching a variant + // rename, where GraphQL's spelling moves and `name()`'s hard-coded + // string doesn't), and `test_capabilities_are_single_capability_bundles` + // holds those same names to the bundle vocabulary the claim parses. // // Requires the `async-graphql` feature, so `cargo test -p models` alone // skips this; a workspace-wide run enables it by feature unification.