Skip to content

control plane: tenant filtering for GraphQL listings#3028

Open
GregorShear wants to merge 4 commits into
greg/service-accounts-phase1from
greg/tenant-filter
Open

control plane: tenant filtering for GraphQL listings#3028
GregorShear wants to merge 4 commits into
greg/service-accounts-phase1from
greg/tenant-filter

Conversation

@GregorShear

Copy link
Copy Markdown
Contributor

Stacked on #2943 (greg/service-accounts-phase1).

Summary

Adds a reusable tenant filter for GraphQL listings and wires it into the serviceAccounts query: serviceAccounts(filter: { tenant: { eq: "acmeCo/" } }).

Design

authorized_prefixes gains a tenant parameter backed by a new tenant_prefixes helper. tenant_prefixes computes the tenant's own reach through the role-grant graph: the tenant prefix itself, plus every prefix reachable from it at the requested capability, using the same BFS as authorization (RoleGrant::reachable_nodes) — admin grants delegate and extend the chain, while plain read/write grants are leaves.

The caller's authorized prefixes are then intersected with the tenant's reach, keeping the narrower prefix of each overlapping pair. Every returned prefix lies within both sets, so the filter can never widen the caller's access, and the listing never extends past the tenant's scope — including the case where a caller's broad grant (e.g. all of betaCo/) is clamped to the tenant's narrower reach (betaCo/nested/).

The existing prefix-overlap filter (catalogPrefix / catalogPrefixOrName on invite links and alert configs) is marked deprecated in favor of the tenant filter, with TODOs noting the migration path for those callers.

Testing

Unit tests cover tenant_prefixes (transitive admin chains, leaf grants, fine-grained capability filtering, cross-tenant exclusion, bare tenants) and the tenant clamping in authorized_prefixes (narrows to tenant scope, cannot widen access, keeps shared reach, clamps broad grants). The serviceAccounts lifecycle test exercises the filter end-to-end.

…int, and stateful bearer authentication

Adds GraphQL operations for managing refresh tokens (refreshTokens query, createRefreshToken / revokeRefreshToken mutations) and a POST /api/v1/auth/token REST endpoint that exchanges a refresh token for a signed access token. The endpoint transitionally delegates to the SQL generate_access_token function, which existing PostgREST clients still depend on; the plan is to migrate those callers here and then retire the SQL function.

Also reworks how the Envelope extractor handles refresh tokens presented as bearer credentials. Previously it called generate_access_token to mint a signed JWT and then immediately verified that JWT — a sign/verify round trip that crosses no trust boundary, since the token never leaves the process. The envelope now validates the credential directly against the refresh_tokens table and constructs claims from the verified row, via a new Verified::assert_stateful_authentication seam in the tokens crate that keeps 'holding a Verified<T>' as a type-level proof of authentication.

One behavior change: single-use refresh tokens are no longer accepted as bearer credentials. The previous path consumed them on first use and discarded the rotated secret, silently bricking the token; they now fail authentication outright. Single-use tokens remain exchangeable via /api/v1/auth/token, which returns the rotation to the caller.

Because the bearer path no longer depends on pgjwt signing (absent from the sqlx::test polyfill), its happy path is now covered by an integration test: create a token via GraphQL, authenticate with it as a bearer, and observe identity and usage stamping.
… cache to 0.8.6 output

The unpinned 'cargo install sqlx-cli' in build:sqlx-prepare silently upgrades to the latest release. sqlx-cli 0.9 requires DATABASE_URL in its own process environment — where cargo's [env] from .cargo/config.toml doesn't reach — breaking the bare 'cargo sqlx prepare' workflow, and its nullability inference differs from 0.8.6 in two cached queries, which would fail CI's 0.8.6 cache check. Pin to 0.8.6 to match ci/sqlx-check and platform-test.yaml, and regenerate the two drifted cache entries with 0.8.6.
Restacked onto greg/refresh-token-exchange, which carries the refresh-token GraphQL operations, the /api/v1/auth/token endpoint, and stateful bearer authentication that this branch previously bundled. This commit squashes the prior branch history (PR feedback, tenant filtering, revocation semantics, SHA-256 key hashing) into the remaining service-account delta:

- internal.service_accounts and internal.api_keys tables. A service account is a non-login auth.users identity whose access is determined solely by its user_grants; its prefix is a management anchor determining who may manage it.
- GraphQL operations: serviceAccounts query (tenant-filterable, paginated, with batch-loaded API keys), createServiceAccount, revokeServiceAccountGrants (the kill switch), createApiKey, revokeApiKey.
- api_key grant on POST /api/v1/auth/token: verifies flow_sa_ keys against SHA-256 hashes and mints access tokens in the application layer.
- ManageServiceAccounts orthogonal capability, bundled into TeamAdmin; authorized_prefixes gains a tenant filter that narrows (never widens) the caller's authorized set.
- Service-account principals are denied refresh tokens, and SA synthetic emails are excluded from Stripe billing-contact selection.
@GregorShear GregorShear force-pushed the greg/service-accounts-phase1 branch from b2d35a5 to 509dcde Compare June 12, 2026 20:03
Adds a reusable tenant filter for GraphQL queries that list resources scoped
to the caller's authorized prefixes, and wires it into the serviceAccounts
query as filter: { tenant: { eq: "acmeCo/" } }.

authorized_prefixes gains a tenant parameter backed by a new tenant_prefixes
helper, which computes the tenant's own reach through the role-grant graph
(its prefix plus every prefix reachable at the requested capability,
following delegating admin grants) and intersects it with the caller's
authorized prefixes, keeping the narrower prefix of each overlapping pair.
Every returned prefix lies within both sets, so the filter can never widen
the caller's access, and listings never extend past the tenant's scope.

The existing prefix-overlap filter (catalogPrefix / catalogPrefixOrName on
invite links and alert configs) is marked deprecated in favor of the tenant
filter; TODOs note the migration path for those callers.
@GregorShear GregorShear force-pushed the greg/service-accounts-phase1 branch 6 times, most recently from f96bc46 to 4c1627b Compare June 17, 2026 23:13
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