From 4a331d6a699dfdbf62523112b6436ebc4672f50b Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 23 May 2026 17:52:46 +0000 Subject: [PATCH] Changes Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com> --- supabase/functions/_shared/internal-auth.ts | 128 ++++++++++++++++++ .../bootstrap-internal-secret/index.ts | 96 +++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 supabase/functions/_shared/internal-auth.ts create mode 100644 supabase/functions/bootstrap-internal-secret/index.ts diff --git a/supabase/functions/_shared/internal-auth.ts b/supabase/functions/_shared/internal-auth.ts new file mode 100644 index 0000000..ef22662 --- /dev/null +++ b/supabase/functions/_shared/internal-auth.ts @@ -0,0 +1,128 @@ +// internal-auth.ts +// Helper compartilhado para validar chamadas internas a edge functions. +// Permite que uma function aceite: +// - X-Internal-Secret válido (chamadas server-to-server, triggers pg_net, cron) +// - JWT de admin (painel admin via supabase.functions.invoke) +// - opcionalmente, JWT do dono do recurso (ownerUserId) +// +// Modo de operação: +// - Se INTERNAL_FUNCTION_SECRET estiver definido E o header X-Internal-Secret bater → OK +// - Senão, tenta validar o JWT do Authorization header +// - Retorna { ok, userId, isAdmin, isOwner, reason } + +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4"; + +export interface InternalAuthResult { + ok: boolean; + userId: string | null; + isAdmin: boolean; + isOwner: boolean; + viaSecret: boolean; + reason?: string; +} + +export interface AuthorizeInternalOptions { + /** Se fornecido, valida ownership contra esse user_id. */ + ownerUserId?: string | null; + /** Se true (default), permite admins. */ + allowAdmin?: boolean; + /** Se true (default), permite o dono. */ + allowOwner?: boolean; + /** Se true (default), permite via X-Internal-Secret. */ + allowSecret?: boolean; +} + +export async function authorizeInternalRequest( + req: Request, + opts: AuthorizeInternalOptions = {}, +): Promise { + const { + ownerUserId = null, + allowAdmin = true, + allowOwner = true, + allowSecret = true, + } = opts; + + // 1) X-Internal-Secret + if (allowSecret) { + const expected = Deno.env.get("INTERNAL_FUNCTION_SECRET") ?? ""; + const received = req.headers.get("x-internal-secret") ?? ""; + if (expected && received && constantTimeEq(expected, received)) { + return { + ok: true, + userId: null, + isAdmin: false, + isOwner: false, + viaSecret: true, + }; + } + } + + // 2) JWT + const authHeader = req.headers.get("authorization") ?? ""; + if (!authHeader.toLowerCase().startsWith("bearer ")) { + return { + ok: false, + userId: null, + isAdmin: false, + isOwner: false, + viaSecret: false, + reason: "missing bearer token", + }; + } + + const supabaseUrl = Deno.env.get("SUPABASE_URL")!; + const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; + const anonKey = Deno.env.get("SUPABASE_ANON_KEY") ?? Deno.env.get("SUPABASE_PUBLISHABLE_KEY") ?? ""; + + const userClient = createClient(supabaseUrl, anonKey, { + global: { headers: { Authorization: authHeader } }, + auth: { persistSession: false, autoRefreshToken: false }, + }); + const { data: userData, error: userErr } = await userClient.auth.getUser(); + if (userErr || !userData?.user) { + return { + ok: false, + userId: null, + isAdmin: false, + isOwner: false, + viaSecret: false, + reason: "invalid jwt", + }; + } + const userId = userData.user.id; + + let isAdmin = false; + if (allowAdmin) { + const admin = createClient(supabaseUrl, serviceKey, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + const { data: roleOk } = await admin.rpc("has_role", { + _user_id: userId, + _role: "admin", + }); + isAdmin = roleOk === true; + } + + const isOwner = allowOwner && ownerUserId !== null && userId === ownerUserId; + + if (isAdmin || isOwner) { + return { ok: true, userId, isAdmin, isOwner, viaSecret: false }; + } + + return { + ok: false, + userId, + isAdmin, + isOwner, + viaSecret: false, + reason: "forbidden", + }; +} + +function constantTimeEq(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + return diff === 0; +} diff --git a/supabase/functions/bootstrap-internal-secret/index.ts b/supabase/functions/bootstrap-internal-secret/index.ts new file mode 100644 index 0000000..c8e0c5b --- /dev/null +++ b/supabase/functions/bootstrap-internal-secret/index.ts @@ -0,0 +1,96 @@ +// bootstrap-internal-secret +// Função one-shot: lê INTERNAL_FUNCTION_SECRET de env e persiste em vault como +// 'internal_function_secret', para que triggers pg_net e cron job possam ler e +// enviar como X-Internal-Secret nas chamadas a outras edge functions. +// +// Requer JWT de admin. Idempotente — pode ser chamada várias vezes. + +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4"; +import { corsHeaders } from "../_shared/cors.ts"; + +const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; +const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; +const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY") ?? Deno.env.get("SUPABASE_PUBLISHABLE_KEY") ?? ""; + +function json(status: number, body: unknown) { + return new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); +} + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); + + const secretValue = Deno.env.get("INTERNAL_FUNCTION_SECRET") ?? ""; + if (!secretValue) { + return json(500, { error: "INTERNAL_FUNCTION_SECRET not set in env" }); + } + + // Validar admin via JWT + const authHeader = req.headers.get("authorization") ?? ""; + if (!authHeader.toLowerCase().startsWith("bearer ")) { + return json(401, { error: "missing bearer token" }); + } + const userClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { + global: { headers: { Authorization: authHeader } }, + auth: { persistSession: false, autoRefreshToken: false }, + }); + const { data: userData, error: userErr } = await userClient.auth.getUser(); + if (userErr || !userData?.user) return json(401, { error: "invalid jwt" }); + + const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + const { data: roleOk } = await admin.rpc("has_role", { + _user_id: userData.user.id, + _role: "admin", + }); + if (!roleOk) return json(403, { error: "admin role required" }); + + // Upsert no vault. Como vault.create_secret falha em nomes duplicados, + // tentamos atualizar existente primeiro via SQL direto. + try { + // 1) Tenta achar entry existente + const { data: existing } = await admin + .from("vault.decrypted_secrets" as never) + .select("id, decrypted_secret") + .eq("name", "internal_function_secret") + .maybeSingle() + .returns<{ id: string; decrypted_secret: string } | null>(); + + if (existing && existing.decrypted_secret === secretValue) { + return json(200, { ok: true, action: "already_synced" }); + } + + if (existing) { + // Atualiza o valor via RPC dedicada (não temos UPDATE direto em vault.secrets via PostgREST, + // então deletamos e recriamos). + await admin.rpc("vault_delete_secret", { secret_id: existing.id }); + } + + const { data: created, error: createErr } = await admin + .rpc("vault_create_secret", { + secret_value: secretValue, + secret_name: "internal_function_secret", + secret_description: "Shared secret for trigger pg_net → edge functions auth", + }) + .single(); + + if (createErr) { + console.error("vault_create_secret failed:", createErr); + return json(500, { error: "vault create failed", detail: createErr.message }); + } + + return json(200, { + ok: true, + action: existing ? "rotated" : "created", + // deno-lint-ignore no-explicit-any + secret_id: (created as any)?.secret_id ?? created, + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error("bootstrap-internal-secret fatal:", msg); + return json(500, { error: msg }); + } +});