Skip to content

graphql: add capability, tenant, and prefix filters to the prefixes query - #3306

Draft
GregorShear wants to merge 2 commits into
greg/gql-tenant-filterfrom
greg/gql-reachable-prefixes
Draft

graphql: add capability, tenant, and prefix filters to the prefixes query#3306
GregorShear wants to merge 2 commits into
greg/gql-tenant-filterfrom
greg/gql-reachable-prefixes

Conversation

@GregorShear

@GregorShear GregorShear commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Description:

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 at all. 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 they can see.

by becomes optional and deprecated. A new filter argument carries three narrowing fields, all optional:

  • withCapabilities: [CapabilityBit!] keeps prefixes where the caller holds every listed bit. Conjunctive: [CatalogRead, SpecEdit] means read and publish. Omitting it lists each reachable prefix whatever the caller holds there — 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 the filtered and unfiltered paths; a literal [] is rejected by the input validator.
  • tenant: Prefix narrows to what one organization reaches through the role-grant graph, reusing tenant_reachable_prefixes and intersect_prefixes from the parent PR. 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 and the tenant string alone, so it reveals nothing about the tenant — including whether it exists. The required capabilities arm this walk too.
  • prefix: PrefixFilter is the shared filter, drilling into a subtree (startsWith) or selecting an exact set (in), matching the returned prefix itself.

by and filter.withCapabilities are alternative spellings of one capability constraint, so supplying both is rejected — the same narrow exclusion storageMappings applies to its own deprecated by. Narrow because by still composes with tenant and prefix, which scope by namespace rather than capability.

Backward compatibility. Lexical ordering and the prefix cursor are unchanged, so this is observationally compatible for existing callers — relaxing by from required to optional does not invalidate any query that passes it. flowctl's ListAuthorizedPrefixes passes by as an inline object literal, so its generated variable stays $min_capability: Capability!; verified cargo check -p flowctl is clean.

Workflow steps:

# The caller's whole access surface. No arguments required any more.
query {
  prefixes(first: 50) {
    pageInfo { hasNextPage }
    edges { cursor node { prefix capabilities } }
  }
}

# Which prefixes may I publish specs and edit billing at?
query {
  prefixes(filter: { withCapabilities: [SpecEdit, EditBilling] }) {
    edges { node { prefix } }
  }
}

# Drill into one subtree of a large hierarchy.
query {
  prefixes(filter: { prefix: { startsWith: "acmeCo/" } }) {
    edges { node { prefix capabilities } }
  }
}

# Scope to one organization's reach.
query {
  prefixes(filter: { tenant: "acmeCo/" }) {
    edges { node { prefix capabilities } }
  }
}

Documentation links affected:

None.

Notes for reviewers:

Stacked on #3292 (filter.tenant on serviceAccounts), which introduced the tenant_reachable_prefixes / intersect_prefixes helpers this reuses. Review that one first.

Note the branch name (greg/gql-reachable-prefixes) is stale: this started as a separate reachablePrefixes query and was redirected into extending prefixes instead, since the two would have been near-duplicate surfaces over the same closure. The branch was force-pushed with clean history; there is no reachablePrefixes in the diff.

Worth a look:

  • by / withCapabilities exclusion follows storageMappings, and is deliberately narrow. Both name the same capability constraint, so supplying both is an error with the same wording that query uses. But the exclusion checks only the overlapping field: by combined with tenant or prefix is allowed, since those scope by namespace. A test pins that allowed combination and is discriminating — admin alone returns two prefixes, tenant: "aliceCo/" alone returns three, and together they return one, so neither constraint can be silently dropped.
  • Empty withCapabilities means "no narrowing", not "nothing". is_superset(empty) is universally true. That is what makes the unfiltered query list a prefix where the caller holds only, say, ViewBilling — a CatalogRead-gated listing would have hidden it, which matters because the UI gates per-feature off these bits.
  • One grant-graph walk per request. authorized_prefixes is itself the walk plus a reduction, so calling it for the tenant scope would have walked the caller's graph twice. The first commit splits that reduction out as authorized_from_reachable, and the resolver derives both the listing and the tenant scope from a single walk. Behavior-preserving; existing callers untouched.
  • prefix filtering matches the returned prefix, not a SQL row. Unlike the SQL-backed listings there is no second predicate to re-narrow, so startsWith here is exact rather than the deliberately-approximate bidirectional overlap narrow_to_overlap performs.
  • Still no MAX_PREFIXES guard. There is no predicate array to bound — the closure is enumerated in memory from the authorization Snapshot with no database access — and pagination bounds the response.
  • userCapability is untouched. It stays on PrefixRef for the dashboard's read/write/admin bucket store, and flowctl reads it too. Retiring it (and by with it) is the follow-up once both migrate to capabilities.

@GregorShear
GregorShear force-pushed the greg/gql-reachable-prefixes branch from 459cac9 to 07765fe Compare August 3, 2026 22:01
@GregorShear GregorShear changed the title graphql: add a reachablePrefixes query graphql: add capability, tenant, and prefix filters to the prefixes query Aug 3, 2026
@GregorShear
GregorShear force-pushed the greg/gql-reachable-prefixes branch from 07765fe to 3024f20 Compare August 3, 2026 23:01
@GregorShear
GregorShear force-pushed the greg/gql-reachable-prefixes branch 2 times, most recently from d47ba20 to 4137847 Compare August 3, 2026 23:44
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.
…uery

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.
@GregorShear
GregorShear force-pushed the greg/gql-reachable-prefixes branch from 4137847 to 6045807 Compare August 3, 2026 23:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant