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..8cbfa263e4a 100644 --- a/crates/models/src/authorizations.rs +++ b/crates/models/src/authorizations.rs @@ -20,6 +20,29 @@ pub struct ControlClaims { // Authorized user email, if known. #[serde(default, skip_serializing_if = "Option::is_none")] pub email: Option, + // 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 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 + // 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 +273,241 @@ 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", + }); + // `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. + None, + Some(serde_json::Value::Null), + Some(serde_json::json!([ + "CatalogRead", + "JournalRead", + "Delegate" + ])), + Some(serde_json::json!(["Viewer"])), + 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 let Some(mask) = mask { + 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( + [ + "Viewer", + ], + ), + CapabilityMask( + EnumSet(CatalogRead | JournalRead | ViewDataPlanePrivateNetworking), + ), + ), + ( + Some( + [], + ), + CapabilityMask( + EnumSet(), + ), + ), + ( + Some( + [ + "SpecEdit", + "FutureCapability", + ], + ), + CapabilityMask( + EnumSet(SpecEdit), + ), + ), + ( + Some( + [ + "FutureCapability", + ], + ), + CapabilityMask( + EnumSet(), + ), + ), + ] + "#); + } + + #[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 { + 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": [] + } + "#); + + // 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 18ce2d4625e..f3852948b59 100644 --- a/crates/models/src/authz.rs +++ b/crates/models/src/authz.rs @@ -45,6 +45,130 @@ impl std::fmt::Display for Capability { } } +impl Capability { + /// PascalCase wire name of this capability. + /// + /// 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, 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", + 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", + } + } +} + +/// 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 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| CapabilityBundle::from_name(name)) + .map(|bundle| bundle.capabilities()) + .fold(CapabilitySet::empty(), |set, bits| set | bits), + ) + } + + /// 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( @@ -63,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 { @@ -132,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 { @@ -150,3 +406,190 @@ impl From for CapabilitySet { bits_for_legacy(capability) } } + +#[cfg(test)] +mod test { + use super::{Capability, CapabilityBundle, CapabilityMask, CapabilitySet}; + + #[test] + 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 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 — 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", + "SpecEdit", + "CreateGrant", + "DeleteGrant", + "CreateInviteLink", + "ViewDataPlanePrivateNetworking", + "ModifyDataPlanePrivateNetworking", + "ViewBilling", + "EditBilling", + "QueryServiceAccounts", + "CreateServiceAccount", + "CreateApiKey", + "RevokeApiKey", + ] + "#); + } + + #[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 = [ + // 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 — including the + // snake_case Postgres spelling, which is not this vocabulary... + 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(); + + // 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( + EnumSet(CatalogRead | Delegate), + ), + CapabilityMask( + EnumSet(CatalogRead | JournalRead | ViewDataPlanePrivateNetworking), + ), + CapabilityMask( + EnumSet(CatalogRead | JournalRead | SpecEdit | ViewDataPlanePrivateNetworking), + ), + 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()); + } + + // 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. + #[cfg(feature = "async-graphql")] + #[test] + fn test_graphql_names_match_claim_names() { + for item in ::items() { + assert_eq!(item.name, item.value.name()); + } + } +}