diff --git a/.sqlx/query-57826264c9cc56151d7b59de332583a6d435241131b833d35609076a2aedba5e.json b/.sqlx/query-57826264c9cc56151d7b59de332583a6d435241131b833d35609076a2aedba5e.json deleted file mode 100644 index fb1a6163741..00000000000 --- a/.sqlx/query-57826264c9cc56151d7b59de332583a6d435241131b833d35609076a2aedba5e.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n select config as \"config!: TextJson\"\n from alert_configs\n where catalog_prefix_or_name = any($1)\n order by length(catalog_prefix_or_name) asc\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "config!: TextJson", - "type_info": "Jsonb" - } - ], - "parameters": { - "Left": [ - "TextArray" - ] - }, - "nullable": [ - false - ] - }, - "hash": "57826264c9cc56151d7b59de332583a6d435241131b833d35609076a2aedba5e" -} diff --git a/.sqlx/query-6336a73b8d0dedacb47563fc753ca867a6e5a285c2862d552910767381170ab0.json b/.sqlx/query-6336a73b8d0dedacb47563fc753ca867a6e5a285c2862d552910767381170ab0.json index 35db09a3d41..84aea275149 100644 --- a/.sqlx/query-6336a73b8d0dedacb47563fc753ca867a6e5a285c2862d552910767381170ab0.json +++ b/.sqlx/query-6336a73b8d0dedacb47563fc753ca867a6e5a285c2862d552910767381170ab0.json @@ -68,7 +68,7 @@ "name": "grant_capability", "kind": { "Enum": [ - "x_00", + "none", "x_01", "x_02", "x_03", diff --git a/.sqlx/query-6b980602f82b52e0f186d532fd5f93141c03a7f704c461194f45047636867855.json b/.sqlx/query-6b980602f82b52e0f186d532fd5f93141c03a7f704c461194f45047636867855.json index ddb4940baa4..78b72104015 100644 --- a/.sqlx/query-6b980602f82b52e0f186d532fd5f93141c03a7f704c461194f45047636867855.json +++ b/.sqlx/query-6b980602f82b52e0f186d532fd5f93141c03a7f704c461194f45047636867855.json @@ -21,7 +21,7 @@ "name": "grant_capability", "kind": { "Enum": [ - "x_00", + "none", "x_01", "x_02", "x_03", diff --git a/.sqlx/query-6bc21fd940535409a6b59d230fed417ff92af5cb2f6c11a35944288b14fde343.json b/.sqlx/query-6bc21fd940535409a6b59d230fed417ff92af5cb2f6c11a35944288b14fde343.json index 40e616a8f30..c1ba203be75 100644 --- a/.sqlx/query-6bc21fd940535409a6b59d230fed417ff92af5cb2f6c11a35944288b14fde343.json +++ b/.sqlx/query-6bc21fd940535409a6b59d230fed417ff92af5cb2f6c11a35944288b14fde343.json @@ -68,7 +68,7 @@ "name": "grant_capability", "kind": { "Enum": [ - "x_00", + "none", "x_01", "x_02", "x_03", diff --git a/.sqlx/query-40735247033c2693395184471553db708524cfc3b6f4c1095c2ae63f377b8800.json b/.sqlx/query-73944c084d652924b6838603b212d97a4b70062ecf5b1787fccfb9b9445f58f9.json similarity index 61% rename from .sqlx/query-40735247033c2693395184471553db708524cfc3b6f4c1095c2ae63f377b8800.json rename to .sqlx/query-73944c084d652924b6838603b212d97a4b70062ecf5b1787fccfb9b9445f58f9.json index de94f405d13..735857bb260 100644 --- a/.sqlx/query-40735247033c2693395184471553db708524cfc3b6f4c1095c2ae63f377b8800.json +++ b/.sqlx/query-73944c084d652924b6838603b212d97a4b70062ecf5b1787fccfb9b9445f58f9.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n g.subject_role AS \"subject_role: models::Prefix\",\n g.object_role AS \"object_role: models::Prefix\",\n g.capability AS \"capability: models::Capability\"\n FROM role_grants g\n ", + "query": "\n SELECT\n g.subject_role AS \"subject_role: models::Prefix\",\n g.object_role AS \"object_role: models::Prefix\",\n g.capability AS \"capability: models::Capability\",\n g.bundles AS \"bundles: Vec\"\n FROM role_grants g\n ", "describe": { "columns": [ { @@ -21,7 +21,7 @@ "name": "grant_capability", "kind": { "Enum": [ - "x_00", + "none", "x_01", "x_02", "x_03", @@ -56,16 +56,45 @@ } } } + }, + { + "ordinal": 3, + "name": "bundles: Vec", + "type_info": { + "Custom": { + "name": "capability_bundle[]", + "kind": { + "Array": { + "Custom": { + "name": "capability_bundle", + "kind": { + "Enum": [ + "viewer", + "writer", + "editor", + "admin", + "billing", + "team_admin", + "delegate", + "assume" + ] + } + } + } + } + } + } } ], "parameters": { "Left": [] }, "nullable": [ + false, false, false, false ] }, - "hash": "40735247033c2693395184471553db708524cfc3b6f4c1095c2ae63f377b8800" + "hash": "73944c084d652924b6838603b212d97a4b70062ecf5b1787fccfb9b9445f58f9" } diff --git a/.sqlx/query-93689648896998b21fc68dae52ae44aa102add628b90556b315fb418233b972e.json b/.sqlx/query-93689648896998b21fc68dae52ae44aa102add628b90556b315fb418233b972e.json index 89b8161ace4..3ba387fb98f 100644 --- a/.sqlx/query-93689648896998b21fc68dae52ae44aa102add628b90556b315fb418233b972e.json +++ b/.sqlx/query-93689648896998b21fc68dae52ae44aa102add628b90556b315fb418233b972e.json @@ -22,7 +22,7 @@ "name": "grant_capability", "kind": { "Enum": [ - "x_00", + "none", "x_01", "x_02", "x_03", diff --git a/.sqlx/query-a927867c7cdf07ea456cc1769952f58d20ec680c1d707433f389968079abc6ac.json b/.sqlx/query-a927867c7cdf07ea456cc1769952f58d20ec680c1d707433f389968079abc6ac.json index 42767179f60..3f43a287970 100644 --- a/.sqlx/query-a927867c7cdf07ea456cc1769952f58d20ec680c1d707433f389968079abc6ac.json +++ b/.sqlx/query-a927867c7cdf07ea456cc1769952f58d20ec680c1d707433f389968079abc6ac.json @@ -19,7 +19,7 @@ "name": "grant_capability", "kind": { "Enum": [ - "x_00", + "none", "x_01", "x_02", "x_03", diff --git a/.sqlx/query-aca6c646d9ad8bb96e60a21dd710757700ffd8170d8fa078cd0500295880f960.json b/.sqlx/query-aca6c646d9ad8bb96e60a21dd710757700ffd8170d8fa078cd0500295880f960.json index 4f9801c5055..bbb2941a0ff 100644 --- a/.sqlx/query-aca6c646d9ad8bb96e60a21dd710757700ffd8170d8fa078cd0500295880f960.json +++ b/.sqlx/query-aca6c646d9ad8bb96e60a21dd710757700ffd8170d8fa078cd0500295880f960.json @@ -16,7 +16,7 @@ "name": "grant_capability", "kind": { "Enum": [ - "x_00", + "none", "x_01", "x_02", "x_03", diff --git a/.sqlx/query-bf114fcbbb3b0c8bd057869c92b7c6ceee073f467105083ec751a75c11478adf.json b/.sqlx/query-db0dd837cff7173737de9699bb9ea397c2e272d70c535b6e330acd699fd4e6bd.json similarity index 62% rename from .sqlx/query-bf114fcbbb3b0c8bd057869c92b7c6ceee073f467105083ec751a75c11478adf.json rename to .sqlx/query-db0dd837cff7173737de9699bb9ea397c2e272d70c535b6e330acd699fd4e6bd.json index 1ad4ac4015f..4fe52c6d951 100644 --- a/.sqlx/query-bf114fcbbb3b0c8bd057869c92b7c6ceee073f467105083ec751a75c11478adf.json +++ b/.sqlx/query-db0dd837cff7173737de9699bb9ea397c2e272d70c535b6e330acd699fd4e6bd.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n g.user_id AS \"user_id: uuid::Uuid\",\n g.object_role AS \"object_role: models::Prefix\",\n g.capability AS \"capability: models::Capability\"\n FROM user_grants g\n ", + "query": "\n SELECT\n g.user_id AS \"user_id: uuid::Uuid\",\n g.object_role AS \"object_role: models::Prefix\",\n g.capability AS \"capability: models::Capability\",\n g.bundles AS \"bundles: Vec\"\n FROM user_grants g\n ", "describe": { "columns": [ { @@ -21,7 +21,7 @@ "name": "grant_capability", "kind": { "Enum": [ - "x_00", + "none", "x_01", "x_02", "x_03", @@ -56,16 +56,45 @@ } } } + }, + { + "ordinal": 3, + "name": "bundles: Vec", + "type_info": { + "Custom": { + "name": "capability_bundle[]", + "kind": { + "Array": { + "Custom": { + "name": "capability_bundle", + "kind": { + "Enum": [ + "viewer", + "writer", + "editor", + "admin", + "billing", + "team_admin", + "delegate", + "assume" + ] + } + } + } + } + } + } } ], "parameters": { "Left": [] }, "nullable": [ + false, false, false, false ] }, - "hash": "bf114fcbbb3b0c8bd057869c92b7c6ceee073f467105083ec751a75c11478adf" + "hash": "db0dd837cff7173737de9699bb9ea397c2e272d70c535b6e330acd699fd4e6bd" } diff --git a/Cargo.lock b/Cargo.lock index ca83e959596..12d0fe75bf2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2052,6 +2052,7 @@ dependencies = [ "coroutines", "derivative", "doc", + "enumset", "flow-client-next", "futures", "gazette", @@ -3050,6 +3051,28 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" +[[package]] +name = "enumset" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f96a4a12fe60ac746ae295a1a4ecb5bb02debc20856506c8635288065f142de" +dependencies = [ + "enumset_derive", + "serde", +] + +[[package]] +name = "enumset_derive" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "env_logger" version = "0.8.4" @@ -5177,6 +5200,7 @@ dependencies = [ "bytes", "caseless", "chrono", + "enumset", "futures", "handlebars 6.3.2", "humantime-serde", @@ -8297,6 +8321,7 @@ dependencies = [ "anyhow", "bytes", "doc", + "enumset", "insta", "itertools 0.14.0", "json", diff --git a/Cargo.toml b/Cargo.toml index 255691eed56..07280361f16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,7 @@ crossterm = "0" csv = "1" dirs = "6" encoding_rs = { version = "0", features = ["serde"] } +enumset = { version = "1", features = ["serde"] } flate2 = { version = "1", features = ["zlib-rs"] } futures = "0" futures-core = "0" diff --git a/crates/agent/src/integration_tests/user_publications.rs b/crates/agent/src/integration_tests/user_publications.rs index e3081cf7ec8..08eb9fb1e02 100644 --- a/crates/agent/src/integration_tests/user_publications.rs +++ b/crates/agent/src/integration_tests/user_publications.rs @@ -139,7 +139,7 @@ async fn test_user_publications() { ) .await; assert!(!dog_result.status.is_success()); - insta::assert_debug_snapshot!(dog_result.errors, @r###" + insta::assert_debug_snapshot!(dog_result.errors, @r#" [ ( "flow://unauthorized/cats/noms", @@ -147,10 +147,10 @@ async fn test_user_publications() { ), ( "flow://materialization/dogs/materialize", - "Specification 'dogs/materialize' is not read-authorized to 'cats/noms'.\nAvailable grants are: [\n {\n \"subject_role\": \"dogs/\",\n \"object_role\": \"dogs/\",\n \"capability\": \"write\"\n },\n {\n \"subject_role\": \"dogs/\",\n \"object_role\": \"ops/dp/public/\",\n \"capability\": \"read\"\n }\n]", + "Specification 'dogs/materialize' is not read-authorized to 'cats/noms'.\nAvailable grants are: [\n {\n \"subject_role\": \"dogs/\",\n \"object_role\": \"dogs/\",\n \"capability\": \"write\",\n \"bundles\": []\n },\n {\n \"subject_role\": \"dogs/\",\n \"object_role\": \"ops/dp/public/\",\n \"capability\": \"read\",\n \"bundles\": []\n }\n]", ), ] - "###); + "#); // Add a user_grant for dogs and assert that a subsequent publication still fails for lack of a role_grant. harness @@ -164,14 +164,14 @@ async fn test_user_publications() { ) .await; assert!(!dog_result.status.is_success()); - insta::assert_debug_snapshot!(dog_result.errors, @r###" + insta::assert_debug_snapshot!(dog_result.errors, @r#" [ ( "flow://materialization/dogs/materialize", - "Specification 'dogs/materialize' is not read-authorized to 'cats/noms'.\nAvailable grants are: [\n {\n \"subject_role\": \"dogs/\",\n \"object_role\": \"dogs/\",\n \"capability\": \"write\"\n },\n {\n \"subject_role\": \"dogs/\",\n \"object_role\": \"ops/dp/public/\",\n \"capability\": \"read\"\n }\n]", + "Specification 'dogs/materialize' is not read-authorized to 'cats/noms'.\nAvailable grants are: [\n {\n \"subject_role\": \"dogs/\",\n \"object_role\": \"dogs/\",\n \"capability\": \"write\",\n \"bundles\": []\n },\n {\n \"subject_role\": \"dogs/\",\n \"object_role\": \"ops/dp/public/\",\n \"capability\": \"read\",\n \"bundles\": []\n }\n]", ), ] - "###); + "#); // Add the role grant, and now dogs can materialize cats/noms harness diff --git a/crates/control-plane-api/Cargo.toml b/crates/control-plane-api/Cargo.toml index ade442dd13e..a7bfadb5d8d 100644 --- a/crates/control-plane-api/Cargo.toml +++ b/crates/control-plane-api/Cargo.toml @@ -44,6 +44,7 @@ chrono = { workspace = true } clap = { workspace = true } colored_json = { workspace = true } derivative = { workspace = true } +enumset = { workspace = true } futures = { workspace = true } humantime = { workspace = true } humantime-serde = { workspace = true } diff --git a/crates/control-plane-api/src/server/mod.rs b/crates/control-plane-api/src/server/mod.rs index fd0c385f687..6b5257bbeaa 100644 --- a/crates/control-plane-api/src/server/mod.rs +++ b/crates/control-plane-api/src/server/mod.rs @@ -317,6 +317,9 @@ fn ops_suffix(task: &snapshot::SnapshotTask) -> String { const fn map_capability_to_gazette(capability: models::Capability) -> u32 { match capability { + models::Capability::None => { + panic!("gazette capability mapping requires Read, Write, or Admin") + } models::Capability::Read => { proto_gazette::capability::LIST | proto_gazette::capability::READ } diff --git a/crates/control-plane-api/src/server/public/graphql/authorized_prefixes.rs b/crates/control-plane-api/src/server/public/graphql/authorized_prefixes.rs index 32b7c9f9c51..c1fad815bc8 100644 --- a/crates/control-plane-api/src/server/public/graphql/authorized_prefixes.rs +++ b/crates/control-plane-api/src/server/public/graphql/authorized_prefixes.rs @@ -12,23 +12,20 @@ pub(super) fn authorized_prefixes( role_grants: &tables::RoleGrants, user_grants: &tables::UserGrants, user_id: uuid::Uuid, - min_capability: models::Capability, + min_capability: impl Into, prefix_filter: Option<&str>, ) -> Vec { - let mut prefixes: Vec = - tables::UserGrant::transitive_roles(role_grants, user_grants, user_id) - .filter(|grant| grant.capability >= min_capability) - .filter(|grant| { - prefix_filter.is_none_or(|pf| { - grant.object_role.starts_with(pf) || pf.starts_with(&*grant.object_role) - }) - }) - .map(|grant| grant.object_role.to_string()) - .collect(); - - // Sort and remove child prefixes that are already covered by a parent prefix. - prefixes.sort(); - prefixes.dedup(); + let min_bits: models::authz::CapabilitySet = min_capability.into(); + + // BTreeMap iteration from reachable_prefixes is already prefix-sorted, + // so the parent-prune step below can run directly on it. + let prefixes = tables::UserGrant::reachable_prefixes(role_grants, user_grants, user_id) + .into_iter() + .filter(|(prefix, _)| { + prefix_filter.is_none_or(|pf| prefix.starts_with(pf) || pf.starts_with(*prefix)) + }) + .filter(|(_, (bits, _))| bits.is_superset(min_bits)) + .map(|(prefix, _)| prefix.to_string()); let mut pruned: Vec = Vec::new(); for p in prefixes { @@ -57,6 +54,7 @@ mod tests { user_id: *id, object_role: models::Prefix::new(*obj), capability: *cap, + bundles: vec![], } })); let rg = tables::RoleGrants::from_iter(role_grants.iter().map(|(sub, obj, cap)| { @@ -64,6 +62,7 @@ mod tests { subject_role: models::Prefix::new(*sub), object_role: models::Prefix::new(*obj), capability: *cap, + bundles: vec![], } })); (ug, rg) @@ -178,4 +177,103 @@ mod tests { let result = authorized_prefixes(&rg, &ug, bob, Read, None); assert!(result.is_empty()); } + + #[test] + fn same_prefix_union_satisfies_min_when_no_grant_does_alone() { + use models::authz::Bundle; + + // Two user grants at the same prefix: one carries Editor's bits, + // the other TeamAdmin's. Admin requires both (Admin = Editor | + // TeamAdmin | Billing; Billing is empty), so neither row alone + // satisfies min=Admin; the per-prefix union does. + let ug = tables::UserGrants::from_iter(vec![ + tables::UserGrant { + user_id: ALICE, + object_role: models::Prefix::new("acmeCo/"), + capability: models::Capability::None, + bundles: vec![Bundle::Editor], + }, + tables::UserGrant { + user_id: ALICE, + object_role: models::Prefix::new("acmeCo/"), + capability: models::Capability::None, + bundles: vec![Bundle::TeamAdmin], + }, + ]); + let rg = tables::RoleGrants::new(); + + let result = authorized_prefixes(&rg, &ug, ALICE, Admin, None); + assert_eq!(result, vec!["acmeCo/"]); + } + + #[test] + fn multi_path_role_grants_union_at_destination() { + use models::authz::Bundle; + + // Alice is admin on acmeCo/. Two role grants reach sharedCo/ from + // acmeCo/: one carries Editor's bits, the other carries + // TeamAdmin's. Admin requires both halves (Admin = Editor | + // TeamAdmin | Billing, and Billing is empty), so neither role + // grant alone gives sharedCo/ admin-level coverage. The BFS emits + // two NodeRefs at sharedCo/ and only the per-prefix union meets + // the min. + let ug = tables::UserGrants::from_iter(vec![tables::UserGrant { + user_id: ALICE, + object_role: models::Prefix::new("acmeCo/"), + capability: models::Capability::Admin, + bundles: vec![], + }]); + let rg = tables::RoleGrants::from_iter(vec![ + tables::RoleGrant { + subject_role: models::Prefix::new("acmeCo/"), + object_role: models::Prefix::new("sharedCo/"), + capability: models::Capability::None, + bundles: vec![Bundle::Editor], + }, + tables::RoleGrant { + subject_role: models::Prefix::new("acmeCo/"), + object_role: models::Prefix::new("sharedCo/"), + capability: models::Capability::None, + bundles: vec![Bundle::TeamAdmin], + }, + ]); + + let result = authorized_prefixes(&rg, &ug, ALICE, Admin, None); + assert_eq!(result, vec!["acmeCo/", "sharedCo/"]); + } + + #[test] + fn same_prefix_union_does_not_synthesize_ancestor_bits() { + use models::authz::Bundle; + + // Regression guard: the union is per-exact-prefix, not across + // ancestors. Admin on acmeCo/ does NOT make acmeCo/data/ appear in + // a min=Admin query; acmeCo/data/'s own grant is only Writer. + let ug = tables::UserGrants::from_iter(vec![ + tables::UserGrant { + user_id: ALICE, + object_role: models::Prefix::new("acmeCo/"), + capability: models::Capability::None, + bundles: vec![Bundle::Admin], + }, + tables::UserGrant { + user_id: ALICE, + object_role: models::Prefix::new("acmeCo/data/"), + capability: models::Capability::None, + bundles: vec![Bundle::Writer], + }, + ]); + let rg = tables::RoleGrants::new(); + + // min=Admin: parent acmeCo/ qualifies; acmeCo/data/ is pruned as a + // child of the qualifying parent. If the union were across + // ancestors, acmeCo/data/ would qualify on its own (Writer + + // inherited Admin bits) — it does not. + let result = authorized_prefixes(&rg, &ug, ALICE, Admin, None); + assert_eq!(result, vec!["acmeCo/"]); + + // min=Write: both qualify on their own bits; parent prunes child. + let result = authorized_prefixes(&rg, &ug, ALICE, Write, None); + assert_eq!(result, vec!["acmeCo/"]); + } } 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 b4cd95f90c3..b3fd177572a 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 @@ -180,6 +180,12 @@ impl InviteLinksMutation { let env = ctx.data::()?; let claims = env.claims()?; + if capability == models::Capability::None { + return Err(async_graphql::Error::new( + "capability must be one of: read, write, admin", + )); + } + if let Err(err) = validator::Validate::validate(&catalog_prefix) { return Err(async_graphql::Error::new(format!( "invalid catalog prefix: {err}" diff --git a/crates/control-plane-api/src/server/public/graphql/prefixes.rs b/crates/control-plane-api/src/server/public/graphql/prefixes.rs index 73bdbe76d81..1b2e36dbd61 100644 --- a/crates/control-plane-api/src/server/public/graphql/prefixes.rs +++ b/crates/control-plane-api/src/server/public/graphql/prefixes.rs @@ -5,8 +5,18 @@ use async_graphql::{Context, types::connection}; pub struct PrefixRef { /// The prefix to which the user is authorized. pub prefix: models::Prefix, - /// The capability granted to the user for this prefix. + /// The literal legacy `capability` column value of the grant(s) that + /// emitted this prefix (max'd if multiple grants land at the same + /// prefix). Reports `none` for prefixes whose authorization comes + /// entirely from the `bundles` column rather than the legacy column. + /// + /// Exists solely so the dashboard's read/write/admin prefix-bucket + /// store keeps working until it migrates to consuming `capabilities` + /// directly. Once that migration lands, this field and its derivation + /// can be deleted. pub user_capability: models::Capability, + /// Fine-grained capabilities the user has at this prefix. + pub capabilities: Vec, } #[derive(Debug, Clone, async_graphql::InputObject)] @@ -40,25 +50,32 @@ impl PrefixesQuery { let env = ctx.data::()?; connection::query(after, None, first, None, |after, _, first, _| async move { - let mut all_roles: Vec = tables::UserGrant::transitive_roles( - &env.snapshot().role_grants, - &env.snapshot().user_grants, - env.claims()?.sub, - ) - .filter(|grant| grant.capability >= by.min_capability) - .filter(|grant| after.as_deref().is_none_or(|min| grant.object_role > min)) - .map(|grant| PrefixRef { - prefix: models::Prefix::new(grant.object_role), - user_capability: grant.capability, - }) - .collect(); - - all_roles.sort_by(|l, r| { - l.prefix - .cmp(&r.prefix) - .then(l.user_capability.cmp(&r.user_capability).reverse()) - }); - all_roles.dedup_by(|l, r| l.prefix == r.prefix); + let snapshot = env.snapshot(); + let user_id = env.claims()?.sub; + + let min_bits: models::authz::CapabilitySet = by.min_capability.into(); + + let reachable = tables::UserGrant::reachable_prefixes( + &snapshot.role_grants, + &snapshot.user_grants, + user_id, + ); + // Cursor pagination: BTreeMap::range jumps directly to the + // first key strictly greater than the previous page's last + // prefix, rather than iterating from the start and filtering + // past it. + let start = after + .as_deref() + .map_or(std::ops::Bound::Unbounded, std::ops::Bound::Excluded); + let all_roles: Vec = reachable + .range::((start, std::ops::Bound::Unbounded)) + .filter(|(_, (bits, _))| bits.is_superset(min_bits)) + .map(|(prefix, (bits, legacy))| PrefixRef { + prefix: models::Prefix::new(*prefix), + user_capability: *legacy, + capabilities: bits.iter().collect(), + }) + .collect(); let take = first.unwrap_or(all_roles.len()); let has_next = first.is_some_and(|limit| all_roles.len() > limit); diff --git a/crates/control-plane-api/src/server/snapshot.rs b/crates/control-plane-api/src/server/snapshot.rs index ae26226c6db..e00f053ed38 100644 --- a/crates/control-plane-api/src/server/snapshot.rs +++ b/crates/control-plane-api/src/server/snapshot.rs @@ -480,7 +480,8 @@ pub async fn try_fetch( SELECT g.subject_role AS "subject_role: models::Prefix", g.object_role AS "object_role: models::Prefix", - g.capability AS "capability: models::Capability" + g.capability AS "capability: models::Capability", + g.bundles AS "bundles: Vec" FROM role_grants g "#, ) @@ -494,13 +495,14 @@ pub async fn try_fetch( SELECT g.user_id AS "user_id: uuid::Uuid", g.object_role AS "object_role: models::Prefix", - g.capability AS "capability: models::Capability" + g.capability AS "capability: models::Capability", + g.bundles AS "bundles: Vec" FROM user_grants g "#, ) .fetch_all(pg_pool) .await - .context("failed to fetch role_grants")?; + .context("failed to fetch user_grants")?; let tasks = sqlx::query_as!( SnapshotTask, diff --git a/crates/control-plane-api/src/server/snapshot_fixture.json b/crates/control-plane-api/src/server/snapshot_fixture.json index e08fb54a80f..aae0951479a 100644 --- a/crates/control-plane-api/src/server/snapshot_fixture.json +++ b/crates/control-plane-api/src/server/snapshot_fixture.json @@ -113,49 +113,58 @@ { "subject_role": "acmeCo/", "object_role": "acmeCo/", - "capability": "write" + "capability": "write", + "bundles": [] }, { "subject_role": "bobCo/", "object_role": "bobCo/", - "capability": "write" + "capability": "write", + "bundles": [] }, { "subject_role": "bobCo/tires/", "object_role": "acmeCo/shared/", - "capability": "read" + "capability": "read", + "bundles": [] }, { "subject_role": "bobCo/", "object_role": "ops/dp/public/", - "capability": "read" + "capability": "read", + "bundles": [] }, { "subject_role": "aliceCo/", "object_role": "ops/dp/public/", - "capability": "read" + "capability": "read", + "bundles": [] } ], "user_grants": [ { "user_id": "20202020-2020-2020-2020-202020202020", "object_role": "bobCo/", - "capability": "write" + "capability": "write", + "bundles": [] }, { "user_id": "20202020-2020-2020-2020-202020202020", "object_role": "bobCo/tires/", - "capability": "admin" + "capability": "admin", + "bundles": [] }, { "user_id": "40404040-4040-4040-4040-404040404040", "object_role": "aliceCo/", - "capability": "admin" + "capability": "admin", + "bundles": [] }, { "user_id": "40404040-4040-4040-4040-404040404040", "object_role": "estuary_support/", - "capability": "admin" + "capability": "admin", + "bundles": [] } ], "tasks": [ diff --git a/crates/flow-client/control-plane-api.graphql b/crates/flow-client/control-plane-api.graphql index 5258ac8a3d7..918fd4da801 100644 --- a/crates/flow-client/control-plane-api.graphql +++ b/crates/flow-client/control-plane-api.graphql @@ -323,6 +323,7 @@ enum Capability { """ Note that the discriminants here align with those in the database type. """ + none read write admin @@ -1081,6 +1082,18 @@ type MutationRoot { scalar Name +enum OrthogonalCapability { + CatalogRead + JournalRead + JournalAppend + SpecEdit + CreateGrant + DeleteGrant + CreateInviteLink + Delegate + Assume +} + """ Information about pagination in a connection """ @@ -1130,9 +1143,14 @@ type PrefixRef { """ prefix: Prefix! """ - The capability granted to the user for this prefix. + The legacy capability granted to the user for this prefix. Prefer + reading `capabilities` for fine-grained authorization decisions. """ userCapability: Capability! + """ + Fine-grained capabilities the user has at this prefix. + """ + capabilities: [OrthogonalCapability!]! } type PrefixRefConnection { diff --git a/crates/models/Cargo.toml b/crates/models/Cargo.toml index 487d7f7682d..154aaf3ced4 100644 --- a/crates/models/Cargo.toml +++ b/crates/models/Cargo.toml @@ -12,6 +12,7 @@ license.workspace = true # NOTE(johnny): DO NOT add proto-flow or proto-gazette to this crate. anyhow = { workspace = true } +enumset = { workspace = true } handlebars = { workspace = true } async-graphql = { workspace = true, optional = true } base64 = { workspace = true } diff --git a/crates/models/src/authz.rs b/crates/models/src/authz.rs new file mode 100644 index 00000000000..d8e676a890d --- /dev/null +++ b/crates/models/src/authz.rs @@ -0,0 +1,77 @@ +use enumset::{EnumSet, EnumSetType}; +use serde::{Deserialize, Serialize}; + +/// A set of fine-grained authorization capabilities. Used throughout the +/// authorization BFS and at authorization-check call sites. +pub type CapabilitySet = EnumSet; + +#[derive(EnumSetType, Debug)] +#[cfg_attr( + feature = "async-graphql", + derive(async_graphql::Enum), + graphql(name = "OrthogonalCapability", rename_items = "PascalCase") +)] +pub enum Capability { + CatalogRead, + JournalRead, + JournalAppend, + SpecEdit, + CreateGrant, + DeleteGrant, + CreateInviteLink, + Delegate, + Assume, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[cfg_attr( + feature = "sqlx-support", + derive(sqlx::Type), + sqlx(type_name = "capability_bundle", rename_all = "snake_case") +)] +pub enum Bundle { + Viewer, + Writer, + Editor, + Admin, + Billing, + TeamAdmin, + Delegate, + Assume, +} + +impl Bundle { + pub fn capabilities(&self) -> CapabilitySet { + use Capability::*; + match self { + Self::Viewer => CatalogRead | JournalRead, + Self::Writer => Self::Viewer.capabilities() | JournalAppend, + Self::Editor => Self::Writer.capabilities() | SpecEdit | Delegate, + Self::Admin => { + Self::Editor.capabilities() + | Self::TeamAdmin.capabilities() + | Self::Billing.capabilities() + } + Self::Billing => EnumSet::empty(), + Self::TeamAdmin => CreateGrant | DeleteGrant | CreateInviteLink, + Self::Delegate => EnumSet::from(Delegate), + Self::Assume => EnumSet::from(Assume), + } + } +} + +pub fn bits_for_legacy(capability: super::Capability) -> CapabilitySet { + match capability { + super::Capability::None => CapabilitySet::empty(), + super::Capability::Read => Bundle::Viewer.capabilities(), + super::Capability::Write => Bundle::Writer.capabilities(), + super::Capability::Admin => Bundle::Admin.capabilities(), + } +} + +impl From for CapabilitySet { + fn from(capability: super::Capability) -> Self { + bits_for_legacy(capability) + } +} diff --git a/crates/models/src/catalogs.rs b/crates/models/src/catalogs.rs index 186c2bfa736..3dbcefde824 100644 --- a/crates/models/src/catalogs.rs +++ b/crates/models/src/catalogs.rs @@ -79,6 +79,7 @@ pub enum CatalogType { )] pub enum Capability { /// Note that the discriminants here align with those in the database type. + None = 0, Read = 10, Write = 20, Admin = 30, @@ -87,6 +88,7 @@ pub enum Capability { impl AsRef for Capability { fn as_ref(&self) -> &str { match self { + Capability::None => "none", Capability::Read => "read", Capability::Write => "write", Capability::Admin => "admin", diff --git a/crates/models/src/lib.rs b/crates/models/src/lib.rs index 337365f419e..2b19a88f238 100644 --- a/crates/models/src/lib.rs +++ b/crates/models/src/lib.rs @@ -2,6 +2,7 @@ use std::collections::BTreeSet; mod alert_config; pub mod authorizations; +pub mod authz; mod captures; mod catalogs; pub mod collate; diff --git a/crates/tables/Cargo.toml b/crates/tables/Cargo.toml index cb2030ae037..ed87aedd405 100644 --- a/crates/tables/Cargo.toml +++ b/crates/tables/Cargo.toml @@ -17,6 +17,7 @@ proto-flow = { path = "../proto-flow" } anyhow = { workspace = true } bytes = { workspace = true } +enumset = { workspace = true } itertools = { workspace = true } pathfinding = { workspace = true } prost = { workspace = true } diff --git a/crates/tables/src/behaviors.rs b/crates/tables/src/behaviors.rs index e9c8f669121..a1394a39a76 100644 --- a/crates/tables/src/behaviors.rs +++ b/crates/tables/src/behaviors.rs @@ -1,3 +1,5 @@ +use enumset::EnumSet; +use models::authz; use superslice::Ext; use url::Url; @@ -51,61 +53,119 @@ impl super::Import { } } +fn effective_bits(legacy: models::Capability, bundles: &[authz::Bundle]) -> authz::CapabilitySet { + let mut bits = authz::bits_for_legacy(legacy); + for b in bundles { + bits |= b.capabilities(); + } + bits +} + +/// True when bits accumulated across `nodes` at prefixes covering +/// `object_role_or_name` satisfy `required`. Bits compose additively +/// across paths: distinct grant paths that each contribute partial bits +/// at covering prefixes can jointly authorize a request that no single +/// path would on its own. +fn any_path_satisfies<'a>( + nodes: impl IntoIterator>, + object_role_or_name: &str, + required: impl Into, +) -> bool { + let mut remaining = required.into(); + for node in nodes { + if object_role_or_name.starts_with(node.object_role) { + remaining -= node.capabilities; + if remaining.is_empty() { + return true; + } + } + } + false +} + impl super::RoleGrant { - /// Given a role or name, enumerate all granted roles and capabilities. - pub fn transitive_roles<'a>( + pub fn reachable_nodes<'a>( role_grants: &'a [super::RoleGrant], role_or_name: &'a str, - ) -> impl Iterator> + 'a { - let seed = super::GrantRef { - subject_role: role_or_name, + ) -> impl Iterator> + 'a { + let seed = super::NodeRef { object_role: role_or_name, - capability: models::Capability::Admin, + capabilities: EnumSet::from(authz::Capability::Assume), + legacy: models::Capability::None, }; - pathfinding::directed::bfs::bfs_reach(seed, |f| { - grant_edges(*f, role_grants, &[], uuid::Uuid::nil()) + pathfinding::directed::bfs::bfs_reach(seed, move |f| { + next_neighbors(f.clone(), role_grants, &[], uuid::Uuid::nil()) }) - .skip(1) // Skip `seed`. + .skip(1) } - /// Given a role or name, determine if it's authorized to the object name for the given capability. pub fn is_authorized<'a>( role_grants: &'a [super::RoleGrant], subject_role_or_name: &'a str, object_role_or_name: &'a str, - capability: models::Capability, + capability: impl Into, ) -> bool { - Self::transitive_roles(role_grants, subject_role_or_name).any(|role_grant| { - object_role_or_name.starts_with(role_grant.object_role) - && role_grant.capability >= capability - }) + any_path_satisfies( + Self::reachable_nodes(role_grants, subject_role_or_name), + object_role_or_name, + capability, + ) } - fn to_ref<'a>(&'a self) -> super::GrantRef<'a> { - super::GrantRef { - subject_role: self.subject_role.as_str(), + fn to_node_ref<'a>(&'a self, delegatable: authz::CapabilitySet) -> super::NodeRef<'a> { + super::NodeRef { object_role: self.object_role.as_str(), - capability: self.capability, + capabilities: effective_bits(self.capability, &self.bundles) & delegatable, + legacy: self.capability, } } } impl super::UserGrant { - /// Given a user, enumerate all granted roles and capabilities. - pub fn transitive_roles<'a>( + pub fn reachable_nodes<'a>( role_grants: &'a [super::RoleGrant], user_grants: &'a [super::UserGrant], user_id: uuid::Uuid, - ) -> impl Iterator> + 'a { - let seed = super::GrantRef { - subject_role: "", - object_role: "", // Empty role causes us to map through user_grants. - capability: models::Capability::Admin, + ) -> impl Iterator> + 'a { + let seed = super::NodeRef { + object_role: "", + capabilities: EnumSet::from(authz::Capability::Assume), + legacy: models::Capability::None, }; pathfinding::directed::bfs::bfs_reach(seed, move |f| { - grant_edges(*f, role_grants, user_grants, user_id) + next_neighbors(f.clone(), role_grants, user_grants, user_id) }) - .skip(1) // Skip `seed`. + .skip(1) + } + + /// Returns each prefix reachable from `user_id` mapped to the union + /// of capability bits granted at that prefix across every path + /// through the grant graph, paired with the max legacy `capability` + /// column value among grants directly emitting that prefix. + /// + /// Bits compose additively (multi-path union); the legacy column is + /// a literal pass-through from storage, max'd across same-prefix + /// arrivals. Applying a min-capability filter to the bit set agrees + /// with `is_authorized` on the same inputs. + pub fn reachable_prefixes<'a>( + role_grants: &'a [super::RoleGrant], + user_grants: &'a [super::UserGrant], + user_id: uuid::Uuid, + ) -> std::collections::BTreeMap<&'a str, (authz::CapabilitySet, models::Capability)> { + let mut out: std::collections::BTreeMap< + &'a str, + (authz::CapabilitySet, models::Capability), + > = Default::default(); + for node in Self::reachable_nodes(role_grants, user_grants, user_id) { + let entry = out + .entry(node.object_role) + .or_insert((authz::CapabilitySet::empty(), models::Capability::None)); + entry.0 |= node.capabilities; + if node.legacy > entry.1 { + entry.1 = node.legacy; + } + } + out } pub fn get_user_capability<'a>( @@ -114,56 +174,81 @@ impl super::UserGrant { user_id: uuid::Uuid, object_role_or_name: &str, ) -> Option { - Self::transitive_roles(role_grants, user_grants, user_id) - .filter(|grant| object_role_or_name.starts_with(grant.object_role)) - .max_by_key(|grant| grant.capability) - .map(|grant| grant.capability) + Self::reachable_nodes(role_grants, user_grants, user_id) + .filter(|n| object_role_or_name.starts_with(n.object_role)) + .map(|n| n.legacy) + .filter(|c| *c != models::Capability::None) + .max() } - /// Given a user, determine if they're authorized to the object name for the given capability. pub fn is_authorized<'a>( role_grants: &'a [super::RoleGrant], user_grants: &'a [super::UserGrant], subject_user_id: uuid::Uuid, object_role_or_name: &'a str, - capability: models::Capability, + capability: impl Into, ) -> bool { - Self::transitive_roles(role_grants, user_grants, subject_user_id).any(|role_grant| { - object_role_or_name.starts_with(role_grant.object_role) - && role_grant.capability >= capability - }) + any_path_satisfies( + Self::reachable_nodes(role_grants, user_grants, subject_user_id), + object_role_or_name, + capability, + ) } - fn to_ref<'a>(&'a self) -> super::GrantRef<'a> { - super::GrantRef { - subject_role: "", + fn to_node_ref<'a>(&'a self, delegatable: authz::CapabilitySet) -> super::NodeRef<'a> { + super::NodeRef { object_role: self.object_role.as_str(), - capability: self.capability, + capabilities: effective_bits(self.capability, &self.bundles) & delegatable, + legacy: self.capability, } } } -fn grant_edges<'a>( - from: super::GrantRef<'a>, - role_grants: &'a [super::RoleGrant], - user_grants: &'a [super::UserGrant], +// Expand a BFS node into its neighbors. A node is terminal (no expansion) +// unless it carries Delegate or Assume. Delegate passes only the node's own +// capabilities through to neighbors (the child receives `edge_bits & parent_bits`); +// Assume passes all capabilities through unfiltered, modeling identity takeover. +// +// Perf note: bfs_reach keys on the whole NodeRef, so the same object_role +// reached with different capability subsets produces distinct BFS nodes — +// up to 2^N per prefix where N is the number of capability bits. If deep +// grant graphs cause latency, replace bfs_reach with a manual BFS that keys +// visited state on object_role alone and prunes dominated capability subsets. +fn next_neighbors<'a>( + from: super::NodeRef<'a>, + role_edges: &'a [super::RoleGrant], + user_edges: &'a [super::UserGrant], user_id: uuid::Uuid, -) -> impl Iterator> + 'a { - let (user_grants, role_grants, prefixes) = match (from.capability, from.object_role) { - // `from` is a place-holder which kicks of exploration through `user_grants` for `user_id`. - (models::Capability::Admin, "") => { - let range = user_grants.equal_range_by(|user_grant| user_grant.user_id.cmp(&user_id)); - (&user_grants[range], &role_grants[..0], None) +) -> impl Iterator> + 'a { + let has_delegate = from.capabilities.contains(authz::Capability::Delegate); + let has_assume = from.capabilities.contains(authz::Capability::Assume); + let is_terminal = !has_delegate && !has_assume; + let delegatable = if has_assume { + EnumSet::all() + } else { + from.capabilities + }; + + let (user_edges, role_edges, prefixes) = match (is_terminal, from.object_role) { + // Terminal node: no Delegate/Assume bit means no further expansion. + (true, _) => (&user_edges[..0], &role_edges[..0], None), + // Seed step: an empty object_role kicks off exploration through + // `user_grants` for `user_id`. This branch is only reached from + // the `UserGrant::reachable_nodes` seed. + (_, "") => { + let range = user_edges.equal_range_by(|user_grant| user_grant.user_id.cmp(&user_id)); + (&user_edges[range], &role_edges[..0], None) } - // We're an admin of `role_or_name`, and are projecting through - // role_grants to identify other roles and capabilities we take on. - (models::Capability::Admin, role_or_name) => { + // We've delegated authority at `role_or_name`, and are projecting + // through role_grants to identify other roles and capabilities we + // take on. + (_, role_or_name) => { // Expand to all roles having a subject_role prefixed by role_or_name. - // In other words, an admin of `acmeCo/org/` may use a role with - // subject `acmeCo/org/team/`. Intuitively, this is because the root - // subject is authorized to create any name under `acmeCo/org/`, - // which implies an ability to create a name under `acmeCo/org/team/`. - let range = role_grants.equal_range_by(|role_grant| { + // In other words, a delegate of `acmeCo/org/` may use a role with + // subject `acmeCo/org/team/`. Intuitively, this is because the + // delegate is authorized to act anywhere under `acmeCo/org/`, + // which includes any name under `acmeCo/org/team/`. + let range = role_edges.equal_range_by(|role_grant| { if role_grant.subject_role.starts_with(role_or_name) { std::cmp::Ordering::Equal } else { @@ -171,12 +256,12 @@ fn grant_edges<'a>( } }); // Expand to all roles having a subject_role which prefixes role_or_name. - // In other words, a task `acmeCo/org/task` or admin of `acmeCo/org/` + // In other words, a task `acmeCo/org/task` or delegate of `acmeCo/org/` // may use a role with subject `acmeCo/`. Intuitively, this is because // the role granted to `acmeCo/` is also granted to any name underneath // `acmeCo/`, which includes the present role or name. // - // First split the source object role into its prefixes: + // First split the source role into its prefixes: // "acmeCo/one/two/three" => ["acmeCo/one/two/", "acmeCo/one/", "acmeCo/"]. let prefixes = role_or_name.char_indices().filter_map(|(ind, chr)| { if chr == '/' { @@ -188,23 +273,22 @@ fn grant_edges<'a>( // Then for each prefix, find all role_grants where it's the exact subject_role. let edges = prefixes .map(|prefix| { - role_grants + role_edges .equal_range_by(|role_grant| role_grant.subject_role.as_str().cmp(prefix)) }) - .map(|range| role_grants[range].into_iter().map(super::RoleGrant::to_ref)) + .map(|range| role_edges[range].into_iter()) .flatten(); - (&user_grants[..0], &role_grants[range], Some(edges)) - } - (_not_admin, _) => { - // We perform no expansion through grants which are not Admin. - (&user_grants[..0], &role_grants[..0], None) + (&user_edges[..0], &role_edges[range], Some(edges)) } }; - let p1 = user_grants.iter().map(super::UserGrant::to_ref); - let p2 = role_grants.iter().map(super::RoleGrant::to_ref); - let p3 = prefixes.into_iter().flatten(); + let p1 = user_edges.iter().map(move |g| g.to_node_ref(delegatable)); + let p2 = role_edges.iter().map(move |g| g.to_node_ref(delegatable)); + let p3 = prefixes + .into_iter() + .flatten() + .map(move |g| g.to_node_ref(delegatable)); p1.chain(p2).chain(p3) } @@ -218,6 +302,8 @@ impl super::StorageMapping { #[cfg(test)] mod test { use crate::{Import, Imports, RoleGrant, RoleGrants, UserGrant, UserGrants}; + use enumset::EnumSet; + use models::authz::{Bundle, Capability}; #[test] fn test_transitive_imports() { @@ -254,219 +340,197 @@ mod test { } #[test] - fn test_transitive_roles() { - use models::Capability::*; - + fn test_legacy_admin_grants_propagate() { let role_grants = RoleGrants::from_iter( [ - ("aliceCo/widgets/", "bobCo/burgers/", Admin), - ("aliceCo/anvils/", "carolCo/paper/", Write), - ("aliceCo/duplicate/", "carolCo/paper/", Read), - ("aliceCo/stuff/", "carolCo/shared/", Read), - ("bobCo/alice-vendor/", "aliceCo/bob-shared/", Admin), - ("carolCo/shared/", "carolCo/hidden/", Read), - ("daveCo/hidden/", "carolCo/hidden/", Admin), - ("carolCo/hidden/", "carolCo/even/more/hidden/", Read), + ( + "aliceCo/widgets/", + "bobCo/burgers/", + models::Capability::Admin, + ), + ( + "aliceCo/anvils/", + "carolCo/paper/", + models::Capability::Write, + ), + ( + "aliceCo/duplicate/", + "carolCo/paper/", + models::Capability::Read, + ), + ( + "aliceCo/stuff/", + "carolCo/shared/", + models::Capability::Read, + ), + ( + "bobCo/alice-vendor/", + "aliceCo/bob-shared/", + models::Capability::Admin, + ), + ( + "carolCo/shared/", + "carolCo/hidden/", + models::Capability::Read, + ), + ( + "daveCo/hidden/", + "carolCo/hidden/", + models::Capability::Admin, + ), + ( + "carolCo/hidden/", + "carolCo/even/more/hidden/", + models::Capability::Read, + ), ] .into_iter() - .map(|(sub, obj, cap)| RoleGrant { + .map(|(sub, obj, capability)| RoleGrant { subject_role: models::Prefix::new(sub), object_role: models::Prefix::new(obj), - capability: cap, + capability, + bundles: vec![], }), ); let user_grants = UserGrants::from_iter( [ - (uuid::Uuid::nil(), "bobCo/", Read), - (uuid::Uuid::nil(), "daveCo/", Admin), - (uuid::Uuid::max(), "aliceCo/widgets/", Admin), - (uuid::Uuid::max(), "carolCo/shared/", Admin), + (uuid::Uuid::nil(), "bobCo/", models::Capability::Read), + (uuid::Uuid::nil(), "daveCo/", models::Capability::Admin), + ( + uuid::Uuid::max(), + "aliceCo/widgets/", + models::Capability::Admin, + ), + ( + uuid::Uuid::max(), + "carolCo/shared/", + models::Capability::Admin, + ), ] .into_iter() - .map(|(user_id, obj, cap)| UserGrant { + .map(|(user_id, obj, capability)| UserGrant { user_id, object_role: models::Prefix::new(obj), - capability: cap, + capability, + bundles: vec![], }), ); - insta::assert_json_snapshot!( - RoleGrant::transitive_roles(&role_grants, "aliceCo/anvils/thing").collect::>(), - @r###" - [ - { - "subject_role": "aliceCo/anvils/", - "object_role": "carolCo/paper/", - "capability": "write" - } - ] - "###, - ); - - insta::assert_json_snapshot!( - RoleGrant::transitive_roles(&role_grants, "daveCo/hidden/task").collect::>(), - @r###" - [ - { - "subject_role": "daveCo/hidden/", - "object_role": "carolCo/hidden/", - "capability": "admin" - }, - { - "subject_role": "carolCo/hidden/", - "object_role": "carolCo/even/more/hidden/", - "capability": "read" - } - ] - "###, - ); - + // Admin on daveCo/hidden/ reaches carolCo/hidden/ (admin) and + // carolCo/even/more/hidden/ (read via viewer bits). assert!(RoleGrant::is_authorized( &role_grants, "daveCo/hidden/thing", "carolCo/hidden/thing", - Write + models::Capability::Write )); assert!(RoleGrant::is_authorized( &role_grants, "daveCo/hidden/", "carolCo/even/more/hidden/", - Read + models::Capability::Read )); assert!(!RoleGrant::is_authorized( &role_grants, "daveCo/hidden/thing", "carolCo/even/more/hidden/", - Write + models::Capability::Write )); - insta::assert_json_snapshot!( - UserGrant::transitive_roles(&role_grants, &user_grants, uuid::Uuid::nil()).collect::>(), - @r###" - [ - { - "subject_role": "", - "object_role": "bobCo/", - "capability": "read" - }, - { - "subject_role": "", - "object_role": "daveCo/", - "capability": "admin" - }, - { - "subject_role": "daveCo/hidden/", - "object_role": "carolCo/hidden/", - "capability": "admin" - }, - { - "subject_role": "carolCo/hidden/", - "object_role": "carolCo/even/more/hidden/", - "capability": "read" - } - ] - "###, - ); + // User nil: read on bobCo/ (terminal), admin on daveCo/ (propagates). + assert!(UserGrant::is_authorized( + &role_grants, + &user_grants, + uuid::Uuid::nil(), + "bobCo/thing", + models::Capability::Read, + )); + assert!(!UserGrant::is_authorized( + &role_grants, + &user_grants, + uuid::Uuid::nil(), + "bobCo/thing", + models::Capability::Write, + )); + assert!(UserGrant::is_authorized( + &role_grants, + &user_grants, + uuid::Uuid::nil(), + "carolCo/hidden/thing", + models::Capability::Read, + )); - insta::assert_json_snapshot!( - UserGrant::transitive_roles(&role_grants, &user_grants, uuid::Uuid::max()).collect::>(), - @r###" - [ - { - "subject_role": "", - "object_role": "aliceCo/widgets/", - "capability": "admin" - }, - { - "subject_role": "", - "object_role": "carolCo/shared/", - "capability": "admin" - }, - { - "subject_role": "aliceCo/widgets/", - "object_role": "bobCo/burgers/", - "capability": "admin" - }, - { - "subject_role": "carolCo/shared/", - "object_role": "carolCo/hidden/", - "capability": "read" - } - ] - "###, - ); + // User max: admin on aliceCo/widgets/ (propagates to bobCo/burgers/). + assert!(UserGrant::is_authorized( + &role_grants, + &user_grants, + uuid::Uuid::max(), + "bobCo/burgers/thing", + models::Capability::Admin, + )); } #[test] - fn test_transitive_roles_more() { + fn test_legacy_roles_more() { let role_grants: Vec = serde_json::from_value(serde_json::json!([ { "subject_role": "acmeCo/", "object_role": "acmeCo/", - "capability": "write" + "capability": "write", + "bundles": [] }, { "subject_role": "other_tenant/", "object_role": "acmeCo/", - "capability": "admin" + "capability": "admin", + "bundles": [] }, { "subject_role": "acmeCo-૨/", "object_role": "acmeCo-૨/", - "capability": "write" + "capability": "write", + "bundles": [] }, { "subject_role": "other_tenant/", "object_role": "acmeCo-૨/", - "capability": "admin" + "capability": "admin", + "bundles": [] }, { "subject_role": "acmeCo-૨/ssss/", "object_role": "acmeCo-૨/", - "capability": "admin" + "capability": "admin", + "bundles": [] }, { "subject_role": "acmeCo-૨/aaaa/", "object_role": "acmeCo-૨/", - "capability": "admin" + "capability": "admin", + "bundles": [] }, { "subject_role": "acmeCo-૨/dddd/", "object_role": "acmeCo-૨/", - "capability": "admin" + "capability": "admin", + "bundles": [] }, { "subject_role": "acmeCo-૨/", "object_role": "ops/dp/public/", - "capability": "read" + "capability": "read", + "bundles": [] }, { "subject_role": "acmeCo/", "object_role": "ops/dp/public/", - "capability": "read" + "capability": "read", + "bundles": [] } ])) .unwrap(); let role_grants = crate::RoleGrants::from_iter(role_grants); - insta::assert_json_snapshot!( - RoleGrant::transitive_roles(&role_grants, "acmeCo-૨/acme-prod-tables/materialize-snowflake").collect::>(), - @r###" - [ - { - "subject_role": "acmeCo-૨/", - "object_role": "acmeCo-૨/", - "capability": "write" - }, - { - "subject_role": "acmeCo-૨/", - "object_role": "ops/dp/public/", - "capability": "read" - } - ] - "###, - ); - assert!(crate::RoleGrant::is_authorized( &role_grants, "acmeCo-૨/acme-prod-tables/materialize-snowflake", @@ -477,17 +541,18 @@ mod test { #[test] fn test_get_user_capability() { - use models::Capability::*; + use models::Capability::{Admin, Read, Write}; let role_grants = RoleGrants::from_iter( [ ("acmeCo/", "acmeCo/", Write), ("acmeCo/", "ops/private/dp/acmeCo/", Read), ] .into_iter() - .map(|(sub, obj, cap)| RoleGrant { + .map(|(sub, obj, capability)| RoleGrant { subject_role: models::Prefix::new(sub), object_role: models::Prefix::new(obj), - capability: cap, + capability, + bundles: vec![], }), ); @@ -500,10 +565,11 @@ mod test { (user2, "ops/private/dp/acmeCo/", Write), ] .into_iter() - .map(|(user_id, obj, cap)| UserGrant { + .map(|(user_id, obj, capability)| UserGrant { user_id, object_role: models::Prefix::new(obj), - capability: cap, + capability, + bundles: vec![], }), ); @@ -546,10 +612,11 @@ mod test { ("acmeCo/", "ops/private/dp/acmeCo/", Read), ] .into_iter() - .map(|(sub, obj, cap)| RoleGrant { + .map(|(sub, obj, capability)| RoleGrant { subject_role: models::Prefix::new(sub), object_role: models::Prefix::new(obj), - capability: cap, + capability, + bundles: vec![], }), ); let user_grants = UserGrants::from_iter( @@ -558,57 +625,857 @@ mod test { (uuid::Uuid::from_bytes([2; 16]), "acmeCo/nested/", Admin), ] .into_iter() - .map(|(user_id, obj, cap)| UserGrant { + .map(|(user_id, obj, capability)| UserGrant { user_id, object_role: models::Prefix::new(obj), - capability: cap, + capability, + bundles: vec![], }), ); - insta::assert_json_snapshot!( - UserGrant::transitive_roles(&role_grants, &user_grants, uuid::Uuid::from_bytes([1;16])).collect::>(), - @r###" - [ - { - "subject_role": "", - "object_role": "acmeCo/", - "capability": "admin" - }, - { - "subject_role": "acmeCo/", - "object_role": "acmeCo/", - "capability": "write" - }, - { - "subject_role": "acmeCo/", - "object_role": "ops/private/dp/acmeCo/", - "capability": "read" - } - ] - "###, + // User 1 has admin on acmeCo/, which propagates through role grants. + assert!(UserGrant::is_authorized( + &role_grants, + &user_grants, + uuid::Uuid::from_bytes([1; 16]), + "ops/private/dp/acmeCo/foo", + models::Capability::Read, + )); + // User 2 has admin on acmeCo/nested/, which also picks up the + // acmeCo/ role grants (parent prefix matching). + assert!(UserGrant::is_authorized( + &role_grants, + &user_grants, + uuid::Uuid::from_bytes([2; 16]), + "ops/private/dp/acmeCo/foo", + models::Capability::Read, + )); + } + + fn build_scenario( + user_edges: Vec<(&str, Vec)>, + role_edges: Vec<(&str, &str, Vec)>, + ) -> (RoleGrants, UserGrants, uuid::Uuid) { + let user_id = uuid::Uuid::from_bytes([1; 16]); + let user_grants = + UserGrants::from_iter(user_edges.into_iter().map(|(obj, bundles)| UserGrant { + user_id, + object_role: models::Prefix::new(obj), + capability: models::Capability::None, + bundles, + })); + let role_grants = + RoleGrants::from_iter(role_edges.into_iter().map(|(sub, obj, bundles)| RoleGrant { + subject_role: models::Prefix::new(sub), + object_role: models::Prefix::new(obj), + capability: models::Capability::None, + bundles, + })); + (role_grants, user_grants, user_id) + } + + fn assert_reachable( + role_grants: &RoleGrants, + user_grants: &UserGrants, + user_id: uuid::Uuid, + expected: Vec<(&str, EnumSet)>, + ) { + let mut nodes: Vec<_> = UserGrant::reachable_nodes(role_grants, user_grants, user_id) + .map(|n| (n.object_role.to_string(), n.capabilities)) + .collect(); + nodes.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.as_u32().cmp(&b.1.as_u32()))); + nodes.dedup(); + + let expected: Vec<(String, EnumSet)> = expected + .into_iter() + .map(|(prefix, caps)| (prefix.to_string(), caps)) + .collect(); + + assert_eq!(nodes, expected); + } + + fn assert_authorized( + role_grants: &RoleGrants, + user_grants: &UserGrants, + user_id: uuid::Uuid, + name: &str, + required: EnumSet, + ) { + assert!( + UserGrant::is_authorized(role_grants, user_grants, user_id, name, required), + "expected {user_id} to have {required:?} on {name}", ); + } - insta::assert_json_snapshot!( - UserGrant::transitive_roles(&role_grants, &user_grants, uuid::Uuid::from_bytes([2;16])).collect::>(), - @r###" - [ - { - "subject_role": "", - "object_role": "acmeCo/nested/", - "capability": "admin" - }, - { - "subject_role": "acmeCo/", - "object_role": "acmeCo/", - "capability": "write" - }, - { - "subject_role": "acmeCo/", - "object_role": "ops/private/dp/acmeCo/", - "capability": "read" - } - ] - "###, + fn assert_not_authorized( + role_grants: &RoleGrants, + user_grants: &UserGrants, + user_id: uuid::Uuid, + name: &str, + required: EnumSet, + ) { + assert!( + !UserGrant::is_authorized(role_grants, user_grants, user_id, name, required), + "expected {user_id} NOT to have {required:?} on {name}", + ); + } + + #[test] + fn test_reachable_nodes_delegate_propagation() { + use Capability::*; + + let (role_grants, user_grants, user_id) = build_scenario( + vec![( + "acmeCo/", + vec![Bundle::Viewer, Bundle::Billing, Bundle::Delegate], + )], + vec![ + ( + "acmeCo/", + "bobCo/shared/", + vec![Bundle::Viewer, Bundle::Billing, Bundle::Delegate], + ), + ( + "bobCo/shared/", + "carolCo/data/", + vec![Bundle::Viewer, Bundle::Delegate], + ), + ( + "carolCo/data/", + "daveCo/sink/", + vec![Bundle::Viewer, Bundle::Billing], + ), + ], + ); + + assert_reachable( + &role_grants, + &user_grants, + user_id, + vec![ + ("acmeCo/", Bundle::Viewer.capabilities() | Delegate), + ("bobCo/shared/", Bundle::Viewer.capabilities() | Delegate), + ("carolCo/data/", Bundle::Viewer.capabilities() | Delegate), + ("daveCo/sink/", Bundle::Viewer.capabilities()), + ], + ); + } + + #[test] + fn test_reachable_nodes_no_delegate_is_terminal() { + use Capability::*; + + let (role_grants, user_grants, user_id) = build_scenario( + vec![("acmeCo/", vec![Bundle::Viewer, Bundle::Delegate])], + vec![ + ("acmeCo/", "bobCo/shared/", vec![Bundle::Viewer]), + ("bobCo/shared/", "carolCo/", vec![Bundle::Viewer]), + ], + ); + + assert_reachable( + &role_grants, + &user_grants, + user_id, + vec![ + ("acmeCo/", Bundle::Viewer.capabilities() | Delegate), + ("bobCo/shared/", Bundle::Viewer.capabilities()), + ], + ); + + let (role_grants, user_grants, user_id) = build_scenario( + vec![("acmeCo/", vec![Bundle::Viewer])], + vec![ + ( + "acmeCo/", + "bobCo/shared/", + vec![Bundle::Viewer, Bundle::Delegate], + ), + ("bobCo/shared/", "carolCo/", vec![Bundle::Viewer]), + ], + ); + + assert_reachable( + &role_grants, + &user_grants, + user_id, + vec![("acmeCo/", Bundle::Viewer.capabilities())], + ); + assert_not_authorized( + &role_grants, + &user_grants, + user_id, + "bobCo/shared/", + Bundle::Viewer.capabilities(), + ); + assert_not_authorized( + &role_grants, + &user_grants, + user_id, + "carolCo/", + Bundle::Viewer.capabilities(), + ); + } + + #[test] + fn test_assume_behavior() { + use Capability::*; + + let (role_grants, user_grants, user_id) = build_scenario( + vec![("acmeCo/", vec![Bundle::Assume])], + vec![( + "acmeCo/", + "bobCo/shared/", + vec![Bundle::Viewer, Bundle::Billing, Bundle::TeamAdmin], + )], + ); + + assert_reachable( + &role_grants, + &user_grants, + user_id, + vec![ + ("acmeCo/", EnumSet::from(Assume)), + ( + "bobCo/shared/", + Bundle::Viewer.capabilities() | Bundle::TeamAdmin.capabilities(), + ), + ], + ); + + assert_authorized( + &role_grants, + &user_grants, + user_id, + "bobCo/shared/nested/", + Bundle::Viewer.capabilities() | Bundle::TeamAdmin.capabilities(), + ); + assert_not_authorized( + &role_grants, + &user_grants, + user_id, + "acmeCo/", + Bundle::Viewer.capabilities(), + ); + + let (role_grants, user_grants, user_id) = build_scenario( + vec![("acmeCo/", vec![Bundle::Writer, Bundle::Assume])], + vec![( + "acmeCo/", + "bobCo/shared/", + vec![Bundle::Viewer, Bundle::Billing, Bundle::TeamAdmin], + )], + ); + assert_authorized( + &role_grants, + &user_grants, + user_id, + "acmeCo/", + Bundle::Writer.capabilities(), + ); + assert_not_authorized( + &role_grants, + &user_grants, + user_id, + "bobCo/shared/", + Bundle::Writer.capabilities(), + ); + } + + #[test] + fn test_assume_supersedes_delegate() { + use Capability::*; + + let (role_grants, user_grants, user_id) = build_scenario( + vec![( + "acmeCo/", + vec![Bundle::Viewer, Bundle::Delegate, Bundle::Assume], + )], + vec![( + "acmeCo/", + "bobCo/shared/", + vec![Bundle::Billing, Bundle::Viewer, Bundle::TeamAdmin], + )], + ); + + assert_reachable( + &role_grants, + &user_grants, + user_id, + vec![ + ("acmeCo/", Bundle::Viewer.capabilities() | Assume | Delegate), + ( + "bobCo/shared/", + Bundle::Viewer.capabilities() | Bundle::TeamAdmin.capabilities(), + ), + ], + ); + + // Contrast: delegate alone attenuates to the intersection. + let (role_grants, user_grants, user_id) = build_scenario( + vec![("acmeCo/", vec![Bundle::Viewer, Bundle::Delegate])], + vec![( + "acmeCo/", + "bobCo/shared/", + vec![Bundle::Viewer, Bundle::Billing, Bundle::TeamAdmin], + )], + ); + + assert_reachable( + &role_grants, + &user_grants, + user_id, + vec![ + ("acmeCo/", Bundle::Viewer.capabilities() | Delegate), + ("bobCo/shared/", Bundle::Viewer.capabilities()), + ], + ); + + // Assume does not add capabilities to the following edge + let (role_grants, user_grants, user_id) = build_scenario( + vec![("acmeCo/", vec![Bundle::Writer, Bundle::Assume])], + vec![( + "acmeCo/", + "bobCo/shared/", + vec![Bundle::Viewer, Bundle::Billing, Bundle::TeamAdmin], + )], + ); + + assert_reachable( + &role_grants, + &user_grants, + user_id, + vec![ + ("acmeCo/", Bundle::Writer.capabilities() | Assume), + ( + "bobCo/shared/", + Bundle::Viewer.capabilities() | Bundle::TeamAdmin.capabilities(), + ), + ], + ); + } + + #[test] + fn test_inherited_capabilities() { + use Capability::*; + + let (role_grants, user_grants, user_id) = build_scenario( + vec![ + ("acmeCo/", vec![Bundle::Viewer]), + ("acmeCo/interns/", vec![Bundle::Writer, Bundle::Delegate]), + ], + vec![( + "acmeCo/", + "betaCo/shareable/", + vec![Bundle::Viewer, Bundle::Writer], + )], + ); + + assert_reachable( + &role_grants, + &user_grants, + user_id, + vec![ + ("acmeCo/", Bundle::Viewer.capabilities()), + ("acmeCo/interns/", Bundle::Writer.capabilities() | Delegate), + ("betaCo/shareable/", Bundle::Writer.capabilities()), + ], + ); + } + + #[test] + fn test_descendent_capabilities() { + use Capability::*; + + let (role_grants, user_grants, user_id) = build_scenario( + vec![ + ("acmeCo/", vec![Bundle::Viewer]), + ("acmeCo/interns/", vec![Bundle::Writer, Bundle::Delegate]), + ], + vec![( + "acmeCo/interns/betaCo/", + "betaCo/shareable/", + vec![Bundle::Viewer, Bundle::Writer], + )], + ); + + assert_reachable( + &role_grants, + &user_grants, + user_id, + vec![ + ("acmeCo/", Bundle::Viewer.capabilities()), + ("acmeCo/interns/", Bundle::Writer.capabilities() | Delegate), + ("betaCo/shareable/", Bundle::Writer.capabilities()), + ], + ); + } + + #[test] + fn test_parent_child_capabilities() { + use Capability::*; + + let (role_grants, user_grants, user_id) = build_scenario( + vec![( + "acmeCo/interns/", + vec![Bundle::Viewer, Bundle::Writer, Bundle::Delegate], + )], + vec![ + ("acmeCo/", "betaCo/shareable/", vec![Bundle::Viewer]), + ( + "acmeCo/interns/betaCo/", + "betaCo/shareable/", + vec![Bundle::Writer], + ), + ], + ); + + assert_reachable( + &role_grants, + &user_grants, + user_id, + vec![ + ("acmeCo/interns/", Bundle::Writer.capabilities() | Delegate), + ("betaCo/shareable/", Bundle::Viewer.capabilities()), + ("betaCo/shareable/", Bundle::Writer.capabilities()), + ], + ); + + assert_authorized( + &role_grants, + &user_grants, + user_id, + "betaCo/shareable/", + Bundle::Writer.capabilities(), + ); + assert_not_authorized( + &role_grants, + &user_grants, + user_id, + "betaCo/shareable/", + EnumSet::from(Delegate), + ); + } + + #[test] + fn test_multi_path() { + use Capability::*; + + let (role_grants, user_grants, user_id) = build_scenario( + vec![ + ("acmeCo/", vec![Bundle::Viewer, Bundle::Delegate]), + ("betaCo/", vec![Bundle::Writer, Bundle::Delegate]), + ], + vec![ + ("acmeCo/", "charlieCo/shareable/", vec![Bundle::Viewer]), + ("betaCo/", "charlieCo/", vec![Bundle::Writer]), + ], + ); + + assert_reachable( + &role_grants, + &user_grants, + user_id, + vec![ + ("acmeCo/", Bundle::Viewer.capabilities() | Delegate), + ("betaCo/", Bundle::Writer.capabilities() | Delegate), + ("charlieCo/", Bundle::Writer.capabilities()), + ("charlieCo/shareable/", Bundle::Viewer.capabilities()), + ], + ); + + assert_authorized( + &role_grants, + &user_grants, + user_id, + "charlieCo/shareable/", + Bundle::Writer.capabilities(), + ); + assert_not_authorized( + &role_grants, + &user_grants, + user_id, + "charlieCo/", + EnumSet::from(Delegate), + ); + } + + #[test] + fn test_is_authorized() { + use Capability::*; + + let (role_grants, user_grants, user_id) = build_scenario( + vec![("acmeCo/", vec![Bundle::Viewer, Bundle::Delegate])], + vec![ + ( + "acmeCo/", + "bobCo/shared/", + vec![Bundle::Viewer, Bundle::Billing, Bundle::Delegate], + ), + ("bobCo/shared/", "carolCo/data/", vec![Bundle::Viewer]), + ], + ); + + assert_authorized( + &role_grants, + &user_grants, + user_id, + "acmeCo/thing", + Bundle::Viewer.capabilities(), + ); + assert_not_authorized( + &role_grants, + &user_grants, + user_id, + "acmeCo/thing", + EnumSet::from(CreateGrant), + ); + + assert_authorized( + &role_grants, + &user_grants, + user_id, + "bobCo/shared/thing", + Bundle::Viewer.capabilities(), + ); + assert_not_authorized( + &role_grants, + &user_grants, + user_id, + "bobCo/shared/thing", + EnumSet::from(CreateGrant), + ); + + assert_authorized( + &role_grants, + &user_grants, + user_id, + "carolCo/data/thing", + Bundle::Viewer.capabilities(), + ); + + let unknown = uuid::Uuid::from_bytes([9; 16]); + assert_not_authorized( + &role_grants, + &user_grants, + unknown, + "acmeCo/thing", + Bundle::Viewer.capabilities(), + ); + } + + #[test] + fn test_mixed_legacy_and_bundles() { + use Capability::*; + + let user_id = uuid::Uuid::from_bytes([1; 16]); + let user_grants = UserGrants::from_iter(vec![UserGrant { + user_id, + object_role: models::Prefix::new("acmeCo/"), + capability: models::Capability::Write, + bundles: vec![Bundle::TeamAdmin], + }]); + let role_grants = RoleGrants::new(); + + let nodes: Vec<_> = + UserGrant::reachable_nodes(&role_grants, &user_grants, user_id).collect(); + + assert_eq!(nodes.len(), 1); + let node = &nodes[0]; + assert_eq!(node.object_role, "acmeCo/"); + + let expected = Bundle::Writer.capabilities() | Bundle::TeamAdmin.capabilities(); + assert_eq!(node.capabilities, expected); + + assert!(node.capabilities.contains(CatalogRead)); + assert!(node.capabilities.contains(JournalAppend)); + assert!(node.capabilities.contains(CreateGrant)); + assert!(!node.capabilities.contains(SpecEdit)); + assert!(!node.capabilities.contains(Delegate)); + } + + #[test] + fn test_assume_propagates_full_capability_set() { + use Capability::*; + + let (role_grants, user_grants, user_id) = build_scenario( + vec![("acmeCo/", vec![Bundle::Viewer, Bundle::Assume])], + vec![( + "acmeCo/", + "bobCo/", + vec![Bundle::Viewer, Bundle::Billing, Bundle::Delegate], + )], + ); + + assert_reachable( + &role_grants, + &user_grants, + user_id, + vec![ + ("acmeCo/", Bundle::Viewer.capabilities() | Assume), + ("bobCo/", Bundle::Viewer.capabilities() | Delegate), + ], + ); + + assert_authorized( + &role_grants, + &user_grants, + user_id, + "bobCo/thing", + Bundle::Viewer.capabilities(), + ); + } + + #[test] + fn test_assume_vs_delegate_capability_filtering() { + use Capability::*; + + let (role_grants, user_grants, user_id) = build_scenario( + vec![("acmeCo/", vec![Bundle::Viewer, Bundle::Delegate])], + vec![( + "acmeCo/", + "bobCo/", + vec![Bundle::Viewer, Bundle::Billing, Bundle::Delegate], + )], + ); + + assert_reachable( + &role_grants, + &user_grants, + user_id, + vec![ + ("acmeCo/", Bundle::Viewer.capabilities() | Delegate), + ("bobCo/", Bundle::Viewer.capabilities() | Delegate), + ], + ); + + let (role_grants, user_grants, user_id) = build_scenario( + vec![("acmeCo/", vec![Bundle::Viewer, Bundle::Assume])], + vec![( + "acmeCo/", + "bobCo/", + vec![Bundle::Viewer, Bundle::Billing, Bundle::Delegate], + )], + ); + + assert_authorized( + &role_grants, + &user_grants, + user_id, + "bobCo/thing", + Bundle::Viewer.capabilities(), + ); + } + + #[test] + fn test_assume_chains_through_edges() { + use Capability::*; + + let (role_grants, user_grants, user_id) = build_scenario( + vec![("acmeCo/", vec![Bundle::Viewer, Bundle::Assume])], + vec![ + ( + "acmeCo/", + "bobCo/", + vec![Bundle::Viewer, Bundle::Billing, Bundle::Assume], + ), + ("bobCo/", "carolCo/", vec![Bundle::Viewer, Bundle::Billing]), + ], + ); + + assert_reachable( + &role_grants, + &user_grants, + user_id, + vec![ + ("acmeCo/", Bundle::Viewer.capabilities() | Assume), + ("bobCo/", Bundle::Viewer.capabilities() | Assume), + ("carolCo/", Bundle::Viewer.capabilities()), + ], + ); + } + + #[test] + fn test_assume_does_not_chain_without_edge_delegate() { + use Capability::*; + + let (role_grants, user_grants, user_id) = build_scenario( + vec![("acmeCo/", vec![Bundle::Viewer, Bundle::Assume])], + vec![ + ("acmeCo/", "bobCo/", vec![Bundle::Viewer, Bundle::Delegate]), + ("bobCo/", "carolCo/", vec![Bundle::Viewer, Bundle::Billing]), + ], + ); + + assert_reachable( + &role_grants, + &user_grants, + user_id, + vec![ + ("acmeCo/", Bundle::Viewer.capabilities() | Assume), + ("bobCo/", Bundle::Viewer.capabilities() | Delegate), + ("carolCo/", Bundle::Viewer.capabilities()), + ], + ); + } + + fn build_role_scenario(role_edges: Vec<(&str, &str, Vec)>) -> RoleGrants { + RoleGrants::from_iter(role_edges.into_iter().map(|(sub, obj, bundles)| RoleGrant { + subject_role: models::Prefix::new(sub), + object_role: models::Prefix::new(obj), + capability: models::Capability::None, + bundles, + })) + } + + fn assert_role_reachable( + role_grants: &RoleGrants, + role_or_name: &str, + expected: Vec<(&str, EnumSet)>, + ) { + let mut nodes: Vec<_> = RoleGrant::reachable_nodes(role_grants, role_or_name) + .map(|n| (n.object_role.to_string(), n.capabilities)) + .collect(); + nodes.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.as_u32().cmp(&b.1.as_u32()))); + nodes.dedup(); + + let expected: Vec<(String, EnumSet)> = expected + .into_iter() + .map(|(prefix, caps)| (prefix.to_string(), caps)) + .collect(); + + assert_eq!(nodes, expected); + } + + fn assert_role_authorized( + role_grants: &RoleGrants, + subject: &str, + object: &str, + required: EnumSet, + ) { + assert!( + RoleGrant::is_authorized(role_grants, subject, object, required), + "expected {subject} to have {required:?} on {object}", + ); + } + + fn assert_role_not_authorized( + role_grants: &RoleGrants, + subject: &str, + object: &str, + required: EnumSet, + ) { + assert!( + !RoleGrant::is_authorized(role_grants, subject, object, required), + "expected {subject} NOT to have {required:?} on {object}", + ); + } + + #[test] + fn test_role_reachable_nodes_delegate_propagation() { + use Capability::*; + + let role_grants = build_role_scenario(vec![ + ( + "acmeCo/", + "bobCo/shared/", + vec![Bundle::Viewer, Bundle::Billing, Bundle::Delegate], + ), + ( + "bobCo/shared/", + "carolCo/data/", + vec![Bundle::Viewer, Bundle::Delegate], + ), + ( + "carolCo/data/", + "daveCo/sink/", + vec![Bundle::Viewer, Bundle::Billing], + ), + ]); + + assert_role_reachable( + &role_grants, + "acmeCo/", + vec![ + ("bobCo/shared/", Bundle::Viewer.capabilities() | Delegate), + ("carolCo/data/", Bundle::Viewer.capabilities() | Delegate), + ("daveCo/sink/", Bundle::Viewer.capabilities()), + ], + ); + } + + #[test] + fn test_role_reachable_nodes_no_delegate_is_terminal() { + let role_grants = build_role_scenario(vec![ + ("acmeCo/", "bobCo/shared/", vec![Bundle::Viewer]), + ("bobCo/shared/", "carolCo/", vec![Bundle::Viewer]), + ]); + + assert_role_reachable( + &role_grants, + "acmeCo/", + vec![("bobCo/shared/", Bundle::Viewer.capabilities())], + ); + } + + #[test] + fn test_role_assume_propagates_all_capabilities() { + use Capability::*; + + let role_grants = build_role_scenario(vec![ + ("acmeCo/", "bobCo/", vec![Bundle::Viewer, Bundle::Assume]), + ( + "bobCo/", + "carolCo/", + vec![Bundle::Viewer, Bundle::Billing, Bundle::Delegate], + ), + ]); + + assert_role_reachable( + &role_grants, + "acmeCo/", + vec![ + ("bobCo/", Bundle::Viewer.capabilities() | Assume), + ("carolCo/", Bundle::Viewer.capabilities() | Delegate), + ], + ); + + assert_role_authorized( + &role_grants, + "acmeCo/", + "carolCo/thing", + Bundle::Viewer.capabilities(), + ); + } + + #[test] + fn test_role_is_authorized() { + let role_grants = build_role_scenario(vec![ + ( + "acmeCo/", + "bobCo/shared/", + vec![Bundle::Viewer, Bundle::Billing, Bundle::Delegate], + ), + ("bobCo/shared/", "carolCo/data/", vec![Bundle::Viewer]), + ]); + + assert_role_authorized( + &role_grants, + "acmeCo/", + "bobCo/shared/thing", + Bundle::Viewer.capabilities(), + ); + assert_role_authorized( + &role_grants, + "acmeCo/", + "carolCo/data/thing", + Bundle::Viewer.capabilities(), + ); + assert_role_not_authorized( + &role_grants, + "acmeCo/", + "unknown/thing", + Bundle::Viewer.capabilities(), ); } } diff --git a/crates/tables/src/lib.rs b/crates/tables/src/lib.rs index afdfed013c4..c513ac53f0a 100644 --- a/crates/tables/src/lib.rs +++ b/crates/tables/src/lib.rs @@ -110,6 +110,7 @@ tables!( key object_role: models::Prefix, // Capability of the subject with respect to the object. val capability: models::Capability, + val bundles: Vec, } table UserGrants (row #[derive(Clone, serde::Deserialize, serde::Serialize)] UserGrant, sql "user_grants") { @@ -119,6 +120,7 @@ tables!( key object_role: models::Prefix, // Capability of the subject with respect to the object. val capability: models::Capability, + val bundles: Vec, } table DraftCaptures (row #[derive(Clone)] DraftCapture, sql "draft_captures") { @@ -405,11 +407,13 @@ impl Error { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, serde::Serialize)] -pub struct GrantRef<'a> { - pub subject_role: &'a str, +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub struct NodeRef<'a> { pub object_role: &'a str, - pub capability: models::Capability, + pub capabilities: models::authz::CapabilitySet, + /// The legacy `capability` column of the grant that emitted this node. + /// Pass-through (no attenuation); reflects storage, not effective bits. + pub legacy: models::Capability, } /// Attempts to parse a catalog type and name from a URL in the form of: @@ -479,6 +483,7 @@ string_wrapper_types!( json_sql_types!( Vec, Vec, + Vec, models::Capability, models::CaptureDef, models::CatalogType, diff --git a/supabase/migrations/20260511120000_orthogonal_capabilities.sql b/supabase/migrations/20260511120000_orthogonal_capabilities.sql new file mode 100644 index 00000000000..08dc1f9ca70 --- /dev/null +++ b/supabase/migrations/20260511120000_orthogonal_capabilities.sql @@ -0,0 +1,73 @@ +begin; + +-- Rename the first placeholder value to 'none', used for grants whose +-- authorization comes entirely from the bundles array column. +alter type grant_capability rename value 'x_00' to 'none'; + +-- Relax check constraints to allow the new 'none' capability value. +alter table role_grants drop constraint valid_capability; +alter table role_grants add constraint valid_capability check ( + capability = any (array[ + 'none'::grant_capability, + 'read'::grant_capability, + 'write'::grant_capability, + 'admin'::grant_capability + ]) +); + +alter table user_grants drop constraint valid_capability; +alter table user_grants add constraint valid_capability check ( + capability = any (array[ + 'none'::grant_capability, + 'read'::grant_capability, + 'write'::grant_capability, + 'admin'::grant_capability + ]) +); + +create type capability_bundle as enum ( + 'viewer', + 'writer', + 'editor', + 'admin', + 'billing', + 'team_admin', + 'delegate', + 'assume' +); + +alter table user_grants + add column bundles capability_bundle[] not null default '{}'; + +alter table role_grants + add column bundles capability_bundle[] not null default '{}'; + +-- Revoke broad table-level grants and re-add column-level grants that +-- exclude `bundles`. Only service_role (the control plane) may +-- read or write the new column; PostgREST-facing roles must not. + +revoke all on role_grants from authenticated, marketplace_integration; +revoke all on role_grants from reporting_user; + +grant select (id, created_at, updated_at, detail, subject_role, object_role, capability), + insert (detail, subject_role, object_role, capability), + update (detail, subject_role, object_role, capability), + delete + on role_grants to authenticated, marketplace_integration; + +grant select (id, created_at, updated_at, detail, subject_role, object_role, capability) + on role_grants to reporting_user; + +revoke all on user_grants from authenticated; +revoke all on user_grants from reporting_user; + +grant select (id, created_at, updated_at, detail, user_id, object_role, capability), + insert (detail, user_id, object_role, capability), + update (detail, user_id, object_role, capability), + delete + on user_grants to authenticated; + +grant select (id, created_at, updated_at, detail, user_id, object_role, capability) + on user_grants to reporting_user; + +commit;