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