Files
motor-de-encuestas/supabase/migrations/20260627000002_audit_log.sql
markosbenitez 430bf7a5da feat(compliance): audit_log append-only — user_roles y surveys.status [SEC-002]
Tabla audit_log con RLS (solo platform_admin), trigger BEFORE UPDATE/DELETE
que aborta (append-only enforcement), triggers AFTER en user_roles y
surveys.status. Patrón SECURITY DEFINER reutilizado de auth_user_roles.
Event trigger DDL incluido (confirmado viable sin extensiones, ver
diagnóstico en ETAPA_AUDIT_LOG_PROMPT.md §4.1). Gap SEC-003 (TRUNCATE por
superusuario) documentado y aceptado, no resuelto.

NO aplicada en VPS — pendiente de aprobación.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 14:51:07 -03:00

191 lines
7.4 KiB
PL/PgSQL

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