diff --git a/crates/control-plane-api/src/server/mod.rs b/crates/control-plane-api/src/server/mod.rs index 97761a76bee..4f3d40c5cd6 100644 --- a/crates/control-plane-api/src/server/mod.rs +++ b/crates/control-plane-api/src/server/mod.rs @@ -145,13 +145,9 @@ where Ok((None, ())) } -/// Looks up the user's authorization grants for each item in +/// 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 capability. 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`). +/// item and its authorization. The `Some` results are returned in a vec. pub fn attach_user_capabilities( snapshot: &Snapshot, claims: &crate::ControlClaims, @@ -161,19 +157,19 @@ pub fn attach_user_capabilities( ) -> Vec where I: IntoIterator, - F: FnMut(String, Option) -> Option, + F: FnMut(String, tables::UserAuthorization) -> Option, { prefixes_or_names .into_iter() .flat_map(|prefix| { - let capability = tables::UserGrant::get_user_capability( + let authorization = tables::UserGrant::get_user_authorization( &snapshot.role_grants, &snapshot.user_grants, claims.sub, &prefix, mask, ); - attach(prefix, capability) + attach(prefix, authorization) }) .collect() } 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/data_planes.rs b/crates/control-plane-api/src/server/public/graphql/data_planes.rs index 85571cf408f..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, user_capability| { + |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,7 +579,8 @@ 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. + user_capability: authorization.legacy_label(), cloud_provider, region, tag, @@ -883,6 +884,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")) 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/live_spec_refs.rs b/crates/control-plane-api/src/server/public/graphql/live_spec_refs.rs index 584400b016e..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 @@ -36,9 +36,28 @@ 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 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( + authorization: tables::UserAuthorization, +) -> Option { + authorization + .bits + .is_superset(models::authz::CapabilityBundle::Viewer.capabilities()) + .then(|| authorization.legacy_label()) +} + #[ComplexObject] impl LiveSpecRef { /// Returns the live spec that the reference points to, if the user has access to it. @@ -166,12 +185,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 +208,15 @@ 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, 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: maybe_capability, + user_capability: user_capability_field(authorization), }) }, ); @@ -365,12 +387,12 @@ impl LiveSpecsQuery { env.claims()?, super::bearer_mask(ctx)?, names, - |name, user_capability| { + |name, authorization| { Some(connection::Edge::new( name.clone(), LiveSpecRef { catalog_name: models::Name::new(name), - user_capability, + user_capability: user_capability_field(authorization), }, )) }, 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..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, user_capability| { + |name, authorization| { Some(LiveSpecRef { catalog_name: models::Name::new(name), - user_capability, + user_capability: super::live_spec_refs::user_capability_field(authorization), }) }, ); @@ -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,330 @@ 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(); + // 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 + // 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" + } + } + ] + } + } + } + } + ] + } + } + } + "#); + + // 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( + 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" + } + } + ] + } + } + } + "#); + } } 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..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 @@ -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, @@ -809,31 +815,26 @@ 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. + let authorization = 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 - )) - })?; - - 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; @@ -1068,6 +1069,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")) 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 """ diff --git a/crates/tables/src/behaviors.rs b/crates/tables/src/behaviors.rs index 80f13587709..51e3b7f9744 100644 --- a/crates/tables/src/behaviors.rs +++ b/crates/tables/src/behaviors.rs @@ -201,23 +201,34 @@ impl super::UserGrant { out } - pub fn get_user_capability<'a>( + /// The user's [`super::UserAuthorization`] for `object_role_or_name`. + /// + /// 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. + 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, - ) -> 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. + ) -> super::UserAuthorization { 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() + .fold( + super::UserAuthorization { + bits: authz::CapabilitySet::empty(), + legacy: None, + }, + |acc, n| { + let node_legacy = Some(n.legacy).filter(|c| *c != models::Capability::None); + super::UserAuthorization { + bits: acc.bits | n.capabilities, + legacy: acc.legacy.max(node_legacy), + } + }, + ) } pub fn is_authorized<'a>( @@ -596,7 +607,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( [ @@ -631,33 +642,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 ) + .legacy ); 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 ) + .legacy ); assert_eq!( None, - UserGrant::get_user_capability( + UserGrant::get_user_authorization( &role_grants, &user_grants, user1, "different/co/altogether", authz::CapabilityMask::ALL_CAPABILITIES ) + .legacy ); } @@ -2095,7 +2109,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 @@ -2120,7 +2134,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) + .legacy, Some(models::Capability::Admin), ); assert!(!UserGrant::is_authorized( @@ -2136,7 +2151,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 + ) + .legacy, None, ); // ...and with Delegate it's reached, reporting its legacy value @@ -2144,8 +2166,160 @@ 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 + ) + .legacy, 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 + ), + crate::UserAuthorization { + bits: CapabilityBundle::Viewer.capabilities(), + legacy: None, + }, + ); + assert_eq!( + UserGrant::get_user_authorization( + &role_grants, + &user_grants, + user_id, + "otherCo/thing", + mask + ), + crate::UserAuthorization { + bits: CapabilityBundle::Admin.capabilities(), + legacy: 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 + ), + crate::UserAuthorization { + bits: EnumSet::empty().into(), + legacy: 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 + ), + crate::UserAuthorization { + bits: EnumSet::from(CatalogRead).into(), + legacy: 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 + ), + crate::UserAuthorization { + bits: EnumSet::empty().into(), + legacy: 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 crate::UserAuthorization { 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)); + } } 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.