diff --git a/supabase/config.toml b/supabase/config.toml index d86d033ab..d3d53a8ba 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -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 + +[functions.send-notification] +verify_jwt = false + +[functions.send-scheduled-reports] +verify_jwt = false + [auth] enable_signup = false -enable_anonymous_sign_ins = false \ No newline at end of file +enable_anonymous_sign_ins = false diff --git a/supabase/functions/_shared/dispatcher-auth.ts b/supabase/functions/_shared/dispatcher-auth.ts index cc98ef84e..d36bf23e5 100644 --- a/supabase/functions/_shared/dispatcher-auth.ts +++ b/supabase/functions/_shared/dispatcher-auth.ts @@ -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: ` (triggers DB, RPCs, cron) +// Modos: +// Modo A — `x-dispatcher-secret: ` (webhook-dispatcher: triggers DB/RPC) // Modo B — `Authorization: Bearer ` + role admin|supervisor|dev (frontend) +// Modo C — `x-cron-secret: ` (cron jobs — agora lê do vault) // -// `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. +// 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 = { 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): 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>(); + +async function getVaultSecret(name: string): Promise { + 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; + } catch { + return ""; + } + })(); + _vaultCache.set(name, promise); + return promise; +} + // ============================================================================ // 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,7 +151,7 @@ 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) { @@ -139,18 +159,12 @@ export async function authorizeDispatcher( 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,15 +265,22 @@ 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; secretEnvName: string; headerName: string }, -): CronAuthResult { +): Promise { 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) ?? ""; + } + const providedSecret = req.headers.get(headerName) ?? ""; if (!expectedSecret) { @@ -267,7 +288,7 @@ export function authorizeCron( 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" }; } diff --git a/supabase/functions/cleanup-notifications/index.ts b/supabase/functions/cleanup-notifications/index.ts index a61da7850..bb7fec6cc 100644 --- a/supabase/functions/cleanup-notifications/index.ts +++ b/supabase/functions/cleanup-notifications/index.ts @@ -7,7 +7,7 @@ const corsHeaders = buildPublicCorsHeaders(); Deno.serve(async (req) => { // Cron: exige x-cron-secret para evitar chamadas diretas não autorizadas if (req.method === "OPTIONS") return new Response(null, { status: 204 }); - const cronAuth = authorizeCron(req, { corsHeaders: {}, secretEnvName: "CRON_SECRET", headerName: "x-cron-secret" }); + const cronAuth = await authorizeCron(req, { corsHeaders: {}, secretEnvName: "CRON_SECRET", headerName: "x-cron-secret" }); if (!cronAuth.ok) return cronAuth.response; if (req.method === 'OPTIONS') { diff --git a/supabase/functions/cleanup-novelties/index.ts b/supabase/functions/cleanup-novelties/index.ts index c20d6f546..388253ea6 100644 --- a/supabase/functions/cleanup-novelties/index.ts +++ b/supabase/functions/cleanup-novelties/index.ts @@ -8,7 +8,7 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4"; Deno.serve(async (req) => { // Cron: exige x-cron-secret para evitar chamadas diretas não autorizadas if (req.method === "OPTIONS") return new Response(null, { status: 204 }); - const cronAuth = authorizeCron(req, { corsHeaders: {}, secretEnvName: "CRON_SECRET", headerName: "x-cron-secret" }); + const cronAuth = await authorizeCron(req, { corsHeaders: {}, secretEnvName: "CRON_SECRET", headerName: "x-cron-secret" }); if (!cronAuth.ok) return cronAuth.response; const corsHeaders = getCorsHeaders(req); diff --git a/supabase/functions/collections-watcher/index.ts b/supabase/functions/collections-watcher/index.ts index efb78cf11..049147f7e 100644 --- a/supabase/functions/collections-watcher/index.ts +++ b/supabase/functions/collections-watcher/index.ts @@ -34,7 +34,7 @@ Deno.serve(async (req) => { if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders }); // Cron: exige x-cron-secret para evitar chamadas diretas não autorizadas - const cronAuth = authorizeCron(req, { + const cronAuth = await authorizeCron(req, { corsHeaders: {}, secretEnvName: "CRON_SECRET", headerName: "x-cron-secret", diff --git a/supabase/functions/comparison-price-watcher/index.ts b/supabase/functions/comparison-price-watcher/index.ts index f7f57b86a..b8cf40dee 100644 --- a/supabase/functions/comparison-price-watcher/index.ts +++ b/supabase/functions/comparison-price-watcher/index.ts @@ -15,7 +15,7 @@ Deno.serve(async (req) => { if (req.method === "OPTIONS") return new Response(null, { headers: getCorsHeaders(req) }); // Cron: exige x-cron-secret para evitar chamadas diretas não autorizadas - const cronAuth = authorizeCron(req, { + const cronAuth = await authorizeCron(req, { corsHeaders: {}, secretEnvName: "CRON_SECRET", headerName: "x-cron-secret", diff --git a/supabase/functions/connections-auto-test/index.ts b/supabase/functions/connections-auto-test/index.ts index 622095d7a..4724ad853 100644 --- a/supabase/functions/connections-auto-test/index.ts +++ b/supabase/functions/connections-auto-test/index.ts @@ -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", diff --git a/supabase/functions/connections-health-check/index.ts b/supabase/functions/connections-health-check/index.ts index bf88f8dd0..e419b6184 100644 --- a/supabase/functions/connections-health-check/index.ts +++ b/supabase/functions/connections-health-check/index.ts @@ -62,7 +62,7 @@ Deno.serve(async (req) => { if (req.method === "OPTIONS") return new Response(null, { headers: getCorsHeaders(req) }); // Cron: exige x-cron-secret para evitar chamadas diretas não autorizadas - const cronAuth = authorizeCron(req, { + const cronAuth = await authorizeCron(req, { corsHeaders: {}, secretEnvName: "CRON_SECRET", headerName: "x-cron-secret", diff --git a/supabase/functions/favorites-watcher/index.ts b/supabase/functions/favorites-watcher/index.ts index 3bcc62c78..2c66bcf12 100644 --- a/supabase/functions/favorites-watcher/index.ts +++ b/supabase/functions/favorites-watcher/index.ts @@ -34,7 +34,7 @@ Deno.serve(async (req) => { if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders }); // Cron: exige x-cron-secret para evitar chamadas diretas não autorizadas - const cronAuth = authorizeCron(req, { + const cronAuth = await authorizeCron(req, { corsHeaders: {}, secretEnvName: "CRON_SECRET", headerName: "x-cron-secret", diff --git a/supabase/functions/ownership-audit/index.ts b/supabase/functions/ownership-audit/index.ts index 3413d1baa..a789f5e96 100644 --- a/supabase/functions/ownership-audit/index.ts +++ b/supabase/functions/ownership-audit/index.ts @@ -19,7 +19,7 @@ Deno.serve(async (req) => { if (req.method === "OPTIONS") return new Response(null, { headers: getCorsHeaders(req) }); // Cron: exige x-cron-secret para evitar chamadas diretas não autorizadas - const cronAuth = authorizeCron(req, { + const cronAuth = await authorizeCron(req, { corsHeaders: {}, secretEnvName: "CRON_SECRET", headerName: "x-cron-secret", diff --git a/supabase/functions/process-queue/index.ts b/supabase/functions/process-queue/index.ts index 868500c8f..21e880003 100644 --- a/supabase/functions/process-queue/index.ts +++ b/supabase/functions/process-queue/index.ts @@ -7,7 +7,7 @@ const corsHeaders = buildPublicCorsHeaders(); Deno.serve(async (req) => { // Cron: exige x-cron-secret para evitar chamadas diretas não autorizadas if (req.method === "OPTIONS") return new Response(null, { status: 204 }); - const cronAuth = authorizeCron(req, { corsHeaders: {}, secretEnvName: "CRON_SECRET", headerName: "x-cron-secret" }); + const cronAuth = await authorizeCron(req, { corsHeaders: {}, secretEnvName: "CRON_SECRET", headerName: "x-cron-secret" }); if (!cronAuth.ok) return cronAuth.response; if (req.method === 'OPTIONS') { diff --git a/supabase/functions/process-scheduled-reports/index.ts b/supabase/functions/process-scheduled-reports/index.ts index 2f0dcb3f3..2634cf0fb 100644 --- a/supabase/functions/process-scheduled-reports/index.ts +++ b/supabase/functions/process-scheduled-reports/index.ts @@ -7,7 +7,7 @@ const corsHeaders = buildPublicCorsHeaders(); Deno.serve(async (req) => { // Cron: exige x-cron-secret para evitar chamadas diretas não autorizadas if (req.method === "OPTIONS") return new Response(null, { status: 204 }); - const cronAuth = authorizeCron(req, { corsHeaders: {}, secretEnvName: "CRON_SECRET", headerName: "x-cron-secret" }); + const cronAuth = await authorizeCron(req, { corsHeaders: {}, secretEnvName: "CRON_SECRET", headerName: "x-cron-secret" }); if (!cronAuth.ok) return cronAuth.response; if (req.method === "OPTIONS") { diff --git a/supabase/functions/quote-followup-reminders/index.ts b/supabase/functions/quote-followup-reminders/index.ts index 959c79c2f..c99d55610 100644 --- a/supabase/functions/quote-followup-reminders/index.ts +++ b/supabase/functions/quote-followup-reminders/index.ts @@ -13,7 +13,7 @@ Deno.serve(async (req) => { if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); // Cron: exige x-cron-secret para evitar chamadas diretas não autorizadas - const cronAuth = authorizeCron(req, { + const cronAuth = await authorizeCron(req, { corsHeaders: {}, secretEnvName: "CRON_SECRET", headerName: "x-cron-secret", diff --git a/supabase/functions/send-digest/index.ts b/supabase/functions/send-digest/index.ts index c8b5f3ddf..54b4a01d5 100644 --- a/supabase/functions/send-digest/index.ts +++ b/supabase/functions/send-digest/index.ts @@ -7,7 +7,7 @@ const corsHeaders = buildPublicCorsHeaders(); Deno.serve(async (req) => { // Cron: exige x-cron-secret para evitar chamadas diretas não autorizadas if (req.method === "OPTIONS") return new Response(null, { status: 204 }); - const cronAuth = authorizeCron(req, { corsHeaders: {}, secretEnvName: "CRON_SECRET", headerName: "x-cron-secret" }); + const cronAuth = await authorizeCron(req, { corsHeaders: {}, secretEnvName: "CRON_SECRET", headerName: "x-cron-secret" }); if (!cronAuth.ok) return cronAuth.response; if (req.method === 'OPTIONS') { diff --git a/supabase/functions/send-notification/index.ts b/supabase/functions/send-notification/index.ts index 2a46122c5..1d562d467 100644 --- a/supabase/functions/send-notification/index.ts +++ b/supabase/functions/send-notification/index.ts @@ -24,7 +24,7 @@ function jsonRes(corsHeaders: Record, body: unknown, status = 20 Deno.serve(async (req) => { // Cron: exige x-cron-secret para evitar chamadas diretas não autorizadas if (req.method === "OPTIONS") return new Response(null, { status: 204 }); - const cronAuth = authorizeCron(req, { corsHeaders: {}, secretEnvName: "CRON_SECRET", headerName: "x-cron-secret" }); + const cronAuth = await authorizeCron(req, { corsHeaders: {}, secretEnvName: "CRON_SECRET", headerName: "x-cron-secret" }); if (!cronAuth.ok) return cronAuth.response; const corsHeaders = getCorsHeaders(req); diff --git a/supabase/functions/send-scheduled-reports/index.ts b/supabase/functions/send-scheduled-reports/index.ts index 77d9d25b5..031aa1162 100644 --- a/supabase/functions/send-scheduled-reports/index.ts +++ b/supabase/functions/send-scheduled-reports/index.ts @@ -9,6 +9,14 @@ Deno.serve(async (req: Request) => { return new Response("ok", { headers: corsHeaders }); } + // Cron: exige x-cron-secret para evitar chamadas diretas não autorizadas + const cronAuth = await authorizeCron(req, { + corsHeaders, + secretEnvName: "CRON_SECRET", + headerName: "x-cron-secret", + }); + if (!cronAuth.ok) return cronAuth.response; + try { const supabaseUrl = Deno.env.get("SUPABASE_URL")!; const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; diff --git a/supabase/migrations/20260515120000_fix_audit_ownership_orphans_uuid_only.sql b/supabase/migrations/20260515120000_fix_audit_ownership_orphans_uuid_only.sql new file mode 100644 index 000000000..911cb68a1 --- /dev/null +++ b/supabase/migrations/20260515120000_fix_audit_ownership_orphans_uuid_only.sql @@ -0,0 +1,82 @@ +-- ============================================================================ +-- Fix: audit_ownership_orphans tentava cast ::uuid em colunas TEXT, quebrava +-- com valores como "system" em enriched_contacts.created_by. Agora só +-- considera colunas com data_type='uuid'. Mais robusto que manter blacklist. +-- +-- Data: 15/mai/2026 +-- Bug detectado: ownership-audit edge function retornava HTTP 500 com +-- "invalid input syntax for type uuid: \"system\"" +-- Causa raiz: enriched_contacts.created_by é TEXT com 48 linhas de "system". +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.audit_ownership_orphans(_triggered_by text DEFAULT 'manual'::text) + RETURNS uuid + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path TO 'public' +AS $function$ +DECLARE + v_started_at timestamptz := clock_timestamp(); + v_report_id uuid; + v_owner_columns text[] := ARRAY['seller_id', 'user_id', 'owner_id', 'created_by']; + v_table record; + v_col text; + v_null_count bigint; + v_orphan_count bigint; + v_total_null bigint := 0; + v_total_orphan bigint := 0; + v_tables_scanned int := 0; + v_details jsonb := '[]'::jsonb; + v_table_entry jsonb; + v_rls jsonb; + v_rls_gaps int := 0; +BEGIN + IF auth.uid() IS NOT NULL AND NOT (has_role(auth.uid(), 'admin'::app_role) OR has_role(auth.uid(), 'dev'::app_role)) THEN + RAISE EXCEPTION 'audit_ownership_orphans: acesso negado'; + END IF; + + FOR v_table IN + SELECT c.table_name, c.column_name + FROM information_schema.columns c + JOIN information_schema.tables t ON t.table_schema = c.table_schema AND t.table_name = c.table_name + WHERE c.table_schema = 'public' + AND c.column_name = ANY(v_owner_columns) + AND c.data_type = 'uuid' -- FIX: ignora colunas TEXT (ex: enriched_contacts.created_by='system') + AND t.table_type = 'BASE TABLE' + AND c.table_name NOT IN ('login_attempts','step_up_audit_log','search_analytics','query_telemetry','mcp_access_violations','product_views','quote_history','optimization_queue','kit_templates') + ORDER BY c.table_name + LOOP + v_col := v_table.column_name; + v_tables_scanned := v_tables_scanned + 1; + EXECUTE format('SELECT count(*) FROM public.%I WHERE %I IS NULL', v_table.table_name, v_col) INTO v_null_count; + EXECUTE format('SELECT count(*) FROM public.%I t WHERE t.%I IS NOT NULL AND NOT EXISTS (SELECT 1 FROM auth.users u WHERE u.id = t.%I)', + v_table.table_name, v_col, v_col) INTO v_orphan_count; + IF v_null_count > 0 OR v_orphan_count > 0 THEN + v_table_entry := jsonb_build_object('table', v_table.table_name, 'owner_column', v_col, + 'null_owner_count', v_null_count, 'missing_user_count', v_orphan_count); + v_details := v_details || v_table_entry; + END IF; + v_total_null := v_total_null + v_null_count; + v_total_orphan := v_total_orphan + v_orphan_count; + END LOOP; + + v_rls := public.audit_rls_coverage(); + SELECT COALESCE(SUM(jsonb_array_length(elem->'missing_ops')),0)::int INTO v_rls_gaps + FROM jsonb_array_elements(v_rls) elem; + + INSERT INTO public.ownership_audit_reports ( + total_tables_scanned, total_issues_found, null_owner_count, missing_user_count, details, + triggered_by, duration_ms, rls_coverage, rls_gaps_count + ) VALUES ( + v_tables_scanned, (v_total_null + v_total_orphan)::int, v_total_null::int, v_total_orphan::int, v_details, + coalesce(_triggered_by, 'manual'), + EXTRACT(MILLISECONDS FROM (clock_timestamp() - v_started_at))::int, + v_rls, v_rls_gaps + ) RETURNING id INTO v_report_id; + + RETURN v_report_id; +END; +$function$; + +COMMENT ON FUNCTION public.audit_ownership_orphans(text) IS + 'Audita propriedade de registros em tabelas com colunas UUID owner. Versão corrigida (15/mai/2026): ignora colunas TEXT como enriched_contacts.created_by que armazenam valores não-UUID como "system".';