Skip to content

Commit ea2c043

Browse files
authored
control-plane: guarded, auto-expiring temporary support access to restricted tenants (#3115)
* supabase: guarded, auto-expiring temporary support access to restricted 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. * supabase: only delete support grants owned by a tracking row 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. * supabase: move grant expiry onto role_grants/user_grants columns 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. * supabase: validate grant duration; audit revocation is explicit-only 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. * supabase: harden support-access extension and revoke paths 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.
1 parent 7af0370 commit ea2c043

2 files changed

Lines changed: 556 additions & 0 deletions

File tree

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
begin;
2+
3+
-- Declarative expiration timestamps on grants, enforced by a sweeper, and
4+
-- guarded temporary support access built on them.
5+
--
6+
-- Grants in role_grants and user_grants gain a nullable expires_at. NULL means
7+
-- permanent (every pre-existing row). A grant whose expires_at has passed is
8+
-- deleted by internal.expire_support_access() on a schedule, so expiry
9+
-- propagates to every consumer of these tables (RLS, the authorization
10+
-- snapshot, publications) with no changes to any of them. Because the guard is
11+
-- structural -- only rows with a non-NULL expires_at are ever deleted -- the
12+
-- sweeper and revoke can never touch a permanent grant.
13+
--
14+
-- Restricted tenants are deliberately not attached to the estuary_support/
15+
-- role, so support cannot reach them by default. The functions below let an
16+
-- operator grant time-boxed support access to a single tenant WITHOUT direct
17+
-- write access to role_grants (the system-wide authorization table): operators
18+
-- receive EXECUTE on the functions (granted out of band), and the functions
19+
-- perform the single sanctioned role_grants write as their owner. A caller
20+
-- therefore cannot write arbitrary grants and cannot escalate their own
21+
-- access. See ADR estuary/security#746.
22+
23+
alter table public.role_grants add column expires_at timestamptz;
24+
alter table public.user_grants add column expires_at timestamptz;
25+
26+
comment on column public.role_grants.expires_at is
27+
'Time at which this grant lapses and is removed by the expiry sweep. NULL means the grant is permanent.';
28+
comment on column public.user_grants.expires_at is
29+
'Time at which this grant lapses and is removed by the expiry sweep. NULL means the grant is permanent.';
30+
31+
-- These tables use column-scoped grants (see 20260511120000_orthogonal_capabilities.sql),
32+
-- and PostgREST `return=representation` (`RETURNING *`) requires read access to
33+
-- every column of rows the dashboard mutates. SELECT only: PostgREST-facing
34+
-- roles may read expiry but must not set it -- expires_at is written solely by
35+
-- SECURITY DEFINER functions.
36+
grant select (expires_at) on public.role_grants to authenticated, marketplace_integration, reporting_user;
37+
grant select (expires_at) on public.user_grants to authenticated, reporting_user;
38+
39+
-- The expiry sweep runs every minute and matches almost no rows (nearly every
40+
-- grant is permanent). Partial indexes keep it from scanning the full tables.
41+
create index idx_role_grants_expires_at on public.role_grants (expires_at) where expires_at is not null;
42+
create index idx_user_grants_expires_at on public.user_grants (expires_at) where expires_at is not null;
43+
44+
-- Append-only audit log and access-review evidence for temporary support
45+
-- grants: who granted or revoked what, when, and why. The grant rows
46+
-- themselves (via expires_at) drive enforcement.
47+
create table internal.support_access (
48+
id public.flowid primary key, -- defaults via the flowid domain
49+
object_role public.catalog_prefix not null, -- the tenant, e.g. 'acmeCo/'
50+
granted_by text not null, -- the operator's own login role
51+
reason text not null,
52+
granted_at timestamptz not null default now(),
53+
expires_at timestamptz not null,
54+
revoked_at timestamptz,
55+
revoked_by text
56+
);
57+
58+
comment on table internal.support_access is
59+
'Append-only audit log for temporary estuary_support/ grants created via '
60+
'internal.grant_support_access(). Doubles as access-review evidence. '
61+
'revoked_at/revoked_by strictly record explicit revocation; a row with '
62+
'revoked_at NULL and a past expires_at lapsed on schedule.';
63+
64+
-- Attach support to a tenant for a bounded window, and log it. session_user
65+
-- (the caller''s own login role, preserved through SECURITY DEFINER) records
66+
-- who did it.
67+
create function internal.grant_support_access(
68+
p_tenant public.catalog_prefix,
69+
p_reason text,
70+
p_duration interval default interval '24 hours'
71+
)
72+
returns internal.support_access
73+
language plpgsql
74+
security definer
75+
-- https://www.postgresql.org/docs/current/sql-createfunction.html#SQL-CREATEFUNCTION-SECURITY
76+
set search_path to ''
77+
as $$
78+
declare
79+
v_expires timestamptz;
80+
v_row internal.support_access;
81+
begin
82+
if p_reason is null or btrim(p_reason) = '' then
83+
raise exception 'a reason is required for temporary support access';
84+
end if;
85+
86+
-- A NULL duration would compute a NULL expires_at: a permanent-looking grant.
87+
-- (The audit insert's NOT NULL would abort it anyway, but fail clearly here.)
88+
if p_duration is null or p_duration <= interval '0' then
89+
raise exception 'support access duration must be positive';
90+
end if;
91+
92+
if not exists (select 1 from public.tenants t where t.tenant = p_tenant) then
93+
raise exception 'unknown tenant: %', p_tenant;
94+
end if;
95+
96+
-- The only role_grants write this function can make: attach support to the
97+
-- tenant with an expiry. An expires_at of NULL marks the permanent grant an
98+
-- unrestricted tenant carries, and the conflict guard refuses to touch it --
99+
-- without the guard, greatest(NULL, x) = x would silently convert a
100+
-- permanent grant into a temporary one. A prior temporary grant (live or
101+
-- lapsed-but-unswept) is extended instead: the later expiry wins.
102+
insert into public.role_grants (subject_role, object_role, capability, detail, expires_at)
103+
values ('estuary_support/', p_tenant, 'admin',
104+
format('temporary support access by %s: %s', session_user, p_reason),
105+
now() + p_duration)
106+
on conflict (subject_role, object_role) do update
107+
set expires_at = greatest(public.role_grants.expires_at, excluded.expires_at),
108+
-- Re-assert admin: PostgREST RLS lets the tenant's own admins update
109+
-- capability, so an extension must not silently preserve a downgrade
110+
-- while the audit row implies admin.
111+
capability = excluded.capability,
112+
-- detail stays with the request whose window is actually in force.
113+
detail = case
114+
when excluded.expires_at > public.role_grants.expires_at
115+
then excluded.detail
116+
else public.role_grants.detail
117+
end,
118+
updated_at = now()
119+
where public.role_grants.expires_at is not null
120+
returning expires_at into v_expires;
121+
122+
if not found then
123+
raise exception 'tenant % already has permanent support access', p_tenant;
124+
end if;
125+
126+
-- The audit row records the effective expiry (which may exceed the request,
127+
-- when a longer prior window is still open).
128+
insert into internal.support_access (object_role, granted_by, reason, expires_at)
129+
values (p_tenant, session_user, p_reason, v_expires)
130+
returning * into v_row;
131+
132+
raise log 'support_access granted: tenant=% by=% reason=% expires=%',
133+
p_tenant, session_user, p_reason, v_expires;
134+
return v_row;
135+
end;
136+
$$;
137+
138+
comment on function internal.grant_support_access(public.catalog_prefix, text, interval) is
139+
'Attach estuary_support/ admin to a tenant for a bounded window and log it. '
140+
'Sanctioned alternative to direct role_grants writes; EXECUTE granted out of band.';
141+
142+
-- Detach support from a tenant early and close out its audit rows. Deletes
143+
-- immediately: with no read-side expiry filters, a lapsed-but-unswept row is
144+
-- still live access, so revocation cannot wait for the sweep.
145+
create function internal.revoke_support_access(
146+
p_tenant public.catalog_prefix
147+
)
148+
returns void
149+
language plpgsql
150+
security definer
151+
set search_path to ''
152+
as $$
153+
begin
154+
-- expires_at IS NOT NULL is the structural guard: a permanent grant can
155+
-- never match, so this function cannot detach an unrestricted tenant.
156+
delete from public.role_grants
157+
where subject_role = 'estuary_support/' and object_role = p_tenant
158+
and expires_at is not null;
159+
160+
if not found then
161+
raise exception 'tenant % has no temporary support access to revoke', p_tenant;
162+
end if;
163+
164+
-- Stamp only windows that were still open: rows of long-lapsed windows keep
165+
-- revoked_at NULL (they expired on schedule and this revoke is not theirs).
166+
update internal.support_access
167+
set revoked_at = now(), revoked_by = session_user
168+
where object_role = p_tenant and revoked_at is null and expires_at > now();
169+
170+
raise log 'support_access revoked: tenant=% by=%', p_tenant, session_user;
171+
end;
172+
$$;
173+
174+
comment on function internal.revoke_support_access(public.catalog_prefix) is
175+
'Detach estuary_support/ from a tenant and mark its support_access rows revoked.';
176+
177+
-- Sweep lapsed grants from both tables. The structural guard (expires_at must
178+
-- be non-NULL and past) makes it impossible to remove a permanent grant, and
179+
-- overlapping support windows share one row whose expires_at is always the
180+
-- latest window's, so nothing is removed while any window remains open.
181+
-- Intended to run on a schedule; see the pg_cron note at the end of this file.
182+
create function internal.expire_support_access()
183+
returns integer
184+
language plpgsql
185+
security definer
186+
set search_path to ''
187+
as $$
188+
declare
189+
v_count integer;
190+
begin
191+
with role_swept as (
192+
delete from public.role_grants
193+
where expires_at is not null and expires_at <= now()
194+
returning 1
195+
),
196+
user_swept as (
197+
delete from public.user_grants
198+
where expires_at is not null and expires_at <= now()
199+
returning 1
200+
)
201+
select (select count(*) from role_swept) + (select count(*) from user_swept)
202+
into v_count;
203+
204+
-- The sweep does not touch internal.support_access: revoked_at/revoked_by
205+
-- strictly record explicit revocation by a named person. A lapsed window is
206+
-- already self-describing (revoked_at IS NULL and expires_at <= now()), and
207+
-- audit rows carry per-request expiries that an extension deliberately does
208+
-- not move, so stamping them here would mislabel subsumed windows as revoked.
209+
210+
return v_count;
211+
end;
212+
$$;
213+
214+
comment on function internal.expire_support_access() is
215+
'Removes grants whose expires_at has passed. Run on a schedule.';
216+
217+
-- Keep these off PUBLIC. EXECUTE is granted to specific operator roles out of band
218+
-- (those roles are managed in a separate private repo, not by flow migrations).
219+
revoke all on function internal.grant_support_access(public.catalog_prefix, text, interval) from public;
220+
revoke all on function internal.revoke_support_access(public.catalog_prefix) from public;
221+
revoke all on function internal.expire_support_access() from public;
222+
223+
-- Expiry enforcement runs via pg_cron, which exists only on the Supabase database
224+
-- (not the sqlx test cluster), so it is NOT scheduled here. Register it once,
225+
-- out of band, on the Supabase database:
226+
--
227+
-- select cron.schedule('expire-support-access', '* * * * *',
228+
-- $$ select internal.expire_support_access() $$);
229+
230+
commit;

0 commit comments

Comments
 (0)