Skip to content

authz: scope a request's authority to one branch of the grant graph - #3407

Draft
GregorShear wants to merge 1 commit into
masterfrom
greg/scoped-prefix-header
Draft

authz: scope a request's authority to one branch of the grant graph#3407
GregorShear wants to merge 1 commit into
masterfrom
greg/scoped-prefix-header

Conversation

@GregorShear

Copy link
Copy Markdown
Contributor

What

Adds an X-Estuary-Scope-Prefix header that narrows a request to one branch of the caller's authorization graph:

X-Estuary-Scope-Prefix: acmeCo/

Authorization computes the prefixes reachable from the user's grants, computes the prefixes reachable from the scope prefix, and intersects them. A user who admins acmeCo/ and betaCo/, where acmeCo/ holds a role grant to charlieCo/, sees acmeCo/ and charlieCo/ under this header and does not see betaCo/.

Why the header can be trusted from the client

The result is an intersection with the user's own reachable prefixes, so a scope can only remove authority:

  • A scope naming a prefix the user cannot reach yields nothing, not access to it.
  • No scope value can produce a prefix outside the unscoped set. test_scope_cannot_widen asserts this over a set of scopes including ones outside the user's grants.

That is what makes it usable as a dashboard tenant selector. The user picks which tenant they are working in and switches freely, with no token refresh and no server-side state, because switching is re-narrowing from an unscoped token rather than widening a scoped one.

A credential that must not be able to widen its own reach is a separate problem, and would carry its scope in the access token rather than the request. This PR does not add that.

Prefix direction

The scope side of the intersection is RoleGrant::scope_nodes, which walks role grants in both prefix directions the way next_neighbors already does. So a scope of acmeCo/team/ still reaches charlieCo/ — the role grant hangs off acmeCo/, and a grant on acmeCo/ reaches everything under it.

Making the scope impossible to skip

Authorization now takes a tables::Principal (user id plus optional scope) instead of a bare Uuid. Handlers build one from Envelope::principal. Since it is the only accepted input, a call site cannot honor the token while overlooking the scope. Every existing call site was updated because the signature change made each one a compile error.

Decisions worth a reviewer's attention

Capability is intersected, not maxed. If a user admins charlieCo/ops/ directly and reaches charlieCo/ through acmeCo/'s read grant, then under a scope of acmeCo/ they hold Read on charlieCo/ops/, not Admin. The reading is that scoping to acmeCo/ means "what acmeCo/ can reach, as acmeCo/ reaches it," so a grant from outside the scope is not in play. Intersecting is the conservative choice; taking the max would be the risky one. Covered by test_scope_caps_capability_at_what_it_delegates. Say so if you want the other behavior.

Discovers stay unscoped. They authorize against the user recorded on the job, not the request that enqueued it, so no scope reaches them. Threading one through would mean persisting it on the job row. Marked with a comment at both call sites.

An empty header value is rejected rather than treated as unscoped. It would otherwise match every prefix and silently do nothing; a client meaning "no scope" omits the header.

Testing

Verified:

  • cargo test -p tables — 41 pass, including 4 new scope tests (branch selection, ancestor delegations under a narrower scope, capability intersection, and the cannot-widen property).
  • New unit tests for header parsing in envelope.rs.
  • cargo check -p tables -p control-plane-api -p agent clean, no warnings.
  • cargo fmt --check clean.

Not run locally: the control-plane-api test suite. Its test-only sqlx::query! macros need a live database, and mise run local:supabase requires systemd, which is unavailable on this macOS host. The same 5 errors reproduce on a clean origin/master in this worktree, so they are environmental and not from this change. These tests need to pass in CI or inside the Lima VM before merge.

No GraphQL schema change, so no flow-client regeneration. No SQL change, so no cargo sqlx prepare.

Follow-ups not in this PR

  • Dashboard wiring for the tenant selector.
  • Token-carried scope for delegated credentials (agents), which would live on the refresh_tokens row following the pg_role pattern and be non-widenable.

Adds an `X-Estuary-Scope-Prefix` header which narrows a request to the part of the user's access reachable from a named catalog prefix.

Authorization computes the prefixes reachable from the user's grants, computes the prefixes reachable from the scope prefix, and intersects them. A user who admins `acmeCo/` and `betaCo/`, where `acmeCo/` holds a role grant to `charlieCo/`, sees `acmeCo/` and `charlieCo/` under a scope of `acmeCo/` and does not see `betaCo/`.

The scope side of that intersection is `RoleGrant::scope_nodes`, which walks role grants in both prefix directions the way `next_neighbors` already does. So a scope of `acmeCo/team/` still reaches `charlieCo/`: the role grant hangs off `acmeCo/`, and a grant on `acmeCo/` reaches everything under it.

Because the result is an intersection with the user's own reachable prefixes, a scope can only remove authority. A scope naming a prefix the user cannot reach yields nothing rather than access to it, and no scope value can produce a prefix outside the unscoped set. That property is what makes the header safe to accept from the client, so the dashboard can use it as a tenant selector and switch freely without re-authenticating.

Authorization now takes a `tables::Principal`, pairing the user id with the optional scope, instead of a bare `Uuid`. Handlers build one from `Envelope::principal`. Making it the only accepted input means a call site cannot honor the token while overlooking the scope; every existing site was updated because the signature change made it a compile error.

Discovers keep unscoped authority. They authorize against the user recorded on the job rather than the request that enqueued it, so no scope reaches them.
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