-- Migration 025: server admin RPCs + server_settings + section_enabled() server layer (Fase 13.2) -- -- Every privileged RPC follows the same shape: -- 1. SECURITY DEFINER. -- 2. Pull v_actor := auth.uid() (NULL → 'unauthenticated' / errcode 28000). -- 3. `if not public.is_server_admin(v_actor) then raise 'forbidden' using errcode = 'P0001';` -- 4. Validate args (raise '' using errcode = '22023' for bad input). -- 5. INSERT into public.admin_actions BEFORE the mutation (so a failing -- mutation still leaves a trace). The audit row + the mutation share one -- transaction (the RPC body is implicit). -- 6. Perform the mutation; return a void or the affected rows. -- -- The `is_server_admin()` call short-circuits on the (PK-indexed) lookup so the -- guard is sub-millisecond. -- -- ── deleted_at on collectives ────────────────────────────────────────────── -- Soft delete. The plan keeps the original SELECT/UPDATE RLS policies untouched -- — non-admin members of a soft-deleted collective WILL still see it in -- queries unless callers add `WHERE deleted_at IS NULL`. We document the -- contract here: the RLS layer does not enforce soft-delete invisibility, -- the app layer does. The /admin area surfaces all rows including deleted ones. -- Hard-delete (cascade) is the operator's escape valve for permanent removal. ALTER TABLE public.collectives ADD COLUMN deleted_at timestamptz NULL; COMMENT ON COLUMN public.collectives.deleted_at IS 'Set by admin_soft_delete_collective; cleared by admin_restore_collective. ' 'NULL = active. App-level callers MUST filter on this where soft-delete ' 'invisibility matters — RLS does not enforce it.'; -- ── server_settings ──────────────────────────────────────────────────────── -- Generic key/jsonb-value bag. First use: 'default_sections' jsonb of -- {section: bool} consumed by section_enabled(). Adding new server-level -- settings is a row insert, not a schema change. CREATE TABLE public.server_settings ( key text PRIMARY KEY, value jsonb NOT NULL DEFAULT '{}'::jsonb, updated_at timestamptz NOT NULL DEFAULT now(), updated_by uuid NULL REFERENCES public.users(id) ON DELETE SET NULL ); ALTER TABLE public.server_settings ENABLE ROW LEVEL SECURITY; -- Everyone can READ server_settings (section_enabled() needs to evaluate it -- from a normal user session). Writes go through admin_set_default_section() -- under SECURITY DEFINER — no INSERT/UPDATE/DELETE policy. CREATE POLICY "select_all" ON public.server_settings FOR SELECT USING (true); COMMENT ON TABLE public.server_settings IS 'Server-level key/value bag. Read by everyone, written only by admin RPCs.'; -- Seed an empty default_sections row so section_enabled never sees a missing -- row vs missing key difference (both behave as "no opinion" anyway, but the -- presence of the row keeps PostgREST queries on this single record cheap). INSERT INTO public.server_settings (key, value) VALUES ('default_sections', '{}'::jsonb) ON CONFLICT (key) DO NOTHING; -- ── section_enabled() extension ──────────────────────────────────────────── -- Prepend a server layer. Precedence becomes: -- server (server_settings.default_sections[p_section]) -- > collective (collectives.feature_flags[p_section]) -- > user (users.feature_flags[p_section]) -- > default true -- This is the one-line diff that migration 023 was designed for; the rest of -- the function body is untouched. CREATE OR REPLACE FUNCTION public.section_enabled( p_section text, p_user uuid, p_collective uuid ) RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$ SELECT COALESCE( (SELECT (value ->> p_section)::boolean FROM public.server_settings WHERE key = 'default_sections'), (SELECT (feature_flags ->> p_section)::boolean FROM public.collectives WHERE id = p_collective), (SELECT (feature_flags ->> p_section)::boolean FROM public.users WHERE id = p_user), true ); $$; COMMENT ON FUNCTION public.section_enabled(text, uuid, uuid) IS 'Effective ON/OFF of a top-level section for (user, collective). ' 'Precedence: server > collective > user > default true.'; -- ── Helper: internal audit writer ────────────────────────────────────────── -- Centralises the INSERT INTO admin_actions so RPCs read cleanly. NOT exposed -- to anyone — only the other SECURITY DEFINER functions in this file call it. CREATE OR REPLACE FUNCTION public._log_admin_action( p_actor uuid, p_action text, p_target_type text, p_target_id uuid, p_payload jsonb DEFAULT '{}'::jsonb ) RETURNS void LANGUAGE sql VOLATILE SECURITY DEFINER SET search_path = public AS $$ INSERT INTO public.admin_actions (actor_id, action, target_type, target_id, payload) VALUES (p_actor, p_action, p_target_type, p_target_id, p_payload); $$; REVOKE ALL ON FUNCTION public._log_admin_action(uuid, text, text, uuid, jsonb) FROM PUBLIC; -- No GRANT; only invoked by sibling functions in the same migration. -- ── grant_server_admin ───────────────────────────────────────────────────── CREATE OR REPLACE FUNCTION public.grant_server_admin(p_user uuid) RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_actor uuid := auth.uid(); BEGIN IF v_actor IS NULL THEN RAISE EXCEPTION 'unauthenticated' USING ERRCODE = '28000'; END IF; IF NOT public.is_server_admin(v_actor) THEN RAISE EXCEPTION 'forbidden' USING ERRCODE = 'P0001'; END IF; IF p_user IS NULL THEN RAISE EXCEPTION 'user_required' USING ERRCODE = '22023'; END IF; IF NOT EXISTS (SELECT 1 FROM public.users WHERE id = p_user) THEN RAISE EXCEPTION 'user_not_found' USING ERRCODE = 'P0002'; END IF; INSERT INTO public.server_admins (user_id, granted_by) VALUES (p_user, v_actor) ON CONFLICT (user_id) DO NOTHING; PERFORM public._log_admin_action( v_actor, 'grant_server_admin', 'user', p_user, jsonb_build_object() ); END; $$; REVOKE ALL ON FUNCTION public.grant_server_admin(uuid) FROM PUBLIC; GRANT EXECUTE ON FUNCTION public.grant_server_admin(uuid) TO authenticated; -- ── revoke_server_admin ──────────────────────────────────────────────────── -- Guard: the last remaining admin cannot be removed (would lock the instance -- out of its own admin area irrecoverably). CREATE OR REPLACE FUNCTION public.revoke_server_admin(p_user uuid) RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_actor uuid := auth.uid(); v_remaining int; BEGIN IF v_actor IS NULL THEN RAISE EXCEPTION 'unauthenticated' USING ERRCODE = '28000'; END IF; IF NOT public.is_server_admin(v_actor) THEN RAISE EXCEPTION 'forbidden' USING ERRCODE = 'P0001'; END IF; IF p_user IS NULL THEN RAISE EXCEPTION 'user_required' USING ERRCODE = '22023'; END IF; -- Compute remaining admins AFTER hypothetical removal. SELECT count(*) INTO v_remaining FROM public.server_admins WHERE user_id <> p_user; IF v_remaining = 0 THEN RAISE EXCEPTION 'last_admin' USING ERRCODE = 'P0003'; END IF; DELETE FROM public.server_admins WHERE user_id = p_user; PERFORM public._log_admin_action( v_actor, 'revoke_server_admin', 'user', p_user, jsonb_build_object() ); END; $$; REVOKE ALL ON FUNCTION public.revoke_server_admin(uuid) FROM PUBLIC; GRANT EXECUTE ON FUNCTION public.revoke_server_admin(uuid) TO authenticated; -- ── admin_list_collectives ───────────────────────────────────────────────── CREATE OR REPLACE FUNCTION public.admin_list_collectives( p_search text DEFAULT NULL, p_limit int DEFAULT 50 ) RETURNS TABLE ( id uuid, name text, emoji text, member_count bigint, created_at timestamptz, deleted_at timestamptz ) LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_actor uuid := auth.uid(); BEGIN IF v_actor IS NULL THEN RAISE EXCEPTION 'unauthenticated' USING ERRCODE = '28000'; END IF; IF NOT public.is_server_admin(v_actor) THEN RAISE EXCEPTION 'forbidden' USING ERRCODE = 'P0001'; END IF; RETURN QUERY SELECT c.id, c.name, c.emoji, (SELECT count(*) FROM public.collective_members cm WHERE cm.collective_id = c.id) AS member_count, c.created_at, c.deleted_at FROM public.collectives c WHERE p_search IS NULL OR c.name ILIKE '%' || p_search || '%' ORDER BY c.deleted_at NULLS FIRST, c.created_at DESC LIMIT LEAST(GREATEST(p_limit, 1), 500); END; $$; REVOKE ALL ON FUNCTION public.admin_list_collectives(text, int) FROM PUBLIC; GRANT EXECUTE ON FUNCTION public.admin_list_collectives(text, int) TO authenticated; -- ── admin_soft_delete_collective ─────────────────────────────────────────── CREATE OR REPLACE FUNCTION public.admin_soft_delete_collective( p_collective_id uuid, p_reason text ) RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_actor uuid := auth.uid(); BEGIN IF v_actor IS NULL THEN RAISE EXCEPTION 'unauthenticated' USING ERRCODE = '28000'; END IF; IF NOT public.is_server_admin(v_actor) THEN RAISE EXCEPTION 'forbidden' USING ERRCODE = 'P0001'; END IF; IF p_collective_id IS NULL THEN RAISE EXCEPTION 'collective_required' USING ERRCODE = '22023'; END IF; IF p_reason IS NULL OR length(btrim(p_reason)) = 0 THEN RAISE EXCEPTION 'reason_required' USING ERRCODE = '22023'; END IF; IF NOT EXISTS (SELECT 1 FROM public.collectives WHERE id = p_collective_id) THEN RAISE EXCEPTION 'collective_not_found' USING ERRCODE = 'P0002'; END IF; UPDATE public.collectives SET deleted_at = now() WHERE id = p_collective_id AND deleted_at IS NULL; PERFORM public._log_admin_action( v_actor, 'soft_delete_collective', 'collective', p_collective_id, jsonb_build_object('reason', p_reason) ); END; $$; REVOKE ALL ON FUNCTION public.admin_soft_delete_collective(uuid, text) FROM PUBLIC; GRANT EXECUTE ON FUNCTION public.admin_soft_delete_collective(uuid, text) TO authenticated; -- ── admin_restore_collective ─────────────────────────────────────────────── CREATE OR REPLACE FUNCTION public.admin_restore_collective(p_collective_id uuid) RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_actor uuid := auth.uid(); BEGIN IF v_actor IS NULL THEN RAISE EXCEPTION 'unauthenticated' USING ERRCODE = '28000'; END IF; IF NOT public.is_server_admin(v_actor) THEN RAISE EXCEPTION 'forbidden' USING ERRCODE = 'P0001'; END IF; IF p_collective_id IS NULL THEN RAISE EXCEPTION 'collective_required' USING ERRCODE = '22023'; END IF; IF NOT EXISTS (SELECT 1 FROM public.collectives WHERE id = p_collective_id) THEN RAISE EXCEPTION 'collective_not_found' USING ERRCODE = 'P0002'; END IF; UPDATE public.collectives SET deleted_at = NULL WHERE id = p_collective_id; PERFORM public._log_admin_action( v_actor, 'restore_collective', 'collective', p_collective_id, jsonb_build_object() ); END; $$; REVOKE ALL ON FUNCTION public.admin_restore_collective(uuid) FROM PUBLIC; GRANT EXECUTE ON FUNCTION public.admin_restore_collective(uuid) TO authenticated; -- ── admin_hard_delete_collective ─────────────────────────────────────────── -- Guard: only allowed when deleted_at IS NOT NULL AND >= 30 days old, OR -- p_force = true. This protects against accidental "soft-delete then -- hard-delete by reflex". CREATE OR REPLACE FUNCTION public.admin_hard_delete_collective( p_collective_id uuid, p_force boolean DEFAULT false ) RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_actor uuid := auth.uid(); v_row public.collectives%ROWTYPE; BEGIN IF v_actor IS NULL THEN RAISE EXCEPTION 'unauthenticated' USING ERRCODE = '28000'; END IF; IF NOT public.is_server_admin(v_actor) THEN RAISE EXCEPTION 'forbidden' USING ERRCODE = 'P0001'; END IF; IF p_collective_id IS NULL THEN RAISE EXCEPTION 'collective_required' USING ERRCODE = '22023'; END IF; SELECT * INTO v_row FROM public.collectives WHERE id = p_collective_id; IF NOT FOUND THEN RAISE EXCEPTION 'collective_not_found' USING ERRCODE = 'P0002'; END IF; IF NOT p_force THEN IF v_row.deleted_at IS NULL THEN RAISE EXCEPTION 'not_soft_deleted' USING ERRCODE = 'P0001'; END IF; IF v_row.deleted_at > now() - INTERVAL '30 days' THEN RAISE EXCEPTION 'too_recent' USING ERRCODE = 'P0001'; END IF; END IF; -- Audit FIRST so the action row exists even if the cascade somehow trips. PERFORM public._log_admin_action( v_actor, 'hard_delete_collective', 'collective', p_collective_id, jsonb_build_object( 'force', p_force, 'name_snapshot', v_row.name, 'created_at', v_row.created_at, 'deleted_at', v_row.deleted_at ) ); DELETE FROM public.collectives WHERE id = p_collective_id; END; $$; REVOKE ALL ON FUNCTION public.admin_hard_delete_collective(uuid, boolean) FROM PUBLIC; GRANT EXECUTE ON FUNCTION public.admin_hard_delete_collective(uuid, boolean) TO authenticated; -- ── admin_remove_member ──────────────────────────────────────────────────── CREATE OR REPLACE FUNCTION public.admin_remove_member( p_collective_id uuid, p_user uuid, p_reason text ) RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_actor uuid := auth.uid(); v_role public.member_role; BEGIN IF v_actor IS NULL THEN RAISE EXCEPTION 'unauthenticated' USING ERRCODE = '28000'; END IF; IF NOT public.is_server_admin(v_actor) THEN RAISE EXCEPTION 'forbidden' USING ERRCODE = 'P0001'; END IF; IF p_collective_id IS NULL OR p_user IS NULL THEN RAISE EXCEPTION 'args_required' USING ERRCODE = '22023'; END IF; IF p_reason IS NULL OR length(btrim(p_reason)) = 0 THEN RAISE EXCEPTION 'reason_required' USING ERRCODE = '22023'; END IF; SELECT role INTO v_role FROM public.collective_members WHERE collective_id = p_collective_id AND user_id = p_user; IF NOT FOUND THEN RAISE EXCEPTION 'membership_not_found' USING ERRCODE = 'P0002'; END IF; -- Audit before mutation so we record the role the user used to have. PERFORM public._log_admin_action( v_actor, 'remove_member', 'collective_member', p_collective_id, jsonb_build_object('user_id', p_user, 'role_was', v_role, 'reason', p_reason) ); DELETE FROM public.collective_members WHERE collective_id = p_collective_id AND user_id = p_user; END; $$; REVOKE ALL ON FUNCTION public.admin_remove_member(uuid, uuid, text) FROM PUBLIC; GRANT EXECUTE ON FUNCTION public.admin_remove_member(uuid, uuid, text) TO authenticated; -- ── admin_set_default_section ────────────────────────────────────────────── -- Patches public.server_settings(key='default_sections') so the section -- toggle applies as the server-layer override read by section_enabled(). CREATE OR REPLACE FUNCTION public.admin_set_default_section( p_section text, p_enabled boolean ) RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_actor uuid := auth.uid(); v_current jsonb; v_next jsonb; BEGIN IF v_actor IS NULL THEN RAISE EXCEPTION 'unauthenticated' USING ERRCODE = '28000'; END IF; IF NOT public.is_server_admin(v_actor) THEN RAISE EXCEPTION 'forbidden' USING ERRCODE = 'P0001'; END IF; IF p_section IS NULL OR p_section = '' THEN RAISE EXCEPTION 'section_required' USING ERRCODE = '22023'; END IF; -- Reject unknown sections to prevent typos from silently shaping the -- visibility surface forever. Future sections require a migration that -- updates known_sections() (see migration 023 comment). IF NOT (p_section = ANY (public.known_sections())) THEN RAISE EXCEPTION 'unknown_section: %', p_section USING ERRCODE = '22023'; END IF; -- Read-modify-write the JSONB row. CONCURRENT writes are not a real -- concern here (admin UI, low frequency); a row-level lock from the -- UPDATE-RETURNING below is sufficient. INSERT INTO public.server_settings (key, value, updated_by, updated_at) VALUES ('default_sections', jsonb_build_object(p_section, p_enabled), v_actor, now()) ON CONFLICT (key) DO UPDATE SET value = public.server_settings.value || jsonb_build_object(p_section, p_enabled), updated_by = v_actor, updated_at = now() RETURNING value INTO v_next; PERFORM public._log_admin_action( v_actor, 'set_default_section', 'server_setting', NULL, jsonb_build_object('section', p_section, 'enabled', p_enabled, 'value_after', v_next) ); END; $$; REVOKE ALL ON FUNCTION public.admin_set_default_section(text, boolean) FROM PUBLIC; GRANT EXECUTE ON FUNCTION public.admin_set_default_section(text, boolean) TO authenticated;