189 lines
15 KiB
PL/PgSQL
189 lines
15 KiB
PL/PgSQL
-- Account restrictions are fresh database state, never user-editable JWT claims.
|
||
alter table public.profiles
|
||
add column account_status text not null default 'active' check(account_status in ('active','deactivated','banned','deleting')),
|
||
add column account_reason text check(account_reason is null or length(btrim(account_reason)) between 10 and 2000),
|
||
add column account_changed_at timestamptz,
|
||
add column account_changed_by uuid references public.profiles(id) on delete set null,
|
||
add column account_status_version integer not null default 0 check(account_status_version>=0);
|
||
create index profiles_account_status on public.profiles(account_status) where account_status<>'active';
|
||
|
||
create function public.protect_account_status() returns trigger
|
||
language plpgsql security invoker set search_path='' as $$
|
||
begin
|
||
-- Auth's FK cleanup may clear a deleted reviewer's reference, never status.
|
||
if tg_op='UPDATE' and current_user='supabase_auth_admin'
|
||
and new.account_changed_by is null and old.account_changed_by is not null
|
||
and new.account_status is not distinct from old.account_status
|
||
and new.account_reason is not distinct from old.account_reason
|
||
and new.account_changed_at is not distinct from old.account_changed_at
|
||
and new.account_status_version is not distinct from old.account_status_version then return new; end if;
|
||
if current_user not in ('postgres','service_role') and (
|
||
(tg_op='INSERT' and (new.account_status<>'active' or new.account_reason is not null or new.account_changed_at is not null or new.account_changed_by is not null or new.account_status_version<>0))
|
||
or (tg_op='UPDATE' and (new.account_status is distinct from old.account_status or new.account_reason is distinct from old.account_reason
|
||
or new.account_changed_at is distinct from old.account_changed_at or new.account_changed_by is distinct from old.account_changed_by
|
||
or new.account_status_version is distinct from old.account_status_version))
|
||
) then raise exception using errcode='42501',message='Account status is managed by Admins'; end if;
|
||
return new;
|
||
end; $$;
|
||
create trigger profiles_protect_account_status before insert or update on public.profiles for each row execute function public.protect_account_status();
|
||
revoke all on function public.protect_account_status() from public,anon,authenticated;
|
||
|
||
create table public.account_appeals (
|
||
id uuid primary key default gen_random_uuid(),
|
||
owner_id uuid not null references public.profiles(id) on delete cascade,
|
||
status_version integer not null check(status_version>0),
|
||
reason text not null check(length(btrim(reason)) between 10 and 2000),
|
||
state text not null default 'open' check(state in ('open','approved','rejected')),
|
||
created_at timestamptz not null default clock_timestamp(),
|
||
reviewer_id uuid references public.profiles(id) on delete set null,
|
||
reviewed_at timestamptz, review_reason text,
|
||
unique(owner_id,status_version)
|
||
);
|
||
create index account_appeals_queue on public.account_appeals(created_at,id) where state='open';
|
||
create table public.account_control_events (
|
||
id uuid primary key default gen_random_uuid(),
|
||
owner_id uuid references public.profiles(id) on delete set null,
|
||
reviewer_id uuid references public.profiles(id) on delete set null,
|
||
action text not null check(action in ('deactivate','ban','restore','prepare_delete','appeal_approved','appeal_rejected')),
|
||
previous_status text not null, new_status text not null,
|
||
status_version integer not null,
|
||
reason text not null check(length(btrim(reason)) between 10 and 2000),
|
||
created_at timestamptz not null default clock_timestamp()
|
||
);
|
||
create index account_control_events_owner on public.account_control_events(owner_id,created_at desc);
|
||
alter table public.account_appeals enable row level security;
|
||
alter table public.account_control_events enable row level security;
|
||
revoke all on public.account_appeals,public.account_control_events from public,anon,authenticated;
|
||
grant select,insert,update,delete on public.account_appeals,public.account_control_events to service_role;
|
||
create policy "Server manages account appeals" on public.account_appeals for all to service_role using(true) with check(true);
|
||
create policy "Server manages account events" on public.account_control_events for all to service_role using(true) with check(true);
|
||
|
||
-- Retain shared maps and moderation history when the Auth account is deleted.
|
||
alter table public.datasets alter column owner_id drop not null;
|
||
alter table public.datasets drop constraint datasets_owner_id_fkey;
|
||
alter table public.datasets add constraint datasets_owner_id_fkey foreign key(owner_id) references auth.users(id) on delete set null;
|
||
alter table public.review_decisions alter column reviewer_id drop not null;
|
||
alter table public.review_decisions drop constraint review_decisions_reviewer_id_fkey;
|
||
alter table public.review_decisions add constraint review_decisions_reviewer_id_fkey foreign key(reviewer_id) references auth.users(id) on delete set null;
|
||
alter table public.reports drop constraint reports_owner_id_fkey;
|
||
alter table public.reports add constraint reports_owner_id_fkey foreign key(owner_id) references auth.users(id) on delete set null;
|
||
alter table public.reports drop constraint reports_reviewer_id_fkey;
|
||
alter table public.reports add constraint reports_reviewer_id_fkey foreign key(reviewer_id) references public.profiles(id) on delete set null;
|
||
alter table public.map_appeals alter column owner_id drop not null;
|
||
alter table public.map_appeals drop constraint map_appeals_owner_id_fkey;
|
||
alter table public.map_appeals add constraint map_appeals_owner_id_fkey foreign key(owner_id) references auth.users(id) on delete set null;
|
||
alter table public.map_appeals drop constraint map_appeals_reviewer_id_fkey;
|
||
alter table public.map_appeals add constraint map_appeals_reviewer_id_fkey foreign key(reviewer_id) references public.profiles(id) on delete set null;
|
||
alter table public.pairing_codes drop constraint pairing_codes_owner_id_fkey;
|
||
alter table public.pairing_codes add constraint pairing_codes_owner_id_fkey foreign key(owner_id) references auth.users(id) on delete cascade;
|
||
|
||
-- The same owner lock is used by API key creation/consumption and account actions.
|
||
create function public.admin_account_action(p_reviewer uuid,p_user uuid,p_action text,p_reason text) returns jsonb
|
||
language plpgsql security invoker set search_path='' as $$
|
||
declare target public.profiles; next_status text;
|
||
begin
|
||
if p_action is null or p_action not in ('deactivate','ban','restore','prepare_delete')
|
||
or coalesce(length(btrim(p_reason)),0) not between 10 and 2000 then
|
||
raise exception using errcode='22023',message='Provide an account action and reason in 10–2000 characters'; end if;
|
||
if p_reviewer=p_user then raise exception using errcode='42501',message='You cannot restrict your own account'; end if;
|
||
-- Deterministic profile lock order also prevents concurrent Admin actions deadlocking.
|
||
perform id from public.profiles where id in(p_reviewer,p_user) order by id for update;
|
||
if not exists(select 1 from public.profiles where id=p_reviewer and role='admin' and account_status='active') then
|
||
raise exception using errcode='42501',message='Active Admin permission required'; end if;
|
||
select * into target from public.profiles where id=p_user;
|
||
if not found then raise exception using errcode='P0002',message='Account not found'; end if;
|
||
if target.role='admin' then raise exception using errcode='42501',message='Admin accounts are protected'; end if;
|
||
if target.account_status='deleting' and p_action<>'prepare_delete' then
|
||
raise exception using errcode='22023',message='Permanent deletion is pending; retry deletion'; end if;
|
||
next_status:=case p_action when 'deactivate' then 'deactivated' when 'ban' then 'banned' when 'restore' then 'active' else 'deleting' end;
|
||
if target.account_status=next_status then
|
||
if next_status='deleting' then return jsonb_build_object('id',target.id,'status',target.account_status,'statusVersion',target.account_status_version); end if;
|
||
raise exception using errcode='22023',message='Account already has this status'; end if;
|
||
update public.profiles set account_status=next_status,account_reason=case when next_status='active' then null else btrim(p_reason) end,
|
||
account_changed_at=clock_timestamp(),account_changed_by=p_reviewer,account_status_version=account_status_version+1 where id=p_user;
|
||
update public.account_appeals set state='rejected',reviewer_id=p_reviewer,reviewed_at=clock_timestamp(),
|
||
review_reason='Closed because the account status changed.' where owner_id=p_user and state='open';
|
||
if next_status<>'active' then
|
||
update public.contributor_tokens set revoked=true where owner_id=p_user and not revoked;
|
||
update public.developer_api_keys set revoked_at=coalesce(revoked_at,clock_timestamp()) where owner_id=p_user;
|
||
delete from public.pairing_codes where owner_id=p_user;
|
||
-- Secret-cookie capabilities must not survive a restriction or a later restore.
|
||
update public.game_sessions set access_hash=replace(gen_random_uuid()::text,'-','')||replace(gen_random_uuid()::text,'-','') where owner_id=p_user;
|
||
-- Close hosted rooms first, so Discord closure hooks run normally.
|
||
update public.multiplayer_rooms set status='closed',transition_at=null where status<>'closed'
|
||
and host_player_id in(select id from public.multiplayer_players where account_id=p_user);
|
||
update public.multiplayer_players set has_left=true,access_hash=replace(gen_random_uuid()::text,'-','')||replace(gen_random_uuid()::text,'-','') where account_id=p_user;
|
||
end if;
|
||
insert into public.account_control_events(owner_id,reviewer_id,action,previous_status,new_status,status_version,reason)
|
||
values(p_user,p_reviewer,p_action,target.account_status,next_status,target.account_status_version+1,btrim(p_reason));
|
||
return jsonb_build_object('id',p_user,'status',next_status,'statusVersion',target.account_status_version+1);
|
||
end; $$;
|
||
|
||
create function public.submit_account_appeal(p_owner uuid,p_reason text) returns uuid
|
||
language plpgsql security invoker set search_path='' as $$
|
||
declare target public.profiles; appeal uuid;
|
||
begin
|
||
select * into target from public.profiles where id=p_owner for update;
|
||
if not found or target.account_status<>'banned' then raise exception using errcode='42501',message='Only banned accounts may appeal'; end if;
|
||
if coalesce(length(btrim(p_reason)),0) not between 10 and 2000 then raise exception using errcode='22023',message='Explain your appeal in 10–2000 characters'; end if;
|
||
if exists(select 1 from public.account_appeals where owner_id=p_owner and status_version=target.account_status_version) then
|
||
raise exception using errcode='22023',message='An appeal has already been submitted for this ban'; end if;
|
||
insert into public.account_appeals(owner_id,status_version,reason) values(p_owner,target.account_status_version,btrim(p_reason)) returning id into appeal;
|
||
return appeal;
|
||
end; $$;
|
||
|
||
create function public.review_account_appeal(p_reviewer uuid,p_appeal uuid,p_decision text,p_reason text) returns void
|
||
language plpgsql security invoker set search_path='' as $$
|
||
declare appeal public.account_appeals; target public.profiles; owner uuid;
|
||
begin
|
||
select owner_id into owner from public.account_appeals where id=p_appeal;
|
||
-- Profile before appeal: same order as status actions and appeal submission.
|
||
perform id from public.profiles where id in(p_reviewer,owner) order by id for update;
|
||
if not exists(select 1 from public.profiles where id=p_reviewer and role in('moderator','admin') and account_status='active') then
|
||
raise exception using errcode='42501',message='Active Moderator permission required'; end if;
|
||
if p_reviewer=owner then raise exception using errcode='42501',message='An independent moderator must review your appeal'; end if;
|
||
select * into target from public.profiles where id=owner;
|
||
select * into appeal from public.account_appeals where id=p_appeal for update;
|
||
if not found or appeal.state<>'open' or target.account_status<>'banned' or target.account_status_version<>appeal.status_version then
|
||
raise exception using errcode='22023',message='Appeal is no longer awaiting review'; end if;
|
||
if p_decision is null or p_decision not in('approve','reject') or coalesce(length(btrim(p_reason)),0) not between 10 and 2000 then
|
||
raise exception using errcode='22023',message='Provide an appeal decision and reason in 10–2000 characters'; end if;
|
||
if p_decision='approve' then
|
||
update public.profiles set account_status='active',account_reason=null,account_changed_at=clock_timestamp(),
|
||
account_changed_by=p_reviewer,account_status_version=account_status_version+1 where id=owner;
|
||
end if;
|
||
update public.account_appeals set state=case when p_decision='approve' then 'approved' else 'rejected' end,
|
||
reviewer_id=p_reviewer,reviewed_at=clock_timestamp(),review_reason=btrim(p_reason) where id=p_appeal;
|
||
insert into public.account_control_events(owner_id,reviewer_id,action,previous_status,new_status,status_version,reason)
|
||
values(owner,p_reviewer,case when p_decision='approve' then 'appeal_approved' else 'appeal_rejected' end,'banned',
|
||
case when p_decision='approve' then 'active' else 'banned' end,target.account_status_version+case when p_decision='approve' then 1 else 0 end,btrim(p_reason));
|
||
end; $$;
|
||
|
||
revoke all on function public.admin_account_action(uuid,uuid,text,text),public.submit_account_appeal(uuid,text),public.review_account_appeal(uuid,uuid,text,text) from public,anon,authenticated;
|
||
grant execute on function public.admin_account_action(uuid,uuid,text,text),public.submit_account_appeal(uuid,text),public.review_account_appeal(uuid,uuid,text,text) to service_role;
|
||
|
||
-- Harden existing server-only functions without duplicating their validated bodies.
|
||
do $$ declare signature text; body text; begin
|
||
for signature in select unnest(array['public.developer_key_create(uuid,text,text[],text,text,integer)','public.developer_key_consume(text)']) loop
|
||
body:=pg_get_functiondef(signature::regprocedure);
|
||
body:=replace(body,'where p.id=p_owner for update','where p.id=p_owner and p.account_status=''active'' for update');
|
||
body:=replace(body,'where p.id=owner for update','where p.id=owner and p.account_status=''active'' for update');
|
||
execute body;
|
||
end loop;
|
||
for signature in select unnest(array['public.prometheus_status(uuid)','public.configure_prometheus(uuid,text)','public.read_prometheus_metrics(text)']) loop
|
||
body:=pg_get_functiondef(signature::regprocedure);
|
||
body:=replace(body,'where id=p_reviewer and role=''admin''','where id=p_reviewer and role=''admin'' and account_status=''active''');
|
||
body:=replace(body,'p.role=''admin''','p.role=''admin'' and p.account_status=''active''');
|
||
execute body;
|
||
end loop;
|
||
end; $$;
|
||
|
||
-- A stale browser JWT cannot use direct Data API reads for private content/jobs.
|
||
drop policy datasets_visible on public.datasets;
|
||
create policy datasets_visible on public.datasets for select to anon,authenticated using(
|
||
state='published' or (owner_id=(select auth.uid()) and exists(select 1 from public.profiles p where p.id=(select auth.uid()) and p.account_status='active')));
|
||
drop policy own_jobs on public.upload_jobs;
|
||
create policy own_jobs on public.upload_jobs for select to authenticated using(
|
||
exists(select 1 from public.datasets where id=dataset_id and owner_id=(select auth.uid()))
|
||
and exists(select 1 from public.profiles p where p.id=(select auth.uid()) and p.account_status='active'));
|