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>
This commit is contained in:
190
supabase/migrations/20260627000002_audit_log.sql
Normal file
190
supabase/migrations/20260627000002_audit_log.sql
Normal file
@@ -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.
|
||||
-- ============================================================
|
||||
Reference in New Issue
Block a user