Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions supabase/migrations/20260630120000_support_access_functions.sql
Original file line number Diff line number Diff line change
@@ -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;
125 changes: 125 additions & 0 deletions supabase/tests/support_access.test.sql
Original file line number Diff line number Diff line change
@@ -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;
Loading