authz: confine a control-plane token to a catalog prefix - #3319
Draft
GregorShear wants to merge 3 commits into
Draft
authz: confine a control-plane token to a catalog prefix#3319GregorShear wants to merge 3 commits into
GregorShear wants to merge 3 commits into
Conversation
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.
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.
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/coversacmeCo/plus every prefixacmeCo/reaches throughrole_grants, at whatever capabilities those edges carry. A role grantacmeCo/ -> sharedCo/therefore putssharedCo/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_onboardgives every tenantreadonops/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, andreachable_prefixesintersect 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_prefixesis 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 holdingacmeCo/inside a scope reaching onlyacmeCo/team/is authorized atacmeCo/team/.crate::Authority— the chokepoint. Bundles the Snapshot's grant tables, the authenticated user, and the token's scope, resolved once per request viaEnvelope::authority(). Every authorization question in the crate now goes through it:evaluate_names_authorization→Authority::evaluate,attach_user_capabilities→Authority::attach_capabilities, plusmay_access,authorized_prefixes, and every directtables::call inprefixes,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 anAuthScope::unscoped()in place, which keeps the complete set of unscoped authorizations greppable.authorize_taskkeeps callingRoleGrant::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 hatches —
refresh_tokens.scope_prefix, stamped into the access token'sscope_prefixclaim bygenerate_access_token, whereAuthority::resolvepicks 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.createRefreshTokentakes an optionalscopePrefixand 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.refreshTokensreportsscopePrefixback.Two paths would have let a scoped token escape completely, both keyed on identity rather than on a catalog prefix:
createRefreshTokenwith noscopePrefixwould have minted an unscoped token. It now inherits the caller's scope: omitting the argument means "as confined as I am".createApiKeygates on the service account'scatalog_name, which is a management anchor — the account's ownuser_grantsmay 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:
connectorsandalertTypes— 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
revokeRefreshTokenis 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 thatsqlx::testdatabases lack, so this can only be covered in pgTAP.Full
control-plane-api,tables,models, andci:sql-tapsuites pass.Note:
directives.test.sqlfailed once under the parallelci:sql-taprunner and passed on re-run and in isolation. Nothing here touchesapplied_directives.Follow-ups
Authorityis 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::resolvewalks the graph withbfs_reach, which keys on the wholeNodeRef(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.