diff --git a/crates/agent/src/discovers.rs b/crates/agent/src/discovers.rs index ca903649de4..c5cb1c51ce5 100644 --- a/crates/agent/src/discovers.rs +++ b/crates/agent/src/discovers.rs @@ -177,7 +177,7 @@ impl DiscoverExecutor { if !tables::UserGrant::is_authorized( &snapshot.role_grants, &snapshot.user_grants, - row.user_id, + tables::Principal::unscoped(row.user_id), &row.capture_name, models::authz::Capability::SpecEdit, ) { @@ -206,7 +206,7 @@ impl DiscoverExecutor { let Some(data_plane) = tables::UserGrant::is_authorized( &snapshot.role_grants, &snapshot.user_grants, - row.user_id, + tables::Principal::unscoped(row.user_id), &row.data_plane_name, models::Capability::Read, ) @@ -313,8 +313,10 @@ async fn prepare_discover<'a>( // conveying SpecEdit convey CatalogRead too. Under a SpecEdit-only // grant the discover proceeds with the live capture treated as absent. let name = &[capture_name.to_string()]; + // Discovers authorize against the user recorded on the job, which is not + // tied to the request that enqueued it, so there is no scope prefix here. let live = live_specs::get_live_specs_filtered( - user_id, + tables::Principal::unscoped(user_id), name, models::authz::Capability::CatalogRead, snapshot, diff --git a/crates/agent/src/integration_tests/harness.rs b/crates/agent/src/integration_tests/harness.rs index 8d346ea39be..51bfc7bd8c4 100644 --- a/crates/agent/src/integration_tests/harness.rs +++ b/crates/agent/src/integration_tests/harness.rs @@ -1696,6 +1696,7 @@ impl TestHarness { pg_pool: self.pool.clone(), refresh: app.snapshot.token(), retry_after: tokens::DateTime::UNIX_EPOCH, + scope_prefix: None, started: tokens::now(), locale: control_plane_api::Locale::EnUS, }; diff --git a/crates/control-plane-api/README.md b/crates/control-plane-api/README.md index 39130cb45e2..47daf655652 100644 --- a/crates/control-plane-api/README.md +++ b/crates/control-plane-api/README.md @@ -49,3 +49,33 @@ Tests use `insta` for snapshot testing. To automatically accept updated snapshot ```bash INSTA_UPDATE=always cargo test -p control-plane-api -- --test-threads=1 ``` + +## Scoping a request to one branch of the grant graph + +Requests may narrow their own authority with the `X-Estuary-Scope-Prefix` +header, naming a catalog prefix: + +``` +X-Estuary-Scope-Prefix: acmeCo/ +``` + +Authorization then considers only prefixes reachable both from the user's +grants and from that prefix, including prefixes it reaches through +`role_grants`. So a user who admins `acmeCo/` and `betaCo/`, where `acmeCo/` +holds a role grant to `charlieCo/`, sees `acmeCo/` and `charlieCo/` under this +header and does not see `betaCo/`. + +Because the result is an intersection with the user's own grants, the header +can only remove authority. A prefix the user cannot reach yields nothing +rather than access to it. That is why a client may set the header freely — the +dashboard uses it to let a user pick which tenant they are working in, and to +switch without re-authenticating. + +Omit the header to request the user's full authority. An empty or malformed +value is rejected with `invalid_argument` rather than silently matching +everything or nothing. + +Handlers reach this through `Envelope::principal`, which pairs the +authenticated user with the scope. It is the only input the +`tables::UserGrant` authorization functions accept, so a handler cannot honor +the token while overlooking the scope. diff --git a/crates/control-plane-api/src/discovers/mod.rs b/crates/control-plane-api/src/discovers/mod.rs index 4f50abd0957..92d231db5fd 100644 --- a/crates/control-plane-api/src/discovers/mod.rs +++ b/crates/control-plane-api/src/discovers/mod.rs @@ -316,8 +316,11 @@ impl DiscoverHandler { .collect::>(); let live = if filter_user_authz { + // Discovers authorize against the user recorded on the job, which + // is not tied to the request that enqueued it, so there is no + // scope prefix to narrow by here. crate::live_specs::get_live_specs_filtered( - user_id, + tables::Principal::unscoped(user_id), &collection_names, models::authz::Capability::CatalogRead, snapshot, diff --git a/crates/control-plane-api/src/envelope.rs b/crates/control-plane-api/src/envelope.rs index 3ca7d720771..8bfd372071b 100644 --- a/crates/control-plane-api/src/envelope.rs +++ b/crates/control-plane-api/src/envelope.rs @@ -48,6 +48,15 @@ pub struct Envelope { pub original_uri: axum::http::Uri, /// The verified control-plane claims, if any. pub maybe_claims: MaybeControlClaims, + /// Catalog prefix which narrows this request's authority, taken from the + /// `X-Estuary-Scope-Prefix` header. + /// + /// Authorization considers only prefixes reachable both from the user's + /// grants and from this prefix, so the header can only ever remove + /// authority. That makes it safe for a client to set freely, which is how + /// the web UI lets a user choose which tenant they are working in and + /// switch between them without re-authenticating. + pub scope_prefix: Option, /// If provided, the `retryAfter` query parameter attached to the request. /// This parameter is used to detect clients that don't honor the Retry-After /// header sent with 307 Temporary Redirect responses. @@ -70,12 +79,73 @@ pub struct Envelope { pub locale: Locale, } +/// Header naming the catalog prefix which narrows a request's authority. +pub const SCOPE_PREFIX_HEADER: &str = "x-estuary-scope-prefix"; + +/// Reads and validates the scope prefix header. +/// +/// A malformed value is rejected rather than passed through, because an +/// unparseable prefix would match no grant and present as an authorization +/// failure that gives the client no way to tell a typo from a missing grant. +/// An empty value is rejected for the same reason in the other direction: it +/// would match every prefix and silently do nothing. +fn parse_scope_prefix( + headers: &axum::http::HeaderMap, +) -> tonic::Result, tonic::Status> { + use validator::Validate; + + let Some(value) = headers.get(SCOPE_PREFIX_HEADER) else { + return Ok(None); + }; + let value = value.to_str().map_err(|_err| { + tonic::Status::invalid_argument(format!("{SCOPE_PREFIX_HEADER} header is not valid UTF-8")) + })?; + + if value.is_empty() { + return Err(tonic::Status::invalid_argument(format!( + "{SCOPE_PREFIX_HEADER} header is empty; omit it to request unscoped authority" + ))); + } + + let prefix = models::Prefix::new(value); + if let Err(err) = prefix.validate() { + return Err(tonic::Status::invalid_argument(format!( + "{SCOPE_PREFIX_HEADER} header '{value}' is not a valid catalog prefix: {err}" + ))); + } + Ok(Some(prefix)) +} + impl Envelope { /// Returns verified ControlClaims or an unauthenticated error. pub fn claims(&self) -> tonic::Result<&crate::ControlClaims> { self.maybe_claims.result() } + /// Returns the principal on whose behalf this request is authorized: + /// the authenticated user, narrowed by `scope_prefix` if it was given. + /// + /// Every user authorization check goes through a `Principal`, so a handler + /// cannot honor the token while overlooking the scope. + pub fn principal(&self) -> tonic::Result> { + let claims = self.claims()?; + + Ok(tables::Principal { + user_id: claims.sub, + scope: self.scope_prefix.as_ref().map(|p| p.as_str()), + }) + } + + /// The authenticated user's email, for inclusion in error messages. + /// Falls back to "user" when the token carries no email. + pub fn user_email(&self) -> &str { + self.maybe_claims + .result() + .ok() + .and_then(|claims| claims.email.as_deref()) + .unwrap_or("user") + } + /// Returns the request's associated Snapshot. pub fn snapshot(&self) -> &crate::Snapshot { self.refresh.result().expect("Snapshot refresh never fails") @@ -263,6 +333,8 @@ impl axum::extract::FromRequestParts> for Envelope { None => MaybeControlClaims::with_unauthenticated(), }; + let scope_prefix = parse_scope_prefix(&parts.headers)?; + // Placeholder. In the future, we should determine this value from // the request headers (e.g. Accept-Language and/or the auth token). // For now, we hard code it, because we don't have translations for @@ -271,6 +343,7 @@ impl axum::extract::FromRequestParts> for Envelope { Ok(Envelope { maybe_claims, + scope_prefix, retry_after: retry_after.unwrap_or(tokens::DateTime::UNIX_EPOCH), refresh: state.snapshot.token(), started: started.unwrap_or_else(|| tokens::now()), @@ -285,3 +358,51 @@ impl axum::extract::FromRequestParts> for Envelope { // Empty impl allows aide to generate OpenAPI specs for handlers using this extractor. // The extractor is an internal detail and doesn't appear in the API documentation. impl aide::operation::OperationInput for Envelope {} + +#[cfg(test)] +mod test { + use super::{SCOPE_PREFIX_HEADER, parse_scope_prefix}; + + fn parse(value: &str) -> tonic::Result, tonic::Status> { + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::HeaderName::from_static(SCOPE_PREFIX_HEADER), + axum::http::HeaderValue::from_str(value).expect("test header value"), + ); + parse_scope_prefix(&headers).map(|p| p.map(|p| p.to_string())) + } + + #[test] + fn test_absent_header_is_unscoped() { + let headers = axum::http::HeaderMap::new(); + assert_eq!(parse_scope_prefix(&headers).unwrap(), None); + } + + #[test] + fn test_valid_prefixes() { + for case in ["acmeCo/", "acmeCo/team/", "acmeCo/one/two/th.ree/"] { + assert_eq!(parse(case).unwrap().as_deref(), Some(case)); + } + } + + #[test] + fn test_rejected_values() { + // Empty would scope to everything, which the caller should express by + // omitting the header. + assert_eq!( + parse("").unwrap_err().code(), + tonic::Code::InvalidArgument, + "empty value must be rejected", + ); + + // A catalog *name* is not a prefix: prefixes must end in '/'. Passing + // one is a client error rather than a scope matching nothing. + for case in ["acmeCo", "/acmeCo/", "acmeCo//team/", "acmeCo/sp ace/", "/"] { + assert_eq!( + parse(case).unwrap_err().code(), + tonic::Code::InvalidArgument, + "'{case}' must be rejected", + ); + } + } +} diff --git a/crates/control-plane-api/src/live_specs/mod.rs b/crates/control-plane-api/src/live_specs/mod.rs index 518eafa569e..30fd16b593c 100644 --- a/crates/control-plane-api/src/live_specs/mod.rs +++ b/crates/control-plane-api/src/live_specs/mod.rs @@ -10,12 +10,12 @@ pub use db::{ fetch_live_spec_names_by_prefix, fetch_live_specs, hard_delete_live_spec, }; -/// Partitions the requested `names` by whether the user holds `capability` +/// Partitions the requested `names` by whether `principal` holds `capability` /// to them, evaluated against the authorization `snapshot`. Returns /// `(authorized, denied)` as references into `names`, each sorted and /// deduplicated. fn partition_by_authorization<'n>( - user_id: Uuid, + principal: tables::Principal<'_>, names: &'n [String], capability: models::authz::CapabilitySet, snapshot: &crate::Snapshot, @@ -25,7 +25,7 @@ fn partition_by_authorization<'n>( tables::UserGrant::is_authorized( &snapshot.role_grants, &snapshot.user_grants, - user_id, + principal, name, capability, ) @@ -47,19 +47,20 @@ fn partition_by_authorization<'n>( /// The `snapshot` is trusted as-is: a grant committed after it was taken is /// invisible until the watch's own background refresh cadence picks it up. pub async fn get_live_specs_filtered( - user_id: Uuid, + principal: tables::Principal<'_>, names: &[String], capability: impl Into, snapshot: &crate::Snapshot, db: &sqlx::PgPool, ) -> anyhow::Result { let (authorized, denied) = - partition_by_authorization(user_id, names, capability.into(), snapshot); + partition_by_authorization(principal, names, capability.into(), snapshot); if !denied.is_empty() { + let user_id = principal.user_id; tracing::debug!(?denied, %user_id, "filtered unauthorized specs from fetch"); } - get_live_specs_unfiltered(user_id, &authorized, db).await + get_live_specs_unfiltered(principal.user_id, &authorized, db).await } /// Fetches live specs as a `tables::LiveCatalog` without any authorization @@ -186,7 +187,12 @@ mod test { "acmeCo/shared/collection".to_string(), "bobCo/tires/capture".to_string(), ]; - let (authorized, denied) = partition_by_authorization(bob, &names, capability, &snapshot); + let (authorized, denied) = partition_by_authorization( + tables::Principal::unscoped(bob), + &names, + capability, + &snapshot, + ); assert_eq!(authorized, names); assert!(denied.is_empty()); @@ -199,7 +205,12 @@ mod test { "acmeCo/private/collection".to_string(), "aliceCo/anvils/pings".to_string(), ]; - let (authorized, denied) = partition_by_authorization(bob, &names, capability, &snapshot); + let (authorized, denied) = partition_by_authorization( + tables::Principal::unscoped(bob), + &names, + capability, + &snapshot, + ); assert_eq!(authorized, vec!["bobCo/tires/capture"]); assert_eq!( denied, diff --git a/crates/control-plane-api/src/server/authorize_user_collection.rs b/crates/control-plane-api/src/server/authorize_user_collection.rs index a02267bab29..b2ee9ad50c9 100644 --- a/crates/control-plane-api/src/server/authorize_user_collection.rs +++ b/crates/control-plane-api/src/server/authorize_user_collection.rs @@ -18,8 +18,13 @@ pub async fn authorize_user_collection( tokens::DateTime::from_timestamp_secs(1 + started_unix as i64).unwrap_or_default(); } - let policy_result = - evaluate_authorization(env.snapshot(), env.claims()?, &collection, capability); + let policy_result = evaluate_authorization( + env.snapshot(), + env.principal()?, + env.user_email(), + &collection, + capability, + ); // Legacy: if `started_unix` was set then use a custom 200 response for client-side retries. let (expiry, (encoding_key, mut claims, broker_address, journal_name_prefix)) = @@ -49,7 +54,8 @@ pub async fn authorize_user_collection( fn evaluate_authorization( snapshot: &crate::Snapshot, - claims: &crate::ControlClaims, + principal: tables::Principal<'_>, + user_email: &str, collection_name: &models::Collection, capability: models::Capability, ) -> crate::AuthZResult<( @@ -58,17 +64,10 @@ fn evaluate_authorization( String, String, )> { - let models::authorizations::ControlClaims { - sub: user_id, - email: user_email, - .. - } = claims; - let user_email = user_email.as_ref().map(String::as_str).unwrap_or("user"); - if !tables::UserGrant::is_authorized( &snapshot.role_grants, &snapshot.user_grants, - *user_id, + principal, collection_name, capability, ) { @@ -82,7 +81,7 @@ fn evaluate_authorization( let has_support_access = tables::UserGrant::is_authorized( &snapshot.role_grants, &snapshot.user_grants, - *user_id, + principal, "estuary_support/", models::Capability::Admin, ); @@ -119,7 +118,7 @@ fn evaluate_authorization( exp: 0, // Filled later. iat: 0, // Filled later. iss: data_plane.data_plane_fqdn.clone(), - sub: user_id.to_string(), + sub: principal.user_id.to_string(), sel: proto_gazette::broker::LabelSelector { include: Some(labels::build_set([ ("name:prefix", collection.journal_template_name.as_str()), @@ -373,7 +372,13 @@ mod tests { email, }; - match evaluate_authorization(&snapshot, &claims, &collection, capability) { + match evaluate_authorization( + &snapshot, + tables::Principal::unscoped(claims.sub), + "test@example.com", + &collection, + capability, + ) { Ok((cordon_at, (_key, mut data_claims, broker_address, journal_name_prefix))) => { // Zero out timestamps for stable snapshots. data_claims.iat = 0; diff --git a/crates/control-plane-api/src/server/authorize_user_prefix.rs b/crates/control-plane-api/src/server/authorize_user_prefix.rs index dec72c990a2..b14e2d9775a 100644 --- a/crates/control-plane-api/src/server/authorize_user_prefix.rs +++ b/crates/control-plane-api/src/server/authorize_user_prefix.rs @@ -21,7 +21,8 @@ pub async fn authorize_user_prefix( let policy_result = evaluate_authorization( env.snapshot(), - env.claims()?, + env.principal()?, + env.user_email(), &prefix, &data_plane, capability, @@ -61,7 +62,8 @@ pub async fn authorize_user_prefix( fn evaluate_authorization( snapshot: &crate::Snapshot, - claims: &crate::ControlClaims, + principal: tables::Principal<'_>, + user_email: &str, prefix: &models::Prefix, data_plane_name: &models::Name, capability: models::Capability, @@ -75,17 +77,10 @@ fn evaluate_authorization( String, // Reactor address. ), )> { - let models::authorizations::ControlClaims { - sub: user_id, - email: user_email, - .. - } = claims; - let user_email = user_email.as_ref().map(String::as_str).unwrap_or("user"); - if !tables::UserGrant::is_authorized( &snapshot.role_grants, &snapshot.user_grants, - *user_id, + principal, prefix, capability, ) { @@ -99,7 +94,7 @@ fn evaluate_authorization( let has_support_access = tables::UserGrant::is_authorized( &snapshot.role_grants, &snapshot.user_grants, - *user_id, + principal, "estuary_support/", models::Capability::Admin, ); @@ -114,7 +109,7 @@ fn evaluate_authorization( if !tables::UserGrant::is_authorized( &snapshot.role_grants, &snapshot.user_grants, - *user_id, + principal, data_plane_name, models::Capability::Read, ) { @@ -141,7 +136,7 @@ fn evaluate_authorization( exp: 0, // Filled later. iat: 0, // Filled later. iss: data_plane.data_plane_fqdn.clone(), - sub: user_id.to_string(), + sub: principal.user_id.to_string(), sel: proto_gazette::broker::LabelSelector { include: Some(labels::build_set([ ("name:prefix", prefix.as_str()), @@ -158,7 +153,7 @@ fn evaluate_authorization( exp: 0, // Filled later. iat: 0, // Filled later. iss: data_plane.data_plane_fqdn.clone(), - sub: user_id.to_string(), + sub: principal.user_id.to_string(), sel: proto_gazette::broker::LabelSelector { include: Some(labels::build_set([ ("id:prefix", format!("capture/{prefix}").as_str()), @@ -553,7 +548,14 @@ mod tests { email, }; - match evaluate_authorization(&snapshot, &claims, &prefix, &data_plane, capability) { + match evaluate_authorization( + &snapshot, + tables::Principal::unscoped(claims.sub), + "test@example.com", + &prefix, + &data_plane, + capability, + ) { Ok(( _cordon_at, (_key, mut broker_claims, broker_address, mut reactor_claims, reactor_address), diff --git a/crates/control-plane-api/src/server/authorize_user_task.rs b/crates/control-plane-api/src/server/authorize_user_task.rs index 493c6060ed6..0fe0cc781e1 100644 --- a/crates/control-plane-api/src/server/authorize_user_task.rs +++ b/crates/control-plane-api/src/server/authorize_user_task.rs @@ -18,7 +18,13 @@ pub async fn authorize_user_task( tokens::DateTime::from_timestamp_secs(1 + started_unix as i64).unwrap_or_default(); } - let policy_result = evaluate_authorization(env.snapshot(), env.claims()?, &task, capability); + let policy_result = evaluate_authorization( + env.snapshot(), + env.principal()?, + env.user_email(), + &task, + capability, + ); // Legacy: if `started_unix` was set then use a custom 200 response for client-side retries. let ( @@ -66,7 +72,8 @@ pub async fn authorize_user_task( fn evaluate_authorization( snapshot: &crate::Snapshot, - claims: &crate::ControlClaims, + principal: tables::Principal<'_>, + user_email: &str, task_name: &models::Name, capability: models::Capability, ) -> tonic::Result<( @@ -82,17 +89,10 @@ fn evaluate_authorization( String, // Shard ID prefix. ), )> { - let models::authorizations::ControlClaims { - sub: user_id, - email: user_email, - .. - } = claims; - let user_email = user_email.as_ref().map(String::as_str).unwrap_or("user"); - if !tables::UserGrant::is_authorized( &snapshot.role_grants, &snapshot.user_grants, - *user_id, + principal, task_name, capability, ) { @@ -106,7 +106,7 @@ fn evaluate_authorization( let has_support_access = tables::UserGrant::is_authorized( &snapshot.role_grants, &snapshot.user_grants, - *user_id, + principal, "estuary_support/", models::Capability::Admin, ); @@ -157,7 +157,7 @@ fn evaluate_authorization( exp: 0, // Filled later. iat: 0, // Filled later. iss: data_plane.data_plane_fqdn.clone(), - sub: user_id.to_string(), + sub: principal.user_id.to_string(), sel: proto_gazette::broker::LabelSelector { include: Some(labels::build_set([ ("name", ops_logs_journal.as_str()), @@ -172,7 +172,7 @@ fn evaluate_authorization( exp: 0, // Filled later. iat: 0, // Filled later. iss: data_plane.data_plane_fqdn.clone(), - sub: user_id.to_string(), + sub: principal.user_id.to_string(), sel: proto_gazette::broker::LabelSelector { include: Some(labels::build_set([( "id:prefix", @@ -500,7 +500,13 @@ mod tests { email, }; - match evaluate_authorization(&snapshot, &claims, &task, capability) { + match evaluate_authorization( + &snapshot, + tables::Principal::unscoped(claims.sub), + "test@example.com", + &task, + capability, + ) { Ok(( cordon_at, ( diff --git a/crates/control-plane-api/src/server/mod.rs b/crates/control-plane-api/src/server/mod.rs index e3eaef0fe71..66589807ff0 100644 --- a/crates/control-plane-api/src/server/mod.rs +++ b/crates/control-plane-api/src/server/mod.rs @@ -93,16 +93,20 @@ pub(crate) async fn wake_tenant_controller( Ok(res.rows_affected() > 0u64) } -/// Evaluate whether the user identified by `claims` is authorized to access all -/// of the enumerated `prefixes_or_names` with at least `min_capability`. +/// Evaluate whether `principal` is authorized to access all of the enumerated +/// `prefixes_or_names` with at least `min_capability`. /// Return a policy_result shape which fits Envelope::authorization_outcome. /// +/// `principal` comes from `Envelope::principal`, so a request which narrows its +/// authority with a scope prefix is evaluated against that narrower authority. +/// /// `min_capability` accepts any value that converts into a `CapabilitySet`: /// legacy `models::Capability` (mapped via `bits_for_legacy`), a single /// `models::authz::Capability` bit, or an explicit `CapabilitySet`. pub fn evaluate_names_authorization<'r, Iter, S, C>( snapshot: &Snapshot, - claims: &crate::ControlClaims, + principal: tables::Principal<'_>, + user_email: &str, min_capability: C, prefixes_or_names: Iter, ) -> AuthZResult<()> @@ -111,35 +115,32 @@ where S: AsRef + std::fmt::Display, C: Into + std::fmt::Display + Copy, { - let models::authorizations::ControlClaims { - sub: user_id, - email: user_email, - .. - } = claims; - let user_email = user_email.as_ref().map(String::as_str).unwrap_or("user"); - for prefix_or_name in prefixes_or_names.into_iter() { if !tables::UserGrant::is_authorized( &snapshot.role_grants, &snapshot.user_grants, - *user_id, + principal, prefix_or_name.as_ref(), min_capability, ) { + let scoped = match principal.scope { + Some(scope) => format!(" within scope prefix '{scope}'"), + None => String::new(), + }; 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}", + "{user_email} is not authorized to access prefix or name '{prefix_or_name}' with required capability {min_capability}{scoped}", ))); } } Ok((None, ())) } -/// Looks up the user's authorization grants for each item in +/// Looks up `principal`'s authorization grants for each item in /// `prefixes_or_names`, and calls the provided `attach` function with each /// item and its capability. The `Some` results are returned in a vec. pub fn attach_user_capabilities( snapshot: &Snapshot, - claims: &crate::ControlClaims, + principal: tables::Principal<'_>, prefixes_or_names: I, mut attach: F, ) -> Vec @@ -153,7 +154,7 @@ where let capability = tables::UserGrant::get_user_capability( &snapshot.role_grants, &snapshot.user_grants, - claims.sub, + principal, &prefix, ); attach(prefix, capability) diff --git a/crates/control-plane-api/src/server/public/graphql/alert_configs.rs b/crates/control-plane-api/src/server/public/graphql/alert_configs.rs index 463b64c591b..4029b631b47 100644 --- a/crates/control-plane-api/src/server/public/graphql/alert_configs.rs +++ b/crates/control-plane-api/src/server/public/graphql/alert_configs.rs @@ -121,14 +121,13 @@ impl AlertConfigsQuery { first: Option, ) -> async_graphql::Result { let env = ctx.data::()?; - let claims = env.claims()?; let snapshot = env.snapshot(); let (read_prefixes, prefix_starts_with, prefix_in) = super::authorized_prefixes::filtered_authorized_prefixes( &snapshot.role_grants, &snapshot.user_grants, - claims.sub, + env.principal()?, models::Capability::Read, filter.and_then(|f| f.catalog_prefix_or_name), "filter.catalogPrefixOrName", @@ -215,7 +214,6 @@ impl AlertConfigsQuery { catalog_prefix_or_name: String, ) -> async_graphql::Result { let env = ctx.data::()?; - let claims = env.claims()?; validate_prefix_or_name(&catalog_prefix_or_name)?; @@ -225,7 +223,8 @@ impl AlertConfigsQuery { // AlertConfigEntry and `effectiveAlertConfig` on liveSpec. let policy_result = crate::server::evaluate_names_authorization( env.snapshot(), - claims, + env.principal()?, + env.user_email(), models::authz::Capability::CatalogRead, [catalog_prefix_or_name.as_str()], ); @@ -269,7 +268,8 @@ impl AlertConfigsMutation { let gov = governing_prefix(&catalog_prefix_or_name)?; let policy_result = crate::server::evaluate_names_authorization( env.snapshot(), - claims, + env.principal()?, + env.user_email(), models::Capability::Admin, [gov.as_str()], ); diff --git a/crates/control-plane-api/src/server/public/graphql/alerts.rs b/crates/control-plane-api/src/server/public/graphql/alerts.rs index 8c3a784e8fd..887ef5dfb34 100644 --- a/crates/control-plane-api/src/server/public/graphql/alerts.rs +++ b/crates/control-plane-api/src/server/public/graphql/alerts.rs @@ -141,7 +141,8 @@ async fn fetch_alert_history_by_prefix( // Verify user authorization to read alerts for the given prefix. let policy_result = crate::server::evaluate_names_authorization( env.snapshot(), - env.claims()?, + env.principal()?, + env.user_email(), models::Capability::Read, [&by.prefix], ); 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 d3186d18170..a5b0120e8a8 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 @@ -11,7 +11,7 @@ pub(super) fn authorized_prefixes( role_grants: &tables::RoleGrants, user_grants: &tables::UserGrants, - user_id: uuid::Uuid, + principal: tables::Principal<'_>, min_capability: impl Into, prefix_filter: Option<&str>, ) -> Vec { @@ -19,7 +19,7 @@ pub(super) fn authorized_prefixes( // 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) + let prefixes = tables::UserGrant::reachable_prefixes(role_grants, user_grants, principal) .into_iter() .filter(|(prefix, _)| { prefix_filter.is_none_or(|pf| prefix.starts_with(pf) || pf.starts_with(*prefix)) @@ -52,7 +52,7 @@ pub(super) fn authorized_prefixes( pub(super) fn filtered_authorized_prefixes( role_grants: &tables::RoleGrants, user_grants: &tables::UserGrants, - user_id: uuid::Uuid, + principal: tables::Principal<'_>, min_capability: impl Into, filter: Option, field: &str, @@ -64,7 +64,7 @@ pub(super) fn filtered_authorized_prefixes( let mut prefixes = authorized_prefixes( role_grants, user_grants, - user_id, + principal, min_capability, starts_with.as_deref(), ); @@ -116,13 +116,13 @@ mod tests { &[], ); - let result = authorized_prefixes(&rg, &ug, ALICE, Admin, None); + let result = authorized_prefixes(&rg, &ug, tables::Principal::unscoped(ALICE), Admin, None); assert_eq!(result, vec!["acmeCo/"]); - let result = authorized_prefixes(&rg, &ug, ALICE, Write, None); + let result = authorized_prefixes(&rg, &ug, tables::Principal::unscoped(ALICE), Write, None); assert_eq!(result, vec!["acmeCo/", "widgets/"]); - let result = authorized_prefixes(&rg, &ug, ALICE, Read, None); + let result = authorized_prefixes(&rg, &ug, tables::Principal::unscoped(ALICE), Read, None); assert_eq!(result, vec!["acmeCo/", "readonly/", "widgets/"]); } @@ -132,7 +132,13 @@ mod tests { // the filter, so "acmeCo/" is included. let (ug, rg) = make_grants(&[(ALICE, "acmeCo/", Admin)], &[]); - let result = authorized_prefixes(&rg, &ug, ALICE, Admin, Some("acmeCo/data/")); + let result = authorized_prefixes( + &rg, + &ug, + tables::Principal::unscoped(ALICE), + Admin, + Some("acmeCo/data/"), + ); assert_eq!(result, vec!["acmeCo/"]); } @@ -142,7 +148,13 @@ mod tests { // with the filter, so "acmeCo/data/" is included. let (ug, rg) = make_grants(&[(ALICE, "acmeCo/data/", Admin)], &[]); - let result = authorized_prefixes(&rg, &ug, ALICE, Admin, Some("acmeCo/")); + let result = authorized_prefixes( + &rg, + &ug, + tables::Principal::unscoped(ALICE), + Admin, + Some("acmeCo/"), + ); assert_eq!(result, vec!["acmeCo/data/"]); } @@ -150,7 +162,13 @@ mod tests { fn filter_excludes_non_overlapping() { let (ug, rg) = make_grants(&[(ALICE, "acmeCo/", Admin), (ALICE, "other/", Admin)], &[]); - let result = authorized_prefixes(&rg, &ug, ALICE, Admin, Some("acmeCo/")); + let result = authorized_prefixes( + &rg, + &ug, + tables::Principal::unscoped(ALICE), + Admin, + Some("acmeCo/"), + ); assert_eq!(result, vec!["acmeCo/"]); } @@ -158,7 +176,7 @@ mod tests { fn no_grants_returns_empty() { let (ug, rg) = make_grants(&[], &[]); - let result = authorized_prefixes(&rg, &ug, ALICE, Admin, None); + let result = authorized_prefixes(&rg, &ug, tables::Principal::unscoped(ALICE), Admin, None); assert!(result.is_empty()); } @@ -170,11 +188,11 @@ mod tests { &[("acmeCo/", "shared/", Write)], ); - let result = authorized_prefixes(&rg, &ug, ALICE, Write, None); + let result = authorized_prefixes(&rg, &ug, tables::Principal::unscoped(ALICE), Write, None); assert_eq!(result, vec!["acmeCo/", "shared/"]); // Admin threshold excludes the transitive Write grant. - let result = authorized_prefixes(&rg, &ug, ALICE, Admin, None); + let result = authorized_prefixes(&rg, &ug, tables::Principal::unscoped(ALICE), Admin, None); assert_eq!(result, vec!["acmeCo/"]); } @@ -187,7 +205,7 @@ mod tests { &[], ); - let result = authorized_prefixes(&rg, &ug, ALICE, Admin, None); + let result = authorized_prefixes(&rg, &ug, tables::Principal::unscoped(ALICE), Admin, None); assert_eq!(result, vec!["acmeCo/"]); } @@ -200,7 +218,7 @@ mod tests { &[("acmeCo/", "acmeCo/team/", Write)], ); - let result = authorized_prefixes(&rg, &ug, ALICE, Write, None); + let result = authorized_prefixes(&rg, &ug, tables::Principal::unscoped(ALICE), Write, None); assert_eq!(result, vec!["acmeCo/"]); } @@ -209,7 +227,7 @@ mod tests { let bob = uuid::Uuid::from_bytes([0x22; 16]); let (ug, rg) = make_grants(&[(ALICE, "acmeCo/", Admin)], &[]); - let result = authorized_prefixes(&rg, &ug, bob, Read, None); + let result = authorized_prefixes(&rg, &ug, tables::Principal::unscoped(bob), Read, None); assert!(result.is_empty()); } @@ -237,7 +255,8 @@ mod tests { ]); let rg = tables::RoleGrants::new(); - let reachable = tables::UserGrant::reachable_prefixes(&rg, &ug, ALICE); + let reachable = + tables::UserGrant::reachable_prefixes(&rg, &ug, tables::Principal::unscoped(ALICE)); assert_eq!( reachable["acmeCo/"].0, CapabilityBundle::Editor.capabilities() | CapabilityBundle::TeamAdmin.capabilities(), @@ -274,7 +293,8 @@ mod tests { }, ]); - let reachable = tables::UserGrant::reachable_prefixes(&rg, &ug, ALICE); + let reachable = + tables::UserGrant::reachable_prefixes(&rg, &ug, tables::Principal::unscoped(ALICE)); assert_eq!( reachable["sharedCo/"].0, CapabilityBundle::Editor.capabilities() | CapabilityBundle::TeamAdmin.capabilities(), @@ -308,11 +328,11 @@ mod tests { // 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); + let result = authorized_prefixes(&rg, &ug, tables::Principal::unscoped(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); + let result = authorized_prefixes(&rg, &ug, tables::Principal::unscoped(ALICE), Write, None); assert_eq!(result, vec!["acmeCo/"]); } @@ -320,9 +340,15 @@ mod tests { fn filtered_no_filter_returns_all_prefixes_and_no_parts() { let (ug, rg) = make_grants(&[(ALICE, "acmeCo/", Admin), (ALICE, "beta/", Admin)], &[]); - let (prefixes, starts_with, r#in) = - filtered_authorized_prefixes(&rg, &ug, ALICE, Admin, None, "filter.catalogPrefix") - .unwrap(); + let (prefixes, starts_with, r#in) = filtered_authorized_prefixes( + &rg, + &ug, + tables::Principal::unscoped(ALICE), + Admin, + None, + "filter.catalogPrefix", + ) + .unwrap(); assert_eq!(prefixes, vec!["acmeCo/", "beta/"]); assert_eq!(starts_with, None); assert_eq!(r#in, None); @@ -339,7 +365,7 @@ mod tests { let (prefixes, starts_with, r#in) = filtered_authorized_prefixes( &rg, &ug, - ALICE, + tables::Principal::unscoped(ALICE), Admin, Some(filter), "filter.catalogPrefix", @@ -364,7 +390,7 @@ mod tests { let (prefixes, starts_with, r#in) = filtered_authorized_prefixes( &rg, &ug, - ALICE, + tables::Principal::unscoped(ALICE), Admin, Some(filter), "filter.catalogPrefix", @@ -386,7 +412,7 @@ mod tests { let err = filtered_authorized_prefixes( &rg, &ug, - ALICE, + tables::Principal::unscoped(ALICE), Admin, Some(filter), "filter.catalogPrefix", diff --git a/crates/control-plane-api/src/server/public/graphql/data_planes.rs b/crates/control-plane-api/src/server/public/graphql/data_planes.rs index 2ea518a00fb..765eaca4640 100644 --- a/crates/control-plane-api/src/server/public/graphql/data_planes.rs +++ b/crates/control-plane-api/src/server/public/graphql/data_planes.rs @@ -479,7 +479,7 @@ impl DataPlanesQuery { last: Option, ) -> async_graphql::Result { let env = ctx.data::()?; - let claims = env.claims()?; + let principal = env.principal()?; let snapshot = env.snapshot(); let DataPlanesFilter { id, closed } = filter.unwrap_or_default(); @@ -504,7 +504,7 @@ impl DataPlanesQuery { tables::UserGrant::is_authorized( &snapshot.role_grants, &snapshot.user_grants, - claims.sub, + principal, &dp.data_plane_name, models::Capability::Read, ) @@ -553,7 +553,7 @@ impl DataPlanesQuery { let edges = crate::server::attach_user_capabilities( env.snapshot(), - env.claims()?, + env.principal()?, names.into_iter(), |data_plane_name, user_capability| { let dp = row_data.get(&data_plane_name)?; 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 d11643742a1..31c91ad8441 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 @@ -81,7 +81,7 @@ impl InviteLinksQuery { super::authorized_prefixes::filtered_authorized_prefixes( &snapshot.role_grants, &snapshot.user_grants, - env.claims()?.sub, + env.principal()?, models::Capability::Admin, filter.and_then(|f| f.catalog_prefix), "filter.catalogPrefix", diff --git a/crates/control-plane-api/src/server/public/graphql/live_spec_refs.rs b/crates/control-plane-api/src/server/public/graphql/live_spec_refs.rs index b594f96901f..cfb5275270c 100644 --- a/crates/control-plane-api/src/server/public/graphql/live_spec_refs.rs +++ b/crates/control-plane-api/src/server/public/graphql/live_spec_refs.rs @@ -185,7 +185,7 @@ pub async fn paginate_live_specs_refs( } let all_refs = crate::server::attach_user_capabilities( env.snapshot(), - env.claims()?, + env.principal()?, all_names, |name, maybe_capability| { if require_min_capability.is_some_and(|min_cap| maybe_capability < Some(min_cap)) { @@ -290,7 +290,8 @@ impl LiveSpecsQuery { // Fail the entire request if it passed a name or prefix that the user is unauthorized to. let policy_result = crate::server::evaluate_names_authorization( env.snapshot(), - env.claims()?, + env.principal()?, + env.user_email(), models::Capability::Read, names .iter() @@ -360,7 +361,7 @@ impl LiveSpecsQuery { // sub-prefixes, so resolve those here. let edges = crate::server::attach_user_capabilities( env.snapshot(), - env.claims()?, + env.principal()?, names, |name, user_capability| { Some(connection::Edge::new( diff --git a/crates/control-plane-api/src/server/public/graphql/live_specs.rs b/crates/control-plane-api/src/server/public/graphql/live_specs.rs index 32948a0248b..1895e0502ff 100644 --- a/crates/control-plane-api/src/server/public/graphql/live_specs.rs +++ b/crates/control-plane-api/src/server/public/graphql/live_specs.rs @@ -98,7 +98,7 @@ impl LiveSpec { }; let attached = crate::server::attach_user_capabilities( env.snapshot(), - env.claims()?, + env.principal()?, [source_capture_name.clone()], |name, user_capability| { Some(LiveSpecRef { diff --git a/crates/control-plane-api/src/server/public/graphql/mod.rs b/crates/control-plane-api/src/server/public/graphql/mod.rs index 9b03bb2db70..240bf6e4757 100644 --- a/crates/control-plane-api/src/server/public/graphql/mod.rs +++ b/crates/control-plane-api/src/server/public/graphql/mod.rs @@ -67,7 +67,7 @@ fn may_access( Ok(tables::UserGrant::is_authorized( &snapshot.role_grants, &snapshot.user_grants, - env.claims()?.sub, + env.principal()?, name, capability, )) @@ -89,7 +89,8 @@ async fn verify_authorization( ) -> async_graphql::Result<()> { let policy_result = crate::server::evaluate_names_authorization( env.snapshot(), - env.claims()?, + env.principal()?, + env.user_email(), capability, [prefix], ); 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 1b2e36dbd61..ed984a257b1 100644 --- a/crates/control-plane-api/src/server/public/graphql/prefixes.rs +++ b/crates/control-plane-api/src/server/public/graphql/prefixes.rs @@ -51,14 +51,14 @@ impl PrefixesQuery { connection::query(after, None, first, None, |after, _, first, _| async move { let snapshot = env.snapshot(); - let user_id = env.claims()?.sub; + let principal = env.principal()?; let min_bits: models::authz::CapabilitySet = by.min_capability.into(); let reachable = tables::UserGrant::reachable_prefixes( &snapshot.role_grants, &snapshot.user_grants, - user_id, + principal, ); // Cursor pagination: BTreeMap::range jumps directly to the // first key strictly greater than the previous page's last diff --git a/crates/control-plane-api/src/server/public/graphql/service_accounts.rs b/crates/control-plane-api/src/server/public/graphql/service_accounts.rs index 6dc27050caa..6816fdf8b61 100644 --- a/crates/control-plane-api/src/server/public/graphql/service_accounts.rs +++ b/crates/control-plane-api/src/server/public/graphql/service_accounts.rs @@ -90,7 +90,7 @@ impl ServiceAccountsQuery { let user_accessible_prefixes = super::authorized_prefixes::authorized_prefixes( &snapshot.role_grants, &snapshot.user_grants, - env.claims()?.sub, + env.principal()?, models::authz::Capability::QueryServiceAccounts, None, ); diff --git a/crates/control-plane-api/src/server/public/graphql/storage_mappings.rs b/crates/control-plane-api/src/server/public/graphql/storage_mappings.rs index 3d57a5522bb..7117b792564 100644 --- a/crates/control-plane-api/src/server/public/graphql/storage_mappings.rs +++ b/crates/control-plane-api/src/server/public/graphql/storage_mappings.rs @@ -206,7 +206,6 @@ impl StorageMappingsMutation { spec: async_graphql::Json, ) -> async_graphql::Result { let env = ctx.data::()?; - let claims = env.claims()?; let snapshot = env.snapshot(); let async_graphql::Json(spec) = spec; @@ -214,7 +213,7 @@ impl StorageMappingsMutation { validate_inputs(&catalog_prefix, &spec)?; // Verify user has admin capability to the catalog prefix and read capability to named data planes. - evaluate_authorization(env, claims, &catalog_prefix, &spec.data_planes).await?; + evaluate_authorization(env, &catalog_prefix, &spec.data_planes).await?; let data_planes = resolve_data_planes(&snapshot, &spec.data_planes)?; @@ -346,7 +345,7 @@ impl StorageMappingsMutation { validate_inputs(&catalog_prefix, &spec)?; // Verify user has admin capability to the catalog prefix and read capability to named data planes. - evaluate_authorization(env, claims, &catalog_prefix, &spec.data_planes).await?; + evaluate_authorization(env, &catalog_prefix, &spec.data_planes).await?; let data_planes = resolve_data_planes(&snapshot, &spec.data_planes)?; @@ -483,7 +482,6 @@ impl StorageMappingsMutation { spec: async_graphql::Json, ) -> async_graphql::Result { let env = ctx.data::()?; - let claims = env.claims()?; let snapshot = env.snapshot(); let async_graphql::Json(spec) = spec; @@ -491,7 +489,7 @@ impl StorageMappingsMutation { validate_inputs(&catalog_prefix, &spec)?; // Verify user has admin capability to the catalog prefix and read capability to named data planes. - evaluate_authorization(env, claims, &catalog_prefix, &spec.data_planes).await?; + evaluate_authorization(env, &catalog_prefix, &spec.data_planes).await?; let data_planes = resolve_data_planes(&snapshot, &spec.data_planes)?; @@ -507,34 +505,32 @@ impl StorageMappingsMutation { async fn evaluate_authorization( env: &crate::Envelope, - claims: &crate::ControlClaims, catalog_prefix: &models::Prefix, data_plane_names: &[String], ) -> Result<(), crate::ApiError> { - let policy_result = - check_authorization(&env.snapshot(), claims, catalog_prefix, data_plane_names); + let policy_result = check_authorization( + &env.snapshot(), + env.principal()?, + env.user_email(), + catalog_prefix, + data_plane_names, + ); env.authorization_outcome(policy_result).await?; Ok(()) } fn check_authorization( snapshot: &crate::Snapshot, - claims: &crate::ControlClaims, + principal: tables::Principal<'_>, + user_email: &str, catalog_prefix: &models::Prefix, data_plane_names: &[String], ) -> crate::AuthZResult<()> { - let models::authorizations::ControlClaims { - sub: user_id, - email: user_email, - .. - } = claims; - let user_email = user_email.as_ref().map(String::as_str).unwrap_or("user"); - // Verify the User admins `catalog_prefix`. if !tables::UserGrant::is_authorized( &snapshot.role_grants, &snapshot.user_grants, - *user_id, + principal, catalog_prefix, models::Capability::Admin, ) { @@ -706,7 +702,7 @@ impl StorageMappingsQuery { super::authorized_prefixes::filtered_authorized_prefixes( &snapshot.role_grants, &snapshot.user_grants, - env.claims()?.sub, + env.principal()?, models::authz::Capability::CatalogRead, prefix_filter, "filter.catalogPrefix", @@ -770,14 +766,14 @@ impl StorageMappingsQuery { .await?; let snapshot = env.snapshot(); - let claims = env.claims()?; + let principal = env.principal()?; let edges = rows .into_iter() .map(|row| { let user_capability = tables::UserGrant::get_user_capability( &snapshot.role_grants, &snapshot.user_grants, - claims.sub, + principal, &row.catalog_prefix, ) .ok_or_else(|| { diff --git a/crates/control-plane-api/src/server/public/open_metrics.rs b/crates/control-plane-api/src/server/public/open_metrics.rs index 942b73812a6..9d3fe2ff091 100644 --- a/crates/control-plane-api/src/server/public/open_metrics.rs +++ b/crates/control-plane-api/src/server/public/open_metrics.rs @@ -18,7 +18,8 @@ pub async fn handle_get_metrics( let policy_result = crate::evaluate_names_authorization( env.snapshot(), - env.claims()?, + env.principal()?, + env.user_email(), models::Capability::Read, [&prefix], ); diff --git a/crates/control-plane-api/src/server/public/status.rs b/crates/control-plane-api/src/server/public/status.rs index bf41fc71e87..e3ed4328631 100644 --- a/crates/control-plane-api/src/server/public/status.rs +++ b/crates/control-plane-api/src/server/public/status.rs @@ -28,7 +28,8 @@ pub(crate) async fn handle_get_status( ) -> Result>, crate::ApiError> { let policy_result = crate::evaluate_names_authorization( env.snapshot(), - env.claims()?, + env.principal()?, + env.user_email(), models::Capability::Read, &name, ); @@ -42,7 +43,7 @@ pub(crate) async fn handle_get_status( let status = if connected { // Filter out any names that the user cannot read before fetching the statuses let unfiltered_names = add_connected_names(&name, &env.pg_pool).await?; - let (snapshot, claims) = (env.snapshot(), env.claims()?); + let (snapshot, principal) = (env.snapshot(), env.principal()?); let filtered = unfiltered_names .into_iter() @@ -50,7 +51,7 @@ pub(crate) async fn handle_get_status( tables::UserGrant::is_authorized( &snapshot.role_grants, &snapshot.user_grants, - claims.sub, + principal, name, models::Capability::Read, ) diff --git a/crates/tables/src/behaviors.rs b/crates/tables/src/behaviors.rs index a041c92a1ca..88737f5a2b2 100644 --- a/crates/tables/src/behaviors.rs +++ b/crates/tables/src/behaviors.rs @@ -86,6 +86,48 @@ fn any_path_satisfies<'a>( false } +/// Narrow one node of a user's walk against the nodes reachable from a scope +/// prefix, yielding the part of `node` which falls inside the scope. +/// +/// Prefix sets are compared by containment, not equality, because a node at +/// `acmeCo/` covers every name beneath it. So a user node and a scope node +/// overlap whenever either prefix contains the other, and the overlap is the +/// more specific of the two: +/// +/// * user `acmeCo/` with scope `acmeCo/team/` overlaps at `acmeCo/team/` +/// * user `acmeCo/team/` with scope `acmeCo/` overlaps at `acmeCo/team/` +/// * user `acmeCo/` with scope `betaCo/` does not overlap +/// +/// Capability is the intersection of both sides. A prefix the scope reaches +/// only by delegation contributes only what was delegated, so scoping to +/// `acmeCo/` grants `charlieCo/` exactly the capability that `acmeCo/`'s role +/// grant conveys — even where the user separately holds more on `charlieCo/` +/// through a grant outside the scope. +/// +/// Because every emitted node is bounded by a node of the user's own walk, +/// the scoped set is always a subset of the unscoped set. A scope cannot add +/// authority regardless of the value supplied. +fn narrow_to_scope<'a, 's>( + node: super::NodeRef<'a>, + scope_nodes: &'s [super::NodeRef<'a>], +) -> impl Iterator> + use<'a, 's> { + scope_nodes.iter().filter_map(move |scope_node| { + let object_role = if node.object_role.starts_with(scope_node.object_role) { + node.object_role + } else if scope_node.object_role.starts_with(node.object_role) { + scope_node.object_role + } else { + return None; + }; + + Some(super::NodeRef { + object_role, + capabilities: node.capabilities & scope_node.capabilities, + legacy: std::cmp::min(node.legacy, scope_node.legacy), + }) + }) +} + impl super::RoleGrant { pub fn reachable_nodes<'a>( role_grants: &'a [super::RoleGrant], @@ -102,6 +144,33 @@ impl super::RoleGrant { .skip(1) } + /// Nodes reachable from `scope`, including `scope` itself. + /// + /// This differs from `reachable_nodes` in two ways, both because the + /// output is used to narrow another node set rather than to authorize on + /// its own: + /// + /// * The seed prefix is included. A request scoped to `acmeCo/` must still + /// reach `acmeCo/`, not only what `acmeCo/` delegates to. + /// * The seed carries every capability bit and `Admin`, so intersecting + /// with it filters by prefix without also removing capability that the + /// user holds at the seed prefix. Bits reached through role grants are + /// still whatever those grants convey, so a delegated prefix contributes + /// only the capability it was delegated. + pub fn scope_nodes<'a>( + role_grants: &'a [super::RoleGrant], + scope: &'a str, + ) -> impl Iterator> + 'a { + let seed = super::NodeRef { + object_role: scope, + capabilities: EnumSet::all(), + legacy: models::Capability::Admin, + }; + pathfinding::directed::bfs::bfs_reach(seed, move |f| { + next_neighbors(f.clone(), role_grants, &[], uuid::Uuid::nil()) + }) + } + pub fn is_authorized<'a>( role_grants: &'a [super::RoleGrant], subject_role_or_name: &'a str, @@ -125,23 +194,44 @@ impl super::RoleGrant { } impl super::UserGrant { + /// Nodes reachable by `principal`. + /// + /// Without a scope this is every node reachable from the user's grants. + /// With one, each reachable node is narrowed against the nodes reachable + /// from the scope prefix: see `narrow_to_scope`. pub fn reachable_nodes<'a>( role_grants: &'a [super::RoleGrant], user_grants: &'a [super::UserGrant], - user_id: uuid::Uuid, + principal: super::Principal<'a>, ) -> 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| { - next_neighbors(f.clone(), role_grants, user_grants, user_id) + let unscoped = pathfinding::directed::bfs::bfs_reach(seed, move |f| { + next_neighbors(f.clone(), role_grants, user_grants, principal.user_id) }) - .skip(1) + .skip(1); + + let Some(scope) = principal.scope else { + return itertools::Either::Left(unscoped); + }; + + // Both walks are bounded by the grant graph and hold tens of prefixes + // in practice, so the scoped set is materialized in one pass. The + // unscoped branch above stays lazy, which is what `is_authorized` + // relies on to stop at the first node that satisfies it. + let scope_nodes: Vec> = + super::RoleGrant::scope_nodes(role_grants, scope).collect(); + let scoped: Vec> = unscoped + .flat_map(|node| narrow_to_scope(node, &scope_nodes).collect::>()) + .collect(); + + itertools::Either::Right(scoped.into_iter()) } - /// Returns each prefix reachable from `user_id` mapped to the union + /// Returns each prefix reachable by `principal` 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. @@ -153,13 +243,13 @@ impl super::UserGrant { pub fn reachable_prefixes<'a>( role_grants: &'a [super::RoleGrant], user_grants: &'a [super::UserGrant], - user_id: uuid::Uuid, + principal: super::Principal<'a>, ) -> 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) { + for node in Self::reachable_nodes(role_grants, user_grants, principal) { let entry = out .entry(node.object_role) .or_insert((authz::CapabilitySet::empty(), models::Capability::None)); @@ -174,10 +264,10 @@ impl super::UserGrant { pub fn get_user_capability<'a>( role_grants: &'a [super::RoleGrant], user_grants: &'a [super::UserGrant], - user_id: uuid::Uuid, + principal: super::Principal<'a>, object_role_or_name: &str, ) -> Option { - Self::reachable_nodes(role_grants, user_grants, user_id) + Self::reachable_nodes(role_grants, user_grants, principal) .filter(|n| object_role_or_name.starts_with(n.object_role)) .map(|n| n.legacy) .filter(|c| *c != models::Capability::None) @@ -187,12 +277,12 @@ impl super::UserGrant { pub fn is_authorized<'a>( role_grants: &'a [super::RoleGrant], user_grants: &'a [super::UserGrant], - subject_user_id: uuid::Uuid, + principal: super::Principal<'a>, object_role_or_name: &'a str, capability: impl Into, ) -> bool { any_path_satisfies( - Self::reachable_nodes(role_grants, user_grants, subject_user_id), + Self::reachable_nodes(role_grants, user_grants, principal), object_role_or_name, capability, ) @@ -304,7 +394,7 @@ impl super::StorageMapping { #[cfg(test)] mod test { - use crate::{Import, Imports, RoleGrant, RoleGrants, UserGrant, UserGrants}; + use crate::{Import, Imports, Principal, RoleGrant, RoleGrants, UserGrant, UserGrants}; use enumset::EnumSet; use models::authz::{Capability, CapabilityBundle}; @@ -444,21 +534,21 @@ mod test { assert!(UserGrant::is_authorized( &role_grants, &user_grants, - uuid::Uuid::nil(), + Principal::unscoped(uuid::Uuid::nil()), "bobCo/thing", models::Capability::Read, )); assert!(!UserGrant::is_authorized( &role_grants, &user_grants, - uuid::Uuid::nil(), + Principal::unscoped(uuid::Uuid::nil()), "bobCo/thing", models::Capability::Write, )); assert!(UserGrant::is_authorized( &role_grants, &user_grants, - uuid::Uuid::nil(), + Principal::unscoped(uuid::Uuid::nil()), "carolCo/hidden/thing", models::Capability::Read, )); @@ -467,7 +557,7 @@ mod test { assert!(UserGrant::is_authorized( &role_grants, &user_grants, - uuid::Uuid::max(), + Principal::unscoped(uuid::Uuid::max()), "bobCo/burgers/thing", models::Capability::Admin, )); @@ -581,7 +671,7 @@ mod test { UserGrant::get_user_capability( &role_grants, &user_grants, - user1, + Principal::unscoped(user1), "ops/private/dp/acmeCo/foooo" ) ); @@ -590,7 +680,7 @@ mod test { UserGrant::get_user_capability( &role_grants, &user_grants, - user2, + Principal::unscoped(user2), "ops/private/dp/acmeCo/foooo" ) ); @@ -599,7 +689,7 @@ mod test { UserGrant::get_user_capability( &role_grants, &user_grants, - user1, + Principal::unscoped(user1), "different/co/altogether" ) ); @@ -640,7 +730,7 @@ mod test { assert!(UserGrant::is_authorized( &role_grants, &user_grants, - uuid::Uuid::from_bytes([1; 16]), + Principal::unscoped(uuid::Uuid::from_bytes([1; 16])), "ops/private/dp/acmeCo/foo", models::Capability::Read, )); @@ -649,12 +739,196 @@ mod test { assert!(UserGrant::is_authorized( &role_grants, &user_grants, - uuid::Uuid::from_bytes([2; 16]), + Principal::unscoped(uuid::Uuid::from_bytes([2; 16])), "ops/private/dp/acmeCo/foo", models::Capability::Read, )); } + /// Grant graph shared by the scope tests below. + /// + /// Alice admins `acmeCo/` and `betaCo/`, and `acmeCo/` holds a read grant + /// to `charlieCo/`. She also admins `charlieCo/ops/` directly, which is + /// how the tests distinguish authority reached through the scope from + /// authority she happens to hold on the same prefix by another path. + fn scope_fixture() -> (RoleGrants, UserGrants, uuid::Uuid) { + use models::Capability::{Admin, Read}; + + let alice = uuid::Uuid::from_bytes([0xa1; 16]); + + let role_grants = RoleGrants::from_iter([("acmeCo/", "charlieCo/", Read)].into_iter().map( + |(sub, obj, capability)| RoleGrant { + subject_role: models::Prefix::new(sub), + object_role: models::Prefix::new(obj), + capability, + bundles: vec![], + }, + )); + let user_grants = UserGrants::from_iter( + [ + (alice, "acmeCo/", Admin), + (alice, "betaCo/", Admin), + (alice, "charlieCo/ops/", Admin), + ] + .into_iter() + .map(|(user_id, obj, capability)| UserGrant { + user_id, + object_role: models::Prefix::new(obj), + capability, + bundles: vec![], + }), + ); + + (role_grants, user_grants, alice) + } + + /// Prefixes reachable by `principal`, each with its legacy capability. + fn scoped_prefixes( + role_grants: &RoleGrants, + user_grants: &UserGrants, + principal: Principal<'_>, + ) -> Vec<(String, models::Capability)> { + let mut out: Vec<_> = UserGrant::reachable_prefixes(role_grants, user_grants, principal) + .into_iter() + .filter(|(_, (bits, _))| !bits.is_empty()) + .map(|(prefix, (_, legacy))| (prefix.to_string(), legacy)) + .collect(); + out.sort(); + out + } + + #[test] + fn test_scope_selects_one_branch() { + use models::Capability::{Admin, Read}; + let (rg, ug, alice) = scope_fixture(); + + // Unscoped, Alice reaches everything she's been granted. + assert_eq!( + scoped_prefixes(&rg, &ug, Principal::unscoped(alice)), + vec![ + ("acmeCo/".to_string(), Admin), + ("betaCo/".to_string(), Admin), + ("charlieCo/".to_string(), Read), + ("charlieCo/ops/".to_string(), Admin), + ], + ); + + // Scoped to `acmeCo/`, she reaches `acmeCo/` and what it delegates to. + // `betaCo/` drops out because it is a sibling branch. + assert_eq!( + scoped_prefixes(&rg, &ug, Principal::scoped(alice, "acmeCo/")), + vec![ + ("acmeCo/".to_string(), Admin), + ("charlieCo/".to_string(), Read), + ("charlieCo/ops/".to_string(), Read), + ], + ); + + // Scoped to `betaCo/`, only `betaCo/` remains: it delegates nothing. + assert_eq!( + scoped_prefixes(&rg, &ug, Principal::scoped(alice, "betaCo/")), + vec![("betaCo/".to_string(), Admin)], + ); + } + + #[test] + fn test_scope_narrower_than_grant_keeps_ancestor_delegations() { + use models::Capability::{Admin, Read}; + let (rg, ug, alice) = scope_fixture(); + + // The role grant to `charlieCo/` hangs off `acmeCo/`, and a grant on + // `acmeCo/` reaches all of `acmeCo/`. So scoping to `acmeCo/team/` + // still reaches `charlieCo/`, even though `acmeCo/team/` is not + // itself the grant's subject. + assert_eq!( + scoped_prefixes(&rg, &ug, Principal::scoped(alice, "acmeCo/team/")), + vec![ + ("acmeCo/team/".to_string(), Admin), + ("charlieCo/".to_string(), Read), + ("charlieCo/ops/".to_string(), Read), + ], + ); + } + + #[test] + fn test_scope_caps_capability_at_what_it_delegates() { + use models::Capability::{Admin, Read}; + let (rg, ug, alice) = scope_fixture(); + + // Alice admins `charlieCo/ops/` directly, so unscoped she holds Admin. + assert!(UserGrant::is_authorized( + &rg, + &ug, + Principal::unscoped(alice), + "charlieCo/ops/thing", + Admin, + )); + + // Within the `acmeCo/` scope her reach to `charlieCo/` comes from that + // tenant's read grant, so the direct Admin grant is not in play and + // Read is the most she holds. + assert!(UserGrant::is_authorized( + &rg, + &ug, + Principal::scoped(alice, "acmeCo/"), + "charlieCo/ops/thing", + Read, + )); + assert!(!UserGrant::is_authorized( + &rg, + &ug, + Principal::scoped(alice, "acmeCo/"), + "charlieCo/ops/thing", + Admin, + )); + } + + #[test] + fn test_scope_cannot_widen() { + use models::Capability::Read; + let (rg, ug, alice) = scope_fixture(); + + // A scope naming a prefix Alice cannot reach yields no authority, + // rather than the authority of that prefix. + assert!( + scoped_prefixes(&rg, &ug, Principal::scoped(alice, "deltaCo/")).is_empty(), + "a scope outside the user's grants must reach nothing", + ); + assert!(!UserGrant::is_authorized( + &rg, + &ug, + Principal::scoped(alice, "deltaCo/"), + "deltaCo/thing", + Read, + )); + + // Every scope, including one the user cannot reach, produces a subset + // of the unscoped prefixes. This is the property that makes the scope + // safe to take from an untrusted request parameter. + let unscoped: std::collections::BTreeSet<_> = + scoped_prefixes(&rg, &ug, Principal::unscoped(alice)) + .into_iter() + .map(|(prefix, _)| prefix) + .collect(); + + for scope in [ + "acmeCo/", + "acmeCo/team/", + "betaCo/", + "charlieCo/", + "charlieCo/ops/", + "deltaCo/", + "estuary_support/", + ] { + for (prefix, _) in scoped_prefixes(&rg, &ug, Principal::scoped(alice, scope)) { + assert!( + unscoped.iter().any(|u| prefix.starts_with(u.as_str())), + "scope '{scope}' reached '{prefix}', which is outside the unscoped set", + ); + } + } + } + fn build_scenario( user_edges: Vec<(&str, Vec)>, role_edges: Vec<(&str, &str, Vec)>, @@ -683,9 +957,10 @@ mod test { 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(); + let mut nodes: Vec<_> = + UserGrant::reachable_nodes(role_grants, user_grants, Principal::unscoped(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(); @@ -705,7 +980,13 @@ mod test { required: EnumSet, ) { assert!( - UserGrant::is_authorized(role_grants, user_grants, user_id, name, required), + UserGrant::is_authorized( + role_grants, + user_grants, + Principal::unscoped(user_id), + name, + required + ), "expected {user_id} to have {required:?} on {name}", ); } @@ -718,7 +999,13 @@ mod test { required: EnumSet, ) { assert!( - !UserGrant::is_authorized(role_grants, user_grants, user_id, name, required), + !UserGrant::is_authorized( + role_grants, + user_grants, + Principal::unscoped(user_id), + name, + required + ), "expected {user_id} NOT to have {required:?} on {name}", ); } @@ -1306,7 +1593,8 @@ mod test { let role_grants = RoleGrants::new(); let nodes: Vec<_> = - UserGrant::reachable_nodes(&role_grants, &user_grants, user_id).collect(); + UserGrant::reachable_nodes(&role_grants, &user_grants, Principal::unscoped(user_id)) + .collect(); assert_eq!(nodes.len(), 1); let node = &nodes[0]; diff --git a/crates/tables/src/lib.rs b/crates/tables/src/lib.rs index 5e8623cafe8..013675f984c 100644 --- a/crates/tables/src/lib.rs +++ b/crates/tables/src/lib.rs @@ -421,6 +421,47 @@ pub struct NodeRef<'a> { pub legacy: models::Capability, } +/// Principal identifies the user whose authority an authorization check +/// evaluates, and optionally narrows that authority to one branch of the +/// grant graph. +/// +/// When `scope` is set, the check considers only prefixes which are reachable +/// both from the user's own grants and from `scope`. Because the result is an +/// intersection, a scope can only ever remove authority: a scope naming a +/// prefix the user cannot reach yields no authority at all, and a request +/// cannot use a scope to reach beyond the user's grants. +/// +/// `scope` is a request parameter, not a statement of authority, so it is +/// safe for a caller to choose it freely. It answers "which part of my access +/// am I using right now", which is why the web UI can switch between tenants +/// by varying it. A credential which must not be able to widen its own reach +/// carries its scope in the access token instead of the request. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct Principal<'a> { + /// The authenticated user. + pub user_id: uuid::Uuid, + /// Catalog prefix narrowing this principal's authority, if any. + pub scope: Option<&'a str>, +} + +impl<'a> Principal<'a> { + /// A principal with its user's full authority. + pub fn unscoped(user_id: uuid::Uuid) -> Self { + Self { + user_id, + scope: None, + } + } + + /// A principal narrowed to the branch of the grant graph rooted at `scope`. + pub fn scoped(user_id: uuid::Uuid, scope: &'a str) -> Self { + Self { + user_id, + scope: Some(scope), + } + } +} + /// 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.