control-plane: guarded, auto-expiring temporary support access to restricted tenants#3115
Conversation
…ed tenants Restricted tenants are deliberately not attached to estuary_support/, so support cannot reach them by default. This adds an internal.support_access tracking table plus SECURITY DEFINER functions to grant, revoke, and expire temporary support access to a single tenant. Operators receive EXECUTE on the functions (granted out of band by the departmental-roles tooling) and never write public.role_grants directly, so they cannot escalate their own access. The grant function only attaches estuary_support/ to a named, existing tenant and logs who granted it, why, and when it expires. Expiry is enforced by pg_cron on the Supabase database, registered out of band since pg_cron is absent from the sqlx test cluster. The sweeper only removes grants that have a support_access tracking row, so the permanent estuary_support/ grants normal tenants receive are never affected. Adds pgTAP coverage in supabase/tests/support_access.test.sql. Implements ADR estuary/security#746.
ad2b4c6 to
f8f4e2e
Compare
The temporary-access functions matched role_grants rows solely on (subject_role, object_role), so they could not tell a temporary grant from the permanent estuary_support/ grant every unrestricted tenant carries: - grant_support_access() on an unrestricted tenant hit the unique conflict, did nothing, but still inserted a tracking row -- arming expire_support_access() to delete the permanent grant later. - revoke_support_access() deleted unconditionally, wiping a permanent grant with no audit trace. - Overlapping windows (a grant extended before it lapsed) share one role_grants row; expiring the earlier window detached the tenant while the later window was still open. A role_grants row is now deleted only while an unrevoked support_access row owns it: grant refuses unrestricted tenants unless extending an active window, revoke requires an active tracking row, and the sweeper skips tenants that still have an open window (and counts only rows actually detached). Also switch the functions to the repo-wide `set search_path to ''` SECURITY DEFINER convention, and extend the pgTAP tests to cover the unrestricted-tenant and overlapping-window cases with exact error message assertions.
qaoj
left a comment
There was a problem hiding this comment.
I think everything here makes sense- SQL edge cases are hard to reason about without actually running it...
|
/cc @GregorShear We've had conversations about adding |
jgraettinger
left a comment
There was a problem hiding this comment.
(marking request changes because I'd like to get group consensus on where these new expiration columns live)
There was a problem hiding this comment.
As far as I see it, we want to accomplish two things here: as we're moving to individual database users for support access, we need a way for staff to record and time-limit support-role use ASAP. Simultaneously, we also want this design to lead into the feature that will allow customers to ask for support themselves, when we get there.
After doing some digging and remembering/loading this into context, I agree with @jgraettinger -- I think it would be better to frame this as plumbing overtop of a new declarative expiration timestamp on grants, rather than as an imperative periodic cron task to delete specific grants.
The internal.support_access table is good because it acts as an audit log of who took what action when: it's possible to have multiple support access requests targeting a specific grant(s), and we'll want to store a log of that full history. I also think the SECURITY DEFINER functions are a great way to enable support staff (via their individual LOGON roles) to record access decisions.
In addition to adding support for expiring grants to the snapshot logic in rust, we'll also need to add it to RLS as we still rely on that for a bunch of authorization decisions today
|
I don't think I have much to contribute here - adding the expiration directly to the user/role_grants sounded like a pretty lightweight change to make on the snapshot. The expiration mechanism is easy to reason about, though I don't have a good sense of how big or risky of a change is needed on the RLS side while it's still alive. |
baaa725 to
b1125f7
Compare
24b9172 to
71d5ff6
Compare
To my knowledge, RLS is used only for AuthZ internal to a publication (e.x. task to task access) today. All gate-keeping about what a user may access from a data-plane is already brokered by the authorization API and its snapshots. I'd recommend a) not touching RLS, b) extending role_grants and user_grants with expiration timestamps (that RLS checks wouldn't use, but snapshots fetches filter on), and c) retaining a cron that clears out grants which have expired (which bounds how long the legacy RLS could continue to allow them). |
|
PTAL @jshearer or @jgraettinger. The natives who would like access to support customers are restless. |
Per PR review, expiration timestamps now live on the grant tables themselves rather than only in the internal.support_access tracking table: both role_grants and user_grants gain a nullable expires_at, where NULL (every pre-existing row) means permanent. Enforcement is unchanged from the sweeper design -- expired rows are DELETED on a schedule -- so no Rust, RLS, authorization-function, or view changes are needed; deletion propagates to every consumer by construction. The columns make the safety guards structural rather than ownership-tracked: - grant_support_access() is one race-free upsert: a fresh grant inserts with an expiry, an extension takes the later expiry (greatest), and the conflict guard's WHERE expires_at IS NOT NULL makes converting a permanent grant impossible (greatest(NULL, x) = x, so the guard is load-bearing). The audit row records the effective expiry. - revoke_support_access() deletes only rows with a non-NULL expires_at, so it cannot detach an unrestricted tenant's permanent grant. It deletes immediately: with no read-side expiry filters, a lapsed-but-unswept row is still live access. - expire_support_access() sweeps both tables where expires_at has passed. Overlapping support windows share one row carrying the latest expiry, so nothing is removed while a window remains open, and permanent grants can never match. expires_at gets SELECT-only column grants for PostgREST-facing roles: readable (required for RETURNING-representation dashboard mutations, pinned by the existing lives_ok tests) but writable only through the SECURITY DEFINER functions. internal.support_access remains as the append-only audit log. Tests: pgTAP covers grant/extend/re-grant-over-lapsed/revoke semantics, permanent-grant refusal, and the sweep across both tables, driven by direct expires_at updates. Full pgTAP suite passes; the control-plane-api suite passes serially against the migrated schema with zero Rust changes, confirming forward compatibility of the added columns.
71d5ff6 to
de5811f
Compare
|
@jshearer I've reworked this to reflect what we discussed on the phone today, at least as I understood it; tell me if I got it wrong.
Read-side filtering of |
|
Confirming your summary matches my understanding 👍 |
| update internal.support_access | ||
| set revoked_at = now(), revoked_by = 'internal.expire_support_access' | ||
| where revoked_at is null and expires_at <= now(); |
There was a problem hiding this comment.
Claude found the following review around handling of multiple overlapping (or subsequently extended) support access grants and whether we should mark them revoked_at=now() if we don't actually revoke anything. I'm happy for this to be a nothing burger, just thought I'd raise it
The revoked_at/revoked_by update here matches on the audit row's own expires_at, which we freeze at grant time and never move when a window gets extended. So the audit row and the role_grants row it's tracking can end up with different expiries, and the sweep stamps a row revoked while support access is still live.
Say you grant an hour, then extend to 24. The extension bumps the single role_grants row to +24h but leaves the first audit row sitting at +1h. An hour in, the sweep deletes nothing from role_grants (access is still live) but marks that first audit row revoked_by = 'internal.expire_support_access', so the access-review trail says support lost access at +1h when it really had another 23 hours. Same thing the other way: if the grant row gets deleted early out of band, the audit rows sit open until their expires_at and then get stamped as if the window ran its full course.
revoked_at is null and expires_at <= now() already tells you a row has lapsed, so I'd drop this update and only set revoked_at/revoked_by from revoke_support_access (which also kills the sentinel revoked_by string).
There was a problem hiding this comment.
Not a nothing burger, it was real: the sweep could stamp a subsumed window as revoked by enforcement while the tenant's access was still live. Fixed in 29fd9eb by going one further than the suggestion: the sweep no longer writes internal.support_access at all. revoked_at/revoked_by now strictly mean a named person revoked explicitly, a lapsed window is self-describing (revoked_at IS NULL and expires_at <= now()), and the sentinel revoked_by string is gone. Tests assert lapsed audit rows stay unstamped.
| if p_reason is null or btrim(p_reason) = '' then | ||
| raise exception 'a reason is required for temporary support access'; | ||
| end if; |
There was a problem hiding this comment.
nit: should we be asserting that the duration is positive?
if p_duration <= interval '0' then
raise exception 'support access duration must be positive';
end if;There was a problem hiding this comment.
Taken in 29fd9eb, with a NULL check too: now() + NULL computes a NULL expires_at, i.e. a permanent-looking grant. It could not actually escape (the audit insert's NOT NULL aborts the transaction) but that was safety by accident with a confusing error. Both cases now raise 'support access duration must be positive', and tests cover each.
Review feedback on the support-access functions: - grant_support_access() rejects a NULL or non-positive duration. A NULL duration computed a NULL expires_at, a permanent-looking grant; the audit insert's NOT NULL constraint would abort the transaction anyway, but only with a confusing error. - expire_support_access() no longer stamps audit rows revoked. Audit rows carry per-request expiries that an extension deliberately does not move, so the sweep could mark a subsumed window as revoked by enforcement while the tenant's access was still live, and the sentinel revoked_by string conflated enforcement with revocation. revoked_at/revoked_by now strictly record explicit revocation by a named person; a row with revoked_at NULL and a past expires_at lapsed on schedule.
Fixes from a final review pass: - revoke_support_access() stamps only windows still open (expires_at > now()). Without the filter, audit rows of long-lapsed windows (which stay revoked_at NULL by design) were mis-stamped as explicitly revoked by whoever next revoked that tenant, corrupting the access-review record. - The extension upsert re-asserts capability = 'admin'. PostgREST RLS lets a tenant's own admins update role_grants.capability, so an extension could otherwise preserve an out-of-band downgrade while logging an admin-implying audit row. - detail now stays with the request whose window is in force: a shorter no-op extension no longer re-attributes the standing window to the losing request. - Partial indexes on role_grants/user_grants (expires_at) WHERE expires_at IS NOT NULL: the per-minute sweep matches almost no rows and otherwise sequential-scans both tables. - The audit insert relies on the flowid domain default rather than calling internal.id_generator() explicitly, matching the table definition's comment. Tests cover the lapsed-history revoke, the capability re-assert, and the detail attribution.
What
Adds declarative expiration timestamps to the grant tables, and a guarded,
auto-expiring mechanism for temporary support access to restricted tenants
that are deliberately excluded from the standing
estuary_support/role.Why
Today, giving a restricted tenant temporary support access means an operator with
broad database access hand-writes a
role_grantsrow and is trusted to remove it.There is no expiry, no record of who granted it or why, and it requires write
access to the system-wide authorization table, which is also the ability to grant
anyone admin on anything. See ADR estuary/security#746.
What's in this PR
One migration plus its pgTAP tests. No Rust, snapshot, RLS, or
authorization-function changes.
role_grants.expires_atanduser_grants.expires_at(nullable timestamptz).NULL means permanent, which is every pre-existing row. SELECT-only column
grants for PostgREST-facing roles: readable (required for
RETURNING *dashboard mutations) but writable only through the functionsbelow.
internal.support_access: append-only audit log of who granted or revokedwhat, when, and why. Doubles as access-review evidence.
internal.grant_support_access(tenant, reason, duration): attachesestuary_support/admin to the tenant with an expiry, as one race-freeupsert. A repeat call extends the window (later expiry wins); a tenant that
already has permanent support access is refused.
internal.revoke_support_access(tenant): detaches immediately and marks theaudit rows revoked.
internal.expire_support_access(): deletes rows in both grant tables whoseexpires_athas passed. Run on a schedule (see below).EXECUTErevoked fromPUBLICon all three functions.Why it's safe
EXECUTEon the functions and never writerole_grantsdirectly, so they cannot escalate their own access. The functions only attach
or detach
estuary_support/on a named, existing tenant, and recordsession_user, reason, and expiry.(revoke, sweep) matches only rows with a non-NULL
expires_at, so thepermanent grants normal tenants receive can never be touched. The grant
function's conflict guard likewise refuses to convert a permanent grant into
a temporary one.
consumer of these tables (RLS, the authorization snapshot, publications) with
no changes to any of them.
Deliberately not in this PR
Read-side filtering of
expires_at(inuser_roles/task_roles, RLS, or theRust snapshot loader). That would allow expiry without deletion, and composes
cleanly on top of these columns as a follow-up if we want it. Nothing writes
user_grants.expires_atyet; the column and its sweep exist so the semanticsare uniform and ready for future use (e.g. customer-initiated support access).
Required at rollout (out of band, not in this migration)
Register the expiry sweep once on the Supabase database, after the
migration is applied (
pg_cronis absent from the sqlx test cluster, so themigration cannot do it):
Verify it's scheduled and firing:
Note the sweep is the only enforcement of expiry: if the job stops, lapsed
grants stay live until revoked manually. Check
cron.job_run_detailsaspart of access reviews.
Grant
EXECUTEon the grant/revoke functions to operator roles via thedepartmental database-roles tooling (a separate private repo, not flow
migrations).
Testing
supabase db resetapplies all migrations cleanly.input validation with exact error messages; refusal to grant over or revoke a
permanent grant; window extension (later expiry wins, single row per tenant,
audit records effective expiry); re-grant over a lapsed-but-unswept window;
immediate revoke; and the sweep across both tables, sparing still-open
windows and permanent grants. Full pgTAP suite passes.
control-plane-apiRust test suite passes against the migrated schemawith zero Rust changes, confirming the added columns are forward-compatible.
Related