diff --git a/.tmp-write-probe.md b/.tmp-write-probe.md new file mode 100644 index 000000000..a24d84ab7 --- /dev/null +++ b/.tmp-write-probe.md @@ -0,0 +1,2 @@ +// supabase/functions/_shared/dispatcher-auth.ts — sentinel test write +// (will be overwritten below if push works) diff --git a/docs/hardening/ONDA-1-EDGE-AUTH.md b/docs/hardening/ONDA-1-EDGE-AUTH.md new file mode 100644 index 000000000..e68913c16 --- /dev/null +++ b/docs/hardening/ONDA-1-EDGE-AUTH.md @@ -0,0 +1,79 @@ +# Onda 1 — Hardening de Auth em Edge Functions + +**Data:** 2026-05-14 +**Branch:** `cleanup/edge-functions-auth-hardening` +**Auditoria base:** `AUDITORIA_REDEPLOY_PROMO_GIFTS_2026-05-13_15-32 (1).md` — Bloqueadores 3.1 e 3.2 + +## Problema + +Duas edge functions estavam expostas publicamente (`verify_jwt = false`) sem nenhuma validação custom de origem, permitindo abuso por qualquer pessoa com a URL: + +- **`webhook-dispatcher`**: chamável anonimamente para disparar webhooks, replay de entregas falhas, ou testar webhooks arbitrários. Atacante poderia poluir endpoints externos cadastrados em `outbound_webhooks`. +- **`connections-auto-test`**: chamável anonimamente para forçar testes de conexões com sistemas externos (CRM, Bitrix). Atacante poderia consumir quota de Edge Function, gerar logs falsos, e indiretamente sondar quais credenciais existem. + +## Solução implementada + +### `webhook-dispatcher` — auth multi-modo + +Três contextos de uso, três modos de auth aceitos: + +| Caller | Mecanismo | Modo | +|---|---|---| +| Trigger DB `dispatch_quote_webhook_event` | Lê secret do vault, envia `x-dispatcher-secret` | A | +| RPC `retry_failed_webhook_deliveries` | Mesmo | A | +| Frontend admin (`WebhookPlaygroundPanel`, `FailedDeliveriesPanel`) | `Authorization: Bearer ` + role ≥ supervisor | B | +| Servidor com service role | `Authorization: Bearer ` (detectado e tratado como Modo A) | A | + +**Operações sensíveis** (`test_mode`, `replay_delivery_id`) exigem Modo B obrigatoriamente — Modo A é rejeitado com 403 para evitar abuso por server-side caller que vazasse o secret. + +### `connections-auto-test` — auth single-mode + +Um caller único (cron `connections-auto-test`), um modo: + +- **Modo C**: cron lê secret do vault, envia `x-cron-secret` + +### Retrocompat + +Se a env var do secret não estiver configurada na edge function (deploy parcial, ambiente de dev), o helper **aceita anônimo com warning log estruturado**. Isto: +- Permite rollback seguro: reverter a env não quebra a função +- Permite ambiente de dev sem dependência de vault +- Loga claramente em produção pra alertar se algo der errado + +## Como rotacionar os secrets + +1. Gerar novo secret: `SELECT encode(gen_random_bytes(32), 'base64');` +2. Atualizar no painel: Supabase Dashboard → Edge Functions → Secrets (`WEBHOOK_DISPATCHER_SECRET` e/ou `CONNECTIONS_AUTO_TEST_SECRET`) +3. Atualizar no vault: `SELECT vault.update_secret((SELECT id FROM vault.secrets WHERE name = 'WEBHOOK_DISPATCHER_SECRET'), '');` +4. Validar: chamar manualmente com novo secret e ver `auto-test-summary` em logs + +**Ordem importa**: setar PRIMEIRO no painel (Deno.env) → DEPOIS no vault. Caso contrário há janela onde triggers DB enviam um valor que a edge ainda não conhece. + +## Checklist pós-deploy + +- [ ] **(automatic)** Cron `connections-auto-test` rodando a cada 15min com 200 OK nos logs +- [ ] **(manual)** Criar/atualizar uma quote via UI, validar que `webhook_deliveries` ganha 1 linha success=true para cada webhook ativo +- [ ] **(manual)** Frontend admin: testar webhook via Playground → deve funcionar (Modo B) +- [ ] **(manual)** Frontend admin: replay de delivery falhada → deve funcionar (Modo B) +- [ ] **(curl)** Chamada anônima sem headers → deve retornar 401: + ```bash + curl -i -X POST https://doufsxqlfjyuvxuezpln.supabase.co/functions/v1/webhook-dispatcher \ + -H "Content-Type: application/json" -d '{"event":"test","payload":{}}' + ``` +- [ ] **(curl)** Chamada com secret errado → deve retornar 401 +- [ ] **(curl)** Chamada `test_mode: true` com x-dispatcher-secret → deve retornar 403 (exige Modo B) +- [ ] **(logs)** Logs Supabase contêm eventos `dispatcher_auth` com `outcome` correto + +## Validação automatizada + +- `supabase/functions/_shared/dispatcher-auth.test.ts` — 12 testes unitários cobrindo: + - `constantTimeEqual`: igual, diferente, tamanhos diferentes, vazio, não-string, base64 real + - `authorizeCron`: env não setada (legacy), sem header (401), header correto (ok), header errado (401), tamanho diferente, chars especiais +- `deno check` em todas as 78 edge functions: passou + +## Rollback de emergência + +Se algo der errado: + +1. **Edge function**: re-deploy do commit anterior. A funcionalidade continua porque os triggers/cron/RPCs continuam enviando o header (que será ignorado pela função antiga). +2. **Banco**: as alterações nas funções SQL podem ser revertidas via MCP `apply_migration` com a versão anterior das funções (ver `git log` para diff). +3. **Vault**: secrets podem ser desabilitados via `UPDATE vault.secrets SET ... WHERE name = ...` — mas a edge function entra em modo retrocompat e aceita anônimo, então não há quebra. diff --git a/scripts/check-no-db-push.mjs b/scripts/check-no-db-push.mjs index 89365af04..c51ecca17 100644 --- a/scripts/check-no-db-push.mjs +++ b/scripts/check-no-db-push.mjs @@ -37,6 +37,11 @@ const ALLOWLIST = [ // Auto-referências: 'scripts/check-no-db-push.mjs', 'scripts/gen-migrations-readme.mjs', + // Relatorios de auditoria (referenciam o comando ao descrever o problema): + 'AUDITORIA_REDEPLOY_PROMO_GIFTS_2026-05-13_15-32 (1).md', + 'docs/AUDITORIA_INDEPENDENTE_PRE_PRODUCAO_2026-05-13.md', + 'docs/AUDIT_INDEPENDENTE.md', + 'docs/hardening/', ]; function isAllowed(path) { diff --git a/supabase/functions/_shared/dispatcher-auth.test.ts b/supabase/functions/_shared/dispatcher-auth.test.ts new file mode 100644 index 000000000..a45e7a940 --- /dev/null +++ b/supabase/functions/_shared/dispatcher-auth.test.ts @@ -0,0 +1,141 @@ +// supabase/functions/_shared/dispatcher-auth.test.ts +// Testes unitários para autorização de webhook-dispatcher e connections-auto-test. + +import { assertEquals, assertStrictEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { authorizeCron, constantTimeEqual } from "./dispatcher-auth.ts"; + +// CORS headers mock simples (não usados nas asserts) +const CORS = { "access-control-allow-origin": "*" }; + +// ─────────────────────────────────────────────────────────────────────── +// constantTimeEqual +// ─────────────────────────────────────────────────────────────────────── + +Deno.test("constantTimeEqual: strings iguais => true", () => { + assertStrictEquals(constantTimeEqual("abc123", "abc123"), true); +}); + +Deno.test("constantTimeEqual: strings diferentes => false", () => { + assertStrictEquals(constantTimeEqual("abc123", "abc124"), false); +}); + +Deno.test("constantTimeEqual: tamanhos diferentes => false (cedo)", () => { + assertStrictEquals(constantTimeEqual("abc", "abcd"), false); +}); + +Deno.test("constantTimeEqual: string vazia vs string vazia => true", () => { + assertStrictEquals(constantTimeEqual("", ""), true); +}); + +Deno.test("constantTimeEqual: input nao-string => false", () => { + // @ts-expect-error - testa runtime guard + assertStrictEquals(constantTimeEqual(null, "abc"), false); + // @ts-expect-error + assertStrictEquals(constantTimeEqual("abc", undefined), false); +}); + +Deno.test("constantTimeEqual: secrets longos do mundo real", () => { + const a = "4aszZ/Nh0cInRX0RVTkt+YGqA8BObghWsoAjEOGB7g8="; + const b = "4aszZ/Nh0cInRX0RVTkt+YGqA8BObghWsoAjEOGB7g8="; + const c = "4aszZ/Nh0cInRX0RVTkt+YGqA8BObghWsoAjEOGB7g9="; + assertStrictEquals(constantTimeEqual(a, b), true); + assertStrictEquals(constantTimeEqual(a, c), false); +}); + +// ─────────────────────────────────────────────────────────────────────── +// authorizeCron — Modo C +// ─────────────────────────────────────────────────────────────────────── + +function makeRequest(headers: Record = {}): Request { + return new Request("https://example.com/", { method: "POST", headers }); +} + +Deno.test("authorizeCron: env nao setada => legacy_no_auth (retrocompat)", () => { + Deno.env.delete("TEST_CRON_SECRET_1"); + const req = makeRequest({}); + const result = authorizeCron(req, { + corsHeaders: CORS, + secretEnvName: "TEST_CRON_SECRET_1", + headerName: "x-cron-secret", + }); + assertEquals(result.ok, true); + if (result.ok) { + assertEquals(result.mode, "legacy_no_auth"); + } +}); + +Deno.test("authorizeCron: env setada + sem header => 401", () => { + Deno.env.set("TEST_CRON_SECRET_2", "supersecret123"); + const req = makeRequest({}); + const result = authorizeCron(req, { + corsHeaders: CORS, + secretEnvName: "TEST_CRON_SECRET_2", + headerName: "x-cron-secret", + }); + assertEquals(result.ok, false); + if (!result.ok) { + assertEquals(result.response.status, 401); + } + Deno.env.delete("TEST_CRON_SECRET_2"); +}); + +Deno.test("authorizeCron: env setada + header correto => ok (modo secret)", () => { + Deno.env.set("TEST_CRON_SECRET_3", "supersecret123"); + const req = makeRequest({ "x-cron-secret": "supersecret123" }); + const result = authorizeCron(req, { + corsHeaders: CORS, + secretEnvName: "TEST_CRON_SECRET_3", + headerName: "x-cron-secret", + }); + assertEquals(result.ok, true); + if (result.ok) { + assertEquals(result.mode, "secret"); + } + Deno.env.delete("TEST_CRON_SECRET_3"); +}); + +Deno.test("authorizeCron: env setada + header errado => 401", () => { + Deno.env.set("TEST_CRON_SECRET_4", "supersecret123"); + const req = makeRequest({ "x-cron-secret": "wrong_secret" }); + const result = authorizeCron(req, { + corsHeaders: CORS, + secretEnvName: "TEST_CRON_SECRET_4", + headerName: "x-cron-secret", + }); + assertEquals(result.ok, false); + if (!result.ok) { + assertEquals(result.response.status, 401); + } + Deno.env.delete("TEST_CRON_SECRET_4"); +}); + +Deno.test("authorizeCron: header de tamanho diferente nao causa timing leak", () => { + // Não é teste de timing real (impreciso em CI), mas valida que NÃO retorna + // sucesso só porque o tamanho difere (a função aborta cedo com return false). + Deno.env.set("TEST_CRON_SECRET_5", "supersecret123"); + const req = makeRequest({ "x-cron-secret": "x" }); + const result = authorizeCron(req, { + corsHeaders: CORS, + secretEnvName: "TEST_CRON_SECRET_5", + headerName: "x-cron-secret", + }); + assertEquals(result.ok, false); + Deno.env.delete("TEST_CRON_SECRET_5"); +}); + +Deno.test("authorizeCron: secret com chars especiais (base64)", () => { + const realSecret = "j/nKCXCqyvYgucMAX1wuHJO6QhEDPVaWLWoIsqlfp+o="; + Deno.env.set("TEST_CRON_SECRET_6", realSecret); + const req = makeRequest({ "x-cron-secret": realSecret }); + const result = authorizeCron(req, { + corsHeaders: CORS, + secretEnvName: "TEST_CRON_SECRET_6", + headerName: "x-cron-secret", + }); + assertEquals(result.ok, true); + Deno.env.delete("TEST_CRON_SECRET_6"); +}); + +// NOTE: testes do authorizeDispatcher dependem de mock de Supabase Auth para +// validar JWT — escopo de integration test, não unit. O fluxo Modo A (secret) +// é simétrico ao authorizeCron e está coberto pelos testes acima. diff --git a/supabase/functions/_shared/dispatcher-auth.ts b/supabase/functions/_shared/dispatcher-auth.ts new file mode 100644 index 000000000..cc98ef84e --- /dev/null +++ b/supabase/functions/_shared/dispatcher-auth.ts @@ -0,0 +1,294 @@ +// supabase/functions/_shared/dispatcher-auth.ts +// -------------------------------------------------------------- +// Autorização para `webhook-dispatcher` e `connections-auto-test`. +// +// Duas edges chamadas por contextos diferentes — autoriza por modos: +// +// `webhook-dispatcher` (3 chamadores): +// Modo A — `x-dispatcher-secret: ` (triggers DB, RPCs, cron) +// Modo B — `Authorization: Bearer ` + role admin|supervisor|dev (frontend) +// +// `connections-auto-test` (1 chamador): +// Modo C — `x-cron-secret: ` (cron job) +// +// Compatibilidade: se a env do secret NÃO estiver setada, log warning e +// aceita (modo retrocompat). Permite rollback seguro e dev sem config. +// +// Segurança: comparação de secret em tempo constante (anti timing attack). +// Logs estruturados (JSON single-line) sem nunca expor o valor do secret. + +import { createClient, SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2.49.4"; + +const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? ""; +const ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY") ?? ""; +const SERVICE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? ""; + +export type AppRole = "dev" | "supervisor" | "agente"; +const ROLE_RANK: Record = { agente: 1, supervisor: 2, dev: 3 }; + +export type DispatcherAuthMode = "secret" | "user_jwt" | "legacy_no_auth"; +export type CronAuthMode = "secret" | "legacy_no_auth"; + +export interface DispatcherAuthOk { + ok: true; + mode: DispatcherAuthMode; + user?: { id: string; email?: string; role: AppRole }; + supabaseAdmin: SupabaseClient; +} +export interface DispatcherAuthErr { + ok: false; + response: Response; +} +export type DispatcherAuthResult = DispatcherAuthOk | DispatcherAuthErr; + +export interface CronAuthOk { + ok: true; + mode: CronAuthMode; +} +export type CronAuthResult = CronAuthOk | DispatcherAuthErr; + +/** + * Comparação em tempo constante (constant-time) para evitar timing attacks + * onde o atacante deduz o secret medindo quanto tempo o servidor leva pra + * retornar 401. Sempre processa todos os caracteres antes de retornar. + */ +export function constantTimeEqual(a: string, b: string): boolean { + if (typeof a !== "string" || typeof b !== "string") return false; + if (a.length !== b.length) return false; + let result = 0; + for (let i = 0; i < a.length; i++) { + result |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return result === 0; +} + +function logAuthEvent(payload: Record): void { + // JSON estruturado pra buscar em logs depois. + // NUNCA logar o valor do secret nem o header completo. + try { + console.log(JSON.stringify({ evt: "dispatcher_auth", ts: new Date().toISOString(), ...payload })); + } catch { + // logger não pode quebrar a request + } +} + +function jsonResponse(body: unknown, status: number, cors: Record): Response { + return new Response(JSON.stringify(body), { + status, + headers: { ...cors, "Content-Type": "application/json" }, + }); +} + +// ============================================================================ +// webhook-dispatcher: Modo A (secret) ou Modo B (user JWT) +// ============================================================================ + +export interface AuthorizeDispatcherOptions { + /** Se true, exige Modo B (user JWT). Modo A retorna 403. Usado em `test_mode` e `replay_delivery_id`. */ + requireUserContext?: boolean; + /** Role mínimo no Modo B. Default: 'supervisor'. */ + minRole?: AppRole; + /** CORS headers para response de erro. */ + corsHeaders: Record; +} + +export async function authorizeDispatcher( + req: Request, + opts: AuthorizeDispatcherOptions, +): Promise { + const { corsHeaders } = opts; + const minRole: AppRole = opts.minRole ?? "supervisor"; + const requireUserContext = !!opts.requireUserContext; + + const expectedSecret = Deno.env.get("WEBHOOK_DISPATCHER_SECRET") ?? ""; + const providedSecret = req.headers.get("x-dispatcher-secret") ?? ""; + const authHeader = req.headers.get("Authorization") ?? req.headers.get("authorization") ?? ""; + + // ───────── Modo A: x-dispatcher-secret ───────── + if (providedSecret && expectedSecret) { + if (!constantTimeEqual(providedSecret, expectedSecret)) { + logAuthEvent({ outcome: "denied", reason: "bad_secret", mode_attempted: "secret" }); + return { ok: false, response: jsonResponse({ error: "unauthorized" }, 401, corsHeaders) }; + } + if (requireUserContext) { + logAuthEvent({ outcome: "denied", reason: "secret_not_allowed_in_user_only_mode" }); + return { + ok: false, + response: jsonResponse( + { error: "user_context_required", message: "Esta operação exige autenticação de usuário (JWT)." }, + 403, + corsHeaders, + ), + }; + } + logAuthEvent({ outcome: "allowed", mode: "secret" }); + return { + ok: true, + mode: "secret", + supabaseAdmin: createClient(SUPABASE_URL, SERVICE_KEY, { + auth: { persistSession: false, autoRefreshToken: false }, + }), + }; + } + + // ───────── Modo B: Bearer user JWT ───────── + if (authHeader.toLowerCase().startsWith("bearer ")) { + const token = authHeader.slice(7).trim(); + if (!token) { + logAuthEvent({ outcome: "denied", reason: "empty_bearer" }); + return { ok: false, response: jsonResponse({ error: "missing_token" }, 401, corsHeaders) }; + } + + // Evita falso-positivo: Bearer com SERVICE_ROLE_KEY não é "user". + // Mas é caller legítimo do servidor → permite como Modo A. + if (SERVICE_KEY && constantTimeEqual(token, SERVICE_KEY)) { + if (requireUserContext) { + logAuthEvent({ outcome: "denied", reason: "service_role_not_allowed_in_user_only_mode" }); + return { + ok: false, + response: jsonResponse( + { error: "user_context_required" }, + 403, + corsHeaders, + ), + }; + } + logAuthEvent({ outcome: "allowed", mode: "secret", via: "service_role_bearer" }); + return { + ok: true, + mode: "secret", + supabaseAdmin: createClient(SUPABASE_URL, SERVICE_KEY, { + auth: { persistSession: false, autoRefreshToken: false }, + }), + }; + } + + const supabaseUser = createClient(SUPABASE_URL, ANON_KEY, { + global: { headers: { Authorization: `Bearer ${token}` } }, + auth: { persistSession: false, autoRefreshToken: false }, + }); + const supabaseAdmin = createClient(SUPABASE_URL, SERVICE_KEY, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + + const { data: userResp, error: userErr } = await supabaseUser.auth.getUser(token); + if (userErr || !userResp?.user) { + logAuthEvent({ outcome: "denied", reason: "invalid_jwt" }); + return { ok: false, response: jsonResponse({ error: "invalid_token" }, 401, corsHeaders) }; + } + + const userId = userResp.user.id; + const { data: roles, error: rolesErr } = await supabaseAdmin + .from("user_roles") + .select("role") + .eq("user_id", userId); + + if (rolesErr) { + logAuthEvent({ outcome: "denied", reason: "role_lookup_failed", user_id: userId }); + return { ok: false, response: jsonResponse({ error: "role_lookup_failed" }, 500, corsHeaders) }; + } + + const userRoles = (roles ?? []).map((r) => r.role as AppRole).filter((r) => r in ROLE_RANK); + const highestRole: AppRole | null = userRoles.length + ? userRoles.reduce((acc, r) => (ROLE_RANK[r] > ROLE_RANK[acc] ? r : acc), userRoles[0]) + : null; + + const requiredRank = ROLE_RANK[minRole]; + const userRank = highestRole ? ROLE_RANK[highestRole] : 0; + if (userRank < requiredRank) { + logAuthEvent({ + outcome: "denied", + reason: "insufficient_role", + user_id: userId, + role_user: highestRole, + role_required: minRole, + }); + return { + ok: false, + response: jsonResponse( + { error: "insufficient_role", required: minRole, you_have: highestRole ?? "none" }, + 403, + corsHeaders, + ), + }; + } + + logAuthEvent({ outcome: "allowed", mode: "user_jwt", user_id: userId, role: highestRole }); + return { + ok: true, + mode: "user_jwt", + user: { id: userId, email: userResp.user.email ?? undefined, role: highestRole! }, + supabaseAdmin, + }; + } + + // ───────── Retrocompat: nenhum env setado → aceita anônimo com warning ───────── + if (!expectedSecret) { + logAuthEvent({ + outcome: "allowed", + mode: "legacy_no_auth", + warning: "WEBHOOK_DISPATCHER_SECRET nao configurado — aceitando chamada anonima. Configure secret para hardening.", + }); + return { + ok: true, + mode: "legacy_no_auth", + supabaseAdmin: createClient(SUPABASE_URL, SERVICE_KEY, { + auth: { persistSession: false, autoRefreshToken: false }, + }), + }; + } + + // Secret configurado mas chamada sem nenhuma forma de auth → 401 + logAuthEvent({ outcome: "denied", reason: "no_auth_provided" }); + return { + ok: false, + response: jsonResponse( + { error: "missing_authentication", message: "Provide x-dispatcher-secret or Bearer token." }, + 401, + corsHeaders, + ), + }; +} + +// ============================================================================ +// connections-auto-test: Modo C (cron secret) +// ============================================================================ + +export function authorizeCron( + req: Request, + opts: { corsHeaders: Record; secretEnvName: string; headerName: string }, +): CronAuthResult { + const { corsHeaders, secretEnvName, headerName } = opts; + const expectedSecret = Deno.env.get(secretEnvName) ?? ""; + const providedSecret = req.headers.get(headerName) ?? ""; + + if (!expectedSecret) { + logAuthEvent({ + outcome: "allowed", + mode: "legacy_no_auth", + env: secretEnvName, + warning: `${secretEnvName} nao configurado — aceitando chamada anonima. Configure secret para hardening.`, + }); + return { ok: true, mode: "legacy_no_auth" }; + } + + if (!providedSecret) { + logAuthEvent({ outcome: "denied", reason: "no_cron_secret_provided", env: secretEnvName }); + return { + ok: false, + response: jsonResponse( + { error: "missing_authentication", message: `Header ${headerName} required.` }, + 401, + corsHeaders, + ), + }; + } + + if (!constantTimeEqual(providedSecret, expectedSecret)) { + logAuthEvent({ outcome: "denied", reason: "bad_cron_secret", env: secretEnvName }); + return { ok: false, response: jsonResponse({ error: "unauthorized" }, 401, corsHeaders) }; + } + + logAuthEvent({ outcome: "allowed", mode: "secret", env: secretEnvName }); + return { ok: true, mode: "secret" }; +} diff --git a/supabase/functions/_shared/edge-authz-manifest.ts b/supabase/functions/_shared/edge-authz-manifest.ts index 37b21b1da..d6866c978 100644 --- a/supabase/functions/_shared/edge-authz-manifest.ts +++ b/supabase/functions/_shared/edge-authz-manifest.ts @@ -71,7 +71,7 @@ export const EDGE_AUTHZ_MANIFEST: Record = { // ---------------- Webhooks (assinatura própria) ---------------- "webhook-inbound": { category: "scoped", rationale: "HMAC-SHA256 inline" }, - "webhook-dispatcher": { category: "scoped", rationale: "Disparo via service-role / cron" }, + "webhook-dispatcher": { category: "scoped", rationale: "Modo A (x-dispatcher-secret) ou Modo B (JWT >= supervisor) — _shared/dispatcher-auth.ts" }, "product-webhook": { category: "scoped", rationale: "Token shared-secret no header" }, // ---------------- Cron / service-to-service ---------------- @@ -87,7 +87,7 @@ export const EDGE_AUTHZ_MANIFEST: Record = { "quote-followup-reminders": { category: "service", rationale: "pg_cron — followup orçamentos" }, "send-notification": { category: "service", rationale: "Disparo interno entre edges" }, "connections-health-check": { category: "service", rationale: "pg_cron — health connections" }, - "connections-auto-test": { category: "service", rationale: "pg_cron — auto-test connections" }, + "connections-auto-test": { category: "service", rationale: "pg_cron — Modo C (x-cron-secret) — _shared/dispatcher-auth.ts" }, "connections-hub-audit": { category: "service", rationale: "pg_cron diário — auditoria" }, "ownership-audit": { category: "service", rationale: "pg_cron diário — auditoria órfãos" }, diff --git a/supabase/functions/connections-auto-test/index.ts b/supabase/functions/connections-auto-test/index.ts index 6ffbe4be5..622095d7a 100644 --- a/supabase/functions/connections-auto-test/index.ts +++ b/supabase/functions/connections-auto-test/index.ts @@ -1,7 +1,13 @@ import { getCorsHeaders } from "../_shared/cors.ts"; -// connections-auto-test: cron-driven (every 30min). Re-tests every active +// connections-auto-test: cron-driven (every 15min). Re-tests every active // connection in `external_connections` and updates last_test_* fields + // inserts a row in `connection_test_history` with triggered_by='cron'. +// +// AUTORIZAÇÃO (Onda 1 hardening, 2026-05-14): +// - Modo C: header `x-cron-secret: ` (cron job) +// - Retrocompat: se CONNECTIONS_AUTO_TEST_SECRET não estiver setado, aceita anônimo com warning +// +// Ver: supabase/functions/_shared/dispatcher-auth.ts import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4"; import { runConnectionTest, type ConnectionType, isTransientFailure } from "../_shared/connection-test-runner.ts"; import { resolveTimeout } from "../_shared/connection-timeouts.ts"; @@ -11,10 +17,11 @@ import { assertServiceClient as sharedAssertServiceClient, castSupabaseClient, } from "../_shared/supabase-client-adapter.ts"; +import { authorizeCron } from "../_shared/dispatcher-auth.ts"; -// ──────────────────────────────────────────────────────────────────────────── +// ─────────────────────────────────────────────────────────────────────── // Schema-validated service client (re-exports do adapter compartilhado) -// ──────────────────────────────────────────────────────────────────────────── +// ─────────────────────────────────────────────────────────────────────── // As definições agora vivem em `_shared/supabase-client-adapter.ts` para que // outras edge functions possam reusar o mesmo padrão sem duplicação. // Re-exportamos aqui para preservar a API pública desta função (callers @@ -119,6 +126,14 @@ Deno.serve(async (req) => { const corsHeaders = getCorsHeaders(req); if (req.method === "OPTIONS") return new Response(null, { headers: getCorsHeaders(req) }); + // Hardening Onda 1: valida x-cron-secret + const auth = authorizeCron(req, { + corsHeaders, + secretEnvName: "CONNECTIONS_AUTO_TEST_SECRET", + headerName: "x-cron-secret", + }); + if (!auth.ok) return auth.response; + const startedAt = Date.now(); try { const service = createClient( diff --git a/supabase/functions/webhook-dispatcher/index.ts b/supabase/functions/webhook-dispatcher/index.ts index a2cd802ef..32a25cbd1 100644 --- a/supabase/functions/webhook-dispatcher/index.ts +++ b/supabase/functions/webhook-dispatcher/index.ts @@ -1,13 +1,19 @@ // webhook-dispatcher: dispatches an event to all active outbound_webhooks // subscribed to that event. HMAC signs payload with webhook secret. Retries // with backoff and logs each attempt to webhook_deliveries. -// Called publicly from DB triggers (no JWT) — but only acts on events -// declared in outbound_webhooks rows that the admin created. -import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4"; +// +// AUTORIZAÇÃO (Onda 1 hardening, 2026-05-14): +// - Modo A: header `x-dispatcher-secret: ` (triggers DB, RPCs, cron) +// - Modo B: `Authorization: Bearer ` + role >= supervisor (frontend) +// - test_mode e replay_delivery_id exigem Modo B (operação sensível) +// - Retrocompat: se WEBHOOK_DISPATCHER_SECRET não estiver setado, aceita anônimo com warning +// +// Ver: supabase/functions/_shared/dispatcher-auth.ts import { crypto } from "https://deno.land/std@0.224.0/crypto/mod.ts"; import { encodeHex } from "https://deno.land/std@0.224.0/encoding/hex.ts"; import { z } from "https://deno.land/x/zod@v3.23.8/mod.ts"; import { buildPublicCorsHeaders } from "../_shared/cors.ts"; +import { authorizeDispatcher } from "../_shared/dispatcher-auth.ts"; const corsHeaders = buildPublicCorsHeaders({ allowMethods: "POST, OPTIONS" }); @@ -43,11 +49,8 @@ Deno.serve(async (req) => { if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); try { - const supabase = createClient( - Deno.env.get("SUPABASE_URL")!, - Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!, - ); - + // Body precisa ser parseado antes da auth pra saber se requer Modo B (test_mode/replay). + // Body parse falha → 400 antes da auth (não vaza info). const parsed = BodySchema.safeParse(await req.json().catch(() => ({}))); if (!parsed.success) { return new Response(JSON.stringify({ error: "Invalid body" }), { @@ -57,6 +60,18 @@ Deno.serve(async (req) => { let { event, payload } = parsed.data; const { replay_delivery_id, test_mode, test_webhook_id } = parsed.data; + // Operações que mexem com webhook específico (test/replay) só por Modo B + const requiresUserContext = !!(test_mode || replay_delivery_id); + + const auth = await authorizeDispatcher(req, { + corsHeaders, + requireUserContext: requiresUserContext, + minRole: "supervisor", + }); + if (!auth.ok) return auth.response; + + const supabase = auth.supabaseAdmin; + // Test mode (Onda 13 #9): single-shot, no retries, no DB write, no breaker if (test_mode) { if (!test_webhook_id) { diff --git a/supabase/migrations/20260514112056_edge_function_secrets_vault_setup.sql b/supabase/migrations/20260514112056_edge_function_secrets_vault_setup.sql new file mode 100644 index 000000000..4d1455304 --- /dev/null +++ b/supabase/migrations/20260514112056_edge_function_secrets_vault_setup.sql @@ -0,0 +1,40 @@ +-- ============================================================================ +-- Edge Function Secrets Setup — Vault +-- ---------------------------------------------------------------------------- +-- ORIGEM: Aplicado via MCP `apply_migration` em 2026-05-14 11:20 UTC, +-- em conformidade com ADR 0006 (db push proibido). +-- ESTE ARQUIVO É SNAPSHOT — não será re-aplicado por `supabase db reset` +-- (ADR 0006: banco é SSOT, repo é histórico). +-- +-- Provisiona dois secrets no Supabase Vault para hardening de autenticação +-- das edge functions `webhook-dispatcher` e `connections-auto-test`. +-- +-- O mesmo valor de secret precisa ser configurado em: +-- 1) Supabase Dashboard → Edge Functions → Secrets (Deno.env.get) +-- 2) Aqui, no vault.secrets (para chamadores SQL: cron, triggers, RPCs) +-- +-- IMPORTANTE: os valores reais dos secrets NÃO estão neste arquivo — +-- foram inseridos diretamente no vault. Para reset/replay use placeholders +-- e atualize via vault.update_secret() depois. +-- ============================================================================ + +DO $$ +BEGIN + -- WEBHOOK_DISPATCHER_SECRET + IF NOT EXISTS (SELECT 1 FROM vault.secrets WHERE name = 'WEBHOOK_DISPATCHER_SECRET') THEN + PERFORM vault.create_secret( + 'PLACEHOLDER_SET_VIA_VAULT_UPDATE', + 'WEBHOOK_DISPATCHER_SECRET', + 'Secret para autenticar chamadas ao webhook-dispatcher edge function (Modo A). Sincronizar com painel Supabase Edge Functions Secrets.' + ); + END IF; + + -- CONNECTIONS_AUTO_TEST_SECRET + IF NOT EXISTS (SELECT 1 FROM vault.secrets WHERE name = 'CONNECTIONS_AUTO_TEST_SECRET') THEN + PERFORM vault.create_secret( + 'PLACEHOLDER_SET_VIA_VAULT_UPDATE', + 'CONNECTIONS_AUTO_TEST_SECRET', + 'Secret para autenticar chamadas ao connections-auto-test edge function (cron job). Sincronizar com painel Supabase Edge Functions Secrets.' + ); + END IF; +END $$; diff --git a/supabase/migrations/20260514112057_edge_function_secrets_callers_hardening.sql b/supabase/migrations/20260514112057_edge_function_secrets_callers_hardening.sql new file mode 100644 index 000000000..ab670bb20 --- /dev/null +++ b/supabase/migrations/20260514112057_edge_function_secrets_callers_hardening.sql @@ -0,0 +1,221 @@ +-- ============================================================================ +-- Edge Function Secrets — Atualiza Chamadores +-- ---------------------------------------------------------------------------- +-- ORIGEM: Aplicado via MCP `apply_migration` em 2026-05-14 11:20 UTC. +-- ESTE ARQUIVO É SNAPSHOT (ADR 0006). +-- +-- Atualiza TODOS os chamadores SQL das edge functions `webhook-dispatcher` +-- e `connections-auto-test` para incluir o header de autenticação. +-- +-- Chamadores atualizados: +-- 1) Helper `public.get_edge_function_secret` (lê vault, SECURITY DEFINER) +-- 2) Trigger function `dispatch_quote_webhook_event` +-- 3) RPC `retry_failed_webhook_deliveries` +-- 4) Cron job `connections-auto-test` +-- +-- Ordem importa: esta migration foi aplicada ANTES do deploy das edge +-- functions com a validação. Edge function antiga ignora headers extras. +-- ============================================================================ + +-- Helper SECURITY DEFINER para ler secret do vault. +-- Restringido a service_role + postgres roles via REVOKE/GRANT. +CREATE OR REPLACE FUNCTION public.get_edge_function_secret(_name text) +RETURNS text +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = vault, public, pg_temp +AS $function$ +DECLARE + _secret text; +BEGIN + IF _name NOT IN ('WEBHOOK_DISPATCHER_SECRET', 'CONNECTIONS_AUTO_TEST_SECRET') THEN + RAISE EXCEPTION 'Nome de secret nao autorizado: %', _name USING ERRCODE = 'insufficient_privilege'; + END IF; + + SELECT decrypted_secret INTO _secret + FROM vault.decrypted_secrets + WHERE name = _name + LIMIT 1; + + IF _secret IS NULL THEN + RAISE EXCEPTION 'Secret % nao encontrado no vault', _name USING ERRCODE = 'no_data_found'; + END IF; + + RETURN _secret; +END; +$function$; + +REVOKE ALL ON FUNCTION public.get_edge_function_secret(text) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_edge_function_secret(text) FROM anon, authenticated; +GRANT EXECUTE ON FUNCTION public.get_edge_function_secret(text) TO service_role; +GRANT EXECUTE ON FUNCTION public.get_edge_function_secret(text) TO postgres; + +COMMENT ON FUNCTION public.get_edge_function_secret(text) IS + 'Le secret de vault.decrypted_secrets. SECURITY DEFINER. Restrito a 2 nomes whitelisted. Usado por triggers/cron/RPCs que chamam edge functions com auth via header.'; + +-- ============================================================================ +-- 1) Trigger function: dispatch_quote_webhook_event +-- ============================================================================ +CREATE OR REPLACE FUNCTION public.dispatch_quote_webhook_event() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, extensions +AS $function$ +DECLARE + _event text; + _payload jsonb; + _project_url text := 'https://doufsxqlfjyuvxuezpln.supabase.co'; + _dispatcher_secret text; +BEGIN + IF TG_TABLE_NAME = 'quotes' THEN + IF TG_OP = 'INSERT' THEN _event := 'quote.created'; + ELSIF TG_OP = 'UPDATE' AND OLD.status IS DISTINCT FROM NEW.status THEN _event := 'quote.' || NEW.status; + ELSE RETURN NEW; END IF; + _payload := jsonb_build_object('id', NEW.id, 'quote_number', NEW.quote_number, 'status', NEW.status, + 'client_name', NEW.client_name, 'client_email', NEW.client_email, 'total', NEW.total, + 'seller_id', NEW.seller_id, 'updated_at', NEW.updated_at); + ELSIF TG_TABLE_NAME = 'orders' THEN + IF TG_OP = 'INSERT' THEN _event := 'order.created'; ELSE RETURN NEW; END IF; + _payload := jsonb_build_object('id', NEW.id, 'order_number', NEW.order_number, 'status', NEW.status, + 'client_name', NEW.client_name, 'total', NEW.total, 'seller_id', NEW.seller_id); + ELSIF TG_TABLE_NAME = 'discount_approval_requests' THEN + IF TG_OP = 'INSERT' THEN _event := 'discount.requested'; + ELSIF TG_OP = 'UPDATE' AND OLD.status IS DISTINCT FROM NEW.status AND NEW.status IN ('approved','rejected') THEN _event := 'discount.' || NEW.status; + ELSE RETURN NEW; END IF; + _payload := jsonb_build_object('id', NEW.id, 'quote_id', NEW.quote_id, + 'requested_discount_percent', NEW.requested_discount_percent, 'status', NEW.status, 'seller_id', NEW.seller_id); + ELSE RETURN NEW; END IF; + + IF NOT EXISTS (SELECT 1 FROM public.outbound_webhooks WHERE active = true AND _event = ANY(events)) THEN + RETURN NEW; + END IF; + + BEGIN + _dispatcher_secret := public.get_edge_function_secret('WEBHOOK_DISPATCHER_SECRET'); + EXCEPTION WHEN OTHERS THEN + _dispatcher_secret := NULL; + END; + + PERFORM extensions.http_post( + url := _project_url || '/functions/v1/webhook-dispatcher', + body := jsonb_build_object('event', _event, 'payload', _payload)::text, + params := '{}'::jsonb, + headers := jsonb_build_object( + 'Content-Type', 'application/json', + 'x-dispatcher-secret', COALESCE(_dispatcher_secret, '') + ), + timeout_milliseconds := 5000 + ); + RETURN NEW; +EXCEPTION WHEN OTHERS THEN + RETURN NEW; +END; +$function$; + +-- ============================================================================ +-- 2) RPC: retry_failed_webhook_deliveries +-- ============================================================================ +CREATE OR REPLACE FUNCTION public.retry_failed_webhook_deliveries() +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $function$ +DECLARE + v_supabase_url text := 'https://doufsxqlfjyuvxuezpln.supabase.co'; + v_service_key text; + v_dispatcher_secret text; + v_retried int := 0; + v_skipped int := 0; + rec record; + v_max_attempts int; +BEGIN + BEGIN + v_service_key := current_setting('app.supabase_service_role_key', true); + EXCEPTION WHEN OTHERS THEN + v_service_key := NULL; + END; + + BEGIN + v_dispatcher_secret := public.get_edge_function_secret('WEBHOOK_DISPATCHER_SECRET'); + EXCEPTION WHEN OTHERS THEN + v_dispatcher_secret := NULL; + END; + + IF v_dispatcher_secret IS NULL THEN + RETURN jsonb_build_object( + 'ok', false, + 'error', 'WEBHOOK_DISPATCHER_SECRET not configured in vault' + ); + END IF; + + FOR rec IN + WITH latest AS ( + SELECT DISTINCT ON (d.webhook_id, d.event, d.payload_hash) + d.id, d.webhook_id, d.event, d.payload, d.attempt, d.success + FROM public.webhook_deliveries d + WHERE d.delivered_at > now() - interval '1 hour' + ORDER BY d.webhook_id, d.event, d.payload_hash, d.attempt DESC + ) + SELECT l.*, w.active, w.retry_policy + FROM latest l + JOIN public.outbound_webhooks w ON w.id = l.webhook_id + WHERE l.success = false AND w.active = true + LOOP + v_max_attempts := COALESCE((rec.retry_policy->>'max_attempts')::int, 3); + + IF rec.attempt >= v_max_attempts THEN + v_skipped := v_skipped + 1; + CONTINUE; + END IF; + + PERFORM net.http_post( + url := v_supabase_url || '/functions/v1/webhook-dispatcher', + headers := jsonb_build_object( + 'Content-Type', 'application/json', + 'x-dispatcher-secret', v_dispatcher_secret, + 'Authorization', COALESCE('Bearer ' || v_service_key, '') + ), + body := jsonb_build_object( + 'event', rec.event, + 'payload', rec.payload + ) + ); + v_retried := v_retried + 1; + END LOOP; + + RETURN jsonb_build_object( + 'ok', true, + 'retried', v_retried, + 'skipped_max_attempts', v_skipped, + 'ran_at', now() + ); +END; +$function$; + +-- ============================================================================ +-- 3) Cron: connections-auto-test +-- ============================================================================ +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'connections-auto-test') THEN + PERFORM cron.unschedule('connections-auto-test'); + END IF; +END $$; + +SELECT cron.schedule( + 'connections-auto-test', + '*/15 * * * *', + $cron$ + SELECT net.http_post( + url := 'https://doufsxqlfjyuvxuezpln.supabase.co/functions/v1/connections-auto-test', + headers := jsonb_build_object( + 'Content-Type', 'application/json', + 'x-cron-secret', public.get_edge_function_secret('CONNECTIONS_AUTO_TEST_SECRET') + ), + body := '{"trigger":"cron"}'::jsonb, + timeout_milliseconds := 30000 + ) AS request_id; + $cron$ +);