Skip to content

authz: confine a control-plane token to a catalog prefix - #3319

Draft
GregorShear wants to merge 3 commits into
masterfrom
greg/scope
Draft

authz: confine a control-plane token to a catalog prefix#3319
GregorShear wants to merge 3 commits into
masterfrom
greg/scope

Conversation

@GregorShear

Copy link
Copy Markdown
Contributor

Confines a control-plane token to a catalog prefix, so a credential can be handed to something that should see one tenant's data and nothing else. Motivated by the dashboard agent work: an agent acting on behalf of a signed-in user should not be able to reach a tenant that isn't connected to the one it's working in.

Scoping follows the role-grant graph. A scope of acmeCo/ covers acmeCo/ plus every prefix acmeCo/ reaches through role_grants, at whatever capabilities those edges carry. A role grant acmeCo/ -> sharedCo/ therefore puts sharedCo/ in scope; two tenants with no edge between them can never appear in one another's scope. This is what makes the feature usable at all — beta_onboard gives every tenant read on ops/dp/public/, so a scope that matched the literal prefix would return zero data planes and break plane selection for every scoped caller.

Three commits

tables::AuthScope — the primitive. AuthScope::resolve(role_grants, prefix) builds the ceiling; UserGrant::is_authorized, get_user_capability, and reachable_prefixes intersect a subject's capabilities at a name with what the scope confers there. Intersection means a scope can only remove authority, which is what makes it safe to freeze in a bearer token: the grant tables stay the source of what the subject may do, so revoking a grant takes effect on the next Snapshot regardless of the token's lifetime.

reachable_prefixes is the subtle one. Intersecting two prefix sets requires splitting: where a user prefix and a scope prefix cover one another, the narrower of the pair is the intersection of the two subtrees and is what gets emitted. A user holding acmeCo/ inside a scope reaching only acmeCo/team/ is authorized at acmeCo/team/.

crate::Authority — the chokepoint. Bundles the Snapshot's grant tables, the authenticated user, and the token's scope, resolved once per request via Envelope::authority(). Every authorization question in the crate now goes through it: evaluate_names_authorizationAuthority::evaluate, attach_user_capabilitiesAuthority::attach_capabilities, plus may_access, authorized_prefixes, and every direct tables:: call in prefixes, data_planes, storage_mappings, status, and the three /authorize/user/* endpoints.

Carrying the scope in the bundle is what makes it enforceable. Threading an Option<&str> through the dozen-plus call sites would leave a dozen chances to omit it, and the value that compiles by default (None) is also the unscoped one. Answering without a scope now requires building an AuthScope::unscoped() in place, which keeps the complete set of unscoped authorizations greppable.

authorize_task keeps calling RoleGrant::is_authorized: it asks what one catalog role may do to another, which is a property of the graph alone, and no token is involved.

Issuance and escape hatchesrefresh_tokens.scope_prefix, stamped into the access token's scope_prefix claim by generate_access_token, where Authority::resolve picks it up. Unscoped tokens emit exactly the claims they did before. The scope lives on the token row, so whoever holds a credential cannot re-scope it.

createRefreshToken takes an optional scopePrefix and requires the caller to be able to read it. Checking that against the caller's own Authority makes scoping non-escalating and also means a scoped caller can only mint equally or more narrowly scoped tokens — no separate rule needed. refreshTokens reports scopePrefix back.

Two paths would have let a scoped token escape completely, both keyed on identity rather than on a catalog prefix:

  • createRefreshToken with no scopePrefix would have minted an unscoped token. It now inherits the caller's scope: omitting the argument means "as confined as I am".
  • createApiKey gates on the service account's catalog_name, which is a management anchor — the account's own user_grants may reach prefixes outside the caller's scope, and the minted key would have carried that reach. A scoped caller now stamps its scope onto the key.

Surfaces a scope does not narrow

Marked where they live rather than left implicit:

  • connectors and alertTypes — global reference data with no catalog prefix to intersect against.
  • fetch_spec_history_no_authz — scope-correct only through its callers. This is the shape worth watching for when adding resolvers: the prefix exists but arrives from a row instead of being checked, which reads as authorized code while enforcing nothing.

Open decision

revokeRefreshToken is still unscoped, so a scoped token can revoke its owner's other credentials. That's an availability question, not a data-access one, so it didn't fit the guardrail this PR is aimed at. One line to gate if we want it.

Testing

  • crates/tables: scope containment, following role grants and clamping to the edge, prefix splitting, legacy-capability clamping.
  • authorized_prefixes: a scope narrows every prefix-scoped list query without per-query work, and a caller-supplied filter naming an out-of-scope prefix returns nothing.
  • refresh_tokens: end-to-end against the grant shape every real tenant has — an unconnected tenant disappears while the role-granted public data plane stays visible — plus the mint-time gate, the no-escalation property, and scope inheritance.
  • supabase/tests/scoped_refresh_tokens.test.sql: the SQL claim stamping. sign() needs pgjwt and the vault-held secret that sqlx::test databases lack, so this can only be covered in pgTAP.

Full control-plane-api, tables, models, and ci:sql-tap suites pass.

Note: directives.test.sql failed once under the parallel ci:sql-tap runner and passed on re-run and in isolation. Nothing here touches applied_directives.

Follow-ups

  • Nothing sets a scope from the dashboard yet — the tenant selector and agent-token minting are UI work.
  • Authority is resolved per request rather than memoized, since resolving a scope is the same order of work as one authorization check. Memoizing would require the scope to own its prefixes instead of borrowing from the Snapshot.
  • AuthScope::resolve walks the graph with bfs_reach, which keys on the whole NodeRef (up to 2^N nodes per prefix). If deep grant graphs cause latency, the scope closure is a function of (prefix, snapshot) and can be cached on the Snapshot.

An AuthScope narrows every authorization answer derived from it by intersecting the subject's capabilities at a name with the capabilities the scope confers there. Intersection means a scope can only remove authority, never add it, which is what makes a scope safe to carry in a bearer token: a scope frozen at issuance cannot over-authorize, and the grant tables stay the sole source of what the subject may do.

A scope's reach follows the grant graph rather than the literal prefix. `AuthScope::resolve(role_grants, "acmeCo/")` covers `acmeCo/` at full capabilities plus every prefix `acmeCo/` reaches through role_grants, at whatever capabilities those edges carry. So a role grant `acmeCo/ -> sharedCo/` places `sharedCo/` inside a scope of `acmeCo/`, while two tenants with no edge between them can never appear in one another's scope. Resolving with Assume leaves edges out of the prefix unattenuated, making the ceiling the full authority footprint a name under that prefix would itself reach.

`UserGrant::is_authorized`, `get_user_capability`, and `reachable_prefixes` now take a scope. `AuthScope::resolve` and `AuthScope::unscoped` are the only constructors, so every deliberately unscoped authorization is visible at its call site rather than being the value a forgotten argument defaults to. `RoleGrant::is_authorized` takes none: it asks what one catalog role may do to another, which is a property of the graph alone, and no token is involved.

Intersection for `reachable_prefixes` requires splitting rather than filtering. Where a user prefix and a scope prefix cover one another, the narrower of the pair is the intersection of the two subtrees and is what gets emitted: a user holding `acmeCo/` within a scope reaching only `acmeCo/team/` is authorized at `acmeCo/team/`. Filtering would have dropped the grant and denied access the user legitimately holds.

`get_user_capability` now also skips nodes whose capabilities are entirely removed by the ceiling. This matters under a scope, where reporting an unnarrowed legacy `admin` would drive dashboard affordances the scope does not permit, and it corrects the same overstatement for fully attenuated nodes in the unscoped case.
…uthority

Adds `crate::Authority`: the caller's resolved authorization inputs for one request — the Snapshot's grant tables, the authenticated user, and the scope their token confines them to. Handlers get one via `Envelope::authority()` and ask it their authorization questions instead of calling `tables::UserGrant` directly.

This is what makes a scope enforceable rather than remembered. Threading an `Option<&str>` scope through the dozen-plus call sites would leave a dozen chances to omit it, and the value that compiles by default (`None`) is also the unscoped one. An Authority already carries the scope, so there is no argument to forget. Answering a question without a scope now requires building an `AuthScope::unscoped()` in place, which keeps the complete set of unscoped authorizations greppable.

Four aggregating helpers become Authority methods, and every direct caller of the grant tables now goes through one of them:

- `evaluate_names_authorization` -> `Authority::evaluate` (verify_authorization, live_spec_refs, alerts, alert_configs, open_metrics, status)
- `attach_user_capabilities` -> `Authority::attach_capabilities` (live_spec_refs, live_specs, data_planes)
- `may_access` and `authorized_prefixes`/`filtered_authorized_prefixes` now take an Authority (alert_configs, invite_links, service_accounts, storage_mappings)
- direct `tables::` calls in prefixes, data_planes, storage_mappings, status, and the three `/authorize/user/*` endpoints

The `/authorize/user/*` endpoints mint data-plane tokens for journal and shard access, so routing them through Authority is what keeps a scoped control-plane token from reading collection data outside its scope. `authorize_task` keeps calling `RoleGrant::is_authorized`: task authorization asks what one catalog role may do to another and involves no user token.

`ControlClaims` grows `scope_prefix`, the claim an Authority resolves its scope from. Named to avoid colliding with the OAuth 2.0 `scope` claim, which is a space-delimited list and means something else. Issuance comes next; today nothing sets it, so every token resolves to an unscoped Authority and behavior is unchanged.

Two tests in `authorized_prefixes` cover what the chokepoint buys: a scope narrows every prefix-scoped list query without per-query work, and a caller-supplied filter naming an out-of-scope prefix returns nothing rather than restoring access to it.
…cope

Adds the issuance half of token scoping. `refresh_tokens` grows a `scope_prefix` column which `generate_access_token` stamps into the access token's `scope_prefix` claim, where `Authority::resolve` picks it up. Unscoped tokens emit exactly the claims they did before.

The scope lives on the token row rather than being chosen at exchange time, so whoever holds a credential cannot re-scope it. That is the property the feature exists for: a credential handed to an agent should be confined by whoever minted it, not by whoever presents it.

`createRefreshToken` takes an optional `scopePrefix`. Requesting one requires being able to read the prefix, checked against the caller's own Authority, which makes scoping non-escalating and also means a scoped caller can only mint equally or more narrowly scoped tokens — their Authority is already confined, so an out-of-scope prefix fails the same check with no separate rule. `refreshTokens` reports `scopePrefix` back so the dashboard can show what a credential is confined to.

Two paths would otherwise have let a scoped token escape completely, both because they are keyed on identity rather than on a catalog prefix:

- `createRefreshToken` with no `scopePrefix` would have minted an unscoped token. It now inherits the caller's scope: omitting the argument means "as confined as I am", not "unconfined".
- `createApiKey` gates on the service account's `catalog_name`, which is a management anchor — the account's own user_grants may reach prefixes outside the caller's scope, and the minted key would have carried that reach. A scoped caller now stamps its scope onto the key, intersecting the account's grants with the same ceiling.

The three surfaces a scope deliberately does not narrow are now marked where they live rather than left implicit: `connectors` and `alertTypes` are global reference data with no prefix to intersect, and `fetch_spec_history_no_authz` is scope-correct only through its callers. That last one is the shape worth watching for — a prefix that arrives from a row rather than being checked reads as authorized code while enforcing nothing.

Tests cover both halves of what a scope means, using the grant shape every real tenant has (`beta_onboard` gives each one `read` on `ops/dp/public/`): an unconnected tenant disappears, while the role-granted public data plane stays visible. A literal prefix match would have returned no data planes at all and broken plane selection for every scoped caller. The SQL claim-stamping is covered by pgTAP, since `sign()` needs pgjwt and the vault-held secret that `sqlx::test` databases lack.
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