graphql: add capability, tenant, and prefix filters to the prefixes query - #3306
Draft
GregorShear wants to merge 2 commits into
Draft
graphql: add capability, tenant, and prefix filters to the prefixes query#3306GregorShear wants to merge 2 commits into
GregorShear wants to merge 2 commits into
Conversation
GregorShear
force-pushed
the
greg/gql-reachable-prefixes
branch
from
August 3, 2026 22:01
459cac9 to
07765fe
Compare
GregorShear
force-pushed
the
greg/gql-reachable-prefixes
branch
from
August 3, 2026 23:01
07765fe to
3024f20
Compare
GregorShear
force-pushed
the
greg/gql-reachable-prefixes
branch
2 times, most recently
from
August 3, 2026 23:44
d47ba20 to
4137847
Compare
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
force-pushed
the
greg/gql-reachable-prefixes
branch
from
August 3, 2026 23:46
4137847 to
6045807
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description:
The
prefixesquery answers "which prefixes do I reach, and what may I do at each" — but it required aby.minCapabilityargument 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.bybecomes optional and deprecated. A newfilterargument 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-prefixcapabilitiesto gate features. An omitted filter collapses to the empty capability set, which every set is a superset of, so oneis_supersettest serves both the filtered and unfiltered paths; a literal[]is rejected by the input validator.tenant: Prefixnarrows to what one organization reaches through the role-grant graph, reusingtenant_reachable_prefixesandintersect_prefixesfrom 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: PrefixFilteris the shared filter, drilling into a subtree (startsWith) or selecting an exact set (in), matching the returned prefix itself.byandfilter.withCapabilitiesare alternative spellings of one capability constraint, so supplying both is rejected — the same narrow exclusionstorageMappingsapplies to its own deprecatedby. Narrow becausebystill composes withtenantandprefix, 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
byfrom required to optional does not invalidate any query that passes it.flowctl'sListAuthorizedPrefixespassesbyas an inline object literal, so its generated variable stays$min_capability: Capability!; verifiedcargo check -p flowctlis clean.Workflow steps:
Documentation links affected:
None.
Notes for reviewers:
Stacked on #3292 (
filter.tenantonserviceAccounts), which introduced thetenant_reachable_prefixes/intersect_prefixeshelpers this reuses. Review that one first.Note the branch name (
greg/gql-reachable-prefixes) is stale: this started as a separatereachablePrefixesquery and was redirected into extendingprefixesinstead, since the two would have been near-duplicate surfaces over the same closure. The branch was force-pushed with clean history; there is noreachablePrefixesin the diff.Worth a look:
by/withCapabilitiesexclusion followsstorageMappings, 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:bycombined withtenantorprefixis allowed, since those scope by namespace. A test pins that allowed combination and is discriminating —adminalone returns two prefixes,tenant: "aliceCo/"alone returns three, and together they return one, so neither constraint can be silently dropped.withCapabilitiesmeans "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— aCatalogRead-gated listing would have hidden it, which matters because the UI gates per-feature off these bits.authorized_prefixesis 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 asauthorized_from_reachable, and the resolver derives both the listing and the tenant scope from a single walk. Behavior-preserving; existing callers untouched.prefixfiltering matches the returned prefix, not a SQL row. Unlike the SQL-backed listings there is no second predicate to re-narrow, sostartsWithhere is exact rather than the deliberately-approximate bidirectional overlapnarrow_to_overlapperforms.MAX_PREFIXESguard. 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.userCapabilityis untouched. It stays onPrefixReffor the dashboard's read/write/admin bucket store, andflowctlreads it too. Retiring it (andbywith it) is the follow-up once both migrate tocapabilities.