Skip to content

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

Open
skord wants to merge 1 commit into
masterfrom
mdanko/temporary-support-access
Open

control-plane: guarded, auto-expiring temporary support access to restricted tenants#3115
skord wants to merge 1 commit into
masterfrom
mdanko/temporary-support-access

Conversation

@skord

@skord skord commented Jul 2, 2026

Copy link
Copy Markdown
Member

Adds a guarded mechanism for time-boxed support access to restricted tenants,
built on declarative grant expiry. Implements ADR estuary/security#746 (the
reference implementation it evaluates); redesigned per review feedback from
the sweeper/tracking-table approach to declarative expiry.

Design

  • role_grants and user_grants gain a nullable expires_at; NULL means
    permanent (every pre-existing row). internal.live_role_grants /
    internal.live_user_grants are the single definition of a "live" grant, and
    every authorization read — the SQL cores (user_roles, task_roles),
    combined_grants_ext, the SSO-migration trigger, and the control-plane Rust
    queries — selects through them. An expired grant is inert everywhere the
    moment it lapses: no sweeper, no cron job, nothing to fail open. Expired
    rows deliberately linger as history; the unique constraints bound growth to
    one row per (subject, object) pair.

  • internal.grant_support_access(tenant, reason, duration default '24h') and
    internal.revoke_support_access(tenant) perform the only sanctioned
    role_grants writes:

    select internal.grant_support_access('acmeCo/', 'debugging ticket SUP-123');
    select internal.revoke_support_access('acmeCo/');

    Operators receive EXECUTE only (granted out of band from the private
    access-management tooling) and never write the authorization table, so they
    cannot escalate their own access.

  • internal.support_access records every grant and revoke with session_user
    attribution, reason, and effective expiry — the access-review evidence.
    Enforcement truth stays in role_grants.

  • The control-plane authorization Snapshot filters expired grants at fetch and
    bounds its refresh interval by the nearest future expiry, so a lapse or
    early revoke reaches data-plane authorization within ~1 minute rather than
    the full refresh interval. (Already-minted authorizations still run to their
    own exp, up to MAX_AUTHORIZATION — parity with grant deletion today.)

Safety properties (pinned by pgTAP tests)

  • Granting over a tenant's permanent support grant refuses; revoking a
    tenant with only a permanent grant refuses. A temporary window can never
    convert a permanent grant, and vice versa.
  • A reason is required; duration must be positive.
  • Overlapping grants extend a single row — the later expiry wins, and a
    shorter no-op "extension" neither shortens the window nor re-attributes it.
  • Revoke lapses the grant in place and closes the audit record — including
    when the enforcement row was deleted directly (tenant admins can do that via
    RLS, as before).
  • Re-provisioning a previously-offboarded tenant name tolerates a lingering
    lapsed grant (the tenant-insert trigger resurrects it to the default
    permanent grant).
  • SSO account migration carries expiry through grant transfer: dead grants are
    not transferred, and colliding grants merge with permanence outranking
    capability, so a temporary elevation can never become permanent.

Testing

pgTAP: support_access.test.sql (new), plus expiry coverage in auth,
combined_grants_ext, and sso_grant_migration. Rust: snapshot fetch test
covers expired-grant exclusion and the min-expiry refresh bound. Grant/revoke
exercised end-to-end against a local stack.

Known follow-ups (deliberately out of scope)

Kept out to avoid growing this PR; none are reachable with what ships here
(nothing yet creates temporary user_grants — support access is granted
through role_grants):

  1. Rust grant writers vs. lapsed rowsupsert_user_grant (invite
    redemption) and the ON CONFLICT DO NOTHING grant installers treat "row
    exists" as "grant in force"; a lapsed user_grants row would block
    re-granting through those paths. Becomes real if/when temporary user
    grants ship.
  2. Minted-token tail — data-plane authorizations minted just before a
    revoke stay valid up to ~80 min (MAX_AUTHORIZATION); bounding minted
    exp by grant expiry (as cordon_at already does) would close it.
  3. Live-view column freeze — the live_*_grants views snapshot their
    column list; a future ALTER TABLE ... ADD COLUMN on the grants tables
    needs a matching view recreate.
  4. SSO both-temporary merge — two colliding temporary grants merge to
    max-capability + later-expiry (never loses access, may extend a short
    strong window to a longer weak grant's horizon). Unreachable today;
    revisit alongside (1).

@skord skord requested a review from jgraettinger July 2, 2026 23:44
@skord skord self-assigned this Jul 2, 2026
@skord skord force-pushed the mdanko/temporary-support-access branch from ad2b4c6 to f8f4e2e Compare July 2, 2026 23:50
@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 from b1125f7 to 24b9172 Compare July 8, 2026 20:49
…tricted tenants

Restricted tenants are deliberately not attached to the estuary_support/
role, so support cannot reach them by default. Add a guarded mechanism
for time-boxed support access, built on declarative grant expiry:

- role_grants and user_grants gain a nullable expires_at; NULL means
  permanent (every pre-existing row). internal.live_role_grants and
  live_user_grants are the single definition of a "live" grant, and
  every authorization read - the SQL cores (user_roles, task_roles),
  combined_grants_ext, the SSO-migration trigger, and the control-plane
  Rust queries - selects through them, so an expired grant is inert
  everywhere the moment it lapses. There is no sweeper and no cron job:
  expiry cannot fail open, and expired rows deliberately linger as
  history (the unique constraints bound growth to one row per pair).

- internal.grant_support_access(tenant, reason, duration default 24h)
  and internal.revoke_support_access(tenant) perform the only
  sanctioned role_grants writes. Operators receive EXECUTE only
  (granted out of band) and never write the authorization table, so
  they cannot escalate their own access. The functions refuse to
  convert or lapse a tenant's permanent grant, require a reason and a
  positive duration, extend a still-open window in place (the later
  expiry wins, without re-attributing it), and record every grant and
  revoke with session_user attribution in internal.support_access,
  which doubles as access-review evidence. Revocation closes the audit
  record even if the enforcement row was removed directly, and the
  tenant-insert trigger tolerates a lingering lapsed grant when a
  previously-offboarded tenant name is re-provisioned.

- The control-plane authorization Snapshot filters expired grants at
  fetch and bounds its refresh interval by the nearest future expiry,
  so a lapse or early revoke propagates to data-plane authorization
  within about a minute rather than the full refresh interval.

- The SSO account-migration trigger carries expiry through grant
  transfer: dead grants are not transferred, and colliding grants merge
  with permanence outranking capability, so a temporary elevation can
  never become permanent.

Covered by pgTAP tests (support_access, auth, combined_grants_ext,
sso_grant_migration) and a Rust snapshot test. See ADR
estuary/security#746.
@skord skord force-pushed the mdanko/temporary-support-access branch 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).

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