feat(fase-13): server_admins + admin_actions audit log model

Migration 024 introduces the orthogonal global role and the append-only
audit log. server_admins is a separate table (not a column on users) so
that promote/revoke don't require a JWT re-issue; is_server_admin() is a
STABLE SECURITY DEFINER helper matching the shape of is_admin/is_member.

admin_actions has only a SELECT policy (admin-only) — no INSERT/UPDATE/
DELETE policies, so only the SECURITY DEFINER RPCs in migration 025 can
write to it. actor_id FK uses RESTRICT so the audit trail outlives
admins.

Bootstrap via infra/db-init/10-server-admin-seed.sh on first volume
init (reads SERVER_ADMIN_EMAIL); supabase/seed.sql also pre-seeds Ana
for dev/test because docker-entrypoint-initdb.d does not re-fire on
long-lived dev volumes. Documented in .env.erosi.example.

20 new pgTAP assertions cover schema shape, RLS posture, FK behaviour,
and the SECURITY DEFINER + STABLE attributes on is_server_admin().

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 04:52:18 +02:00
parent 99096f9f4a
commit b1858542d0
5 changed files with 345 additions and 0 deletions

View File

@@ -0,0 +1,70 @@
#!/bin/bash
# Fase 13.1.2 — server admin bootstrap.
#
# Runs once on first volume init (docker-entrypoint-initdb.d). Reads
# SERVER_ADMIN_EMAIL from the environment. If the env var is set AND the
# `public.server_admins` table is empty AND there's a matching `public.users`
# row, promotes that user to server admin.
#
# Idempotent + safe to re-run:
# * If SERVER_ADMIN_EMAIL is unset → no-op (logged).
# * If the table already has at least one admin → no-op (logged). This is the
# guard the plan calls "second invocation with a different email shouldn't
# touch previous rows".
# * If the email is set but no matching user exists yet (first prod boot:
# volume is fresh, nobody has signed in via Keycloak yet) → log a warning
# and exit cleanly. The operator must re-run this script (or insert
# manually) after the first login. Documented in docs/deployment.md.
#
# Order: the leading digit (10-) puts this AFTER 00-role-passwords.sh so all
# the supporting role + schema bootstrap has happened.
#
# Caveat: `docker-entrypoint-initdb.d` only fires on a FRESH volume. On a
# long-lived dev volume it never runs again after the first init. We rely on
# supabase/seed.sql to also insert the seed dev admin (Ana) so the integration
# + e2e suites have a deterministic admin without depending on initdb timing.
set -e
if [ -z "${SERVER_ADMIN_EMAIL:-}" ]; then
echo "==> server-admin-seed: SERVER_ADMIN_EMAIL not set, skipping"
exit 0
fi
echo "==> server-admin-seed: looking up ${SERVER_ADMIN_EMAIL}"
# Bash expands ${SERVER_ADMIN_EMAIL} inside the unquoted heredoc tag below.
# The PL/pgSQL `$$ ... $$` dollar-quoting needs to be escaped (\$\$) so bash
# doesn't try to interpolate it. Same idea as 00-role-passwords.sh.
psql -v ON_ERROR_STOP=1 --username postgres --dbname postgres <<EOSQL
DO \$\$
DECLARE
v_email text := '${SERVER_ADMIN_EMAIL}';
v_user_id uuid;
v_existing int;
BEGIN
SELECT count(*) INTO v_existing FROM public.server_admins;
IF v_existing > 0 THEN
RAISE NOTICE 'server-admin-seed: server_admins already populated (% row(s)), skipping', v_existing;
RETURN;
END IF;
SELECT id INTO v_user_id
FROM public.users
WHERE lower(email) = lower(v_email);
IF v_user_id IS NULL THEN
RAISE WARNING 'server-admin-seed: no public.users row matches email %, skipping (re-run after first login)', v_email;
RETURN;
END IF;
INSERT INTO public.server_admins (user_id)
VALUES (v_user_id)
ON CONFLICT (user_id) DO NOTHING;
RAISE NOTICE 'server-admin-seed: promoted user % (email %)', v_user_id, v_email;
END
\$\$;
EOSQL
echo "==> server-admin-seed: done"