control-plane: guarded, auto-expiring temporary support access to restricted tenants#3115
control-plane: guarded, auto-expiring temporary support access to restricted tenants#3115skord wants to merge 1 commit into
Conversation
ad2b4c6 to
f8f4e2e
Compare
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
b1125f7 to
24b9172
Compare
…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.
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). |
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_grantsanduser_grantsgain a nullableexpires_at;NULLmeanspermanent (every pre-existing row).
internal.live_role_grants/internal.live_user_grantsare the single definition of a "live" grant, andevery authorization read — the SQL cores (
user_roles,task_roles),combined_grants_ext, the SSO-migration trigger, and the control-plane Rustqueries — 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')andinternal.revoke_support_access(tenant)perform the only sanctionedrole_grantswrites: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_accessrecords every grant and revoke withsession_userattribution, 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 toMAX_AUTHORIZATION— parity with grant deletion today.)Safety properties (pinned by pgTAP tests)
tenant with only a permanent grant refuses. A temporary window can never
convert a permanent grant, and vice versa.
shorter no-op "extension" neither shortens the window nor re-attributes it.
when the enforcement row was deleted directly (tenant admins can do that via
RLS, as before).
lapsed grant (the tenant-insert trigger resurrects it to the default
permanent grant).
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 inauth,combined_grants_ext, andsso_grant_migration. Rust: snapshot fetch testcovers 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 grantedthrough
role_grants):upsert_user_grant(inviteredemption) and the
ON CONFLICT DO NOTHINGgrant installers treat "rowexists" as "grant in force"; a lapsed
user_grantsrow would blockre-granting through those paths. Becomes real if/when temporary user
grants ship.
revoke stay valid up to ~80 min (
MAX_AUTHORIZATION); bounding mintedexpby grant expiry (ascordon_atalready does) would close it.live_*_grantsviews snapshot theircolumn list; a future
ALTER TABLE ... ADD COLUMNon the grants tablesneeds a matching view recreate.
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).