-- ============================================================ -- 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. -- ============================================================