diff --git a/supabase/migrations/20260510131942_a5fcc99c-92cc-4be6-895a-f9a0d453a871.sql b/supabase/migrations/20260510131942_a5fcc99c-92cc-4be6-895a-f9a0d453a871.sql new file mode 100644 index 000000000..efc88fc88 --- /dev/null +++ b/supabase/migrations/20260510131942_a5fcc99c-92cc-4be6-895a-f9a0d453a871.sql @@ -0,0 +1,217 @@ +-- ============================================================================ +-- Onda 9.1 — Vault Healthcheck Migration (idempotent) +-- ============================================================================ +-- Cria infraestrutura de monitoramento contínuo do estado do Supabase Vault +-- para detectar corrupções de ciphertext (problema da Onda 9 — pgsodium key +-- substituída em 2026-04-29 corrompeu 31 secrets ao postgres reiniciar). +-- +-- Componentes: +-- - public.vault_healthcheck_log (tabela append-only) +-- - public.fn_vault_healthcheck() (função read-only que valida 24/24) +-- - public.fn_vault_healthcheck_run() (executa + persiste log) +-- - public.fn_vault_healthcheck_cleanup() (retenção 30d) +-- - public.v_vault_health (view top-20 últimas leituras) +-- - cron 'vault_healthcheck' (a cada 15min) +-- - cron 'vault_healthcheck_cleanup' (4am diário) +-- +-- Idempotência: 100% — pode rodar 2x sem efeito colateral. +-- - Tabela: CREATE TABLE IF NOT EXISTS (preserva dados históricos) +-- - Funções/View: CREATE OR REPLACE +-- - Cron: cron.unschedule() em DO...EXCEPTION + cron.schedule() +-- +-- Ref: /workspace/notes/onda-9-vault-recovery-2026-05-09.md +-- ============================================================================ + +-- ---------------------------------------------------------------------------- +-- 1) Tabela de log do healthcheck (idempotente — preserva dados) +-- ---------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS public.vault_healthcheck_log ( + id bigserial PRIMARY KEY, + checked_at timestamptz NOT NULL DEFAULT now(), + status text NOT NULL, + ok_count integer NOT NULL, + fail_count integer NOT NULL, + defer_count integer NOT NULL, + failed_names text[] NOT NULL DEFAULT '{}', + full_result jsonb NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_vault_healthcheck_log_checked_at + ON public.vault_healthcheck_log (checked_at DESC); + +COMMENT ON TABLE public.vault_healthcheck_log IS + 'Onda 9.1 — log do healthcheck do Supabase Vault. Append-only, retenção 30d via cron.'; + +-- ---------------------------------------------------------------------------- +-- 2) Função core: fn_vault_healthcheck — read-only, retorna jsonb +-- ---------------------------------------------------------------------------- +-- Itera sobre vault.secrets, tenta decifrar via vault.decrypted_secrets, +-- conta ok/fail/defer (description LIKE 'DEFER%' = pendência conhecida). +-- Cada decryption em sub-bloco EXCEPTION pra capturar invalid ciphertext +-- sem abortar o loop completo. +CREATE OR REPLACE FUNCTION public.fn_vault_healthcheck() +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public', 'vault', 'extensions' +AS $function$ +DECLARE + rec RECORD; + v_decrypted text; + v_ok int := 0; + v_fail int := 0; + v_defer int := 0; + v_failed_names text[] := '{}'; +BEGIN + FOR rec IN SELECT name, description FROM vault.secrets ORDER BY name + LOOP + IF rec.description LIKE 'DEFER%' THEN + v_defer := v_defer + 1; + ELSE + BEGIN + SELECT decrypted_secret INTO v_decrypted FROM vault.decrypted_secrets WHERE name = rec.name; + IF v_decrypted IS NOT NULL AND v_decrypted != '' THEN + v_ok := v_ok + 1; + ELSE + v_fail := v_fail + 1; + v_failed_names := v_failed_names || rec.name; + END IF; + EXCEPTION WHEN OTHERS THEN + v_fail := v_fail + 1; + v_failed_names := v_failed_names || rec.name; + END; + END IF; + END LOOP; + + RETURN jsonb_build_object( + 'checked_at', now(), + 'ok', v_ok, + 'fail', v_fail, + 'defer', v_defer, + 'failed_names', to_jsonb(v_failed_names), + 'status', CASE WHEN v_fail = 0 THEN 'healthy' ELSE 'degraded' END + ); +END +$function$; + +COMMENT ON FUNCTION public.fn_vault_healthcheck() IS + 'Onda 9.1 — retorna jsonb com ok/fail/defer/status. Read-only, safe pra polling.'; + +-- Hardening (least privilege para SECURITY DEFINER): +-- read-only, mas eleva privilégios pra ler vault.decrypted_secrets — restringir. +REVOKE ALL ON FUNCTION public.fn_vault_healthcheck() FROM public, anon; +GRANT EXECUTE ON FUNCTION public.fn_vault_healthcheck() TO authenticated, service_role; + +-- ---------------------------------------------------------------------------- +-- 3) Função wrapper: fn_vault_healthcheck_run — executa + persiste log +-- ---------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.fn_vault_healthcheck_run() +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public', 'extensions' +AS $function$ +DECLARE + v_result jsonb; +BEGIN + v_result := public.fn_vault_healthcheck(); + INSERT INTO public.vault_healthcheck_log ( + checked_at, status, ok_count, fail_count, defer_count, failed_names, full_result + ) VALUES ( + (v_result->>'checked_at')::timestamptz, + v_result->>'status', + (v_result->>'ok')::int, + (v_result->>'fail')::int, + (v_result->>'defer')::int, + ARRAY(SELECT jsonb_array_elements_text(v_result->'failed_names')), + v_result + ); +END +$function$; + +COMMENT ON FUNCTION public.fn_vault_healthcheck_run() IS + 'Onda 9.1 — wrapper invocado pelo cron. Executa o check e persiste em vault_healthcheck_log.'; + +-- Hardening (least privilege para SECURITY DEFINER): +-- escreve em vault_healthcheck_log + lê vault — restringir a service_role (cron usa essa role). +REVOKE ALL ON FUNCTION public.fn_vault_healthcheck_run() FROM public, anon; +GRANT EXECUTE ON FUNCTION public.fn_vault_healthcheck_run() TO service_role; + +-- ---------------------------------------------------------------------------- +-- 4) Função de limpeza: fn_vault_healthcheck_cleanup — retenção 30 dias +-- ---------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.fn_vault_healthcheck_cleanup() +RETURNS void +LANGUAGE sql +AS $function$ + DELETE FROM public.vault_healthcheck_log WHERE checked_at < now() - interval '30 days'; +$function$; + +COMMENT ON FUNCTION public.fn_vault_healthcheck_cleanup() IS + 'Onda 9.1 — retenção: deleta logs com mais de 30 dias.'; + +-- Hardening (least privilege; função é SECURITY INVOKER, mas restringimos surface): +-- DELETE em log — apenas service_role pode disparar. +REVOKE ALL ON FUNCTION public.fn_vault_healthcheck_cleanup() FROM public, anon; +GRANT EXECUTE ON FUNCTION public.fn_vault_healthcheck_cleanup() TO service_role; + +-- ---------------------------------------------------------------------------- +-- 5) View: v_vault_health — top 20 últimas leituras com idade calculada +-- ---------------------------------------------------------------------------- +CREATE OR REPLACE VIEW public.v_vault_health AS +SELECT + l.checked_at, + l.status, + l.ok_count, + l.fail_count, + l.defer_count, + l.failed_names, + age(now(), l.checked_at) AS age +FROM public.vault_healthcheck_log l +ORDER BY l.id DESC +LIMIT 20; + +COMMENT ON VIEW public.v_vault_health IS + 'Onda 9.1 — atalho de leitura: top 20 últimas leituras do healthcheck.'; + +-- ---------------------------------------------------------------------------- +-- 6) Cron jobs (idempotentes via DO...EXCEPTION) +-- ---------------------------------------------------------------------------- +-- 6a) vault_healthcheck — a cada 15min +DO $$ +BEGIN + PERFORM cron.unschedule('vault_healthcheck') + WHERE EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'vault_healthcheck'); +EXCEPTION WHEN OTHERS THEN NULL; +END $$; + +SELECT cron.schedule( + 'vault_healthcheck', + '*/15 * * * *', + $$ SELECT public.fn_vault_healthcheck_run(); $$ +); + +-- 6b) vault_healthcheck_cleanup — diário às 4am UTC +DO $$ +BEGIN + PERFORM cron.unschedule('vault_healthcheck_cleanup') + WHERE EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'vault_healthcheck_cleanup'); +EXCEPTION WHEN OTHERS THEN NULL; +END $$; + +SELECT cron.schedule( + 'vault_healthcheck_cleanup', + '0 4 * * *', + $$ SELECT public.fn_vault_healthcheck_cleanup(); $$ +); + +-- ---------------------------------------------------------------------------- +-- 7) Smoke test (não falha se vault tiver DEFER — só documenta estado) +-- ---------------------------------------------------------------------------- +DO $$ +DECLARE + v_result jsonb; +BEGIN + v_result := public.fn_vault_healthcheck(); + RAISE NOTICE 'Onda 9.1 smoke test: %', v_result; +END $$;