Files
MineGeo/supabase/migrations/20261013070138_account_controls_and_appeals.sql

189 lines
15 KiB
PL/PgSQL
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- 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'));