agent: storage-mappings directive gates tenant admin via Snapshot - #3438
Open
bbartman wants to merge 2 commits into
Open
agent: storage-mappings directive gates tenant admin via Snapshot#3438bbartman wants to merge 2 commits into
bbartman wants to merge 2 commits into
Conversation
bbartman
force-pushed
the
bmb/strangle-3-storage-mappings-directive
branch
from
August 28, 2026 17:48
92d73d0 to
55798dc
Compare
bbartman
force-pushed
the
bmb/strangle-3-storage-mappings-directive
branch
2 times, most recently
from
September 1, 2026 12:02
022100b to
1caa8fe
Compare
Pins the intended authorization of the storage-mappings directive ahead of moving it onto the Snapshot: only an admin of the entire tenant may set its storage mapping, since a new mapping implicitly overrides storage for everything beneath it. The sub-prefix-admin case is red against the current internal.user_roles() gate, which accepts any admin role beneath the tenant — the very looseness the code comment above the check disclaims.
Thread the agent's snapshot_watch into DirectiveHandler, pin one Snapshot per directive application at the top of poll (matching the publications executor), and hand it to storage_mappings::apply — the only directive which authorizes. The SQL user_has_admin_capability helper is deleted from control-plane-api along with its sqlx cache entry, completing the strangle: no Rust code calls internal.user_roles() any longer. This deliberately tightens the check to what its own comment always claimed: only an admin of the entire tenant may set its storage mapping. The replaced SQL accepted any admin role beneath the tenant, letting a sub-prefix admin rewrite storage for sibling prefixes they held nothing on. The red test from the prior commit pins the flip. A denial nudges an early Snapshot refresh: directives have no retry protocol, so a user whose grant postdates the pinned Snapshot sees the denial in their applied directive's status and may simply re-apply. The #2848 workaround in invite_links stays, its comment updated: the remaining user_roles() surface is entirely SQL-side (RLS and the PostgREST-facing views over auth_roles()).
bbartman
force-pushed
the
bmb/strangle-3-storage-mappings-directive
branch
from
September 1, 2026 13:29
1caa8fe to
ae2a1a3
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.
Part 3 (final) of the 3-PR stack strangling the last reachable Rust call sites of
internal.user_roles(). Stacked on #3436. With this PR, no Rust code callsinternal.user_roles()any longer — the remaining surface is entirely SQL-side (RLS policies and the PostgREST-facing views and functions overauth_roles()).Plan
The storage-mappings directive was the last and hardest call site: unlike the two
/admin/*endpoints, it runs in the agent's directive executor, which had no Snapshot. This PR:snapshot_watchintoDirectiveHandler(both the agent binary and the test harness construct it).poll, mirroring the publications executor's "pin one Snapshot for the entire operation" pattern, and hands it only tostorage_mappings::apply— the sole directive that authorizes.user_has_admin_capabilityhelper withsnapshot.is_user_authorized(user_id, claims.catalog_prefix, Admin), deleting the helper fromcontrol-plane-apiand its orphaned.sqlxcache entry (via fullcargo sqlx prepare; clean this time — no metadata drift).This PR changes who may set a tenant's storage mapping. The replaced SQL —
user_roles($user,'admin') where starts_with(role_prefix, $prefix)— accepted any user holding an admin role anywhere beneath the tenant: an admin of onlyacmeCo/sub/could rewriteacmeCo/'s tenant-wide storage mapping, affecting sibling prefixes they held nothing on. The code comment above the check has always said the intent is admin of the entire tenant (a mapping ata/implicitly overrides everything beneath it). The Snapshot walk enforces exactly that: sub-prefix admins that previously succeeded are now denied with aninvalidClaimsstatus. Simultaneously the check gains the ancestor-subject grant walk, consistent with the rest of the stack (#3435, #3436).Staleness semantics (agreed trade-off)
The SQL check was transactionally consistent; the pinned Snapshot may lag grants by up to ~5 minutes. Directives have no retry protocol (no 307 dance like the HTTP endpoints), so a denial under a stale Snapshot is terminal for that application: the user sees the denial in their applied directive's status and re-applies. A denial nudges
snapshot.request_refresh()— borrowed fromauthorization_outcome's behavior — so a prompt re-apply likely sees fresh grants.Tests (red-green)
The first commit lands the directive-level tests red against the SQL gate; the second turns them green:
'admin' capability to 'mappingCo/'in the status error) — red before the swap, since that user previously cleared the gate and failed later at the bucket check with a different error. This is the pin of the tightening.The harness's
APPLIED_DIRECTIVESrunner now refreshes the executor Snapshot before polling, mirroring the publications/discovers arms.Not in scope
The #2848 workaround
ensure_private_data_plane_grantsstays; its comment is updated to state that the remaininguser_roles()surface is SQL-only. Removing it is gated on the RLS-side migration.