diff --git a/supabase/migrations/20260627000002_audit_log.sql b/supabase/migrations/20260627000002_audit_log.sql new file mode 100644 index 0000000..5b7fb3e --- /dev/null +++ b/supabase/migrations/20260627000002_audit_log.sql @@ -0,0 +1,190 @@ +-- ============================================================ +-- Migration: audit_log append-only — SEC-002 (Sprint 5, compliance) +-- +-- Loggea cambios de configuración crítica: +-- - user_roles: altas/bajas/cambios de app_role u org_id (quién es admin) +-- - surveys.status: activación/desactivación de encuestas +-- - DDL en schema public: toda migración aplicada (CREATE/ALTER/DROP), +-- vía event trigger ddl_command_end. Confirmado viable en este Postgres +-- self-hosted sin extensiones adicionales: el rol `postgres` tiene +-- rolsuper=false pero pudo crear el event trigger sin error +-- (diagnóstico ETAPA_AUDIT_LOG_PROMPT.md §4.1, 2026-06-27). +-- +-- NO loggea acceso a datos sensibles (is_sensitive=true): hoy ningún +-- camino de código (ni /admin/responses ni las RPCs rpc_me_*) lee esas +-- columnas (COMPLIANCE.md §3.2). Deferido a cuando ese código exista — +-- ese mismo cambio debe agregar su propio log de acceso, no esta migración. +-- +-- GAP CONOCIDO (SEC-003, aceptado, NO resuelto aquí): un operador con +-- acceso directo a `psql -U postgres` en el VPS puede TRUNCATE/DROP esta +-- tabla, o saltear los triggers con `SET session_replication_role = +-- replica` / `ALTER TABLE ... DISABLE TRIGGER ALL`. El append-only de +-- abajo cierra la escritura vía API (anon/authenticated/PostgREST) y +-- bloquea UPDATE/DELETE para cualquier caller normal (incluido +-- service_role, que bypasa RLS pero no bypasa triggers) — no protege +-- contra un operador con shell en la base. +-- ============================================================ + +CREATE TABLE public.audit_log ( + id bigserial PRIMARY KEY, + occurred_at timestamptz NOT NULL DEFAULT now(), + actor_role text, + actor_user_id uuid, + action text NOT NULL, + target_table text NOT NULL, + target_id text, + detail jsonb +); + +COMMENT ON TABLE public.audit_log IS + 'Append-only. INSERT solo vía funciones SECURITY DEFINER de los triggers de esta migración. UPDATE/DELETE bloqueados por trigger audit_log_block_mutation. Gap conocido: no protege contra TRUNCATE/DROP por superusuario directo en el VPS (SEC-003).'; + +COMMENT ON COLUMN public.audit_log.actor_role IS + 'current_app_role() del que disparó el cambio (user_roles/surveys), o session_user para eventos DDL.'; + +ALTER TABLE public.audit_log ENABLE ROW LEVEL SECURITY; + +-- Solo platform_admin puede leer. No hay policies de INSERT/UPDATE/DELETE +-- para ningún rol vía API — el único camino de escritura son las +-- funciones SECURITY DEFINER (corren como propietario de la tabla, +-- `postgres`, que por default de Postgres bypasa RLS sobre objetos +-- propios sin necesitar FORCE ROW LEVEL SECURITY). +CREATE POLICY "audit_log_select_platform_admin" + ON public.audit_log FOR SELECT + TO authenticated + USING (public.current_app_role() = 'platform_admin'); + +REVOKE INSERT, UPDATE, DELETE ON public.audit_log FROM PUBLIC, anon, authenticated; +GRANT SELECT ON public.audit_log TO authenticated; + +-- ============================================================ +-- Barrera 2: append-only enforcement a nivel de trigger. +-- Se aplica a CUALQUIER caller (incluido service_role) salvo que alguien +-- deshabilite triggers con privilegios de superusuario real en el VPS +-- (gap documentado arriba, SEC-003). +-- ============================================================ +CREATE OR REPLACE FUNCTION public.audit_log_block_mutation() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = public +AS $$ +BEGIN + RAISE EXCEPTION 'audit_log es append-only: % no permitido', TG_OP; +END; +$$; + +REVOKE ALL ON FUNCTION public.audit_log_block_mutation() FROM PUBLIC; + +CREATE TRIGGER audit_log_no_update + BEFORE UPDATE ON public.audit_log + FOR EACH ROW EXECUTE FUNCTION public.audit_log_block_mutation(); + +CREATE TRIGGER audit_log_no_delete + BEFORE DELETE ON public.audit_log + FOR EACH ROW EXECUTE FUNCTION public.audit_log_block_mutation(); + +-- ============================================================ +-- Trigger 1: cambios en user_roles (altas/bajas/cambios de acceso) +-- Función disparada automáticamente por el trigger — no requiere GRANT +-- EXECUTE a ningún rol (el mecanismo de triggers no pasa por una llamada +-- SQL directa del caller). +-- ============================================================ +CREATE OR REPLACE FUNCTION public.log_user_roles_change() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + INSERT INTO public.audit_log (actor_role, actor_user_id, action, target_table, target_id, detail) + VALUES ( + public.current_app_role(), + auth.uid(), + 'user_roles_' || lower(TG_OP), + 'user_roles', + COALESCE(NEW.user_id, OLD.user_id)::text, + jsonb_build_object( + 'old', CASE WHEN TG_OP IN ('UPDATE','DELETE') THEN to_jsonb(OLD) ELSE NULL END, + 'new', CASE WHEN TG_OP IN ('UPDATE','INSERT') THEN to_jsonb(NEW) ELSE NULL END + ) + ); + RETURN COALESCE(NEW, OLD); +END; +$$; + +REVOKE ALL ON FUNCTION public.log_user_roles_change() FROM PUBLIC; + +CREATE TRIGGER user_roles_audit + AFTER INSERT OR UPDATE OR DELETE ON public.user_roles + FOR EACH ROW EXECUTE FUNCTION public.log_user_roles_change(); + +-- ============================================================ +-- Trigger 2: cambios de status en surveys (activación/desactivación) +-- ============================================================ +CREATE OR REPLACE FUNCTION public.log_survey_status_change() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + IF NEW.status IS DISTINCT FROM OLD.status THEN + INSERT INTO public.audit_log (actor_role, actor_user_id, action, target_table, target_id, detail) + VALUES ( + public.current_app_role(), + auth.uid(), + 'survey_status_change', + 'surveys', + NEW.id::text, + jsonb_build_object('old_status', OLD.status, 'new_status', NEW.status, 'title', NEW.title) + ); + END IF; + RETURN NEW; +END; +$$; + +REVOKE ALL ON FUNCTION public.log_survey_status_change() FROM PUBLIC; + +CREATE TRIGGER surveys_status_audit + AFTER UPDATE ON public.surveys + FOR EACH ROW EXECUTE FUNCTION public.log_survey_status_change(); + +-- ============================================================ +-- Trigger 3 (Prioridad 2, confirmado viable — ver §4.1 del prompt de etapa): +-- DDL en schema public → loggea toda migración aplicada. +-- ============================================================ +CREATE OR REPLACE FUNCTION public.log_ddl_command() +RETURNS event_trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + obj record; +BEGIN + FOR obj IN SELECT * FROM pg_event_trigger_ddl_commands() LOOP + IF obj.schema_name = 'public' OR obj.schema_name IS NULL THEN + INSERT INTO public.audit_log (actor_role, actor_user_id, action, target_table, target_id, detail) + VALUES ( + session_user, + NULL, + 'ddl_' || obj.command_tag, + COALESCE(obj.object_identity, obj.schema_name, 'unknown'), + obj.objid::text, + jsonb_build_object('command_tag', obj.command_tag, 'object_type', obj.object_type) + ); + END IF; + END LOOP; +END; +$$; + +REVOKE ALL ON FUNCTION public.log_ddl_command() FROM PUBLIC; + +CREATE EVENT TRIGGER audit_ddl_public + ON ddl_command_end + EXECUTE FUNCTION public.log_ddl_command(); + +-- ============================================================ +-- Fin de migración. NO aplicar en el VPS sin aprobación explícita del +-- director — ver ETAPA_AUDIT_LOG_PROMPT.md §4.3. +-- ============================================================