From 003e777f521ce2428bfa99268ca475edc879b741 Mon Sep 17 00:00:00 2001 From: Brian Bartman Date: Thu, 27 Aug 2026 12:45:15 +0000 Subject: [PATCH 01/10] tables: get_user_authorization pairs effective bits with the legacy label 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. --- crates/tables/src/behaviors.rs | 162 +++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) diff --git a/crates/tables/src/behaviors.rs b/crates/tables/src/behaviors.rs index 80f13587709..d9d2ce9580d 100644 --- a/crates/tables/src/behaviors.rs +++ b/crates/tables/src/behaviors.rs @@ -220,6 +220,32 @@ impl super::UserGrant { .max() } + /// The user's authorization for `object_role_or_name`: effective + /// capability bits accumulated additively across every covering node + /// (the decision input), paired with the max legacy label among covering + /// nodes that carry one (compatibility metadata). + /// + /// The bits are mask-attenuated like every walk emission; the legacy + /// label passes through un-attenuated per `get_user_capability`, and is + /// `None` when coverage comes entirely from `bundles`-column grants. + pub fn get_user_authorization<'a>( + role_grants: &'a [super::RoleGrant], + user_grants: &'a [super::UserGrant], + user_id: uuid::Uuid, + object_role_or_name: &str, + mask: authz::CapabilityMask, + ) -> (authz::CapabilitySet, Option) { + Self::reachable_nodes(role_grants, user_grants, user_id, mask) + .filter(|n| object_role_or_name.starts_with(n.object_role)) + .fold( + (authz::CapabilitySet::empty(), None), + |(bits, legacy), n| { + let node_legacy = Some(n.legacy).filter(|c| *c != models::Capability::None); + (bits | n.capabilities, legacy.max(node_legacy)) + }, + ) + } + pub fn is_authorized<'a>( role_grants: &'a [super::RoleGrant], user_grants: &'a [super::UserGrant], @@ -2148,4 +2174,140 @@ mod test { Some(models::Capability::Read), ); } + + #[test] + fn test_get_user_authorization() { + use Capability::*; + + let user_id = uuid::Uuid::from_bytes([1; 16]); + let user_grants = UserGrants::from_iter([ + // A bundles-only grant: authorization comes entirely from the + // bundles column and there is no legacy value to report. + UserGrant { + user_id, + object_role: models::Prefix::new("acmeCo/"), + capability: models::Capability::None, + bundles: vec![CapabilityBundle::Viewer], + }, + // A legacy grant, whose node carries both bits and a label. + UserGrant { + user_id, + object_role: models::Prefix::new("otherCo/"), + capability: models::Capability::Admin, + bundles: vec![], + }, + ]); + let role_grants = RoleGrants::from_iter([RoleGrant { + subject_role: models::Prefix::new("otherCo/"), + object_role: models::Prefix::new("sharedCo/"), + capability: models::Capability::Read, + bundles: vec![], + }]); + + // Unmasked: the bundles-only grant reports its full Viewer bits with + // no legacy label; the legacy grant reports both halves. + let mask = authz::CapabilityMask::ALL_CAPABILITIES; + assert_eq!( + UserGrant::get_user_authorization( + &role_grants, + &user_grants, + user_id, + "acmeCo/thing", + mask + ), + (CapabilityBundle::Viewer.capabilities(), None), + ); + assert_eq!( + UserGrant::get_user_authorization( + &role_grants, + &user_grants, + user_id, + "otherCo/thing", + mask + ), + ( + CapabilityBundle::Admin.capabilities(), + Some(models::Capability::Admin) + ), + ); + + // An uncovered name reports neither bits nor a label. + assert_eq!( + UserGrant::get_user_authorization( + &role_grants, + &user_grants, + user_id, + "unrelatedCo/thing", + mask + ), + (EnumSet::empty().into(), None), + ); + + // Masked: bits are the mask-attenuated effective bits, while a + // reached node's legacy label passes through un-attenuated. + let mask = authz::CapabilityMask::bounded(CatalogRead | Delegate); + assert_eq!( + UserGrant::get_user_authorization( + &role_grants, + &user_grants, + user_id, + "sharedCo/thing", + mask + ), + ( + EnumSet::from(CatalogRead).into(), + Some(models::Capability::Read) + ), + ); + + // An identity-only mask attenuates bits to nothing while a directly + // granted node still reports its legacy label. + let mask = authz::CapabilityMask::bounded(EnumSet::empty()); + assert_eq!( + UserGrant::get_user_authorization( + &role_grants, + &user_grants, + user_id, + "otherCo/thing", + mask + ), + (EnumSet::empty().into(), Some(models::Capability::Admin)), + ); + } + + #[test] + fn test_get_user_authorization_multi_path_composition() { + // Two grants cover the same name with complementary authority: + // bits compose additively across covering nodes, and the legacy + // label is the max across nodes that carry one. + let user_id = uuid::Uuid::from_bytes([1; 16]); + let user_grants = UserGrants::from_iter([ + UserGrant { + user_id, + object_role: models::Prefix::new("acmeCo/"), + capability: models::Capability::Read, + bundles: vec![], + }, + UserGrant { + user_id, + object_role: models::Prefix::new("acmeCo/data/"), + capability: models::Capability::None, + bundles: vec![CapabilityBundle::Editor], + }, + ]); + let role_grants = RoleGrants::from_iter([]); + + let (bits, legacy) = UserGrant::get_user_authorization( + &role_grants, + &user_grants, + user_id, + "acmeCo/data/thing", + authz::CapabilityMask::ALL_CAPABILITIES, + ); + assert_eq!( + bits, + CapabilityBundle::Viewer.capabilities() | CapabilityBundle::Editor.capabilities(), + ); + assert_eq!(legacy, Some(models::Capability::Read)); + } } From 76be8945b0bcd87d61694d0c05a7f1aeeeb184bb Mon Sep 17 00:00:00 2001 From: Brian Bartman Date: Thu, 27 Aug 2026 12:52:33 +0000 Subject: [PATCH 02/10] control-plane-api: LiveSpecRef access decisions key on effective bits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/control-plane-api/src/server/mod.rs | 20 +++-- .../src/server/public/graphql/data_planes.rs | 2 +- .../server/public/graphql/live_spec_refs.rs | 37 ++++++-- .../src/server/public/graphql/live_specs.rs | 85 ++++++++++++++++++- 4 files changed, 121 insertions(+), 23 deletions(-) diff --git a/crates/control-plane-api/src/server/mod.rs b/crates/control-plane-api/src/server/mod.rs index 97761a76bee..3f071064333 100644 --- a/crates/control-plane-api/src/server/mod.rs +++ b/crates/control-plane-api/src/server/mod.rs @@ -145,13 +145,15 @@ where Ok((None, ())) } -/// Looks up the user's authorization grants for each item in -/// `prefixes_or_names`, and calls the provided `attach` function with each -/// item and its capability. The `Some` results are returned in a vec. +/// Looks up the user's authorization for each item in `prefixes_or_names`, +/// and calls the provided `attach` function with each item, its effective +/// capability bits, and its legacy capability label. The `Some` results are +/// returned in a vec. /// -/// Grant reachability is evaluated under the bearer's capability `mask`; -/// the legacy capability of a reached grant passes through un-attenuated -/// (see `tables::UserGrant::get_user_capability`). +/// The bits are mask-attenuated and are the only authorization decision +/// input; the legacy label is un-attenuated compatibility metadata, `None` +/// when coverage comes entirely from `bundles`-column grants (see +/// `tables::UserGrant::get_user_authorization`). pub fn attach_user_capabilities( snapshot: &Snapshot, claims: &crate::ControlClaims, @@ -161,19 +163,19 @@ pub fn attach_user_capabilities( ) -> Vec where I: IntoIterator, - F: FnMut(String, Option) -> Option, + F: FnMut(String, models::authz::CapabilitySet, Option) -> Option, { prefixes_or_names .into_iter() .flat_map(|prefix| { - let capability = tables::UserGrant::get_user_capability( + let (bits, legacy) = tables::UserGrant::get_user_authorization( &snapshot.role_grants, &snapshot.user_grants, claims.sub, &prefix, mask, ); - attach(prefix, capability) + attach(prefix, bits, legacy) }) .collect() } diff --git a/crates/control-plane-api/src/server/public/graphql/data_planes.rs b/crates/control-plane-api/src/server/public/graphql/data_planes.rs index 85571cf408f..133eb0999fd 100644 --- a/crates/control-plane-api/src/server/public/graphql/data_planes.rs +++ b/crates/control-plane-api/src/server/public/graphql/data_planes.rs @@ -569,7 +569,7 @@ impl DataPlanesQuery { env.claims()?, super::bearer_mask(ctx)?, names.into_iter(), - |data_plane_name, user_capability| { + |data_plane_name, _bits, user_capability| { let dp = row_data.get(&data_plane_name)?; let details = details_map.get(&data_plane_name); let (cloud_provider, region, tag, is_public) = diff --git a/crates/control-plane-api/src/server/public/graphql/live_spec_refs.rs b/crates/control-plane-api/src/server/public/graphql/live_spec_refs.rs index 584400b016e..b8fbfce2950 100644 --- a/crates/control-plane-api/src/server/public/graphql/live_spec_refs.rs +++ b/crates/control-plane-api/src/server/public/graphql/live_spec_refs.rs @@ -36,9 +36,27 @@ pub struct LiveSpecRef { /// name, and passing a name that the user cannot access. In either case, /// the result would be `userCapability: null`, and all other fields on the /// LiveSpecRef would also be null. + /// + /// Access is decided by the user's effective capability bits under the + /// bearer's mask, and a non-null value is the literal legacy `capability` + /// column of the covering grant(s): informational compatibility metadata, + /// reported as `none` when access comes entirely from the `bundles` + /// column. pub user_capability: Option, } +/// The `userCapability` value for a referent covered by `bits` and labeled +/// `legacy`: the legacy label when the effective bits grant access, `none` +/// standing in for access that has no legacy label, and null (no access — +/// every other field is gated to null) when the bits fall short of Viewer. +pub(super) fn user_capability_field( + bits: models::authz::CapabilitySet, + legacy: Option, +) -> Option { + bits.is_superset(models::authz::CapabilityBundle::Viewer.capabilities()) + .then(|| legacy.unwrap_or(models::Capability::None)) +} + #[ComplexObject] impl LiveSpecRef { /// Returns the live spec that the reference points to, if the user has access to it. @@ -166,12 +184,13 @@ impl LiveSpecRef { /// order, both of `all_names` and the query results, must always be ascending, /// regardless of whether forward or reverse pagination is being used. Source: /// https://relay.dev/graphql/connections.htm#sec-Edge-order -/// If `require_min_capability` is `Some`, then `all_specs` will be filtered to -/// only include those specs for which the user has the required minimum -/// capability. +/// If `require_min_capability` is `Some`, then `all_specs` is filtered to +/// only the specs where the user's effective capability bits cover that +/// required set — names falling short are omitted entirely, never surfaced +/// as inaccessible refs. pub async fn paginate_live_specs_refs( ctx: &Context<'_>, - require_min_capability: Option, + require_min_capability: Option, all_names: Vec, after: Option, before: Option, @@ -188,13 +207,13 @@ pub async fn paginate_live_specs_refs( env.claims()?, super::bearer_mask(ctx)?, all_names, - |name, maybe_capability| { - if require_min_capability.is_some_and(|min_cap| maybe_capability < Some(min_cap)) { + |name, bits, legacy| { + if require_min_capability.is_some_and(|required| !bits.is_superset(required)) { return None; } Some(LiveSpecRef { catalog_name: models::Name::new(name), - user_capability: maybe_capability, + user_capability: user_capability_field(bits, legacy), }) }, ); @@ -365,12 +384,12 @@ impl LiveSpecsQuery { env.claims()?, super::bearer_mask(ctx)?, names, - |name, user_capability| { + |name, bits, legacy| { Some(connection::Edge::new( name.clone(), LiveSpecRef { catalog_name: models::Name::new(name), - user_capability, + user_capability: user_capability_field(bits, legacy), }, )) }, diff --git a/crates/control-plane-api/src/server/public/graphql/live_specs.rs b/crates/control-plane-api/src/server/public/graphql/live_specs.rs index da9ec1d149a..5a1cf7f5b76 100644 --- a/crates/control-plane-api/src/server/public/graphql/live_specs.rs +++ b/crates/control-plane-api/src/server/public/graphql/live_specs.rs @@ -101,10 +101,10 @@ impl LiveSpec { env.claims()?, super::bearer_mask(ctx)?, [source_capture_name.clone()], - |name, user_capability| { + |name, bits, legacy| { Some(LiveSpecRef { catalog_name: models::Name::new(name), - user_capability, + user_capability: super::live_spec_refs::user_capability_field(bits, legacy), }) }, ); @@ -136,7 +136,7 @@ impl LiveSpec { } let conn = paginate_live_specs_refs( ctx, - Some(models::Capability::Read), + Some(models::authz::CapabilityBundle::Viewer.capabilities()), self.written_by.clone(), after, before, @@ -162,7 +162,7 @@ impl LiveSpec { } let conn = paginate_live_specs_refs( ctx, - Some(models::Capability::Read), + Some(models::authz::CapabilityBundle::Viewer.capabilities()), self.read_by.clone(), after, before, @@ -369,4 +369,81 @@ mod tests { } "#); } + + #[sqlx::test( + migrations = "../../supabase/migrations", + fixtures(path = "../../../fixtures", scripts("data_planes", "alice")) + )] + async fn test_graphql_live_specs_bundles_only_grant(pool: sqlx::PgPool) { + let _guard = test_server::init(); + + // Carol's authorization comes entirely from the bundles column: her + // grant row carries no legacy capability. Effective bits are the + // decision input everywhere, so she lists and reads specs exactly + // like a legacy `read` grant would, while `userCapability` reports + // the literal legacy column: `none`. + let carol_uid = uuid::Uuid::from_bytes([0x33; 16]); + sqlx::query("INSERT INTO auth.users (id, email) VALUES ($1, 'carol@example.test')") + .bind(carol_uid) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO public.user_grants (user_id, object_role, capability, bundles) + VALUES ($1, 'aliceCo/', 'none', ARRAY['viewer']::capability_bundle[])", + ) + .bind(carol_uid) + .execute(&pool) + .await + .unwrap(); + + let server = + test_server::TestServer::start(pool.clone(), test_server::snapshot(pool, true).await) + .await; + let token = server.make_access_token(carol_uid, Some("carol@example.test")); + + let response: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + query { + liveSpecs(by: { prefix: "aliceCo/data/" }) { + edges { + node { + catalogName + userCapability + liveSpec { + catalogType + } + } + } + } + } + "# + }), + Some(&token), + ) + .await; + + insta::assert_json_snapshot!(response, + @r#" + { + "data": { + "liveSpecs": { + "edges": [ + { + "node": { + "catalogName": "aliceCo/data/foo", + "liveSpec": { + "catalogType": "collection" + }, + "userCapability": "none" + } + } + ] + } + } + } + "#); + } } From 8c22068521aa9b5e05f622ad6bd2e8302042d581 Mon Sep 17 00:00:00 2001 From: Brian Bartman Date: Thu, 27 Aug 2026 12:56:59 +0000 Subject: [PATCH 03/10] control-plane-api: storageMappings serves bundles-only rows as none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../server/public/graphql/storage_mappings.rs | 90 +++++++++++++++++-- 1 file changed, 82 insertions(+), 8 deletions(-) diff --git a/crates/control-plane-api/src/server/public/graphql/storage_mappings.rs b/crates/control-plane-api/src/server/public/graphql/storage_mappings.rs index 159bde4aab3..c39ffc9ddb5 100644 --- a/crates/control-plane-api/src/server/public/graphql/storage_mappings.rs +++ b/crates/control-plane-api/src/server/public/graphql/storage_mappings.rs @@ -809,19 +809,18 @@ impl StorageMappingsQuery { let edges = rows .into_iter() .map(|row| { - let user_capability = tables::UserGrant::get_user_capability( + // The row is already authorized by the effective-bits prefix + // pre-filter; the legacy label is reporting metadata only, + // and reads `none` where coverage comes entirely from the + // bundles column. + let (_bits, legacy) = tables::UserGrant::get_user_authorization( &snapshot.role_grants, &snapshot.user_grants, claims.sub, &row.catalog_prefix, mask, - ) - .ok_or_else(|| { - async_graphql::Error::new(format!( - "missing capability for catalog prefix '{}'", - row.catalog_prefix - )) - })?; + ); + let user_capability = legacy.unwrap_or(models::Capability::None); Ok(connection::Edge::new( row.catalog_prefix.clone(), @@ -1068,6 +1067,81 @@ mod test { "#); } + #[sqlx::test( + migrations = "../../supabase/migrations", + fixtures(path = "../../../fixtures", scripts("data_planes", "alice")) + )] + async fn storage_mappings_list_under_a_bundles_only_grant(pool: sqlx::PgPool) { + let _guard = test_server::init(); + + let mut store = models::Store::example(); + *store.prefix_mut() = models::Prefix::new("tenant/collection-data/"); + let spec = crate::TextJson(models::StorageDef { + data_planes: Vec::new(), + stores: vec![store], + }); + sqlx::query("INSERT INTO storage_mappings (catalog_prefix, spec) VALUES ($1, $2)") + .bind("aliceCo/") + .bind(&spec) + .execute(&pool) + .await + .unwrap(); + + // Carol's authorization comes entirely from the bundles column, so + // her rows carry no legacy label: the listing serves them with + // `userCapability: none` rather than treating the absent label as + // an error. + let carol_uid = uuid::Uuid::from_bytes([0x33; 16]); + sqlx::query("INSERT INTO auth.users (id, email) VALUES ($1, 'carol@example.test')") + .bind(carol_uid) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO public.user_grants (user_id, object_role, capability, bundles) + VALUES ($1, 'aliceCo/', 'none', ARRAY['viewer']::capability_bundle[])", + ) + .bind(carol_uid) + .execute(&pool) + .await + .unwrap(); + + let snapshot = test_server::snapshot(pool.clone(), false).await; + let server = test_server::TestServer::start(pool.clone(), snapshot).await; + let carol = server.make_access_token(carol_uid, Some("carol@example.test")); + + let response: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + query { + storageMappings { + edges { node { catalogPrefix userCapability } } + } + } + "#, + }), + Some(&carol), + ) + .await; + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "storageMappings": { + "edges": [ + { + "node": { + "catalogPrefix": "aliceCo/", + "userCapability": "none" + } + } + ] + } + } + } + "#); + } + #[sqlx::test( migrations = "../../supabase/migrations", fixtures(path = "../../../fixtures", scripts("data_planes", "alice")) From 4841ef7a85a798cb14506c3f43e04390a483d0b1 Mon Sep 17 00:00:00 2001 From: Brian Bartman Date: Thu, 27 Aug 2026 13:01:48 +0000 Subject: [PATCH 04/10] control-plane-api: dataPlanes serves bundles-only rows as none 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. --- .../src/server/public/graphql/data_planes.rs | 77 ++++++++++++++++++- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/crates/control-plane-api/src/server/public/graphql/data_planes.rs b/crates/control-plane-api/src/server/public/graphql/data_planes.rs index 133eb0999fd..c4a2cbb6a2a 100644 --- a/crates/control-plane-api/src/server/public/graphql/data_planes.rs +++ b/crates/control-plane-api/src/server/public/graphql/data_planes.rs @@ -569,7 +569,7 @@ impl DataPlanesQuery { env.claims()?, super::bearer_mask(ctx)?, names.into_iter(), - |data_plane_name, _bits, user_capability| { + |data_plane_name, _bits, legacy| { let dp = row_data.get(&data_plane_name)?; let details = details_map.get(&data_plane_name); let (cloud_provider, region, tag, is_public) = @@ -579,7 +579,10 @@ impl DataPlanesQuery { name: data_plane_name.clone(), fqdn: dp.data_plane_fqdn.clone(), reactor_address: dp.reactor_address.clone(), - user_capability: user_capability.expect("capability guaranteed by pre-filter"), + // The row is authorized by the effective-bits pre-filter; + // the legacy label is reporting metadata, `none` where + // coverage comes entirely from the bundles column. + user_capability: legacy.unwrap_or(models::Capability::None), cloud_provider, region, tag, @@ -883,6 +886,76 @@ mod tests { use super::*; use crate::test_server; + #[sqlx::test( + migrations = "../../supabase/migrations", + fixtures(path = "../../../fixtures", scripts("data_planes", "alice")) + )] + async fn test_graphql_data_planes_bundles_only_grant(pool: sqlx::PgPool) { + let _guard = test_server::init(); + + // Carol's read authorization to the public data planes comes + // entirely from the bundles column: the listing's pre-filter admits + // them on effective bits, and each row's userCapability reports the + // literal legacy column — none. + let carol_uid = uuid::Uuid::from_bytes([0x33; 16]); + sqlx::query("INSERT INTO auth.users (id, email) VALUES ($1, 'carol@example.test')") + .bind(carol_uid) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO public.user_grants (user_id, object_role, capability, bundles) + VALUES ($1, 'ops/dp/public/', 'none', ARRAY['viewer']::capability_bundle[])", + ) + .bind(carol_uid) + .execute(&pool) + .await + .unwrap(); + + let server = + test_server::TestServer::start(pool.clone(), test_server::snapshot(pool, false).await) + .await; + let carol = server.make_access_token(carol_uid, Some("carol@example.test")); + + let response: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + query { + dataPlanes { + edges { node { name userCapability } } + } + } + "# + }), + Some(&carol), + ) + .await; + + insta::assert_json_snapshot!(response, @r#" + { + "data": { + "dataPlanes": { + "edges": [ + { + "node": { + "name": "ops/dp/public/aws-us-west-2-c1", + "userCapability": "none" + } + }, + { + "node": { + "name": "ops/dp/public/gcp-us-central1-c2", + "userCapability": "none" + } + } + ] + } + } + } + "#); + } + #[sqlx::test( migrations = "../../supabase/migrations", fixtures(path = "../../../fixtures", scripts("data_planes", "alice")) From d093516b22835ee629db264431edc506215d03b3 Mon Sep 17 00:00:00 2001 From: Brian Bartman Date: Thu, 27 Aug 2026 13:11:53 +0000 Subject: [PATCH 05/10] control-plane-api: pin ref filtering and the mask's Viewer refusal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/server/public/graphql/live_specs.rs | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) diff --git a/crates/control-plane-api/src/server/public/graphql/live_specs.rs b/crates/control-plane-api/src/server/public/graphql/live_specs.rs index 5a1cf7f5b76..9aa6f9433d6 100644 --- a/crates/control-plane-api/src/server/public/graphql/live_specs.rs +++ b/crates/control-plane-api/src/server/public/graphql/live_specs.rs @@ -370,6 +370,168 @@ mod tests { "#); } + #[sqlx::test( + migrations = "../../supabase/migrations", + fixtures(path = "../../../fixtures", scripts("data_planes", "alice")) + )] + async fn test_graphql_live_specs_mask_below_viewer_is_refused(pool: sqlx::PgPool) { + let _guard = test_server::init(); + let server = + test_server::TestServer::start(pool.clone(), test_server::snapshot(pool, true).await) + .await; + + // A mask below the Viewer threshold is a definitive refusal at the + // root query, before any ref is constructed: reached grants' legacy + // labels are never a side-channel around the mask. + let masked = server.make_masked_access_token( + uuid::Uuid::from_bytes([0x11; 16]), + None, + Some(vec!["Delegate"]), + ); + let response: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + query { + liveSpecs(by: { prefix: "aliceCo/" }) { + edges { node { catalogName userCapability } } + } + } + "# + }), + Some(&masked), + ) + .await; + + insta::assert_json_snapshot!(response, + @r#" + { + "data": null, + "errors": [ + { + "extensions": { + "error": "missing_capabilities", + "missing_capabilities": [ + "CatalogRead", + "JournalRead", + "ViewDataPlanePrivateNetworking" + ] + }, + "locations": [ + { + "column": 25, + "line": 3 + } + ], + "message": "the bearer token's capability mask does not enable required capabilities: CatalogRead, JournalRead, ViewDataPlanePrivateNetworking", + "path": [ + "liveSpecs" + ] + } + ] + } + "#); + } + + #[sqlx::test( + migrations = "../../supabase/migrations", + fixtures(path = "../../../fixtures", scripts("data_planes", "alice")) + )] + async fn test_graphql_written_by_read_by_filter_on_effective_bits(pool: sqlx::PgPool) { + let _guard = test_server::init(); + + // capture-foo writes to data/foo, which materialize-bar reads. + sqlx::query( + "INSERT INTO public.live_spec_flows (source_id, target_id, flow_type) VALUES + ('000000000005'::flowid, '000000000001'::flowid, 'capture'), + ('000000000001'::flowid, '000000000006'::flowid, 'materialization')", + ) + .execute(&pool) + .await + .unwrap(); + + // Carol can read aliceCo/data/ and aliceCo/in/ — both entirely from + // the bundles column — but not aliceCo/out/. writtenBy and readBy + // filter on her effective bits: the bundles-only-covered capture + // appears (labeled none), and the unauthorized materialization's + // name is withheld entirely. + let carol_uid = uuid::Uuid::from_bytes([0x33; 16]); + sqlx::query("INSERT INTO auth.users (id, email) VALUES ($1, 'carol@example.test')") + .bind(carol_uid) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO public.user_grants (user_id, object_role, capability, bundles) VALUES + ($1, 'aliceCo/data/', 'none', ARRAY['viewer']::capability_bundle[]), + ($1, 'aliceCo/in/', 'none', ARRAY['viewer']::capability_bundle[])", + ) + .bind(carol_uid) + .execute(&pool) + .await + .unwrap(); + + let server = + test_server::TestServer::start(pool.clone(), test_server::snapshot(pool, true).await) + .await; + let carol = server.make_access_token(carol_uid, Some("carol@example.test")); + + let response: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + query { + liveSpecs(by: { names: ["aliceCo/data/foo"] }) { + edges { + node { + catalogName + liveSpec { + writtenBy { edges { node { catalogName userCapability } } } + readBy { edges { node { catalogName userCapability } } } + } + } + } + } + } + "# + }), + Some(&carol), + ) + .await; + + insta::assert_json_snapshot!(response, + @r#" + { + "data": { + "liveSpecs": { + "edges": [ + { + "node": { + "catalogName": "aliceCo/data/foo", + "liveSpec": { + "readBy": { + "edges": [] + }, + "writtenBy": { + "edges": [ + { + "node": { + "catalogName": "aliceCo/in/capture-foo", + "userCapability": "none" + } + } + ] + } + } + } + } + ] + } + } + } + "#); + } + #[sqlx::test( migrations = "../../supabase/migrations", fixtures(path = "../../../fixtures", scripts("data_planes", "alice")) From aa4781a61a089b9fa45213d67f47d8fd440fd392 Mon Sep 17 00:00:00 2001 From: Brian Bartman Date: Thu, 27 Aug 2026 13:14:10 +0000 Subject: [PATCH 06/10] tables: get_user_authorization is the sole single-name walk reduction 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. --- crates/tables/src/behaviors.rs | 61 +++++++++++++++++----------------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/crates/tables/src/behaviors.rs b/crates/tables/src/behaviors.rs index d9d2ce9580d..1b00ce61afc 100644 --- a/crates/tables/src/behaviors.rs +++ b/crates/tables/src/behaviors.rs @@ -201,33 +201,16 @@ impl super::UserGrant { out } - pub fn get_user_capability<'a>( - role_grants: &'a [super::RoleGrant], - user_grants: &'a [super::UserGrant], - user_id: uuid::Uuid, - object_role_or_name: &str, - mask: authz::CapabilityMask, - ) -> Option { - // The mask gates which nodes are *reachable* (traversal stops where - // the mask strips Delegate/Assume), but the legacy value of a - // reached node passes through un-attenuated: it's compatibility - // metadata, never an authorization decision, and may legitimately - // read broader than the token's effective bits. - Self::reachable_nodes(role_grants, user_grants, user_id, mask) - .filter(|n| object_role_or_name.starts_with(n.object_role)) - .map(|n| n.legacy) - .filter(|c| *c != models::Capability::None) - .max() - } - /// The user's authorization for `object_role_or_name`: effective /// capability bits accumulated additively across every covering node /// (the decision input), paired with the max legacy label among covering /// nodes that carry one (compatibility metadata). /// - /// The bits are mask-attenuated like every walk emission; the legacy - /// label passes through un-attenuated per `get_user_capability`, and is - /// `None` when coverage comes entirely from `bundles`-column grants. + /// The bits are mask-attenuated like every walk emission. The legacy + /// label of a reached node passes through un-attenuated — it reflects + /// storage, never an authorization decision, and may legitimately read + /// broader than the effective bits — and is `None` when coverage comes + /// entirely from `bundles`-column grants. pub fn get_user_authorization<'a>( role_grants: &'a [super::RoleGrant], user_grants: &'a [super::UserGrant], @@ -622,7 +605,7 @@ mod test { } #[test] - fn test_get_user_capability() { + fn test_get_user_authorization_legacy_label() { use models::Capability::{Admin, Read, Write}; let role_grants = RoleGrants::from_iter( [ @@ -657,33 +640,36 @@ mod test { assert_eq!( Some(Read), - UserGrant::get_user_capability( + UserGrant::get_user_authorization( &role_grants, &user_grants, user1, "ops/private/dp/acmeCo/foooo", authz::CapabilityMask::ALL_CAPABILITIES ) + .1 ); assert_eq!( Some(Write), - UserGrant::get_user_capability( + UserGrant::get_user_authorization( &role_grants, &user_grants, user2, "ops/private/dp/acmeCo/foooo", authz::CapabilityMask::ALL_CAPABILITIES ) + .1 ); assert_eq!( None, - UserGrant::get_user_capability( + UserGrant::get_user_authorization( &role_grants, &user_grants, user1, "different/co/altogether", authz::CapabilityMask::ALL_CAPABILITIES ) + .1 ); } @@ -2121,7 +2107,7 @@ mod test { } #[test] - fn test_masked_walk_get_user_capability() { + fn test_masked_walk_legacy_label() { use Capability::*; // Legacy-capability grants, so nodes carry a legacy value the @@ -2146,7 +2132,8 @@ mod test { // direct grants, while authorization under the same mask denies. let mask = authz::CapabilityMask::bounded(EnumSet::empty()); assert_eq!( - UserGrant::get_user_capability(&role_grants, &user_grants, user_id, "acmeCo/", mask), + UserGrant::get_user_authorization(&role_grants, &user_grants, user_id, "acmeCo/", mask) + .1, Some(models::Capability::Admin), ); assert!(!UserGrant::is_authorized( @@ -2162,7 +2149,14 @@ mod test { // terminates at the direct grant, so sharedCo/ has no legacy // value to report... assert_eq!( - UserGrant::get_user_capability(&role_grants, &user_grants, user_id, "sharedCo/", mask), + UserGrant::get_user_authorization( + &role_grants, + &user_grants, + user_id, + "sharedCo/", + mask + ) + .1, None, ); // ...and with Delegate it's reached, reporting its legacy value @@ -2170,7 +2164,14 @@ mod test { // beyond CatalogRead. let mask = authz::CapabilityMask::bounded(CatalogRead | Delegate); assert_eq!( - UserGrant::get_user_capability(&role_grants, &user_grants, user_id, "sharedCo/", mask), + UserGrant::get_user_authorization( + &role_grants, + &user_grants, + user_id, + "sharedCo/", + mask + ) + .1, Some(models::Capability::Read), ); } From db6fcf1bae07aec3e37483f1a36157ac32d16088 Mon Sep 17 00:00:00 2001 From: Brian Bartman Date: Thu, 27 Aug 2026 13:18:46 +0000 Subject: [PATCH 07/10] control-plane-api: prefix thresholds speak the bundle vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/server/public/graphql/alert_configs.rs | 2 +- .../src/server/public/graphql/invite_links.rs | 2 +- .../src/server/public/graphql/storage_mappings.rs | 6 ++++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/control-plane-api/src/server/public/graphql/alert_configs.rs b/crates/control-plane-api/src/server/public/graphql/alert_configs.rs index 846711feddf..6a5b071c370 100644 --- a/crates/control-plane-api/src/server/public/graphql/alert_configs.rs +++ b/crates/control-plane-api/src/server/public/graphql/alert_configs.rs @@ -130,7 +130,7 @@ impl AlertConfigsQuery { &snapshot.user_grants, claims.sub, super::bearer_mask(ctx)?, - models::Capability::Read, + models::authz::CapabilityBundle::Viewer, filter.and_then(|f| f.catalog_prefix_or_name), "filter.catalogPrefixOrName", )?; diff --git a/crates/control-plane-api/src/server/public/graphql/invite_links.rs b/crates/control-plane-api/src/server/public/graphql/invite_links.rs index 96930ba580f..f207282b2e0 100644 --- a/crates/control-plane-api/src/server/public/graphql/invite_links.rs +++ b/crates/control-plane-api/src/server/public/graphql/invite_links.rs @@ -83,7 +83,7 @@ impl InviteLinksQuery { &snapshot.user_grants, env.claims()?.sub, super::bearer_mask(ctx)?, - models::Capability::Admin, + models::authz::CapabilityBundle::Admin, filter.and_then(|f| f.catalog_prefix), "filter.catalogPrefix", )?; diff --git a/crates/control-plane-api/src/server/public/graphql/storage_mappings.rs b/crates/control-plane-api/src/server/public/graphql/storage_mappings.rs index c39ffc9ddb5..1b5522df41a 100644 --- a/crates/control-plane-api/src/server/public/graphql/storage_mappings.rs +++ b/crates/control-plane-api/src/server/public/graphql/storage_mappings.rs @@ -323,6 +323,9 @@ impl StorageMappingsMutation { catalog_prefix, detail, spec: async_graphql::Json(collection_spec), + // Informational metadata, not a grant-row readback: this + // mutation requires admin-level effective bits on the + // prefix, which is what the label reports. user_capability: models::Capability::Admin, }, }) @@ -477,6 +480,9 @@ impl StorageMappingsMutation { catalog_prefix, detail, spec: async_graphql::Json(collection_spec), + // Informational metadata, not a grant-row readback: this + // mutation requires admin-level effective bits on the + // prefix, which is what the label reports. user_capability: models::Capability::Admin, }, republish, From f6788fca9f185409ca42b4fd7a8df77032d6f09f Mon Sep 17 00:00:00 2001 From: Brian Bartman Date: Thu, 27 Aug 2026 13:56:51 +0000 Subject: [PATCH 08/10] tables, control-plane-api: name the walk reduction's result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/control-plane-api/src/server/mod.rs | 18 ++--- .../src/server/public/graphql/data_planes.rs | 8 +-- .../server/public/graphql/live_spec_refs.rs | 29 ++++---- .../server/public/graphql/storage_mappings.rs | 16 ++--- crates/tables/src/behaviors.rs | 69 +++++++++++-------- crates/tables/src/lib.rs | 22 ++++++ 6 files changed, 93 insertions(+), 69 deletions(-) diff --git a/crates/control-plane-api/src/server/mod.rs b/crates/control-plane-api/src/server/mod.rs index 3f071064333..4f3d40c5cd6 100644 --- a/crates/control-plane-api/src/server/mod.rs +++ b/crates/control-plane-api/src/server/mod.rs @@ -145,15 +145,9 @@ where Ok((None, ())) } -/// Looks up the user's authorization for each item in `prefixes_or_names`, -/// and calls the provided `attach` function with each item, its effective -/// capability bits, and its legacy capability label. The `Some` results are -/// returned in a vec. -/// -/// The bits are mask-attenuated and are the only authorization decision -/// input; the legacy label is un-attenuated compatibility metadata, `None` -/// when coverage comes entirely from `bundles`-column grants (see -/// `tables::UserGrant::get_user_authorization`). +/// Looks up the user's `tables::UserAuthorization` for each item in +/// `prefixes_or_names`, and calls the provided `attach` function with each +/// item and its authorization. The `Some` results are returned in a vec. pub fn attach_user_capabilities( snapshot: &Snapshot, claims: &crate::ControlClaims, @@ -163,19 +157,19 @@ pub fn attach_user_capabilities( ) -> Vec where I: IntoIterator, - F: FnMut(String, models::authz::CapabilitySet, Option) -> Option, + F: FnMut(String, tables::UserAuthorization) -> Option, { prefixes_or_names .into_iter() .flat_map(|prefix| { - let (bits, legacy) = tables::UserGrant::get_user_authorization( + let authorization = tables::UserGrant::get_user_authorization( &snapshot.role_grants, &snapshot.user_grants, claims.sub, &prefix, mask, ); - attach(prefix, bits, legacy) + attach(prefix, authorization) }) .collect() } diff --git a/crates/control-plane-api/src/server/public/graphql/data_planes.rs b/crates/control-plane-api/src/server/public/graphql/data_planes.rs index c4a2cbb6a2a..8c251404b25 100644 --- a/crates/control-plane-api/src/server/public/graphql/data_planes.rs +++ b/crates/control-plane-api/src/server/public/graphql/data_planes.rs @@ -569,7 +569,7 @@ impl DataPlanesQuery { env.claims()?, super::bearer_mask(ctx)?, names.into_iter(), - |data_plane_name, _bits, legacy| { + |data_plane_name, authorization| { let dp = row_data.get(&data_plane_name)?; let details = details_map.get(&data_plane_name); let (cloud_provider, region, tag, is_public) = @@ -579,10 +579,8 @@ impl DataPlanesQuery { name: data_plane_name.clone(), fqdn: dp.data_plane_fqdn.clone(), reactor_address: dp.reactor_address.clone(), - // The row is authorized by the effective-bits pre-filter; - // the legacy label is reporting metadata, `none` where - // coverage comes entirely from the bundles column. - user_capability: legacy.unwrap_or(models::Capability::None), + // The row is authorized by the effective-bits pre-filter. + user_capability: authorization.legacy_label(), cloud_provider, region, tag, diff --git a/crates/control-plane-api/src/server/public/graphql/live_spec_refs.rs b/crates/control-plane-api/src/server/public/graphql/live_spec_refs.rs index b8fbfce2950..ed6f83c4520 100644 --- a/crates/control-plane-api/src/server/public/graphql/live_spec_refs.rs +++ b/crates/control-plane-api/src/server/public/graphql/live_spec_refs.rs @@ -45,16 +45,17 @@ pub struct LiveSpecRef { pub user_capability: Option, } -/// The `userCapability` value for a referent covered by `bits` and labeled -/// `legacy`: the legacy label when the effective bits grant access, `none` -/// standing in for access that has no legacy label, and null (no access — -/// every other field is gated to null) when the bits fall short of Viewer. +/// The `userCapability` value for a referent the user holds `authorization` +/// to: the legacy label when the effective bits grant access, and null (no +/// access — every other field is gated to null) when the bits fall short of +/// Viewer. pub(super) fn user_capability_field( - bits: models::authz::CapabilitySet, - legacy: Option, + authorization: tables::UserAuthorization, ) -> Option { - bits.is_superset(models::authz::CapabilityBundle::Viewer.capabilities()) - .then(|| legacy.unwrap_or(models::Capability::None)) + authorization + .bits + .is_superset(models::authz::CapabilityBundle::Viewer.capabilities()) + .then(|| authorization.legacy_label()) } #[ComplexObject] @@ -207,13 +208,15 @@ pub async fn paginate_live_specs_refs( env.claims()?, super::bearer_mask(ctx)?, all_names, - |name, bits, legacy| { - if require_min_capability.is_some_and(|required| !bits.is_superset(required)) { + |name, authorization| { + if require_min_capability + .is_some_and(|required| !authorization.bits.is_superset(required)) + { return None; } Some(LiveSpecRef { catalog_name: models::Name::new(name), - user_capability: user_capability_field(bits, legacy), + user_capability: user_capability_field(authorization), }) }, ); @@ -384,12 +387,12 @@ impl LiveSpecsQuery { env.claims()?, super::bearer_mask(ctx)?, names, - |name, bits, legacy| { + |name, authorization| { Some(connection::Edge::new( name.clone(), LiveSpecRef { catalog_name: models::Name::new(name), - user_capability: user_capability_field(bits, legacy), + user_capability: user_capability_field(authorization), }, )) }, diff --git a/crates/control-plane-api/src/server/public/graphql/storage_mappings.rs b/crates/control-plane-api/src/server/public/graphql/storage_mappings.rs index 1b5522df41a..87fe72adb83 100644 --- a/crates/control-plane-api/src/server/public/graphql/storage_mappings.rs +++ b/crates/control-plane-api/src/server/public/graphql/storage_mappings.rs @@ -816,29 +816,25 @@ impl StorageMappingsQuery { .into_iter() .map(|row| { // The row is already authorized by the effective-bits prefix - // pre-filter; the legacy label is reporting metadata only, - // and reads `none` where coverage comes entirely from the - // bundles column. - let (_bits, legacy) = tables::UserGrant::get_user_authorization( + // pre-filter. + let authorization = tables::UserGrant::get_user_authorization( &snapshot.role_grants, &snapshot.user_grants, claims.sub, &row.catalog_prefix, mask, ); - let user_capability = legacy.unwrap_or(models::Capability::None); - - Ok(connection::Edge::new( + connection::Edge::new( row.catalog_prefix.clone(), StorageMapping { catalog_prefix: models::Prefix::new(row.catalog_prefix), detail: row.detail, spec: async_graphql::Json(row.spec), - user_capability, + user_capability: authorization.legacy_label(), }, - )) + ) }) - .collect::, async_graphql::Error>>()?; + .collect(); let mut conn = PaginatedStorageMappings::new(has_prev, has_next); conn.edges = edges; diff --git a/crates/tables/src/behaviors.rs b/crates/tables/src/behaviors.rs index 1b00ce61afc..51e3b7f9744 100644 --- a/crates/tables/src/behaviors.rs +++ b/crates/tables/src/behaviors.rs @@ -201,30 +201,32 @@ impl super::UserGrant { out } - /// The user's authorization for `object_role_or_name`: effective - /// capability bits accumulated additively across every covering node - /// (the decision input), paired with the max legacy label among covering - /// nodes that carry one (compatibility metadata). + /// The user's [`super::UserAuthorization`] for `object_role_or_name`. /// - /// The bits are mask-attenuated like every walk emission. The legacy + /// Its bits are mask-attenuated like every walk emission. The legacy /// label of a reached node passes through un-attenuated — it reflects /// storage, never an authorization decision, and may legitimately read - /// broader than the effective bits — and is `None` when coverage comes - /// entirely from `bundles`-column grants. + /// broader than the effective bits. pub fn get_user_authorization<'a>( role_grants: &'a [super::RoleGrant], user_grants: &'a [super::UserGrant], user_id: uuid::Uuid, object_role_or_name: &str, mask: authz::CapabilityMask, - ) -> (authz::CapabilitySet, Option) { + ) -> super::UserAuthorization { Self::reachable_nodes(role_grants, user_grants, user_id, mask) .filter(|n| object_role_or_name.starts_with(n.object_role)) .fold( - (authz::CapabilitySet::empty(), None), - |(bits, legacy), n| { + super::UserAuthorization { + bits: authz::CapabilitySet::empty(), + legacy: None, + }, + |acc, n| { let node_legacy = Some(n.legacy).filter(|c| *c != models::Capability::None); - (bits | n.capabilities, legacy.max(node_legacy)) + super::UserAuthorization { + bits: acc.bits | n.capabilities, + legacy: acc.legacy.max(node_legacy), + } }, ) } @@ -647,7 +649,7 @@ mod test { "ops/private/dp/acmeCo/foooo", authz::CapabilityMask::ALL_CAPABILITIES ) - .1 + .legacy ); assert_eq!( Some(Write), @@ -658,7 +660,7 @@ mod test { "ops/private/dp/acmeCo/foooo", authz::CapabilityMask::ALL_CAPABILITIES ) - .1 + .legacy ); assert_eq!( None, @@ -669,7 +671,7 @@ mod test { "different/co/altogether", authz::CapabilityMask::ALL_CAPABILITIES ) - .1 + .legacy ); } @@ -2133,7 +2135,7 @@ mod test { let mask = authz::CapabilityMask::bounded(EnumSet::empty()); assert_eq!( UserGrant::get_user_authorization(&role_grants, &user_grants, user_id, "acmeCo/", mask) - .1, + .legacy, Some(models::Capability::Admin), ); assert!(!UserGrant::is_authorized( @@ -2156,7 +2158,7 @@ mod test { "sharedCo/", mask ) - .1, + .legacy, None, ); // ...and with Delegate it's reached, reporting its legacy value @@ -2171,7 +2173,7 @@ mod test { "sharedCo/", mask ) - .1, + .legacy, Some(models::Capability::Read), ); } @@ -2216,7 +2218,10 @@ mod test { "acmeCo/thing", mask ), - (CapabilityBundle::Viewer.capabilities(), None), + crate::UserAuthorization { + bits: CapabilityBundle::Viewer.capabilities(), + legacy: None, + }, ); assert_eq!( UserGrant::get_user_authorization( @@ -2226,10 +2231,10 @@ mod test { "otherCo/thing", mask ), - ( - CapabilityBundle::Admin.capabilities(), - Some(models::Capability::Admin) - ), + crate::UserAuthorization { + bits: CapabilityBundle::Admin.capabilities(), + legacy: Some(models::Capability::Admin), + }, ); // An uncovered name reports neither bits nor a label. @@ -2241,7 +2246,10 @@ mod test { "unrelatedCo/thing", mask ), - (EnumSet::empty().into(), None), + crate::UserAuthorization { + bits: EnumSet::empty().into(), + legacy: None, + }, ); // Masked: bits are the mask-attenuated effective bits, while a @@ -2255,10 +2263,10 @@ mod test { "sharedCo/thing", mask ), - ( - EnumSet::from(CatalogRead).into(), - Some(models::Capability::Read) - ), + crate::UserAuthorization { + bits: EnumSet::from(CatalogRead).into(), + legacy: Some(models::Capability::Read), + }, ); // An identity-only mask attenuates bits to nothing while a directly @@ -2272,7 +2280,10 @@ mod test { "otherCo/thing", mask ), - (EnumSet::empty().into(), Some(models::Capability::Admin)), + crate::UserAuthorization { + bits: EnumSet::empty().into(), + legacy: Some(models::Capability::Admin), + }, ); } @@ -2298,7 +2309,7 @@ mod test { ]); let role_grants = RoleGrants::from_iter([]); - let (bits, legacy) = UserGrant::get_user_authorization( + let crate::UserAuthorization { bits, legacy } = UserGrant::get_user_authorization( &role_grants, &user_grants, user_id, diff --git a/crates/tables/src/lib.rs b/crates/tables/src/lib.rs index 5e8623cafe8..490cccf5710 100644 --- a/crates/tables/src/lib.rs +++ b/crates/tables/src/lib.rs @@ -421,6 +421,28 @@ pub struct NodeRef<'a> { pub legacy: models::Capability, } +/// A user's resolved authorization for one object role or name — the reduction +/// of every covering [`NodeRef`] that `UserGrant::get_user_authorization` +/// returns. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct UserAuthorization { + /// Mask-attenuated effective capability bits, accumulated additively + /// across covering grant paths: the only authorization decision input. + pub bits: models::authz::CapabilitySet, + /// Max legacy `capability` column among covering grants that carry one: + /// un-attenuated reporting metadata, `None` when coverage comes entirely + /// from `bundles`-column grants. + pub legacy: Option, +} + +impl UserAuthorization { + /// The legacy label as listing APIs report it: the literal column value, + /// with `none` standing in for bundles-only coverage. + pub fn legacy_label(&self) -> models::Capability { + self.legacy.unwrap_or(models::Capability::None) + } +} + /// Attempts to parse a catalog type and name from a URL in the form of: /// `flow:///`. Returns None if the URL doesn't /// have a valid `CatalogType`, or if the scheme doesn't match. From 55213b497e334a2fd1b84e77e32d34f7cbb0bafe Mon Sep 17 00:00:00 2001 From: Brian Bartman Date: Thu, 27 Aug 2026 13:56:51 +0000 Subject: [PATCH 09/10] control-plane-api: pin the null presentation of an inaccessible ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/server/public/graphql/live_specs.rs | 91 ++++++++++++++++++- 1 file changed, 89 insertions(+), 2 deletions(-) diff --git a/crates/control-plane-api/src/server/public/graphql/live_specs.rs b/crates/control-plane-api/src/server/public/graphql/live_specs.rs index 9aa6f9433d6..5cf91cfb10e 100644 --- a/crates/control-plane-api/src/server/public/graphql/live_specs.rs +++ b/crates/control-plane-api/src/server/public/graphql/live_specs.rs @@ -101,10 +101,10 @@ impl LiveSpec { env.claims()?, super::bearer_mask(ctx)?, [source_capture_name.clone()], - |name, bits, legacy| { + |name, authorization| { Some(LiveSpecRef { catalog_name: models::Name::new(name), - user_capability: super::live_spec_refs::user_capability_field(bits, legacy), + user_capability: super::live_spec_refs::user_capability_field(authorization), }) }, ); @@ -449,6 +449,17 @@ mod tests { .execute(&pool) .await .unwrap(); + // The capture's model also names a collection outside carol's + // grants, exercising the writesTo path where an inaccessible + // referent presents as a null ref. + sqlx::query( + "UPDATE public.live_specs + SET writes_to = ARRAY['aliceCo/data/foo', 'aliceCo/out/forbidden']::catalog_name[] + WHERE catalog_name = 'aliceCo/in/capture-foo'", + ) + .execute(&pool) + .await + .unwrap(); // Carol can read aliceCo/data/ and aliceCo/in/ — both entirely from // the bundles column — but not aliceCo/out/. writtenBy and readBy @@ -530,6 +541,82 @@ mod tests { } } "#); + + // writesTo refs are un-filtered: the accessible referent serves its + // gated fields under the bundles-only label, while the referent + // outside carol's grants presents as a null ref — userCapability + // null and every gated field null. + let response: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + query { + liveSpecs(by: { names: ["aliceCo/in/capture-foo"] }) { + edges { + node { + catalogName + liveSpec { + writesTo { + edges { + node { + catalogName + userCapability + activeAlerts { alertType } + liveSpec { catalogType } + } + } + } + } + } + } + } + } + "# + }), + Some(&carol), + ) + .await; + + insta::assert_json_snapshot!(response, + @r#" + { + "data": { + "liveSpecs": { + "edges": [ + { + "node": { + "catalogName": "aliceCo/in/capture-foo", + "liveSpec": { + "writesTo": { + "edges": [ + { + "node": { + "activeAlerts": [], + "catalogName": "aliceCo/data/foo", + "liveSpec": { + "catalogType": "collection" + }, + "userCapability": "none" + } + }, + { + "node": { + "activeAlerts": null, + "catalogName": "aliceCo/out/forbidden", + "liveSpec": null, + "userCapability": null + } + } + ] + } + } + } + } + ] + } + } + } + "#); } #[sqlx::test( From e87c6d17020b396be1e968e8e8a8678ff3fedeb0 Mon Sep 17 00:00:00 2001 From: Brian Bartman Date: Fri, 28 Aug 2026 16:02:27 +0000 Subject: [PATCH 10/10] control-plane-api: regenerate flow-client GraphQL SDL The userCapability docstring gained legacy-metadata semantics in this branch, but the committed SDL was not regenerated to match. --- crates/flow-client/control-plane-api.graphql | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/flow-client/control-plane-api.graphql b/crates/flow-client/control-plane-api.graphql index 9e237f659ca..20229ec47d5 100644 --- a/crates/flow-client/control-plane-api.graphql +++ b/crates/flow-client/control-plane-api.graphql @@ -1155,6 +1155,12 @@ type LiveSpecRef { name, and passing a name that the user cannot access. In either case, the result would be `userCapability: null`, and all other fields on the LiveSpecRef would also be null. + + Access is decided by the user's effective capability bits under the + bearer's mask, and a non-null value is the literal legacy `capability` + column of the covering grant(s): informational compatibility metadata, + reported as `none` when access comes entirely from the `bundles` + column. """ userCapability: Capability """