diff --git a/supabase/migrations/20260630120000_support_access_functions.sql b/supabase/migrations/20260630120000_support_access_functions.sql new file mode 100644 index 00000000000..817b81225c1 --- /dev/null +++ b/supabase/migrations/20260630120000_support_access_functions.sql @@ -0,0 +1,144 @@ +begin; + +-- Guarded, auto-expiring temporary support access to restricted tenants. +-- +-- Restricted tenants are deliberately not attached to the estuary_support/ role, +-- so support cannot reach them by default. These objects +-- let an operator grant time-boxed support access to a single tenant WITHOUT +-- direct write access to public.role_grants (the system-wide authorization table): +-- operators receive EXECUTE on the functions below (granted out of band), and the +-- functions perform the single sanctioned role_grants write as their owner. A +-- caller therefore cannot write arbitrary grants and cannot escalate their own +-- access. See ADR estuary/security#746. + +-- Source of truth for temporary grants, separate from role_grants so expiry can +-- never touch the permanent estuary_support/ grants every tenant receives. +create table internal.support_access ( + id public.flowid primary key, -- defaults via the flowid domain + object_role public.catalog_prefix not null, -- the tenant, e.g. 'acmeCo/' + granted_by text not null, -- the operator's own login role + reason text not null, + granted_at timestamptz not null default now(), + expires_at timestamptz not null, + revoked_at timestamptz, + revoked_by text +); + +comment on table internal.support_access is + 'Audit log and lifecycle record for temporary estuary_support/ grants created via ' + 'internal.grant_support_access(). Doubles as access-review evidence.'; + +-- Attach support to a tenant for a bounded window, and log it. session_user (the +-- caller''s own login role, preserved through SECURITY DEFINER) records who did it. +create function internal.grant_support_access( + p_tenant public.catalog_prefix, + p_reason text, + p_duration interval default interval '24 hours' +) +returns internal.support_access +language plpgsql +security definer +set search_path = pg_catalog, public, internal, pg_temp +as $$ +declare + v_row internal.support_access; +begin + if p_reason is null or btrim(p_reason) = '' then + raise exception 'a reason is required for temporary support access'; + end if; + + if not exists (select 1 from public.tenants t where t.tenant = p_tenant) then + raise exception 'unknown tenant: %', p_tenant; + end if; + + -- The only role_grants write this function can make: attach support to the tenant. + insert into public.role_grants (subject_role, object_role, capability, detail) + values ('estuary_support/', p_tenant, 'admin', + format('temporary support access by %s: %s', session_user, p_reason)) + on conflict (subject_role, object_role) do nothing; + + insert into internal.support_access (id, object_role, granted_by, reason, expires_at) + values (internal.id_generator(), p_tenant, session_user, p_reason, now() + p_duration) + returning * into v_row; + + raise log 'support_access granted: tenant=% by=% reason=%', + p_tenant, session_user, p_reason; + return v_row; +end; +$$; + +comment on function internal.grant_support_access(public.catalog_prefix, text, interval) is + 'Attach estuary_support/ admin to a tenant for a bounded window and log it. ' + 'Sanctioned alternative to direct role_grants writes; EXECUTE granted out of band.'; + +-- Detach support from a tenant early and close out its audit rows. +create function internal.revoke_support_access( + p_tenant public.catalog_prefix +) +returns void +language plpgsql +security definer +set search_path = pg_catalog, public, internal, pg_temp +as $$ +begin + delete from public.role_grants + where subject_role = 'estuary_support/' and object_role = p_tenant; + + update internal.support_access + set revoked_at = now(), revoked_by = session_user + where object_role = p_tenant and revoked_at is null; + + raise log 'support_access revoked: tenant=% by=%', p_tenant, session_user; +end; +$$; + +comment on function internal.revoke_support_access(public.catalog_prefix) is + 'Detach estuary_support/ from a tenant and mark its support_access rows revoked.'; + +-- Sweep expired grants. Only deletes role_grants rows that have a support_access +-- record, so it can never revoke the permanent estuary_support/ grants. Intended +-- to be run on a schedule; see the pg_cron note at the end of this file. +create function internal.expire_support_access() +returns integer +language plpgsql +security definer +set search_path = pg_catalog, public, internal, pg_temp +as $$ +declare + v_tenant public.catalog_prefix; + v_count integer := 0; +begin + for v_tenant in + select object_role from internal.support_access + where revoked_at is null and expires_at <= now() + loop + delete from public.role_grants + where subject_role = 'estuary_support/' and object_role = v_tenant; + v_count := v_count + 1; + end loop; + + update internal.support_access + set revoked_at = now(), revoked_by = 'internal.expire_support_access' + where revoked_at is null and expires_at <= now(); + + return v_count; +end; +$$; + +comment on function internal.expire_support_access() is + 'Removes temporary support grants whose expires_at has passed. Run on a schedule.'; + +-- Keep these off PUBLIC. EXECUTE is granted to specific operator roles out of band +-- (those roles are managed in a separate private repo, not by flow migrations). +revoke all on function internal.grant_support_access(public.catalog_prefix, text, interval) from public; +revoke all on function internal.revoke_support_access(public.catalog_prefix) from public; +revoke all on function internal.expire_support_access() from public; + +-- Expiry enforcement runs via pg_cron, which exists only on the Supabase database +-- (not the sqlx test cluster), so it is NOT scheduled here. Register it once, +-- out of band, on the Supabase database: +-- +-- select cron.schedule('expire-support-access', '* * * * *', +-- $$ select internal.expire_support_access() $$); + +commit; diff --git a/supabase/tests/support_access.test.sql b/supabase/tests/support_access.test.sql new file mode 100644 index 00000000000..340d8c516d8 --- /dev/null +++ b/supabase/tests/support_access.test.sql @@ -0,0 +1,125 @@ +-- Tests for temporary support access (20260630120000_support_access_functions.sql). +-- Covers: grant attaches estuary_support/ and logs a tracking row; input validation; +-- the sweeper removes only expired, tracked grants and never the permanent +-- estuary_support/ grants a normal tenant receives; and early revoke. +-- +-- A "restricted" tenant is simulated by inserting a tenant and then deleting the +-- estuary_support/ grant the tenant-insert trigger auto-creates, so the baseline +-- is "support not attached". + +create function tests.test_grant_support_access_attaches_and_logs() +returns setof text as $$ +begin + set role postgres; + + insert into tenants (tenant) values ('supportGrant/') on conflict (tenant) do nothing; + delete from role_grants where subject_role = 'estuary_support/' and object_role = 'supportGrant/'; + + return query select is( + (select count(*)::int from role_grants + where subject_role = 'estuary_support/' and object_role = 'supportGrant/'), + 0, 'baseline: support not attached to the restricted tenant'); + + perform internal.grant_support_access('supportGrant/', 'ticket SUP-1', interval '2 hours'); + + return query select is( + (select count(*)::int from role_grants + where subject_role = 'estuary_support/' and object_role = 'supportGrant/' + and capability = 'admin'), + 1, 'grant attaches estuary_support/ admin to the tenant'); + + return query select ok( + exists(select 1 from internal.support_access + where object_role = 'supportGrant/' and revoked_at is null + and reason = 'ticket SUP-1' and expires_at > now()), + 'grant logs a tracking row with reason and a future expiry'); +end; +$$ language plpgsql; + + +create function tests.test_grant_support_access_validates_input() +returns setof text as $$ +begin + set role postgres; + insert into tenants (tenant) values ('supportValid/') on conflict (tenant) do nothing; + + return query select throws_ok( + $q$ select internal.grant_support_access('supportValid/', ' ') $q$, + null, null, 'a blank reason is rejected'); + + return query select throws_ok( + $q$ select internal.grant_support_access('no-such-tenant/', 'valid reason') $q$, + null, null, 'an unknown tenant is rejected'); +end; +$$ language plpgsql; + + +create function tests.test_expire_support_access_sweeps_only_expired_tracked() +returns setof text as $$ +declare + v_count int; +begin + set role postgres; + + -- Restricted tenant with an already-expired temporary grant. + insert into tenants (tenant) values ('supportExpired/') on conflict (tenant) do nothing; + delete from role_grants where subject_role = 'estuary_support/' and object_role = 'supportExpired/'; + perform internal.grant_support_access('supportExpired/', 'expired', interval '-1 hours'); + + -- Restricted tenant with a still-valid temporary grant. + insert into tenants (tenant) values ('supportValidWindow/') on conflict (tenant) do nothing; + delete from role_grants where subject_role = 'estuary_support/' and object_role = 'supportValidWindow/'; + perform internal.grant_support_access('supportValidWindow/', 'valid', interval '2 hours'); + + -- Normal tenant: permanent auto-granted support access, and NO tracking row. + insert into tenants (tenant) values ('supportPermanent/') on conflict (tenant) do nothing; + + select internal.expire_support_access() into v_count; + + return query select is(v_count, 1, 'expire sweeps exactly the one expired grant'); + + return query select is( + (select count(*)::int from role_grants + where subject_role = 'estuary_support/' and object_role = 'supportExpired/'), + 0, 'the expired temporary grant is detached'); + + return query select ok( + exists(select 1 from internal.support_access + where object_role = 'supportExpired/' and revoked_at is not null + and revoked_by = 'internal.expire_support_access'), + 'the expired tracking row is marked revoked by the sweeper'); + + return query select is( + (select count(*)::int from role_grants + where subject_role = 'estuary_support/' and object_role = 'supportValidWindow/'), + 1, 'a still-valid temporary grant is left attached'); + + return query select is( + (select count(*)::int from role_grants + where subject_role = 'estuary_support/' and object_role = 'supportPermanent/'), + 1, 'a permanent support grant (no tracking row) is never swept'); +end; +$$ language plpgsql; + + +create function tests.test_revoke_support_access_detaches_and_marks() +returns setof text as $$ +begin + set role postgres; + insert into tenants (tenant) values ('supportRevoke/') on conflict (tenant) do nothing; + delete from role_grants where subject_role = 'estuary_support/' and object_role = 'supportRevoke/'; + perform internal.grant_support_access('supportRevoke/', 'ticket', interval '2 hours'); + + perform internal.revoke_support_access('supportRevoke/'); + + return query select is( + (select count(*)::int from role_grants + where subject_role = 'estuary_support/' and object_role = 'supportRevoke/'), + 0, 'revoke detaches support from the tenant'); + + return query select ok( + exists(select 1 from internal.support_access + where object_role = 'supportRevoke/' and revoked_at is not null), + 'revoke marks the tracking row revoked'); +end; +$$ language plpgsql;