Skip to content

control-plane: guarded, auto-expiring temporary support access to restricted tenants#3115

Merged
skord merged 5 commits into
masterfrom
mdanko/temporary-support-access
Jul 16, 2026
Merged

control-plane: guarded, auto-expiring temporary support access to restricted tenants#3115
skord merged 5 commits into
masterfrom
mdanko/temporary-support-access

Conversation

@skord

@skord skord commented Jul 2, 2026

Copy link
Copy Markdown
Member

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_grants row 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_at and user_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 functions
    below.
  • internal.support_access: append-only audit log of who granted or revoked
    what, when, and why. Doubles as access-review evidence.
  • internal.grant_support_access(tenant, reason, duration): attaches
    estuary_support/ admin to the tenant with an expiry, as one race-free
    upsert. 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 the
    audit rows revoked.
  • internal.expire_support_access(): deletes rows in both grant tables whose
    expires_at has passed. Run on a schedule (see below).
  • EXECUTE revoked from PUBLIC on all three functions.

Why it's safe

  • Operators get EXECUTE on the functions and never write role_grants
    directly, so they cannot escalate their own access. The functions only attach
    or detach estuary_support/ on a named, existing tenant, and record
    session_user, reason, and expiry.
  • Permanent grants are protected structurally: every destructive statement
    (revoke, sweep) matches only rows with a non-NULL expires_at, so the
    permanent 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.
  • Because expiry is enforced by deleting the grant row, it propagates to every
    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 (in user_roles/task_roles, RLS, or the
Rust 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_at yet; the column and its sweep exist so the semantics
are uniform and ready for future use (e.g. customer-initiated support access).

Required at rollout (out of band, not in this migration)

  1. Register the expiry sweep once on the Supabase database, after the
    migration is applied (pg_cron is absent from the sqlx test cluster, so the
    migration cannot do it):

    select cron.schedule('expire-support-access', '* * * * *',
                         $$ select internal.expire_support_access() $$);

    Verify it's scheduled and firing:

    select jobid, jobname, schedule, active from cron.job
      where jobname = 'expire-support-access';
    select * from cron.job_run_details order by start_time desc limit 5;

    Note the sweep is the only enforcement of expiry: if the job stops, lapsed
    grants stay live until revoked manually. Check cron.job_run_details as
    part of access reviews.

  2. Grant EXECUTE on the grant/revoke functions to operator roles via the
    departmental database-roles tooling (a separate private repo, not flow
    migrations).

Testing

  • supabase db reset applies all migrations cleanly.
  • pgTAP covers: grant attaches with a future expiry and logs the audit row;
    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.
  • The control-plane-api Rust test suite passes against the migrated schema
    with zero Rust changes, confirming the added columns are forward-compatible.

Related

  • Implements ADR estuary/security#746.

@skord
skord requested a review from jgraettinger July 2, 2026 23:44
@skord skord self-assigned this Jul 2, 2026
…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.
@skord
skord force-pushed the mdanko/temporary-support-access branch from ad2b4c6 to f8f4e2e Compare July 2, 2026 23:50
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.
@skord
skord requested review from a team and removed request for jgraettinger July 6, 2026 14:11
qaoj
qaoj previously approved these changes Jul 6, 2026

@qaoj qaoj left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think everything here makes sense- SQL edge cases are hard to reason about without actually running it...

@jgraettinger

Copy link
Copy Markdown
Member

/cc @GregorShear

We've had conversations about adding expires_at / revoked_at or similar as nullable new columns of user_grants and role_grants. Thoughts on doing that instead, and making it the primary lever for support access? In particular we can have the Snapshot loader push down filters on these columns so they're reacted too immediately with each Snapshot refresh.

@jgraettinger jgraettinger left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(marking request changes because I'd like to get group consensus on where these new expiration columns live)

@jshearer jshearer left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@GregorShear

Copy link
Copy Markdown
Contributor

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.

@skord
skord force-pushed the mdanko/temporary-support-access branch from baaa725 to b1125f7 Compare July 8, 2026 19:33
@skord skord changed the title supabase: guarded, auto-expiring temporary support access to restricted tenants control-plane: guarded, auto-expiring temporary support access to restricted tenants Jul 8, 2026
@skord
skord requested review from jgraettinger and jshearer July 8, 2026 19:36
@skord
skord force-pushed the mdanko/temporary-support-access branch 2 times, most recently from 24b9172 to 71d5ff6 Compare July 8, 2026 21:00
@jgraettinger

Copy link
Copy Markdown
Member

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

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).

@skord

skord commented Jul 15, 2026

Copy link
Copy Markdown
Member Author

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.
@skord
skord force-pushed the mdanko/temporary-support-access branch from 71d5ff6 to de5811f Compare July 15, 2026 15:09
@skord

skord commented Jul 15, 2026

Copy link
Copy Markdown
Member Author

@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.

  • expires_at (nullable timestamptz) now lives on role_grants and user_grants directly. NULL means permanent, which is every pre-existing row.
  • Enforcement is unchanged from the original design: the scheduled sweeper deletes lapsed rows, so expiry propagates to every consumer with no changes anywhere else. No Rust/snapshot changes, no RLS or authorization-function changes; the PR is one migration plus its pgTAP tests.
  • The columns make the safety guards structural: nothing with a NULL expires_at can ever be deleted by the grant/revoke/sweep paths, so permanent grants are protected by shape rather than by bookkeeping. Extending a window is a greatest() upsert on the tenant's single grant row.
  • internal.support_access stays as the append-only audit log of who granted/revoked what and why.

Read-side filtering of expires_at (SQL cores, snapshot push-down) composes cleanly on top of these columns later if we want expiry without deletion. Deliberately not in this PR.

@jgraettinger

Copy link
Copy Markdown
Member

Confirming your summary matches my understanding 👍

jshearer
jshearer previously approved these changes Jul 15, 2026

@jshearer jshearer left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM % two small pieces of feedback neither of which I believe are blocking. The overlapping/extended-grants finding seems legit but also probably unlikely to actually happen in real life? WDYT

Comment on lines +180 to +182
update internal.support_access
set revoked_at = now(), revoked_by = 'internal.expire_support_access'
where revoked_at is null and expires_at <= now();

@jshearer jshearer Jul 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +75 to +77
if p_reason is null or btrim(p_reason) = '' then
raise exception 'a reason is required for temporary support access';
end if;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@jshearer jshearer left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@skord
skord merged commit ea2c043 into master Jul 16, 2026
13 of 14 checks passed
@skord
skord deleted the mdanko/temporary-support-access branch July 16, 2026 17:47
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.

5 participants