-- ============================================================================= -- Migration: 20260420000002_audit_logs_lgpd -- Sessao 11 - Fase 2a (Opcao C). -- -- Resolve: LGPD Art. 37 - registro das operacoes de tratamento. -- Projeto ja tinha logs pontuais (document_access_logs, patient_status_history, -- notification_logs, addon_transactions) mas nao registrava: -- - Edicao de dados do paciente (nome/CPF/endereco) -- - CRUD de sessoes na agenda -- - CRUD de registros financeiros -- - CRUD de documentos (metadata) -- - Mudancas de permissao / members do tenant -- -- Cria tabela audit_logs imutavel + funcao trigger generica + triggers nas -- tabelas criticas. RLS: tenant member le; ninguem INSERT/UPDATE/DELETE direto. -- ============================================================================= -- --------------------------------------------------------------------------- -- Tabela audit_logs -- --------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS public.audit_logs ( id BIGSERIAL PRIMARY KEY, tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE, user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, entity_type TEXT NOT NULL, entity_id TEXT, action TEXT NOT NULL CHECK (action IN ('insert', 'update', 'delete')), old_values JSONB, new_values JSONB, changed_fields TEXT[], metadata JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_audit_logs_tenant_created ON public.audit_logs (tenant_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_audit_logs_entity ON public.audit_logs (entity_type, entity_id); CREATE INDEX IF NOT EXISTS idx_audit_logs_user_created ON public.audit_logs (user_id, created_at DESC) WHERE user_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_audit_logs_changed_fields ON public.audit_logs USING GIN (changed_fields); COMMENT ON TABLE public.audit_logs IS 'Registro imutavel de operacoes de tratamento (LGPD Art. 37). INSERT apenas via trigger SECURITY DEFINER.'; -- --------------------------------------------------------------------------- -- Funcao trigger generica -- --------------------------------------------------------------------------- CREATE OR REPLACE FUNCTION public.log_audit_change() RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, pg_temp AS $$ DECLARE v_tenant_id UUID; v_entity_id TEXT; v_old JSONB; v_new JSONB; v_changed TEXT[]; v_heavy_fields TEXT[] := ARRAY[ 'content', 'content_html', 'content_json', 'raw_data', 'signature_data', 'pdf_blob', 'binary', 'body_html', 'body_text' ]; v_noise_fields TEXT[] := ARRAY['updated_at', 'last_seen_at', 'last_activity_at']; BEGIN IF TG_OP = 'DELETE' THEN v_tenant_id := OLD.tenant_id; v_entity_id := OLD.id::TEXT; v_old := to_jsonb(OLD) - v_heavy_fields; v_new := NULL; ELSIF TG_OP = 'INSERT' THEN v_tenant_id := NEW.tenant_id; v_entity_id := NEW.id::TEXT; v_old := NULL; v_new := to_jsonb(NEW) - v_heavy_fields; ELSE -- UPDATE v_tenant_id := NEW.tenant_id; v_entity_id := NEW.id::TEXT; v_old := to_jsonb(OLD) - v_heavy_fields; v_new := to_jsonb(NEW) - v_heavy_fields; -- calcular campos realmente alterados SELECT array_agg(key ORDER BY key) INTO v_changed FROM jsonb_each(to_jsonb(NEW)) AS kv(key, value) WHERE (to_jsonb(OLD))->kv.key IS DISTINCT FROM kv.value; -- se nada mudou, ignora IF v_changed IS NULL THEN RETURN NEW; END IF; -- se mudou apenas campos de ruido (ex: updated_at), ignora IF v_changed <@ v_noise_fields THEN RETURN NEW; END IF; END IF; INSERT INTO public.audit_logs ( tenant_id, user_id, entity_type, entity_id, action, old_values, new_values, changed_fields ) VALUES ( v_tenant_id, auth.uid(), TG_TABLE_NAME, v_entity_id, lower(TG_OP), v_old, v_new, v_changed ); RETURN COALESCE(NEW, OLD); END; $$; COMMENT ON FUNCTION public.log_audit_change() IS 'Trigger generica de audit. Filtra campos pesados (content, signature_data) e ruido (updated_at).'; -- --------------------------------------------------------------------------- -- Triggers nas tabelas criticas -- --------------------------------------------------------------------------- -- patients DROP TRIGGER IF EXISTS trg_audit_patients ON public.patients; CREATE TRIGGER trg_audit_patients AFTER INSERT OR UPDATE OR DELETE ON public.patients FOR EACH ROW EXECUTE FUNCTION public.log_audit_change(); -- agenda_eventos DROP TRIGGER IF EXISTS trg_audit_agenda_eventos ON public.agenda_eventos; CREATE TRIGGER trg_audit_agenda_eventos AFTER INSERT OR UPDATE OR DELETE ON public.agenda_eventos FOR EACH ROW EXECUTE FUNCTION public.log_audit_change(); -- financial_records DROP TRIGGER IF EXISTS trg_audit_financial_records ON public.financial_records; CREATE TRIGGER trg_audit_financial_records AFTER INSERT OR UPDATE OR DELETE ON public.financial_records FOR EACH ROW EXECUTE FUNCTION public.log_audit_change(); -- documents DROP TRIGGER IF EXISTS trg_audit_documents ON public.documents; CREATE TRIGGER trg_audit_documents AFTER INSERT OR UPDATE OR DELETE ON public.documents FOR EACH ROW EXECUTE FUNCTION public.log_audit_change(); -- tenant_members (mudanca de permissao) DROP TRIGGER IF EXISTS trg_audit_tenant_members ON public.tenant_members; CREATE TRIGGER trg_audit_tenant_members AFTER INSERT OR UPDATE OR DELETE ON public.tenant_members FOR EACH ROW EXECUTE FUNCTION public.log_audit_change(); -- --------------------------------------------------------------------------- -- RLS: tenant member le; saas_admin le tudo; ninguem escreve direto -- --------------------------------------------------------------------------- ALTER TABLE public.audit_logs ENABLE ROW LEVEL SECURITY; ALTER TABLE public.audit_logs FORCE ROW LEVEL SECURITY; DROP POLICY IF EXISTS "audit_logs: select tenant" ON public.audit_logs; DROP POLICY IF EXISTS "audit_logs: saas_admin all" ON public.audit_logs; DROP POLICY IF EXISTS "audit_logs: no direct insert" ON public.audit_logs; DROP POLICY IF EXISTS "audit_logs: no direct update" ON public.audit_logs; DROP POLICY IF EXISTS "audit_logs: no direct delete" ON public.audit_logs; CREATE POLICY "audit_logs: select tenant" ON public.audit_logs FOR SELECT TO authenticated USING ( public.is_saas_admin() OR tenant_id IN ( SELECT tm.tenant_id FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.status = 'active' ) ); -- Explicitamente NEGA insert/update/delete via API -- (SECURITY DEFINER na funcao trigger bypassa RLS; app nao consegue escrever direto) CREATE POLICY "audit_logs: no direct insert" ON public.audit_logs FOR INSERT TO authenticated WITH CHECK (false); CREATE POLICY "audit_logs: no direct update" ON public.audit_logs FOR UPDATE TO authenticated USING (false) WITH CHECK (false); CREATE POLICY "audit_logs: no direct delete" ON public.audit_logs FOR DELETE TO authenticated USING (false); -- --------------------------------------------------------------------------- -- Marca hardening na auditoria -- --------------------------------------------------------------------------- COMMENT ON COLUMN public.audit_logs.old_values IS 'Estado anterior (jsonb); NULL em INSERT; campos pesados removidos'; COMMENT ON COLUMN public.audit_logs.new_values IS 'Estado posterior (jsonb); NULL em DELETE; campos pesados removidos'; COMMENT ON COLUMN public.audit_logs.changed_fields IS 'Lista de campos alterados em UPDATE (NULL em INSERT/DELETE)';