Add account restrictions, appeals, and admin moderation tools

This commit is contained in:
2026-10-13 12:32:37 +05:00
parent 018be5049e
commit cfe5820802
28 changed files with 1936 additions and 125 deletions

View File

@@ -0,0 +1,188 @@
-- 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'));