From 33318592f0d2c4665961ea77832a125f1d4b3dac Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Fri, 8 May 2026 16:15:09 -0400 Subject: [PATCH 1/3] authz: orthogonal capability model Adds an orthogonal capability system alongside the existing hierarchical (read/write/admin) authorization model. Both RoleGrant::is_authorized and UserGrant::is_authorized now accept `impl Into`, dispatching to either the legacy BFS (transitive_roles/GrantRef) or the new orthogonal BFS (reachable_nodes/NodeRef). --- ...1558559381b25bd5f0242815b471198fcf84.json} | 32 +- ...583a6d435241131b833d35609076a2aedba5e.json | 22 - ...7e26c54cebdd3c875e5bf47f088775311cdf.json} | 32 +- crates/control-plane-api/src/server/mod.rs | 5 +- .../public/graphql/authorized_prefixes.rs | 2 + .../src/server/public/graphql/prefixes.rs | 36 +- .../control-plane-api/src/server/snapshot.rs | 32 +- .../src/server/snapshot_fixture.json | 27 +- crates/flow-client/control-plane-api.graphql | 26 + crates/models/src/catalogs.rs | 89 ++ crates/models/src/lib.rs | 2 +- crates/tables/src/behaviors.rs | 949 +++++++++++++++++- crates/tables/src/lib.rs | 9 + ...20260511120000_orthogonal_capabilities.sql | 19 + 14 files changed, 1201 insertions(+), 81 deletions(-) rename .sqlx/{query-bf114fcbbb3b0c8bd057869c92b7c6ceee073f467105083ec751a75c11478adf.json => query-38d66d2132d5063abdc345bdd6491558559381b25bd5f0242815b471198fcf84.json} (62%) delete mode 100644 .sqlx/query-57826264c9cc56151d7b59de332583a6d435241131b833d35609076a2aedba5e.json rename .sqlx/{query-40735247033c2693395184471553db708524cfc3b6f4c1095c2ae63f377b8800.json => query-75cb5e46c0fa1f6c711fc9fdbe487e26c54cebdd3c875e5bf47f088775311cdf.json} (62%) create mode 100644 supabase/migrations/20260511120000_orthogonal_capabilities.sql diff --git a/.sqlx/query-bf114fcbbb3b0c8bd057869c92b7c6ceee073f467105083ec751a75c11478adf.json b/.sqlx/query-38d66d2132d5063abdc345bdd6491558559381b25bd5f0242815b471198fcf84.json similarity index 62% rename from .sqlx/query-bf114fcbbb3b0c8bd057869c92b7c6ceee073f467105083ec751a75c11478adf.json rename to .sqlx/query-38d66d2132d5063abdc345bdd6491558559381b25bd5f0242815b471198fcf84.json index 1ad4ac4015f..49924cb32df 100644 --- a/.sqlx/query-bf114fcbbb3b0c8bd057869c92b7c6ceee073f467105083ec751a75c11478adf.json +++ b/.sqlx/query-38d66d2132d5063abdc345bdd6491558559381b25bd5f0242815b471198fcf84.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.capabilities AS \"capabilities: Vec\"\n FROM user_grants g\n ", "describe": { "columns": [ { @@ -56,16 +56,44 @@ } } } + }, + { + "ordinal": 3, + "name": "capabilities: Vec", + "type_info": { + "Custom": { + "name": "orthogonal_capability[]", + "kind": { + "Array": { + "Custom": { + "name": "orthogonal_capability", + "kind": { + "Enum": [ + "read", + "write", + "admin", + "billing", + "team_admin", + "delegate", + "assume" + ] + } + } + } + } + } + } } ], "parameters": { "Left": [] }, "nullable": [ + false, false, false, false ] }, - "hash": "bf114fcbbb3b0c8bd057869c92b7c6ceee073f467105083ec751a75c11478adf" + "hash": "38d66d2132d5063abdc345bdd6491558559381b25bd5f0242815b471198fcf84" } 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-40735247033c2693395184471553db708524cfc3b6f4c1095c2ae63f377b8800.json b/.sqlx/query-75cb5e46c0fa1f6c711fc9fdbe487e26c54cebdd3c875e5bf47f088775311cdf.json similarity index 62% rename from .sqlx/query-40735247033c2693395184471553db708524cfc3b6f4c1095c2ae63f377b8800.json rename to .sqlx/query-75cb5e46c0fa1f6c711fc9fdbe487e26c54cebdd3c875e5bf47f088775311cdf.json index de94f405d13..e61aa834aca 100644 --- a/.sqlx/query-40735247033c2693395184471553db708524cfc3b6f4c1095c2ae63f377b8800.json +++ b/.sqlx/query-75cb5e46c0fa1f6c711fc9fdbe487e26c54cebdd3c875e5bf47f088775311cdf.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.capabilities AS \"capabilities: Vec\"\n FROM role_grants g\n ", "describe": { "columns": [ { @@ -56,16 +56,44 @@ } } } + }, + { + "ordinal": 3, + "name": "capabilities: Vec", + "type_info": { + "Custom": { + "name": "orthogonal_capability[]", + "kind": { + "Array": { + "Custom": { + "name": "orthogonal_capability", + "kind": { + "Enum": [ + "read", + "write", + "admin", + "billing", + "team_admin", + "delegate", + "assume" + ] + } + } + } + } + } + } } ], "parameters": { "Left": [] }, "nullable": [ + false, false, false, false ] }, - "hash": "40735247033c2693395184471553db708524cfc3b6f4c1095c2ae63f377b8800" + "hash": "75cb5e46c0fa1f6c711fc9fdbe487e26c54cebdd3c875e5bf47f088775311cdf" } diff --git a/crates/control-plane-api/src/server/mod.rs b/crates/control-plane-api/src/server/mod.rs index fd0c385f687..ca9a22cd1b4 100644 --- a/crates/control-plane-api/src/server/mod.rs +++ b/crates/control-plane-api/src/server/mod.rs @@ -66,7 +66,7 @@ impl App { pub fn evaluate_names_authorization<'r, Iter, S>( snapshot: &Snapshot, claims: &crate::ControlClaims, - min_capability: models::Capability, + min_capability: impl Into, prefixes_or_names: Iter, ) -> AuthZResult<()> where @@ -79,6 +79,7 @@ where .. } = claims; let user_email = user_email.as_ref().map(String::as_str).unwrap_or("user"); + let min_capability: models::AnyCapability = min_capability.into(); for prefix_or_name in prefixes_or_names.into_iter() { if !tables::UserGrant::is_authorized( @@ -86,7 +87,7 @@ where &snapshot.user_grants, *user_id, prefix_or_name.as_ref(), - min_capability, + min_capability.clone(), ) { return Err(tonic::Status::permission_denied(format!( "{user_email} is not authorized to access prefix or name '{prefix_or_name}' with required capability {min_capability}", 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..b8b71ec26cd 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 @@ -57,6 +57,7 @@ mod tests { user_id: *id, object_role: models::Prefix::new(*obj), capability: *cap, + capabilities: vec![], } })); let rg = tables::RoleGrants::from_iter(role_grants.iter().map(|(sub, obj, cap)| { @@ -64,6 +65,7 @@ mod tests { subject_role: models::Prefix::new(*sub), object_role: models::Prefix::new(*obj), capability: *cap, + capabilities: vec![], } })); (ug, rg) 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..74276454357 100644 --- a/crates/control-plane-api/src/server/public/graphql/prefixes.rs +++ b/crates/control-plane-api/src/server/public/graphql/prefixes.rs @@ -7,6 +7,10 @@ pub struct PrefixRef { pub prefix: models::Prefix, /// The capability granted to the user for this prefix. pub user_capability: models::Capability, + /// Orthogonal capabilities the user has at this prefix, independent of + /// the read/write/admin hierarchy. Empty when no orthogonal capabilities + /// are granted. + pub capabilities: Vec, } #[derive(Debug, Clone, async_graphql::InputObject)] @@ -40,16 +44,32 @@ impl PrefixesQuery { let env = ctx.data::()?; connection::query(after, None, first, None, |after, _, first, _| async move { + let snapshot = env.snapshot(); + let user_id = env.claims()?.sub; + let mut all_roles: Vec = tables::UserGrant::transitive_roles( - &env.snapshot().role_grants, - &env.snapshot().user_grants, - env.claims()?.sub, + &snapshot.role_grants, + &snapshot.user_grants, + user_id, ) .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, + .map(|grant| { + let mut capabilities: Vec = snapshot + .effective_capabilities( + &snapshot.role_grants, + &snapshot.user_grants, + user_id, + grant.object_role, + ) + .into_iter() + .collect(); + capabilities.sort(); + PrefixRef { + prefix: models::Prefix::new(grant.object_role), + user_capability: grant.capability, + capabilities, + } }) .collect(); @@ -110,6 +130,7 @@ mod tests { node { prefix userCapability + capabilities } } } @@ -128,18 +149,21 @@ mod tests { "edges": [ { "node": { + "capabilities": [], "prefix": "aliceCo/", "userCapability": "admin" } }, { "node": { + "capabilities": [], "prefix": "aliceCo/data/", "userCapability": "write" } }, { "node": { + "capabilities": [], "prefix": "ops/dp/public/", "userCapability": "read" } diff --git a/crates/control-plane-api/src/server/snapshot.rs b/crates/control-plane-api/src/server/snapshot.rs index ae26226c6db..be3761290fe 100644 --- a/crates/control-plane-api/src/server/snapshot.rs +++ b/crates/control-plane-api/src/server/snapshot.rs @@ -1,5 +1,5 @@ use anyhow::Context; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; // SnapshotData encapsulates all data required to construct a Snapshot. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] @@ -341,6 +341,28 @@ impl Snapshot { }) } + pub fn effective_capabilities( + &self, + role_grants: &[tables::RoleGrant], + user_grants: &[tables::UserGrant], + user_id: uuid::Uuid, + prefix: &str, + ) -> HashSet { + let mut bfs_nodes = 0usize; + let result = tables::UserGrant::reachable_nodes(role_grants, user_grants, user_id) + .inspect(|_| bfs_nodes += 1) + .filter(|node| prefix.starts_with(node.object_role)) + .flat_map(|node| node.capabilities) + .collect(); + + // bfs_nodes tracks how many grant-graph nodes the BFS expanded. + // If a tenant has deep cross-tenant role grants, this number can + // grow combinatorially — filter logs on "orthogonal capability BFS" + // and watch for spikes to know when to optimize the traversal. + tracing::debug!(%user_id, %prefix, bfs_nodes, "orthogonal capability BFS"); + result + } + // Minimal interval between Snapshot refreshes. // We will postpone a requested refresh prior to this interval. pub const MIN_REFRESH_INTERVAL: chrono::TimeDelta = chrono::TimeDelta::seconds(20); @@ -480,7 +502,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.capabilities AS "capabilities: Vec" FROM role_grants g "#, ) @@ -494,13 +517,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.capabilities AS "capabilities: 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..c0234e34ce0 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", + "capabilities": [] }, { "subject_role": "bobCo/", "object_role": "bobCo/", - "capability": "write" + "capability": "write", + "capabilities": [] }, { "subject_role": "bobCo/tires/", "object_role": "acmeCo/shared/", - "capability": "read" + "capability": "read", + "capabilities": [] }, { "subject_role": "bobCo/", "object_role": "ops/dp/public/", - "capability": "read" + "capability": "read", + "capabilities": [] }, { "subject_role": "aliceCo/", "object_role": "ops/dp/public/", - "capability": "read" + "capability": "read", + "capabilities": [] } ], "user_grants": [ { "user_id": "20202020-2020-2020-2020-202020202020", "object_role": "bobCo/", - "capability": "write" + "capability": "write", + "capabilities": [] }, { "user_id": "20202020-2020-2020-2020-202020202020", "object_role": "bobCo/tires/", - "capability": "admin" + "capability": "admin", + "capabilities": [] }, { "user_id": "40404040-4040-4040-4040-404040404040", "object_role": "aliceCo/", - "capability": "admin" + "capability": "admin", + "capabilities": [] }, { "user_id": "40404040-4040-4040-4040-404040404040", "object_role": "estuary_support/", - "capability": "admin" + "capability": "admin", + "capabilities": [] } ], "tasks": [ diff --git a/crates/flow-client/control-plane-api.graphql b/crates/flow-client/control-plane-api.graphql index 5258ac8a3d7..f877955f012 100644 --- a/crates/flow-client/control-plane-api.graphql +++ b/crates/flow-client/control-plane-api.graphql @@ -1081,6 +1081,26 @@ type MutationRoot { scalar Name +""" +Unified capability enum covering both the legacy hierarchical capabilities +(read/write/admin) and the new orthogonal capabilities (billing, team_admin). + +The legacy variants mirror `Capability` and exist so that authorization +checks can be written against a single type. Today, read/write/admin are +checked via BFS traversal of the old `capability` column, while +billing/team_admin are checked via the `capabilities` array on +`user_grants`. A future migration will consolidate both into the array. +""" +enum OrthogonalCapability { + READ + WRITE + ADMIN + BILLING + TEAM_ADMIN + DELEGATE + ASSUME +} + """ Information about pagination in a connection """ @@ -1133,6 +1153,12 @@ type PrefixRef { The capability granted to the user for this prefix. """ userCapability: Capability! + """ + Orthogonal capabilities the user has at this prefix, independent of + the read/write/admin hierarchy. Empty when no orthogonal capabilities + are granted. + """ + capabilities: [OrthogonalCapability!]! } type PrefixRefConnection { diff --git a/crates/models/src/catalogs.rs b/crates/models/src/catalogs.rs index 186c2bfa736..471b47fb4e1 100644 --- a/crates/models/src/catalogs.rs +++ b/crates/models/src/catalogs.rs @@ -100,6 +100,95 @@ impl std::fmt::Display for Capability { } } +/// Unified capability enum covering both the legacy hierarchical capabilities +/// (read/write/admin) and the new orthogonal capabilities (billing, team_admin). +/// +/// The legacy variants mirror `Capability` and exist so that authorization +/// checks can be written against a single type. Today, read/write/admin are +/// checked via BFS traversal of the old `capability` column, while +/// billing/team_admin are checked via the `capabilities` array on +/// `user_grants`. A future migration will consolidate both into the array. +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[serde(rename_all = "snake_case")] +#[cfg_attr( + feature = "sqlx-support", + derive(sqlx::Type), + sqlx(type_name = "orthogonal_capability", rename_all = "snake_case") +)] +#[cfg_attr( + feature = "async-graphql", + derive(async_graphql::Enum), + graphql(rename_items = "SCREAMING_SNAKE_CASE") +)] +pub enum OrthogonalCapability { + Read, + Write, + Admin, + Billing, + TeamAdmin, + Delegate, + Assume, +} + +impl OrthogonalCapability { + pub fn all() -> Vec { + use OrthogonalCapability::*; + vec![Read, Write, Admin, Billing, TeamAdmin, Delegate, Assume] + } +} + +/// Accepts either a single legacy `Capability` or a set of +/// `OrthogonalCapability`s, letting authorization functions dispatch +/// to the appropriate graph traversal. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum AnyCapability { + Legacy(Capability), + Orthogonal(Vec), +} + +impl std::fmt::Display for OrthogonalCapability { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Read => f.write_str("read"), + Self::Write => f.write_str("write"), + Self::Admin => f.write_str("admin"), + Self::Billing => f.write_str("billing"), + Self::TeamAdmin => f.write_str("team_admin"), + Self::Delegate => f.write_str("delegate"), + Self::Assume => f.write_str("assume"), + } + } +} + +impl std::fmt::Display for AnyCapability { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Legacy(cap) => cap.fmt(f), + Self::Orthogonal(caps) => { + for (i, cap) in caps.iter().enumerate() { + if i > 0 { + f.write_str(", ")?; + } + cap.fmt(f)?; + } + Ok(()) + } + } + } +} + +impl From for AnyCapability { + fn from(cap: Capability) -> Self { + Self::Legacy(cap) + } +} + +impl From> for AnyCapability { + fn from(caps: Vec) -> Self { + Self::Orthogonal(caps) + } +} + impl Catalog { /// Build a root JSON schema for the Catalog model. pub fn root_json_schema() -> schemars::Schema { diff --git a/crates/models/src/lib.rs b/crates/models/src/lib.rs index 337365f419e..e2792227ff2 100644 --- a/crates/models/src/lib.rs +++ b/crates/models/src/lib.rs @@ -37,7 +37,7 @@ pub use crate::alert_config::{ }; pub use crate::labels::{Label, LabelSelector, LabelSet}; pub use captures::{AutoDiscover, CaptureBinding, CaptureDef, CaptureEndpoint}; -pub use catalogs::{Capability, Catalog, CatalogType}; +pub use catalogs::{AnyCapability, Capability, Catalog, CatalogType, OrthogonalCapability}; pub use collections::{CollectionDef, Projection}; pub use connector::{ ConnectorConfig, DEKAF_IMAGE_NAME_PREFIX, DEKAF_IMAGE_TAG, DekafConfig, LocalConfig, diff --git a/crates/tables/src/behaviors.rs b/crates/tables/src/behaviors.rs index e9c8f669121..3b818ad12d2 100644 --- a/crates/tables/src/behaviors.rs +++ b/crates/tables/src/behaviors.rs @@ -68,17 +68,57 @@ impl super::RoleGrant { .skip(1) // Skip `seed`. } + /// Given a role or name, enumerate all reachable nodes and their orthogonal capabilities. + pub fn reachable_nodes<'a>( + role_grants: &'a [super::RoleGrant], + role_or_name: &'a str, + ) -> impl Iterator> + 'a { + let seed = super::NodeRef { + object_role: role_or_name, + // Seed with Assume so the first expansion assumes all capabilities + // through unfiltered — the role itself is the trust root. + capabilities: vec![models::OrthogonalCapability::Assume], + }; + pathfinding::directed::bfs::bfs_reach(seed, move |f| { + next_neighbors(f.clone(), role_grants, &[], uuid::Uuid::nil()) + }) + .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 - }) + match capability.into() { + models::AnyCapability::Legacy(cap) => { + 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 >= cap + }) + } + models::AnyCapability::Orthogonal(required) => { + if required.is_empty() { + debug_assert!( + false, + "is_authorized called with empty orthogonal capabilities" + ); + return false; + } + let mut remaining = required; + for node in Self::reachable_nodes(role_grants, subject_role_or_name) { + if object_role_or_name.starts_with(node.object_role) { + remaining.retain(|cap| !node.capabilities.contains(cap)); + if remaining.is_empty() { + return true; + } + } + } + false + } + } } fn to_ref<'a>(&'a self) -> super::GrantRef<'a> { @@ -88,6 +128,23 @@ impl super::RoleGrant { capability: self.capability, } } + + fn to_node_ref<'a>( + &'a self, + delegatable: &[models::OrthogonalCapability], + ) -> super::NodeRef<'a> { + let mut capabilities: Vec<_> = self + .capabilities + .iter() + .filter(|c| delegatable.contains(c)) + .copied() + .collect(); + capabilities.sort(); + super::NodeRef { + object_role: self.object_role.as_str(), + capabilities, + } + } } impl super::UserGrant { @@ -108,6 +165,24 @@ impl super::UserGrant { .skip(1) // Skip `seed`. } + /// Given a user, enumerate all reachable nodes and their orthogonal capabilities. + 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::NodeRef { + object_role: "", + // Seed with Assume so the first expansion delegates all capabilities + // through unfiltered — user_grants are the trust root. + capabilities: vec![models::OrthogonalCapability::Assume], + }; + pathfinding::directed::bfs::bfs_reach(seed, move |f| { + next_neighbors(f.clone(), role_grants, user_grants, user_id) + }) + .skip(1) + } + pub fn get_user_capability<'a>( role_grants: &'a [super::RoleGrant], user_grants: &'a [super::UserGrant], @@ -126,12 +201,41 @@ impl super::UserGrant { 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 - }) + match capability.into() { + models::AnyCapability::Legacy(cap) => { + 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 >= cap + }, + ) + } + models::AnyCapability::Orthogonal(required) => { + if required.is_empty() { + debug_assert!( + false, + "is_authorized called with empty orthogonal capabilities" + ); + return false; + } + // Capabilities may be split across multiple covering nodes + // (e.g. read via one path, write via another). Collect the + // union across all nodes whose object_role is a prefix of + // the target, bailing early once all required caps are found. + let mut remaining = required; + for node in Self::reachable_nodes(role_grants, user_grants, subject_user_id) { + if object_role_or_name.starts_with(node.object_role) { + remaining.retain(|cap| !node.capabilities.contains(cap)); + if remaining.is_empty() { + return true; + } + } + } + false + } + } } fn to_ref<'a>(&'a self) -> super::GrantRef<'a> { @@ -141,6 +245,23 @@ impl super::UserGrant { capability: self.capability, } } + + fn to_node_ref<'a>( + &'a self, + delegatable: &[models::OrthogonalCapability], + ) -> super::NodeRef<'a> { + let mut capabilities: Vec<_> = self + .capabilities + .iter() + .filter(|c| delegatable.contains(c)) + .copied() + .collect(); + capabilities.sort(); + super::NodeRef { + object_role: self.object_role.as_str(), + capabilities, + } + } } fn grant_edges<'a>( @@ -209,6 +330,89 @@ fn grant_edges<'a>( p1.chain(p2).chain(p3) } +// 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; Assume passes all capabilities. +// +// Perf note: bfs_reach keys on (object_role, capabilities), so the same prefix +// with different capability subsets produces distinct BFS nodes — up to 2^N per +// prefix where N is the number of capabilities. 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 has_delegate = from + .capabilities + .contains(&models::OrthogonalCapability::Delegate); + let has_assume = from + .capabilities + .contains(&models::OrthogonalCapability::Assume); + let is_terminal = !has_delegate && !has_assume; + let delegatable = std::sync::Arc::new(if has_assume { + models::OrthogonalCapability::all() + } else if has_delegate { + from.capabilities + } else { + vec![] + }); + + let (user_edges, role_edges, prefixes) = match (is_terminal, from.object_role) { + // the from node is terminal: no further exploration. + (true, _) => (&user_edges[..0], &role_edges[..0], None), + // This is the seed: traverse through user_grants to kick off exploration. + (_, "") => { + let range = user_edges.equal_range_by(|user_grant| user_grant.user_id.cmp(&user_id)); + (&user_edges[range], &role_edges[..0], None) + } + + // Expand downward (grants whose subject is under role_or_name) + // and upward (grants on parent prefixes of role_or_name). + (_, role_or_name) => { + let range = role_edges.equal_range_by(|role_grant| { + if role_grant.subject_role.starts_with(role_or_name) { + std::cmp::Ordering::Equal + } else { + role_grant.subject_role.as_str().cmp(role_or_name) + } + }); + // Decompose into parent prefixes and binary-search each one + // (instead of a linear scan for role_or_name.starts_with(subject)) + let prefixes = role_or_name.char_indices().filter_map(|(ind, chr)| { + if chr == '/' { + Some(&role_or_name[..ind + 1]) + } else { + None + } + }); + let edges = prefixes + .map(|prefix| { + role_edges + .equal_range_by(|role_grant| role_grant.subject_role.as_str().cmp(prefix)) + }) + .map(|range| role_edges[range].into_iter()) + .flatten(); + + (&user_edges[..0], &role_edges[range], Some(edges)) + } + }; + + let a1 = delegatable.clone(); + let a2 = delegatable.clone(); + + 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(&a1)); + let p3 = prefixes + .into_iter() + .flatten() + .map(move |g| g.to_node_ref(&a2)); + + p1.chain(p2).chain(p3) +} + impl super::StorageMapping { pub fn scope(&self) -> url::Url { crate::synthetic_scope("storageMapping", &self.catalog_prefix) @@ -273,6 +477,7 @@ mod test { subject_role: models::Prefix::new(sub), object_role: models::Prefix::new(obj), capability: cap, + capabilities: vec![], }), ); let user_grants = UserGrants::from_iter( @@ -287,12 +492,13 @@ mod test { user_id, object_role: models::Prefix::new(obj), capability: cap, + capabilities: vec![], }), ); insta::assert_json_snapshot!( RoleGrant::transitive_roles(&role_grants, "aliceCo/anvils/thing").collect::>(), - @r###" + @r#" [ { "subject_role": "aliceCo/anvils/", @@ -300,12 +506,12 @@ mod test { "capability": "write" } ] - "###, + "#, ); insta::assert_json_snapshot!( RoleGrant::transitive_roles(&role_grants, "daveCo/hidden/task").collect::>(), - @r###" + @r#" [ { "subject_role": "daveCo/hidden/", @@ -318,7 +524,7 @@ mod test { "capability": "read" } ] - "###, + "#, ); assert!(RoleGrant::is_authorized( @@ -342,7 +548,7 @@ mod test { insta::assert_json_snapshot!( UserGrant::transitive_roles(&role_grants, &user_grants, uuid::Uuid::nil()).collect::>(), - @r###" + @r#" [ { "subject_role": "", @@ -365,12 +571,12 @@ mod test { "capability": "read" } ] - "###, + "#, ); insta::assert_json_snapshot!( UserGrant::transitive_roles(&role_grants, &user_grants, uuid::Uuid::max()).collect::>(), - @r###" + @r#" [ { "subject_role": "", @@ -393,7 +599,7 @@ mod test { "capability": "read" } ] - "###, + "#, ); } @@ -403,47 +609,56 @@ mod test { { "subject_role": "acmeCo/", "object_role": "acmeCo/", - "capability": "write" + "capability": "write", + "capabilities": [] }, { "subject_role": "other_tenant/", "object_role": "acmeCo/", - "capability": "admin" + "capability": "admin", + "capabilities": [] }, { "subject_role": "acmeCo-૨/", "object_role": "acmeCo-૨/", - "capability": "write" + "capability": "write", + "capabilities": [] }, { "subject_role": "other_tenant/", "object_role": "acmeCo-૨/", - "capability": "admin" + "capability": "admin", + "capabilities": [] }, { "subject_role": "acmeCo-૨/ssss/", "object_role": "acmeCo-૨/", - "capability": "admin" + "capability": "admin", + "capabilities": [] }, { "subject_role": "acmeCo-૨/aaaa/", "object_role": "acmeCo-૨/", - "capability": "admin" + "capability": "admin", + "capabilities": [] }, { "subject_role": "acmeCo-૨/dddd/", "object_role": "acmeCo-૨/", - "capability": "admin" + "capability": "admin", + "capabilities": [] }, { "subject_role": "acmeCo-૨/", "object_role": "ops/dp/public/", - "capability": "read" + "capability": "read", + "capabilities": [] }, { "subject_role": "acmeCo/", "object_role": "ops/dp/public/", - "capability": "read" + "capability": "read", + "capabilities": [] } ])) .unwrap(); @@ -451,7 +666,7 @@ mod test { insta::assert_json_snapshot!( RoleGrant::transitive_roles(&role_grants, "acmeCo-૨/acme-prod-tables/materialize-snowflake").collect::>(), - @r###" + @r#" [ { "subject_role": "acmeCo-૨/", @@ -464,7 +679,7 @@ mod test { "capability": "read" } ] - "###, + "#, ); assert!(crate::RoleGrant::is_authorized( @@ -488,6 +703,7 @@ mod test { subject_role: models::Prefix::new(sub), object_role: models::Prefix::new(obj), capability: cap, + capabilities: vec![], }), ); @@ -504,6 +720,7 @@ mod test { user_id, object_role: models::Prefix::new(obj), capability: cap, + capabilities: vec![], }), ); @@ -550,6 +767,7 @@ mod test { subject_role: models::Prefix::new(sub), object_role: models::Prefix::new(obj), capability: cap, + capabilities: vec![], }), ); let user_grants = UserGrants::from_iter( @@ -562,12 +780,13 @@ mod test { user_id, object_role: models::Prefix::new(obj), capability: cap, + capabilities: vec![], }), ); insta::assert_json_snapshot!( UserGrant::transitive_roles(&role_grants, &user_grants, uuid::Uuid::from_bytes([1;16])).collect::>(), - @r###" + @r#" [ { "subject_role": "", @@ -585,12 +804,12 @@ mod test { "capability": "read" } ] - "###, + "#, ); insta::assert_json_snapshot!( UserGrant::transitive_roles(&role_grants, &user_grants, uuid::Uuid::from_bytes([2;16])).collect::>(), - @r###" + @r#" [ { "subject_role": "", @@ -608,7 +827,671 @@ mod test { "capability": "read" } ] - "###, + "#, + ); + } + + fn build_orthogonal_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, caps)| UserGrant { + user_id, + object_role: models::Prefix::new(obj), + capability: models::Capability::Admin, + capabilities: caps, + })); + let role_grants = + RoleGrants::from_iter(role_edges.into_iter().map(|(sub, obj, caps)| RoleGrant { + subject_role: models::Prefix::new(sub), + object_role: models::Prefix::new(obj), + capability: models::Capability::Admin, + capabilities: caps, + })); + (role_grants, user_grants, user_id) + } + + fn assert_reachable( + role_grants: &RoleGrants, + user_grants: &UserGrants, + user_id: uuid::Uuid, + expected: Vec<(&str, Vec)>, + ) { + 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(); + nodes.dedup(); + + let expected: Vec<(String, Vec)> = 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: Vec, + ) { + assert!( + UserGrant::is_authorized( + role_grants, + user_grants, + user_id, + name, + models::AnyCapability::Orthogonal(required.clone()), + ), + "expected {user_id} to have {required:?} on {name}", + ); + } + + fn assert_not_authorized( + role_grants: &RoleGrants, + user_grants: &UserGrants, + user_id: uuid::Uuid, + name: &str, + required: Vec, + ) { + assert!( + !UserGrant::is_authorized( + role_grants, + user_grants, + user_id, + name, + models::AnyCapability::Orthogonal(required.clone()), + ), + "expected {user_id} NOT to have {required:?} on {name}", + ); + } + + #[test] + fn test_reachable_nodes_delegate_propagation() { + use models::OrthogonalCapability::*; + + // Given: user has [read, billing, delegate] on acmeCo/ + // And cross-tenant role grants: + // acmeCo/ -[read, billing, delegate]-> bobCo/shared/ + // bobCo/shared/ -[read, delegate]-> carolCo/data/ + // carolCo/data/ -[read, billing]-> daveCo/sink/ (no delegate — terminal) + let (rg, ug, uid) = build_orthogonal_scenario( + vec![("acmeCo/", vec![Read, Billing, Delegate])], + vec![ + ("acmeCo/", "bobCo/shared/", vec![Read, Billing, Delegate]), + ("bobCo/shared/", "carolCo/data/", vec![Read, Delegate]), + ("carolCo/data/", "daveCo/sink/", vec![Read, Billing]), + ], + ); + + assert_reachable( + &rg, + &ug, + uid, + vec![ + ("acmeCo/", vec![Read, Billing, Delegate]), + ("bobCo/shared/", vec![Read, Billing, Delegate]), + ("carolCo/data/", vec![Read, Delegate]), + ("daveCo/sink/", vec![Read]), + ], ); } + + #[test] + fn test_reachable_nodes_no_delegate_is_terminal() { + use models::OrthogonalCapability::*; + + // Given: user has [read, delegate] on acmeCo/ + // And cross-tenant role grants: + // acmeCo/ -[read]-> bobCo/shared/ (no delegate — terminal) + // bobCo/shared/ -[read]-> carolCo/ (unreachable from user) + let (rg, ug, uid) = build_orthogonal_scenario( + vec![("acmeCo/", vec![Read, Delegate])], + vec![ + ("acmeCo/", "bobCo/shared/", vec![Read]), + ("bobCo/shared/", "carolCo/", vec![Read]), + ], + ); + + assert_reachable( + &rg, + &ug, + uid, + vec![ + ("acmeCo/", vec![Read, Delegate]), + ("bobCo/shared/", vec![Read]), + // carolCo/ is NOT reachable — bobCo/shared/ has no delegate + ], + ); + + let (rg, ug, uid) = build_orthogonal_scenario( + vec![("acmeCo/", vec![Read])], + vec![ + ("acmeCo/", "bobCo/shared/", vec![Read, Delegate]), + ("bobCo/shared/", "carolCo/", vec![Read]), + ], + ); + + assert_reachable(&rg, &ug, uid, vec![("acmeCo/", vec![Read])]); + assert_not_authorized(&rg, &ug, uid, "bobCo/shared/", vec![Read]); + assert_not_authorized(&rg, &ug, uid, "carolCo/", vec![Read]); + } + + #[test] + fn test_assume_behavior() { + use models::OrthogonalCapability::*; + + // Assume does not grant anything to the object itself + let (rg, ug, uid) = build_orthogonal_scenario( + vec![("acmeCo/", vec![Assume])], + vec![("acmeCo/", "bobCo/shared/", vec![Read, Billing, TeamAdmin])], + ); + + assert_reachable( + &rg, + &ug, + uid, + vec![ + ("acmeCo/", vec![Assume]), + ("bobCo/shared/", vec![Read, Billing, TeamAdmin]), + ], + ); + + assert_authorized( + &rg, + &ug, + uid, + "bobCo/shared/nested/", + vec![Read, Billing, TeamAdmin], + ); + assert_not_authorized(&rg, &ug, uid, "acmeCo/", vec![Read]); + + // Assume does not add capabilities to the following edge + let (rg, ug, uid) = build_orthogonal_scenario( + vec![("acmeCo/", vec![Write, Assume])], + vec![("acmeCo/", "bobCo/shared/", vec![Read, Billing, TeamAdmin])], + ); + assert_authorized(&rg, &ug, uid, "acmeCo/", vec![Write]); + assert_not_authorized(&rg, &ug, uid, "bobCo/shared/", vec![Write]); + } + + #[test] + fn test_assume_supersedes_delegate() { + use models::OrthogonalCapability::*; + + // Given: user has [read, delegate, assume] on acmeCo/ + // And cross-tenant role grants: + // acmeCo/ -[read, billing, team_admin]-> bobCo/shared/ + // + // With delegate alone, bobCo/shared/ would only receive [read] + // (the intersection of the parent's caps with the edge's caps). + // With assume, bobCo/shared/ receives the full edge: [billing, read, team_admin]. + let (rg, ug, uid) = build_orthogonal_scenario( + vec![("acmeCo/", vec![Read, Delegate, Assume])], + vec![("acmeCo/", "bobCo/shared/", vec![Billing, Read, TeamAdmin])], + ); + + assert_reachable( + &rg, + &ug, + uid, + vec![ + ("acmeCo/", vec![Assume, Delegate, Read]), + ("bobCo/shared/", vec![Read, Billing, TeamAdmin]), + ], + ); + + // Contrast: delegate alone attenuates to the intersection. + let (rg, ug, uid) = build_orthogonal_scenario( + vec![("acmeCo/", vec![Read, Delegate])], + vec![("acmeCo/", "bobCo/shared/", vec![Read, Billing, TeamAdmin])], + ); + + assert_reachable( + &rg, + &ug, + uid, + vec![ + ("acmeCo/", vec![Delegate, Read]), + ("bobCo/shared/", vec![Read]), + ], + ); + + // Assume does not add capabilities to the following edge + let (rg, ug, uid) = build_orthogonal_scenario( + vec![("acmeCo/", vec![Write, Assume])], + vec![("acmeCo/", "bobCo/shared/", vec![Read, Billing, TeamAdmin])], + ); + + assert_reachable( + &rg, + &ug, + uid, + vec![ + ("acmeCo/", vec![Write, Assume]), + ("bobCo/shared/", vec![Billing, Read, TeamAdmin]), + ], + ); + } + + #[test] + fn test_inherited_capabilities() { + use models::OrthogonalCapability::*; + + let (rg, ug, uid) = build_orthogonal_scenario( + vec![ + ("acmeCo/", vec![Read]), + ("acmeCo/interns/", vec![Write, Delegate]), + ], + vec![("acmeCo/", "betaCo/shareable/", vec![Read, Write])], + ); + + assert_reachable( + &rg, + &ug, + uid, + vec![ + ("acmeCo/", vec![Read]), + ("acmeCo/interns/", vec![Write, Delegate]), + ("betaCo/shareable/", vec![Write]), + ], + ); + } + + #[test] + fn test_descendent_capabilities() { + use models::OrthogonalCapability::*; + + let (rg, ug, uid) = build_orthogonal_scenario( + vec![ + ("acmeCo/", vec![Read]), + ("acmeCo/interns/", vec![Write, Delegate]), + ], + vec![( + "acmeCo/interns/betaCo/", + "betaCo/shareable/", + vec![Read, Write], + )], + ); + + assert_reachable( + &rg, + &ug, + uid, + vec![ + ("acmeCo/", vec![Read]), + ("acmeCo/interns/", vec![Write, Delegate]), + ("betaCo/shareable/", vec![Write]), + ], + ); + } + + #[test] + fn test_parent_child_capabilities() { + use models::OrthogonalCapability::*; + + let (rg, ug, uid) = build_orthogonal_scenario( + vec![("acmeCo/interns/", vec![Read, Write, Delegate])], + vec![ + ("acmeCo/", "betaCo/shareable/", vec![Read]), + ("acmeCo/interns/betaCo/", "betaCo/shareable/", vec![Write]), + ], + ); + + assert_reachable( + &rg, + &ug, + uid, + vec![ + ("acmeCo/interns/", vec![Read, Write, Delegate]), + ("betaCo/shareable/", vec![Read]), + ("betaCo/shareable/", vec![Write]), + ], + ); + + assert_authorized(&rg, &ug, uid, "betaCo/shareable/", vec![Read, Write]); + assert_not_authorized(&rg, &ug, uid, "betaCo/shareable/", vec![Delegate]); + } + + #[test] + fn test_multi_path() { + use models::OrthogonalCapability::*; + + let (rg, ug, uid) = build_orthogonal_scenario( + vec![ + ("acmeCo/", vec![Read, Delegate]), + ("betaCo/", vec![Write, Delegate]), + ], + vec![ + ("acmeCo/", "charlieCo/shareable/", vec![Read]), + ("betaCo/", "charlieCo/", vec![Write]), + ], + ); + + assert_reachable( + &rg, + &ug, + uid, + vec![ + ("acmeCo/", vec![Read, Delegate]), + ("betaCo/", vec![Write, Delegate]), + ("charlieCo/", vec![Write]), + ("charlieCo/shareable/", vec![Read]), + ], + ); + + assert_authorized(&rg, &ug, uid, "charlieCo/shareable/", vec![Read, Write]); + assert_not_authorized(&rg, &ug, uid, "charlieCo/", vec![Read]); + } + + #[test] + fn test_orthogonal_is_authorized() { + use models::OrthogonalCapability::*; + + // Given: user has [read, delegate] on acmeCo/ + // And cross-tenant role grants: + // acmeCo/ -[read, billing, delegate]-> bobCo/shared/ + // bobCo/shared/ -[read]-> carolCo/data/ (no delegate — terminal) + let (rg, ug, uid) = build_orthogonal_scenario( + vec![("acmeCo/", vec![Read, Delegate])], + vec![ + ("acmeCo/", "bobCo/shared/", vec![Read, Billing, Delegate]), + ("bobCo/shared/", "carolCo/data/", vec![Read]), + ], + ); + + // Direct grant: user has read on acmeCo/ + assert_authorized(&rg, &ug, uid, "acmeCo/thing", vec![Read]); + // User grant doesn't include billing + assert_not_authorized(&rg, &ug, uid, "acmeCo/thing", vec![Billing]); + + // bobCo/shared/: delegatable [read] ∩ grant [read, billing, delegate] = [read] + delegate + assert_authorized(&rg, &ug, uid, "bobCo/shared/thing", vec![Read]); + assert_not_authorized(&rg, &ug, uid, "bobCo/shared/thing", vec![Billing]); + assert_not_authorized(&rg, &ug, uid, "bobCo/shared/thing", vec![Read, Billing]); + + // carolCo/data/: delegatable [read] ∩ grant [read] = [read], terminal + assert_authorized(&rg, &ug, uid, "carolCo/data/thing", vec![Read]); + + // Unknown user has nothing + let unknown = uuid::Uuid::from_bytes([9; 16]); + assert_not_authorized(&rg, &ug, unknown, "acmeCo/thing", vec![Read]); + } + + #[test] + fn test_assume_propagates_full_capability_set() { + use models::OrthogonalCapability::*; + + // User has [Read, Assume] on acmeCo/. + // Role grant acmeCo/ -> bobCo/ carries [Read, Billing, Delegate]. + // Assume means all capabilities are delegatable, so bobCo/ gets the + // full edge set [Read, Billing, Delegate] — not just the intersection + // with the user's own capabilities. + let (rg, ug, uid) = build_orthogonal_scenario( + vec![("acmeCo/", vec![Read, Assume])], + vec![("acmeCo/", "bobCo/", vec![Read, Billing, Delegate])], + ); + + assert_reachable( + &rg, + &ug, + uid, + vec![ + ("acmeCo/", vec![Read, Assume]), + ("bobCo/", vec![Read, Billing, Delegate]), + ], + ); + + assert_authorized(&rg, &ug, uid, "bobCo/thing", vec![Read]); + assert_authorized(&rg, &ug, uid, "bobCo/thing", vec![Billing]); + assert_authorized(&rg, &ug, uid, "bobCo/thing", vec![Read, Billing]); + } + + #[test] + fn test_assume_vs_delegate_capability_filtering() { + use models::OrthogonalCapability::*; + + // With regular Delegate, only the user's own capabilities pass through. + // User has [Read, Delegate] — Billing is NOT delegatable. + let (rg, ug, uid) = build_orthogonal_scenario( + vec![("acmeCo/", vec![Read, Delegate])], + vec![("acmeCo/", "bobCo/", vec![Read, Billing, Delegate])], + ); + + assert_reachable( + &rg, + &ug, + uid, + vec![ + ("acmeCo/", vec![Read, Delegate]), + ("bobCo/", vec![Read, Delegate]), + ], + ); + assert_not_authorized(&rg, &ug, uid, "bobCo/thing", vec![Billing]); + + // Same topology but with Assume — Billing passes through. + let (rg, ug, uid) = build_orthogonal_scenario( + vec![("acmeCo/", vec![Read, Assume])], + vec![("acmeCo/", "bobCo/", vec![Read, Billing, Delegate])], + ); + + assert_authorized(&rg, &ug, uid, "bobCo/thing", vec![Billing]); + } + + #[test] + fn test_assume_chains_through_edges() { + use models::OrthogonalCapability::*; + + // Assume on the user grant opens the first hop. + // The edge to bobCo/ carries Assume, so bobCo/ also propagates everything. + // The edge to carolCo/ carries only [Read, Billing] (no delegate) — terminal. + let (rg, ug, uid) = build_orthogonal_scenario( + vec![("acmeCo/", vec![Read, Assume])], + vec![ + ("acmeCo/", "bobCo/", vec![Read, Billing, Assume]), + ("bobCo/", "carolCo/", vec![Read, Billing]), + ], + ); + + assert_reachable( + &rg, + &ug, + uid, + vec![ + ("acmeCo/", vec![Read, Assume]), + ("bobCo/", vec![Read, Billing, Assume]), + ("carolCo/", vec![Read, Billing]), + ], + ); + + assert_authorized(&rg, &ug, uid, "carolCo/thing", vec![Billing]); + } + + #[test] + fn test_assume_does_not_chain_without_edge_delegate() { + use models::OrthogonalCapability::*; + + // Assume on user grant, but the edge to bobCo/ only carries + // [Read, Delegate] (not Assume). bobCo/ gets [Read, Delegate] and + // can continue traversal, but only propagates its own caps (Read, Delegate). + // The edge to carolCo/ carries [Read, Billing] — bobCo/ can only + // delegate [Read, Delegate], so carolCo/ gets [Read] (Billing filtered out). + let (rg, ug, uid) = build_orthogonal_scenario( + vec![("acmeCo/", vec![Read, Assume])], + vec![ + ("acmeCo/", "bobCo/", vec![Read, Delegate]), + ("bobCo/", "carolCo/", vec![Read, Billing]), + ], + ); + + assert_reachable( + &rg, + &ug, + uid, + vec![ + ("acmeCo/", vec![Read, Assume]), + ("bobCo/", vec![Read, Delegate]), + ("carolCo/", vec![Read]), + ], + ); + + assert_not_authorized(&rg, &ug, uid, "carolCo/thing", vec![Billing]); + } + + fn build_role_scenario( + role_edges: Vec<(&str, &str, Vec)>, + ) -> RoleGrants { + RoleGrants::from_iter(role_edges.into_iter().map(|(sub, obj, caps)| RoleGrant { + subject_role: models::Prefix::new(sub), + object_role: models::Prefix::new(obj), + capability: models::Capability::Admin, + capabilities: caps, + })) + } + + fn assert_role_reachable( + role_grants: &RoleGrants, + role_or_name: &str, + expected: Vec<(&str, Vec)>, + ) { + let mut nodes: Vec<_> = RoleGrant::reachable_nodes(role_grants, role_or_name) + .map(|n| (n.object_role.to_string(), n.capabilities)) + .collect(); + nodes.sort(); + nodes.dedup(); + + let expected: Vec<(String, Vec)> = 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: Vec, + ) { + assert!( + RoleGrant::is_authorized( + role_grants, + subject, + object, + models::AnyCapability::Orthogonal(required.clone()), + ), + "expected {subject} to have {required:?} on {object}", + ); + } + + fn assert_role_not_authorized( + role_grants: &RoleGrants, + subject: &str, + object: &str, + required: Vec, + ) { + assert!( + !RoleGrant::is_authorized( + role_grants, + subject, + object, + models::AnyCapability::Orthogonal(required.clone()), + ), + "expected {subject} NOT to have {required:?} on {object}", + ); + } + + #[test] + fn test_role_reachable_nodes_delegate_propagation() { + use models::OrthogonalCapability::*; + + let rg = build_role_scenario(vec![ + ("acmeCo/", "bobCo/shared/", vec![Read, Billing, Delegate]), + ("bobCo/shared/", "carolCo/data/", vec![Read, Delegate]), + ("carolCo/data/", "daveCo/sink/", vec![Read, Billing]), + ]); + + assert_role_reachable( + &rg, + "acmeCo/", + vec![ + ("bobCo/shared/", vec![Read, Billing, Delegate]), + ("carolCo/data/", vec![Read, Delegate]), + ("daveCo/sink/", vec![Read]), + ], + ); + } + + #[test] + fn test_role_reachable_nodes_no_delegate_is_terminal() { + use models::OrthogonalCapability::*; + + let rg = build_role_scenario(vec![ + ("acmeCo/", "bobCo/shared/", vec![Read]), + ("bobCo/shared/", "carolCo/", vec![Read]), + ]); + + assert_role_reachable(&rg, "acmeCo/", vec![("bobCo/shared/", vec![Read])]); + } + + #[test] + fn test_role_assume_propagates_all_capabilities() { + use models::OrthogonalCapability::*; + + // Assume on the first edge opens up the full capability set, + // so the second edge's Billing passes through even though the + // first edge doesn't carry Billing. + let rg = build_role_scenario(vec![ + ("acmeCo/", "bobCo/", vec![Read, Assume]), + ("bobCo/", "carolCo/", vec![Read, Billing, Delegate]), + ]); + + assert_role_reachable( + &rg, + "acmeCo/", + vec![ + ("bobCo/", vec![Read, Assume]), + ("carolCo/", vec![Read, Billing, Delegate]), + ], + ); + + assert_role_authorized(&rg, "acmeCo/", "carolCo/thing", vec![Read, Billing]); + } + + #[test] + fn test_role_is_authorized_orthogonal() { + use models::OrthogonalCapability::*; + + let rg = build_role_scenario(vec![ + ("acmeCo/", "bobCo/shared/", vec![Read, Billing, Delegate]), + ("bobCo/shared/", "carolCo/data/", vec![Read]), + ]); + + assert_role_authorized(&rg, "acmeCo/", "bobCo/shared/thing", vec![Read]); + assert_role_authorized(&rg, "acmeCo/", "bobCo/shared/thing", vec![Billing]); + assert_role_authorized(&rg, "acmeCo/", "bobCo/shared/thing", vec![Read, Billing]); + + // carolCo/data/ is reachable but only with Read (Billing filtered by delegatable) + assert_role_authorized(&rg, "acmeCo/", "carolCo/data/thing", vec![Read]); + assert_role_not_authorized(&rg, "acmeCo/", "carolCo/data/thing", vec![Billing]); + + // Unreachable prefix + assert_role_not_authorized(&rg, "acmeCo/", "unknown/thing", vec![Read]); + } + + #[test] + fn test_empty_orthogonal_capabilities_returns_false() { + let (rg, ug, uid) = build_orthogonal_scenario( + vec![("acmeCo/", vec![models::OrthogonalCapability::Read])], + vec![], + ); + + assert_not_authorized(&rg, &ug, uid, "acmeCo/", vec![]); + } } diff --git a/crates/tables/src/lib.rs b/crates/tables/src/lib.rs index afdfed013c4..67f74318206 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 capabilities: 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 capabilities: Vec, } table DraftCaptures (row #[derive(Clone)] DraftCapture, sql "draft_captures") { @@ -412,6 +414,12 @@ pub struct GrantRef<'a> { pub capability: models::Capability, } +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct NodeRef<'a> { + pub object_role: &'a str, + pub capabilities: Vec, +} + /// 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. @@ -479,6 +487,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..3b4155423bd --- /dev/null +++ b/supabase/migrations/20260511120000_orthogonal_capabilities.sql @@ -0,0 +1,19 @@ +begin; + +create type orthogonal_capability as enum ( + 'read', + 'write', + 'admin', + 'billing', + 'team_admin', + 'delegate', + 'assume' +); + +alter table user_grants + add column capabilities orthogonal_capability[] not null default '{}'; + +alter table role_grants + add column capabilities orthogonal_capability[] not null default '{}'; + +commit; From 47d36dbe5330d3098dbb7987c154c4a1e2fd2ef1 Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Mon, 11 May 2026 16:11:44 -0400 Subject: [PATCH 2/3] omit orthogonal capabilities from postgrest access --- ...20260511120000_orthogonal_capabilities.sql | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/supabase/migrations/20260511120000_orthogonal_capabilities.sql b/supabase/migrations/20260511120000_orthogonal_capabilities.sql index 3b4155423bd..a24b84027dd 100644 --- a/supabase/migrations/20260511120000_orthogonal_capabilities.sql +++ b/supabase/migrations/20260511120000_orthogonal_capabilities.sql @@ -16,4 +16,32 @@ alter table user_grants alter table role_grants add column capabilities orthogonal_capability[] not null default '{}'; +-- Revoke broad table-level grants and re-add column-level grants that +-- exclude `capabilities`. 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; From d37f1c97c939833dbb45ac5e788c779322b0a9e2 Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Wed, 13 May 2026 00:28:19 -0400 Subject: [PATCH 3/3] fixing tests --- crates/tables/src/behaviors.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/tables/src/behaviors.rs b/crates/tables/src/behaviors.rs index 3b818ad12d2..c0ac26d0852 100644 --- a/crates/tables/src/behaviors.rs +++ b/crates/tables/src/behaviors.rs @@ -867,7 +867,10 @@ mod test { let expected: Vec<(String, Vec)> = expected .into_iter() - .map(|(prefix, caps)| (prefix.to_string(), caps)) + .map(|(prefix, mut caps)| { + caps.sort(); + (prefix.to_string(), caps) + }) .collect(); assert_eq!(nodes, expected); @@ -1367,7 +1370,10 @@ mod test { let expected: Vec<(String, Vec)> = expected .into_iter() - .map(|(prefix, caps)| (prefix.to_string(), caps)) + .map(|(prefix, mut caps)| { + caps.sort(); + (prefix.to_string(), caps) + }) .collect(); assert_eq!(nodes, expected); @@ -1486,7 +1492,8 @@ mod test { } #[test] - fn test_empty_orthogonal_capabilities_returns_false() { + #[should_panic(expected = "is_authorized called with empty orthogonal capabilities")] + fn test_empty_orthogonal_capabilities_panics_in_debug() { let (rg, ug, uid) = build_orthogonal_scenario( vec![("acmeCo/", vec![models::OrthogonalCapability::Read])], vec![],