From a16453db320ec609940b6289e19f85ecb8e1b5ba Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Mon, 3 Aug 2026 18:01:15 -0400 Subject: [PATCH 1/2] graphql: split authorized_from_reachable out of authorized_prefixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit authorized_prefixes walks the caller's grant graph and then reduces the result: keep the prefixes holding all required capabilities, prune children covered by a qualifying parent. A caller that already holds the walked map — because it lists the prefixes themselves rather than SQL rows scoped by them — would otherwise walk the graph a second time to get that reduction. Expose the reduction as authorized_from_reachable, taking an already-walked ReachablePrefixMap, and define authorized_prefixes as the walk composed with it. Behavior and existing callers are unchanged. --- .../public/graphql/authorized_prefixes.rs | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) 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 fd9f167627f..10d4420d0ed 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 @@ -1,3 +1,9 @@ +/// Each prefix a user reaches, mapped to the union of capability bits granted +/// there and the legacy `capability` column value: the output of +/// `tables::UserGrant::reachable_prefixes`. +pub(super) type ReachablePrefixMap<'a> = + std::collections::BTreeMap<&'a str, (models::authz::CapabilitySet, models::Capability)>; + /// Returns catalog prefixes where the authenticated user holds all /// `required_capabilities`. /// @@ -10,13 +16,30 @@ pub(super) fn authorized_prefixes( user_grants: &tables::UserGrants, user_id: uuid::Uuid, required_capabilities: impl Into, +) -> Vec { + authorized_from_reachable( + &tables::UserGrant::reachable_prefixes(role_grants, user_grants, user_id), + required_capabilities, + ) +} + +/// Reduces an already-walked `reachable` map to the prefixes holding all +/// `required_capabilities`, pruned of children covered by a qualifying parent. +/// +/// `authorized_prefixes` is this composed with the grant-graph walk. A caller +/// that already holds the map — because it lists the prefixes themselves rather +/// than SQL rows scoped by them — uses this instead, so the walk runs once per +/// request rather than once per consumer. +pub(super) fn authorized_from_reachable( + reachable: &ReachablePrefixMap<'_>, + required_capabilities: impl Into, ) -> Vec { let required_bits: models::authz::CapabilitySet = required_capabilities.into(); - // BTreeMap iteration from reachable_prefixes is already prefix-sorted, - // so the parent-prune step below can run directly on it. - let prefixes = tables::UserGrant::reachable_prefixes(role_grants, user_grants, user_id) - .into_iter() + // BTreeMap iteration is already prefix-sorted, so the parent-prune step + // below can run directly on it. + let prefixes = reachable + .iter() .filter(|(_, (bits, _))| bits.is_superset(required_bits)) .map(|(prefix, _)| prefix.to_string()); From 604580749e645ccfc7fbab9b4b798b0c6007f45b Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Mon, 3 Aug 2026 18:01:30 -0400 Subject: [PATCH 2/2] graphql: add capability, tenant, and prefix filters to the prefixes query The prefixes query answers "which prefixes do I reach, and what may I do at each", but it required a `by.minCapability` argument naming a point on the legacy read/write/admin ladder, and offered no way to narrow the result. Different capability bits map to different product features, so which bit makes a prefix interesting is the client's question rather than the resolver's, and a caller browsing a large namespace needs to drill down rather than page through everything. `by` becomes optional and deprecated. A new `filter` argument carries three narrowing fields: - `withCapabilities` keeps prefixes where the caller holds every listed bit. Omitting it lists each reachable prefix whatever the caller holds there, which is the shape a client wants when it reads per-prefix `capabilities` to gate features. An omitted filter collapses to the empty capability set, which every set is a superset of, so one `is_superset` test serves both cases; an empty list is rejected during input validation. - `tenant` narrows to what one organization reaches through the role-grant graph, reusing tenant_reachable_prefixes and intersect_prefixes. The walk starts from the caller's own footholds within the tenant and the reachable set is intersected with the caller's authorized prefixes, so the filter only ever removes entries and shows only reach flowing from namespace the caller occupies. Naming a tenant requires at least one foothold; the denial turns on the caller's own grants alone and reveals nothing about the tenant, including whether it exists. The required capabilities arm this walk too. - `prefix` is the shared PrefixFilter, drilling into a subtree or selecting an exact set, matching the returned prefix itself. `by` and `filter.withCapabilities` are alternative spellings of one capability constraint, so supplying both is rejected, following the same narrow exclusion storageMappings uses for its own deprecated `by`. `by` still composes with `tenant` and `prefix`, which scope by namespace rather than capability. Lexical ordering and the prefix cursor are unchanged, so the addition is observationally backward compatible for existing callers: BTreeMap::range still jumps straight past a previous page, and the tenant scope is derived from the same single grant-graph walk the listing uses. --- .../src/server/public/graphql/prefixes.rs | 383 +++++++++++++++++- ...s__tests__graphql_prefixes_filters-10.snap | 21 + ...s__tests__graphql_prefixes_filters-11.snap | 21 + ...s__tests__graphql_prefixes_filters-12.snap | 21 + ...es__tests__graphql_prefixes_filters-2.snap | 22 + ...es__tests__graphql_prefixes_filters-3.snap | 21 + ...es__tests__graphql_prefixes_filters-4.snap | 17 + ...es__tests__graphql_prefixes_filters-5.snap | 11 + ...es__tests__graphql_prefixes_filters-6.snap | 22 + ...es__tests__graphql_prefixes_filters-7.snap | 17 + ...es__tests__graphql_prefixes_filters-8.snap | 27 ++ ...es__tests__graphql_prefixes_filters-9.snap | 17 + ...ixes__tests__graphql_prefixes_filters.snap | 87 ++++ crates/flow-client/control-plane-api.graphql | 64 ++- 14 files changed, 746 insertions(+), 5 deletions(-) create mode 100644 crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-10.snap create mode 100644 crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-11.snap create mode 100644 crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-12.snap create mode 100644 crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-2.snap create mode 100644 crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-3.snap create mode 100644 crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-4.snap create mode 100644 crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-5.snap create mode 100644 crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-6.snap create mode 100644 crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-7.snap create mode 100644 crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-8.snap create mode 100644 crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-9.snap create mode 100644 crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters.snap 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..0f64bddf059 100644 --- a/crates/control-plane-api/src/server/public/graphql/prefixes.rs +++ b/crates/control-plane-api/src/server/public/graphql/prefixes.rs @@ -25,6 +25,49 @@ pub struct PrefixesBy { pub min_capability: models::Capability, } +/// Composable filter for the `prefixes` query. Every field is optional and only +/// narrows the result set; the caller's reach is resolved independently, so a +/// filter can never widen what they see. +#[derive(Debug, Clone, Default, async_graphql::InputObject)] +pub struct PrefixesFilter { + /// Keep only prefixes where the caller holds *every* listed capability. + /// Bits are conjunctive, so `[CatalogRead, SpecEdit]` answers "where may I + /// both read and publish", not "where may I do either". Omit the field to + /// list every reachable prefix whatever the caller holds there; an empty + /// list is rejected during input validation rather than silently matching + /// nothing. + /// + /// This replaces the deprecated `by.minCapability`, which selects a point on + /// the legacy read/write/admin ladder rather than naming bits. The two are + /// alternative spellings of one constraint and are mutually exclusive; + /// `by` does compose with this filter's other fields, which scope by + /// namespace rather than capability. + /// + /// These capabilities also arm `tenant`: when both are given, a prefix must + /// be one the caller holds them all at *and* one the tenant reaches with + /// them all. + #[graphql(validator(min_items = 1))] + pub with_capabilities: Option>, + /// Keep only prefixes that this tenant reaches through the role-grant + /// graph — the tenant's own namespace, plus any namespace a qualifying + /// chain of role grants projects it into. A chain qualifies when it carries + /// every required capability, so with no capability filter at all any + /// delegatable chain qualifies. + /// + /// The walk starts from the caller's own footholds within the tenant, and + /// the reachable set is intersected with the caller's authorized prefixes. + /// The filter therefore narrows a listing to one organization's namespace, + /// never surfaces a prefix the caller could not already see, and shows only + /// reach flowing from namespace the caller occupies. Naming a tenant + /// requires at least one foothold within (or covering) its namespace, + /// holding the required capabilities there. + pub tenant: Option, + /// Narrow to a subtree or an exact set of prefixes. The match is against + /// the returned prefix itself, so `startsWith: "acmeCo/"` keeps `acmeCo/` + /// and its descendants, and `in` keeps only exact members of the set. + pub prefix: Option, +} + pub type PaginatedPrefixes = connection::Connection< String, PrefixRef, @@ -40,26 +83,89 @@ pub struct PrefixesQuery; #[async_graphql::Object] impl PrefixesQuery { + /// Every prefix the caller reaches through the grant graph, with the + /// capability bits they hold at each. + /// + /// Unfiltered, this is the caller's whole access surface: a prefix is listed + /// whatever the caller holds there, and each entry's `capabilities` carry + /// the bits a client gates features on. `filter.withCapabilities` inverts + /// that read, answering "which prefixes may I do X at" instead. + /// + /// Ordered lexically, which walks the prefix tree depth-first: a parent + /// immediately precedes its own descendants. The cursor is the prefix + /// itself. pub async fn prefixes( &self, ctx: &Context<'_>, - by: PrefixesBy, + #[graphql( + deprecation = "Prefer `filter: { withCapabilities }`, which names capability bits \ + directly instead of a point on the legacy read/write/admin \ + ladder. `by` is retained only for existing clients and is \ + mutually exclusive with it." + )] + by: Option, + filter: Option, after: Option, first: Option, ) -> async_graphql::Result { let env = ctx.data::()?; + let filter = filter.unwrap_or_default(); + // `filter` is the going-forward replacement for `by`. Both name the same + // capability constraint — `by.minCapability` as a point on the legacy + // read/write/admin ladder, `filter.withCapabilities` as bits — so they + // are alternative spellings and mutually exclusive. `by` composes freely + // with the filter's other fields, which scope by namespace rather than + // capability. + // + // With neither supplied the required set is empty, which every + // capability set is a superset of, so the same `is_superset` test that + // narrows a filtered query admits everything for an unfiltered one. + let required: models::authz::CapabilitySet = match (by, filter.with_capabilities) { + (Some(_), Some(_)) => { + return Err( + "provide either `by` or `filter.withCapabilities`, not both; `by` is deprecated" + .into(), + ); + } + (Some(by), None) => by.min_capability.into(), + (None, Some(bits)) => bits.into_iter().collect(), + (None, None) => models::authz::CapabilitySet::empty(), + }; + + let tenant = match filter.tenant { + Some(tenant) => Some(super::tenant::validate_tenant_name(tenant.as_str())?), + None => None, + }; + let (starts_with, r#in) = match filter.prefix { + Some(prefix) => prefix.into_parts("filter.prefix")?, + None => (None, None), + }; + connection::query(after, None, first, None, |after, _, first, _| async move { let snapshot = env.snapshot(); let user_id = env.claims()?.sub; - let min_bits: models::authz::CapabilitySet = by.min_capability.into(); - + // The single grant-graph walk this request performs. Both the + // listing and the tenant scope are derived from it, and it is pure + // in-memory work over the authorization Snapshot — this resolver + // never touches the database. let reachable = tables::UserGrant::reachable_prefixes( &snapshot.role_grants, &snapshot.user_grants, user_id, ); + + let tenant_scope = match &tenant { + Some(tenant) => Some(tenant_scope( + &snapshot.role_grants, + &reachable, + tenant.as_str(), + required, + )?), + None => None, + }; + // Cursor pagination: BTreeMap::range jumps directly to the // first key strictly greater than the previous page's last // prefix, rather than iterating from the start and filtering @@ -69,7 +175,18 @@ impl PrefixesQuery { .map_or(std::ops::Bound::Unbounded, std::ops::Bound::Excluded); let all_roles: Vec = reachable .range::((start, std::ops::Bound::Unbounded)) - .filter(|(_, (bits, _))| bits.is_superset(min_bits)) + .filter(|(prefix, (bits, _))| { + bits.is_superset(required) + && tenant_scope.as_ref().is_none_or(|scope| { + scope.iter().any(|s| prefix.starts_with(s.as_str())) + }) + && starts_with + .as_deref() + .is_none_or(|sw| prefix.starts_with(sw)) + && r#in + .as_deref() + .is_none_or(|exact| exact.iter().any(|e| e.as_str() == **prefix)) + }) .map(|(prefix, (bits, legacy))| PrefixRef { prefix: models::Prefix::new(*prefix), user_capability: *legacy, @@ -97,6 +214,46 @@ impl PrefixesQuery { } } +/// Resolves the prefix scope that `filter.tenant` narrows to: the prefixes the +/// tenant reaches with `required`, intersected with the caller's own authorized +/// prefixes so the filter can only ever remove entries. An empty `required` +/// admits any chain the graph can delegate along. +/// +/// The walk starts from the caller's footholds within the tenant — their +/// authorized prefixes clamped into its subtree — so the caller witnesses only +/// reach flowing from namespace they occupy. Naming a tenant requires at least +/// one foothold. A tenant's reachable set is derived from `role_grants`, which +/// are not otherwise readable here: without that gate, filtering by a tenant the +/// caller knows nothing about and observing whether prefixes come back in some +/// *other* namespace would reveal that a role grant connects the two. The check +/// is a function of the caller's own grants and the tenant string alone, so the +/// denial itself reveals nothing about the tenant — including whether it exists. +fn tenant_scope( + role_grants: &tables::RoleGrants, + reachable: &super::authorized_prefixes::ReachablePrefixMap<'_>, + tenant: &str, + required: models::authz::CapabilitySet, +) -> async_graphql::Result> { + let caller = super::authorized_prefixes::authorized_from_reachable(reachable, required); + + let seeds = super::authorized_prefixes::intersect_prefixes(&caller, &[tenant.to_string()]); + if seeds.is_empty() { + return Err(async_graphql::Error::new(format!( + "not authorized to filter by tenant '{tenant}'" + ))); + } + + // The walk starts from the footholds rather than the tenant root, so the + // caller witnesses only reach flowing from namespace they occupy: edges + // granted to sibling branches of their footholds contribute nothing. + let reached = + super::authorized_prefixes::tenant_reachable_prefixes(role_grants, &seeds, required); + + Ok(super::authorized_prefixes::intersect_prefixes( + &caller, &reached, + )) +} + #[cfg(test)] mod tests { use crate::test_server; @@ -208,4 +365,222 @@ mod tests { } "#); } + + /// Runs `query` as alice and returns the JSON response. + async fn run(server: &test_server::TestServer, query: &str) -> serde_json::Value { + let token = server.make_access_token(uuid::Uuid::from_bytes([0x11; 16]), None); + server + .graphql(&serde_json::json!({ "query": query }), Some(&token)) + .await + } + + #[sqlx::test( + migrations = "../../supabase/migrations", + fixtures(path = "../../../fixtures", scripts("data_planes", "alice")) + )] + async fn test_graphql_prefixes_filters(pool: sqlx::PgPool) { + let _guard = test_server::init(); + + // The alice fixture grants admin on aliceCo/, plus role grants reaching + // aliceCo/data/ and ops/dp/public/. zebraCo/ adds a second root, which + // sorts last under the lexical ordering this query preserves. + sqlx::query( + "INSERT INTO public.user_grants (user_id, object_role, capability) + VALUES ($1, 'zebraCo/', 'admin')", + ) + .bind(uuid::Uuid::from_bytes([0x11; 16])) + .execute(&pool) + .await + .unwrap(); + + let server = + test_server::TestServer::start(pool.clone(), test_server::snapshot(pool, false).await) + .await; + + // `by` is now optional. Omitting every argument lists the caller's whole + // access surface, in lexical order, whatever they hold at each prefix. + let response = run( + &server, + r#" + query { + prefixes { + edges { cursor node { prefix userCapability capabilities } } + } + } + "#, + ) + .await; + insta::assert_json_snapshot!(response); + + // `withCapabilities` is conjunctive. Alice admins aliceCo/ and zebraCo/, + // so she holds both bits only there; aliceCo/data/ reaches her with + // write (no SpecEdit) and ops/dp/public/ with read. + let response = run( + &server, + r#" + query { + prefixes(filter: { withCapabilities: [CatalogRead, SpecEdit] }) { + edges { node { prefix } } + } + } + "#, + ) + .await; + insta::assert_json_snapshot!(response); + + // `by` and `withCapabilities` are alternative spellings of one + // constraint, so supplying both is rejected. + let response = run( + &server, + r#" + query { + prefixes( + by: { minCapability: read } + filter: { withCapabilities: [SpecEdit] } + ) { + edges { node { prefix } } + } + } + "#, + ) + .await; + insta::assert_json_snapshot!(response); + + // The exclusion is narrow: `by` composes with the filter's other fields, + // which scope by namespace rather than capability. `admin` alone admits + // the two prefixes she admins, and the aliceCo/ tenant scope excludes + // zebraCo/, so a result of just aliceCo/ proves both were applied. + let response = run( + &server, + r#" + query { + prefixes(by: { minCapability: admin }, filter: { tenant: "aliceCo/" }) { + edges { node { prefix } } + } + } + "#, + ) + .await; + insta::assert_json_snapshot!(response); + + // A bit no grant carries anywhere yields an empty listing rather than + // falling back to the unfiltered set. + let response = run( + &server, + r#" + query { + prefixes(filter: { withCapabilities: [Assume] }) { + edges { node { prefix } } + } + } + "#, + ) + .await; + insta::assert_json_snapshot!(response); + + // `startsWith` drills into one subtree, matching the returned prefix + // itself, so the ops/dp/public/ and zebraCo/ roots drop out. + let response = run( + &server, + r#" + query { + prefixes(filter: { prefix: { startsWith: "aliceCo/" } }) { + edges { node { prefix } } + } + } + "#, + ) + .await; + insta::assert_json_snapshot!(response); + + // `in` matches exactly, selecting aliceCo/data/ without its parent. + let response = run( + &server, + r#" + query { + prefixes(filter: { prefix: { in: ["aliceCo/data/", "ghostCo/"] } }) { + edges { node { prefix } } + } + } + "#, + ) + .await; + insta::assert_json_snapshot!(response); + + // `tenant` keeps what aliceCo/ reaches with the required capabilities — + // its own namespace plus ops/dp/public/ through the role grant — and + // drops zebraCo/, which alice sees but aliceCo/ does not reach. + let response = run( + &server, + r#" + query { + prefixes(filter: { tenant: "aliceCo/" }) { + edges { node { prefix } } + } + } + "#, + ) + .await; + insta::assert_json_snapshot!(response); + + // The capabilities also arm the tenant walk: the aliceCo/ -> + // ops/dp/public/ edge carries only read, so requiring SpecEdit drops it + // where the unfiltered tenant query keeps it. + let response = run( + &server, + r#" + query { + prefixes(filter: { tenant: "aliceCo/", withCapabilities: [SpecEdit] }) { + edges { node { prefix } } + } + } + "#, + ) + .await; + insta::assert_json_snapshot!(response); + + // Naming a tenant alice has no foothold in is denied. The denial turns + // only on her own grants, so it reveals nothing about the tenant — + // including whether it exists. + let response = run( + &server, + r#" + query { + prefixes(filter: { tenant: "ghostCo/" }) { + edges { node { prefix } } + } + } + "#, + ) + .await; + insta::assert_json_snapshot!(response); + + // `startsWith` and `in` are mutually exclusive. + let response = run( + &server, + r#" + query { + prefixes(filter: { prefix: { startsWith: "aliceCo/", in: ["aliceCo/"] } }) { + edges { node { prefix } } + } + } + "#, + ) + .await; + insta::assert_json_snapshot!(response); + + // An empty capability list is rejected during input validation. + let response = run( + &server, + r#" + query { + prefixes(filter: { withCapabilities: [] }) { + edges { node { prefix } } + } + } + "#, + ) + .await; + insta::assert_json_snapshot!(response); + } } diff --git a/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-10.snap b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-10.snap new file mode 100644 index 00000000000..ed3a1993f6c --- /dev/null +++ b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-10.snap @@ -0,0 +1,21 @@ +--- +source: crates/control-plane-api/src/server/public/graphql/prefixes.rs +expression: response +--- +{ + "data": null, + "errors": [ + { + "locations": [ + { + "column": 17, + "line": 3 + } + ], + "message": "not authorized to filter by tenant 'ghostCo/'", + "path": [ + "prefixes" + ] + } + ] +} diff --git a/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-11.snap b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-11.snap new file mode 100644 index 00000000000..699a646796b --- /dev/null +++ b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-11.snap @@ -0,0 +1,21 @@ +--- +source: crates/control-plane-api/src/server/public/graphql/prefixes.rs +expression: response +--- +{ + "data": null, + "errors": [ + { + "locations": [ + { + "column": 17, + "line": 3 + } + ], + "message": "`filter.prefix.startsWith` and `.in` are mutually exclusive; provide only one", + "path": [ + "prefixes" + ] + } + ] +} diff --git a/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-12.snap b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-12.snap new file mode 100644 index 00000000000..b3c8857daa4 --- /dev/null +++ b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-12.snap @@ -0,0 +1,21 @@ +--- +source: crates/control-plane-api/src/server/public/graphql/prefixes.rs +expression: response +--- +{ + "data": null, + "errors": [ + { + "locations": [ + { + "column": 34, + "line": 3 + } + ], + "message": "Failed to parse \"[CapabilityBit!]\": the value length is 0, must be greater than or equal to 1 (occurred while parsing \"PrefixesFilter\")", + "path": [ + "prefixes" + ] + } + ] +} diff --git a/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-2.snap b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-2.snap new file mode 100644 index 00000000000..1586af62312 --- /dev/null +++ b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-2.snap @@ -0,0 +1,22 @@ +--- +source: crates/control-plane-api/src/server/public/graphql/prefixes.rs +expression: response +--- +{ + "data": { + "prefixes": { + "edges": [ + { + "node": { + "prefix": "aliceCo/" + } + }, + { + "node": { + "prefix": "zebraCo/" + } + } + ] + } + } +} diff --git a/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-3.snap b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-3.snap new file mode 100644 index 00000000000..a7e669e5203 --- /dev/null +++ b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-3.snap @@ -0,0 +1,21 @@ +--- +source: crates/control-plane-api/src/server/public/graphql/prefixes.rs +expression: response +--- +{ + "data": null, + "errors": [ + { + "locations": [ + { + "column": 17, + "line": 3 + } + ], + "message": "provide either `by` or `filter.withCapabilities`, not both; `by` is deprecated", + "path": [ + "prefixes" + ] + } + ] +} diff --git a/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-4.snap b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-4.snap new file mode 100644 index 00000000000..e26833ff781 --- /dev/null +++ b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-4.snap @@ -0,0 +1,17 @@ +--- +source: crates/control-plane-api/src/server/public/graphql/prefixes.rs +expression: response +--- +{ + "data": { + "prefixes": { + "edges": [ + { + "node": { + "prefix": "aliceCo/" + } + } + ] + } + } +} diff --git a/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-5.snap b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-5.snap new file mode 100644 index 00000000000..ead7da348ee --- /dev/null +++ b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-5.snap @@ -0,0 +1,11 @@ +--- +source: crates/control-plane-api/src/server/public/graphql/prefixes.rs +expression: response +--- +{ + "data": { + "prefixes": { + "edges": [] + } + } +} diff --git a/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-6.snap b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-6.snap new file mode 100644 index 00000000000..bddb22ad3cc --- /dev/null +++ b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-6.snap @@ -0,0 +1,22 @@ +--- +source: crates/control-plane-api/src/server/public/graphql/prefixes.rs +expression: response +--- +{ + "data": { + "prefixes": { + "edges": [ + { + "node": { + "prefix": "aliceCo/" + } + }, + { + "node": { + "prefix": "aliceCo/data/" + } + } + ] + } + } +} diff --git a/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-7.snap b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-7.snap new file mode 100644 index 00000000000..4b42c534aff --- /dev/null +++ b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-7.snap @@ -0,0 +1,17 @@ +--- +source: crates/control-plane-api/src/server/public/graphql/prefixes.rs +expression: response +--- +{ + "data": { + "prefixes": { + "edges": [ + { + "node": { + "prefix": "aliceCo/data/" + } + } + ] + } + } +} diff --git a/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-8.snap b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-8.snap new file mode 100644 index 00000000000..a6ec34df78c --- /dev/null +++ b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-8.snap @@ -0,0 +1,27 @@ +--- +source: crates/control-plane-api/src/server/public/graphql/prefixes.rs +expression: response +--- +{ + "data": { + "prefixes": { + "edges": [ + { + "node": { + "prefix": "aliceCo/" + } + }, + { + "node": { + "prefix": "aliceCo/data/" + } + }, + { + "node": { + "prefix": "ops/dp/public/" + } + } + ] + } + } +} diff --git a/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-9.snap b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-9.snap new file mode 100644 index 00000000000..e26833ff781 --- /dev/null +++ b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters-9.snap @@ -0,0 +1,17 @@ +--- +source: crates/control-plane-api/src/server/public/graphql/prefixes.rs +expression: response +--- +{ + "data": { + "prefixes": { + "edges": [ + { + "node": { + "prefix": "aliceCo/" + } + } + ] + } + } +} diff --git a/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters.snap b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters.snap new file mode 100644 index 00000000000..1ef7e6dda43 --- /dev/null +++ b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__prefixes__tests__graphql_prefixes_filters.snap @@ -0,0 +1,87 @@ +--- +source: crates/control-plane-api/src/server/public/graphql/prefixes.rs +expression: response +--- +{ + "data": { + "prefixes": { + "edges": [ + { + "cursor": "aliceCo/", + "node": { + "capabilities": [ + "CatalogRead", + "JournalRead", + "JournalAppend", + "SpecEdit", + "CreateGrant", + "DeleteGrant", + "CreateInviteLink", + "ViewDataPlanePrivateNetworking", + "ModifyDataPlanePrivateNetworking", + "ViewBilling", + "EditBilling", + "QueryServiceAccounts", + "CreateServiceAccount", + "CreateApiKey", + "RevokeApiKey", + "Delegate" + ], + "prefix": "aliceCo/", + "userCapability": "admin" + } + }, + { + "cursor": "aliceCo/data/", + "node": { + "capabilities": [ + "CatalogRead", + "JournalRead", + "JournalAppend", + "ViewDataPlanePrivateNetworking" + ], + "prefix": "aliceCo/data/", + "userCapability": "write" + } + }, + { + "cursor": "ops/dp/public/", + "node": { + "capabilities": [ + "CatalogRead", + "JournalRead", + "ViewDataPlanePrivateNetworking" + ], + "prefix": "ops/dp/public/", + "userCapability": "read" + } + }, + { + "cursor": "zebraCo/", + "node": { + "capabilities": [ + "CatalogRead", + "JournalRead", + "JournalAppend", + "SpecEdit", + "CreateGrant", + "DeleteGrant", + "CreateInviteLink", + "ViewDataPlanePrivateNetworking", + "ModifyDataPlanePrivateNetworking", + "ViewBilling", + "EditBilling", + "QueryServiceAccounts", + "CreateServiceAccount", + "CreateApiKey", + "RevokeApiKey", + "Delegate" + ], + "prefix": "zebraCo/", + "userCapability": "admin" + } + } + ] + } + } +} diff --git a/crates/flow-client/control-plane-api.graphql b/crates/flow-client/control-plane-api.graphql index 840e71941ba..a0c55566bd8 100644 --- a/crates/flow-client/control-plane-api.graphql +++ b/crates/flow-client/control-plane-api.graphql @@ -1607,6 +1607,55 @@ input PrefixesBy { minCapability: Capability! } +""" +Composable filter for the `prefixes` query. Every field is optional and only +narrows the result set; the caller's reach is resolved independently, so a +filter can never widen what they see. +""" +input PrefixesFilter { + """ + Keep only prefixes where the caller holds *every* listed capability. + Bits are conjunctive, so `[CatalogRead, SpecEdit]` answers "where may I + both read and publish", not "where may I do either". Omit the field to + list every reachable prefix whatever the caller holds there; an empty + list is rejected during input validation rather than silently matching + nothing. + + This replaces the deprecated `by.minCapability`, which selects a point on + the legacy read/write/admin ladder rather than naming bits. The two are + alternative spellings of one constraint and are mutually exclusive; + `by` does compose with this filter's other fields, which scope by + namespace rather than capability. + + These capabilities also arm `tenant`: when both are given, a prefix must + be one the caller holds them all at *and* one the tenant reaches with + them all. + """ + withCapabilities: [CapabilityBit!] + """ + Keep only prefixes that this tenant reaches through the role-grant + graph — the tenant's own namespace, plus any namespace a qualifying + chain of role grants projects it into. A chain qualifies when it carries + every required capability, so with no capability filter at all any + delegatable chain qualifies. + + The walk starts from the caller's own footholds within the tenant, and + the reachable set is intersected with the caller's authorized prefixes. + The filter therefore narrows a listing to one organization's namespace, + never surfaces a prefix the caller could not already see, and shows only + reach flowing from namespace the caller occupies. Naming a tenant + requires at least one foothold within (or covering) its namespace, + holding the required capabilities there. + """ + tenant: Prefix + """ + Narrow to a subtree or an exact set of prefixes. The match is against + the returned prefix itself, so `startsWith: "acmeCo/"` keeps `acmeCo/` + and its descendants, and `in` keeps only exact members of the set. + """ + prefix: PrefixFilter +} + """ A configured private link and its controller-observed provisioning status. """ @@ -1834,7 +1883,20 @@ type QueryRoot { Returns all possible alert types with their user-facing metadata. """ alertTypes: [AlertTypeInfo!]! - prefixes(by: PrefixesBy!, after: String, first: Int): PrefixRefConnection! + """ + Every prefix the caller reaches through the grant graph, with the + capability bits they hold at each. + + Unfiltered, this is the caller's whole access surface: a prefix is listed + whatever the caller holds there, and each entry's `capabilities` carry + the bits a client gates features on. `filter.withCapabilities` inverts + that read, answering "which prefixes may I do X at" instead. + + Ordered lexically, which walks the prefix tree depth-first: a parent + immediately precedes its own descendants. The cursor is the prefix + itself. + """ + prefixes(by: PrefixesBy @deprecated(reason: "Prefer `filter: { withCapabilities }`, which names capability bits directly instead of a point on the legacy read/write/admin ladder. `by` is retained only for existing clients and is mutually exclusive with it."), filter: PrefixesFilter, after: String, first: Int): PrefixRefConnection! """ Returns a complete list of alert subscriptions. """