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>
71 lines
2.7 KiB
Bash
Executable File
71 lines
2.7 KiB
Bash
Executable File
#!/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"
|