-
Notifications
You must be signed in to change notification settings - Fork 0
feat(crons): vault-based single source of truth para CRON_SECRET + fix ownership-audit UUID bug #223
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(crons): vault-based single source of truth para CRON_SECRET + fix ownership-audit UUID bug #223
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,6 +27,49 @@ verify_jwt = false | |
| [functions.e2e-cleanup] | ||
| verify_jwt = false | ||
|
|
||
| # Cron functions — autenticadas via x-cron-secret (vault-based, ver _shared/dispatcher-auth.ts) | ||
| # Não usam JWT do Supabase Auth porque pg_cron não tem usuário/sessão. | ||
| # Segurança: header x-cron-secret comparado em tempo constante com vault.CRON_SECRET. | ||
|
|
||
| [functions.cleanup-notifications] | ||
| verify_jwt = false | ||
|
|
||
| [functions.cleanup-novelties] | ||
| verify_jwt = false | ||
|
|
||
| [functions.collections-watcher] | ||
| verify_jwt = false | ||
|
|
||
| [functions.comparison-price-watcher] | ||
| verify_jwt = false | ||
|
|
||
| [functions.connections-health-check] | ||
| verify_jwt = false | ||
|
|
||
| [functions.favorites-watcher] | ||
| verify_jwt = false | ||
|
|
||
| [functions.ownership-audit] | ||
| verify_jwt = false | ||
|
|
||
| [functions.process-queue] | ||
| verify_jwt = false | ||
|
|
||
| [functions.process-scheduled-reports] | ||
| verify_jwt = false | ||
|
|
||
| [functions.quote-followup-reminders] | ||
| verify_jwt = false | ||
|
|
||
| [functions.send-digest] | ||
| verify_jwt = false | ||
|
Comment on lines
+64
to
+65
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The same auth switch also affects Useful? React with 👍 / 👎. |
||
|
|
||
| [functions.send-notification] | ||
| verify_jwt = false | ||
|
|
||
| [functions.send-scheduled-reports] | ||
| verify_jwt = false | ||
|
|
||
| [auth] | ||
| enable_signup = false | ||
| enable_anonymous_sign_ins = false | ||
| enable_anonymous_sign_ins = false | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,21 +1,19 @@ | ||
| // supabase/functions/_shared/dispatcher-auth.ts | ||
| // -------------------------------------------------------------- | ||
| // Autorização para `webhook-dispatcher` e `connections-auto-test`. | ||
| // Autorização para `webhook-dispatcher`, `connections-auto-test` e crons. | ||
| // | ||
| // Duas edges chamadas por contextos diferentes — autoriza por modos: | ||
| // | ||
| // `webhook-dispatcher` (3 chamadores): | ||
| // Modo A — `x-dispatcher-secret: <SECRET>` (triggers DB, RPCs, cron) | ||
| // Modos: | ||
| // Modo A — `x-dispatcher-secret: <SECRET>` (webhook-dispatcher: triggers DB/RPC) | ||
| // Modo B — `Authorization: Bearer <user JWT>` + role admin|supervisor|dev (frontend) | ||
| // Modo C — `x-cron-secret: <SECRET>` (cron jobs — agora lê do vault) | ||
| // | ||
| // `connections-auto-test` (1 chamador): | ||
| // Modo C — `x-cron-secret: <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. | ||
| // Vault-based SoT (15/mai/2026): | ||
| // `authorizeCron` agora lê o secret esperado do vault PostgreSQL via | ||
| // RPC `get_edge_function_secret(_name)` quando service_role está disponível. | ||
| // Fallback para `Deno.env.get()` se vault indisponível (retrocompat). | ||
| // Cache em memória por cold-start: 1 RPC por instância. | ||
| // | ||
| // Segurança: comparação de secret em tempo constante (anti timing attack). | ||
| // Logs estruturados (JSON single-line) sem nunca expor o valor do secret. | ||
| // Segurança: comparação em tempo constante. Logs estruturados sem expor secret. | ||
|
|
||
| import { createClient, SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2.49.4"; | ||
|
|
||
|
|
@@ -27,7 +25,7 @@ export type AppRole = "dev" | "supervisor" | "agente"; | |
| const ROLE_RANK: Record<AppRole, number> = { agente: 1, supervisor: 2, dev: 3 }; | ||
|
|
||
| export type DispatcherAuthMode = "secret" | "user_jwt" | "legacy_no_auth"; | ||
| export type CronAuthMode = "secret" | "legacy_no_auth"; | ||
| export type CronAuthMode = "secret" | "secret_vault" | "legacy_no_auth"; | ||
|
|
||
| export interface DispatcherAuthOk { | ||
| ok: true; | ||
|
|
@@ -48,9 +46,7 @@ export interface CronAuthOk { | |
| 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. | ||
| * Comparação em tempo constante (constant-time) para evitar timing attacks. | ||
| */ | ||
| export function constantTimeEqual(a: string, b: string): boolean { | ||
| if (typeof a !== "string" || typeof b !== "string") return false; | ||
|
|
@@ -63,8 +59,6 @@ export function constantTimeEqual(a: string, b: string): boolean { | |
| } | ||
|
|
||
| function logAuthEvent(payload: Record<string, unknown>): 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 { | ||
|
|
@@ -79,12 +73,38 @@ function jsonResponse(body: unknown, status: number, cors: Record<string, string | |
| }); | ||
| } | ||
|
|
||
| // -------------------------------------------------------------- | ||
| // Vault secret fetcher — single source of truth para cron secrets. | ||
| // Cache em memória durante o cold-start para evitar 1 RPC por request. | ||
| // -------------------------------------------------------------- | ||
|
|
||
| const _vaultCache = new Map<string, Promise<string>>(); | ||
|
|
||
| async function getVaultSecret(name: string): Promise<string> { | ||
| if (_vaultCache.has(name)) return _vaultCache.get(name)!; | ||
| const promise = (async () => { | ||
| if (!SUPABASE_URL || !SERVICE_KEY) return ""; | ||
| try { | ||
| const client = createClient(SUPABASE_URL, SERVICE_KEY, { | ||
| auth: { persistSession: false, autoRefreshToken: false }, | ||
| }); | ||
| const { data, error } = await client.rpc("get_edge_function_secret", { _name: name }); | ||
| if (error || !data) return ""; | ||
| return data as string; | ||
|
Comment on lines
+83
to
+93
|
||
| } catch { | ||
| return ""; | ||
| } | ||
| })(); | ||
| _vaultCache.set(name, promise); | ||
| return promise; | ||
| } | ||
|
Comment on lines
+83
to
+100
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Não cacheie resultado vazio do Vault como definitivo. Se a primeira RPC falhar/retornar vazio, esse 💡 Ajuste sugerido async function getVaultSecret(name: string): Promise<string> {
if (_vaultCache.has(name)) return _vaultCache.get(name)!;
const promise = (async () => {
@@
} catch {
return "";
}
})();
_vaultCache.set(name, promise);
+ promise.then((value) => {
+ if (!value) _vaultCache.delete(name);
+ }).catch(() => {
+ _vaultCache.delete(name);
+ });
return promise;
}🤖 Prompt for AI Agents |
||
|
|
||
| // ============================================================================ | ||
| // 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`. */ | ||
| /** Se true, exige Modo B (user JWT). Modo A retorna 403. */ | ||
| requireUserContext?: boolean; | ||
| /** Role mínimo no Modo B. Default: 'supervisor'. */ | ||
| minRole?: AppRole; | ||
|
|
@@ -104,7 +124,7 @@ export async function authorizeDispatcher( | |
| const providedSecret = req.headers.get("x-dispatcher-secret") ?? ""; | ||
| const authHeader = req.headers.get("Authorization") ?? req.headers.get("authorization") ?? ""; | ||
|
|
||
| // ───────── Modo A: x-dispatcher-secret ───────── | ||
| // Modo A: x-dispatcher-secret | ||
| if (providedSecret && expectedSecret) { | ||
| if (!constantTimeEqual(providedSecret, expectedSecret)) { | ||
| logAuthEvent({ outcome: "denied", reason: "bad_secret", mode_attempted: "secret" }); | ||
|
|
@@ -131,26 +151,20 @@ export async function authorizeDispatcher( | |
| }; | ||
| } | ||
|
|
||
| // ───────── Modo B: Bearer user JWT ───────── | ||
| // 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, | ||
| ), | ||
| response: jsonResponse({ error: "user_context_required" }, 403, corsHeaders), | ||
| }; | ||
| } | ||
| logAuthEvent({ outcome: "allowed", mode: "secret", via: "service_role_bearer" }); | ||
|
|
@@ -222,7 +236,7 @@ export async function authorizeDispatcher( | |
| }; | ||
| } | ||
|
|
||
| // ───────── Retrocompat: nenhum env setado → aceita anônimo com warning ───────── | ||
| // Retrocompat: nenhum env setado → aceita anônimo com warning | ||
| if (!expectedSecret) { | ||
| logAuthEvent({ | ||
| outcome: "allowed", | ||
|
|
@@ -251,23 +265,30 @@ export async function authorizeDispatcher( | |
| } | ||
|
|
||
| // ============================================================================ | ||
| // connections-auto-test: Modo C (cron secret) | ||
| // crons: Modo C (cron secret) — agora vault-based com fallback env | ||
| // ============================================================================ | ||
|
|
||
| export function authorizeCron( | ||
| export async function authorizeCron( | ||
| req: Request, | ||
| opts: { corsHeaders: Record<string, string>; secretEnvName: string; headerName: string }, | ||
| ): CronAuthResult { | ||
| ): Promise<CronAuthResult> { | ||
| const { corsHeaders, secretEnvName, headerName } = opts; | ||
| const expectedSecret = Deno.env.get(secretEnvName) ?? ""; | ||
|
|
||
| // Vault first (single source of truth), fallback env (retrocompat) | ||
| let expectedSecret = await getVaultSecret(secretEnvName); | ||
| const viaVault = !!expectedSecret; | ||
| if (!expectedSecret) { | ||
| expectedSecret = Deno.env.get(secretEnvName) ?? ""; | ||
|
Comment on lines
+278
to
+281
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When these cron functions pass Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| 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.`, | ||
| warning: `${secretEnvName} nao configurado em vault nem env — aceitando chamada anonima. Configure para hardening.`, | ||
| }); | ||
| return { ok: true, mode: "legacy_no_auth" }; | ||
| } | ||
|
|
@@ -285,10 +306,10 @@ export function authorizeCron( | |
| } | ||
|
|
||
| if (!constantTimeEqual(providedSecret, expectedSecret)) { | ||
| logAuthEvent({ outcome: "denied", reason: "bad_cron_secret", env: secretEnvName }); | ||
| logAuthEvent({ outcome: "denied", reason: "bad_cron_secret", env: secretEnvName, via_vault: viaVault }); | ||
| return { ok: false, response: jsonResponse({ error: "unauthorized" }, 401, corsHeaders) }; | ||
| } | ||
|
|
||
| logAuthEvent({ outcome: "allowed", mode: "secret", env: secretEnvName }); | ||
| return { ok: true, mode: "secret" }; | ||
| logAuthEvent({ outcome: "allowed", mode: viaVault ? "secret_vault" : "secret", env: secretEnvName }); | ||
| return { ok: true, mode: viaVault ? "secret_vault" : "secret" }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -127,7 +127,7 @@ Deno.serve(async (req) => { | |||||||||||||||||
| if (req.method === "OPTIONS") return new Response(null, { headers: getCorsHeaders(req) }); | ||||||||||||||||||
|
|
||||||||||||||||||
| // Hardening Onda 1: valida x-cron-secret | ||||||||||||||||||
| const auth = authorizeCron(req, { | ||||||||||||||||||
| const auth = await authorizeCron(req, { | ||||||||||||||||||
| corsHeaders, | ||||||||||||||||||
| secretEnvName: "CONNECTIONS_AUTO_TEST_SECRET", | ||||||||||||||||||
| headerName: "x-cron-secret", | ||||||||||||||||||
|
Comment on lines
+130
to
133
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Aqui a autenticação usa 💡 Ajuste sugerido const auth = await authorizeCron(req, {
corsHeaders,
- secretEnvName: "CONNECTIONS_AUTO_TEST_SECRET",
+ secretEnvName: "CRON_SECRET",
headerName: "x-cron-secret",
});📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
|
|
||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Disabling JWT verification for
process-queuemakes the function depend on the in-functionx-cron-secretguard, but the existing pg_cron job insupabase/cron/cron-config.sqlstill posts onlyAuthorizationandContent-Typeheaders to/functions/v1/process-queue(checked lines 18-22). OnceCRON_SECRETis actually available through vault/env, that scheduled queue processor will receive nox-cron-secretand return 401 every minute; the schedule needs to include the same vault-backed secret header before relying on this auth mode.Useful? React with 👍 / 👎.