Skip to content

agent: storage-mappings directive gates tenant admin via Snapshot - #3438

Open
bbartman wants to merge 2 commits into
bmb/strangle-2-update-l2-reportingfrom
bmb/strangle-3-storage-mappings-directive
Open

agent: storage-mappings directive gates tenant admin via Snapshot#3438
bbartman wants to merge 2 commits into
bmb/strangle-2-update-l2-reportingfrom
bmb/strangle-3-storage-mappings-directive

Conversation

@bbartman

Copy link
Copy Markdown
Contributor

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 calls internal.user_roles() any longer — the remaining surface is entirely SQL-side (RLS policies and the PostgREST-facing views and functions over auth_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:

  1. Threads the agent's snapshot_watch into DirectiveHandler (both the agent binary and the test harness construct it).
  2. Pins one Snapshot per directive application at the top of poll, mirroring the publications executor's "pin one Snapshot for the entire operation" pattern, and hands it only to storage_mappings::apply — the sole directive that authorizes.
  3. Replaces the SQL user_has_admin_capability helper with snapshot.is_user_authorized(user_id, claims.catalog_prefix, Admin), deleting the helper from control-plane-api and its orphaned .sqlx cache entry (via full cargo sqlx prepare; clean this time — no metadata drift).

⚠️ Deliberate semantic tightening

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 only acmeCo/sub/ could rewrite acmeCo/'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 at a/ implicitly overrides everything beneath it). The Snapshot walk enforces exactly that: sub-prefix admins that previously succeeded are now denied with an invalidClaims status. 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 from authorization_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:

  • Sub-prefix admin → denied ('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.
  • Unrelated-tenant admin → denied — the baseline gate.
  • Tenant admin → clears the gate, then fails at the bucket-access check; asserted as "the failure is not the capability denial" (bucket-error wording varies by environment). Guards against over-tightening. A full success path would require a real bucket and is out of scope.

The harness's APPLIED_DIRECTIVES runner now refreshes the executor Snapshot before polling, mirroring the publications/discovers arms.

Not in scope

The #2848 workaround ensure_private_data_plane_grants stays; its comment is updated to state that the remaining user_roles() surface is SQL-only. Removing it is gated on the RLS-side migration.

@bbartman
bbartman force-pushed the bmb/strangle-3-storage-mappings-directive branch from 92d73d0 to 55798dc Compare August 28, 2026 17:48
@bbartman
bbartman force-pushed the bmb/strangle-3-storage-mappings-directive branch 2 times, most recently from 022100b to 1caa8fe Compare September 1, 2026 12:02
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
bbartman force-pushed the bmb/strangle-3-storage-mappings-directive branch from 1caa8fe to ae2a1a3 Compare September 1, 2026 13:29
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