-- 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'));