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>
191 lines
7.4 KiB
PL/PgSQL
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.
|
|
-- ============================================================
|