-
Notifications
You must be signed in to change notification settings - Fork 0
fix(connections): destrava módulo /admin/conexoes — credenciais via DB-first, cron órfão, RLS dev #70
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
fix(connections): destrava módulo /admin/conexoes — credenciais via DB-first, cron órfão, RLS dev #70
Changes from all commits
4210451
5b80b38
99feeb3
a70c74d
cffcb45
dba68fc
291f505
038c9e4
096dbcf
02a9e6c
98e69b9
b7ce9ec
9d7698b
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 |
|---|---|---|
| @@ -0,0 +1,188 @@ | ||
| # 🔌 Runbook — Módulo `/admin/conexoes` | ||
|
|
||
| Guia operacional do módulo de Conexões: diagnóstico, rotação de credenciais, | ||
| auto-test cron, e interpretação de logs. | ||
|
|
||
| --- | ||
|
|
||
| ## 🩺 Sintoma → Causa → Ação | ||
|
|
||
| ### "Carregando..." infinito no seletor de empresas (front) | ||
|
|
||
| **Sintoma:** UI fica travada em "Carregando..." por 15-20s antes de ficar vazio. | ||
|
|
||
| **Causa raiz:** `crm-db-bridge` não consegue resolver `EXTERNAL_CRM_URL` / | ||
| `EXTERNAL_CRM_SERVICE_ROLE_KEY`. React Query reentra 3× em cada erro 500. | ||
|
|
||
| **Diagnóstico:** | ||
|
|
||
| ```sql | ||
| -- 1. Confirmar que credenciais existem no DB | ||
| SELECT secret_name, secret_source, is_active | ||
| FROM public.integration_credentials | ||
| WHERE secret_name LIKE 'EXTERNAL_CRM%'; | ||
|
|
||
| -- 2. Ver últimos erros do bridge nos logs estruturados | ||
| -- Filtrar por evt='credential_resolved' e source='none' | ||
| ``` | ||
|
|
||
| **Ação:** | ||
|
|
||
| 1. Cadastrar credenciais via `/admin/conexoes` ou diretamente: | ||
| ```sql | ||
| -- Via secrets-manager (preferido) | ||
| -- POST /functions/v1/secrets-manager | ||
| -- { "action": "set", "name": "EXTERNAL_CRM_URL", "value": "https://..." } | ||
| ``` | ||
| 2. Aliases legados (`CRM_SUPABASE_URL`) também funcionam como fallback via env. | ||
|
|
||
| --- | ||
|
|
||
| ### Banner "Credenciais alteradas" não mostra qual secret mudou | ||
|
|
||
| **Causa raiz:** payload realtime usa coluna `secret_name`, não `name`. | ||
| Foi corrigido em PR #70 (`CredentialsChangedBanner.tsx`). | ||
|
|
||
| **Validar:** alterar uma credencial e verificar que o toast mostra o nome. | ||
|
|
||
| --- | ||
|
|
||
| ### `AutoTestJobStatusCard` sempre "untested" | ||
|
|
||
| **Causa raiz:** cron `connections-auto-test` órfão (sem schedule). | ||
|
|
||
| **Diagnóstico:** | ||
|
|
||
| ```sql | ||
| SELECT jobname, schedule, active | ||
| FROM cron.job | ||
| WHERE jobname = 'connections-auto-test'; | ||
| -- Se vazio: migration 20260429163414_* não foi aplicada | ||
| ``` | ||
|
|
||
| **Ação:** | ||
|
|
||
| ```sql | ||
| -- Aplicar a migration ou rodar manualmente: | ||
| SELECT cron.schedule( | ||
| 'connections-auto-test', | ||
| '*/15 * * * *', | ||
| $$ SELECT net.http_post(...); $$ | ||
| ); | ||
| ``` | ||
|
|
||
| **Validar intervalo configurado:** | ||
|
|
||
| ```sql | ||
| SELECT public.get_connections_auto_test_interval(); | ||
| -- Deve retornar 15 (default) ou outro valor configurado via UI. | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ## 🔐 Rotação de Credenciais CRM | ||
|
|
||
| 1. **Provedor:** gerar nova `service_role` key no dashboard Supabase do CRM. | ||
| 2. **Aplicar:** `/admin/conexoes` → Tab "Supabase" → Card CRM → "Rotacionar". | ||
| 3. **Verificar:** seletor de empresas no front carrega em <3s. | ||
| 4. **Auditar:** linha em `secret_rotation_log` com user_id e timestamp. | ||
|
|
||
| > Cache TTL é 60s — propagação para edge functions é praticamente instantânea | ||
| > via `invalidateCredentialCache()` chamado pelo secrets-manager após rotação. | ||
|
|
||
| --- | ||
|
|
||
| ## 🩺 Endpoints de Diagnóstico do `crm-db-bridge` | ||
|
|
||
| Todos com **bypass de auth** — operadores precisam diagnosticar mesmo com JWT | ||
| quebrado ou breaker aberto. | ||
|
|
||
| ``` | ||
| GET /functions/v1/crm-db-bridge?op=ping → liveness | ||
| GET /functions/v1/crm-db-bridge?op=diag → boot/runtime metrics | ||
| GET /functions/v1/crm-db-bridge?op=breaker_status → circuit breaker | ||
| GET /functions/v1/crm-db-bridge?op=creds_health → resolução das credenciais | ||
| ``` | ||
|
|
||
| Exemplo de `creds_health` em estado saudável: | ||
|
|
||
| ```json | ||
| { | ||
| "ok": true, "ts": 1745000000000, "health": "healthy", | ||
| "credentials": [ | ||
| { "name": "EXTERNAL_CRM_URL", "present": true, | ||
| "source": "db", "via_alias": false, "resolved_name": "EXTERNAL_CRM_URL", | ||
| "value_length": 41, "suffix4": "e.co" }, | ||
| { "name": "EXTERNAL_CRM_SERVICE_ROLE_KEY", "present": true, | ||
| "source": "db", "via_alias": false, "resolved_name": "EXTERNAL_CRM_SERVICE_ROLE_KEY", | ||
| "value_length": 219, "suffix4": "x9wA" }, | ||
| { "name": "EXTERNAL_CRM_ANON_KEY", "present": true, | ||
| "source": "env", "via_alias": true, "resolved_name": "CRM_SUPABASE_ANON_KEY", | ||
| "value_length": 219, "suffix4": "g4V0" } | ||
| ] | ||
| } | ||
| ``` | ||
|
|
||
| | `health` | Significado | Ação | | ||
| |---|---|---| | ||
| | `healthy` | URL + 1 key presentes | nenhuma | | ||
| | `degraded` | só URL ou só key | cadastrar a parte faltante | | ||
| | `missing` | sem URL | bridge fora do ar — cadastrar credenciais | | ||
|
|
||
| --- | ||
|
|
||
| ## 📊 Logs Estruturados | ||
|
|
||
| ### `connections-auto-test` | ||
|
|
||
| | Evento | Payload | | ||
| |---|---| | ||
| | `auto-test` | `{type, name, ok, status, latency_ms, attempts, retried, error}` | | ||
| | `auto-test-summary` | `{tested, ok_count, failed, retried, recovered, duration_ms}` | | ||
| | `auto-test-error` | `{id, type, error}` (erro por conexão) | | ||
| | `auto-test-fatal` | `{error}` (erro global, batch abortado) | | ||
|
|
||
| ### `_shared/credentials.ts` | ||
|
|
||
| | Evento | Payload | | ||
| |---|---| | ||
| | `credential_resolved` | `{name, resolved_name, source, has_value, cached, duration_ms, via_alias}` | | ||
|
|
||
| > `source: "none"` indica credencial não encontrada — alarme precoce. | ||
| > Desabilitar verbosidade: `LOG_CREDENTIAL_RESOLUTION=off` no env. | ||
|
|
||
| --- | ||
|
|
||
| ## 🛡️ RLS de `integration_credentials` | ||
|
|
||
| Policies aceitam role `admin` **OU** `dev` (alinhado ao `secrets-manager`). | ||
| Migration: `20260429163441_align_integration_credentials_rls_with_dev.sql`. | ||
|
|
||
| ```sql | ||
| -- Validar policies | ||
| SELECT policyname, cmd FROM pg_policies | ||
| WHERE tablename = 'integration_credentials'; | ||
| -- 4 linhas esperadas: SELECT, INSERT, UPDATE, DELETE | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ## 🔁 Edge Functions que dependem de credenciais CRM | ||
|
|
||
| | Função | Aliases consumidos | Impacto se faltar | | ||
| |---|---|---| | ||
| | `crm-db-bridge` | `EXTERNAL_CRM_URL`, `_SERVICE_ROLE_KEY`, `_ANON_KEY` | front "Carregando..." | | ||
| | `quote-sync` | `EXTERNAL_CRM_URL`, `_SERVICE_ROLE_KEY` | sync de orçamentos pausa | | ||
| | `expert-chat` | `EXTERNAL_CRM_URL`, `_SERVICE_ROLE_KEY` | chat sem contexto de cliente | | ||
| | `quote-public-view` | `EXTERNAL_CRM_URL`, `_SERVICE_ROLE_KEY` | link público quebra | | ||
|
|
||
| Todas usam `resolveCredential()` — DB-first, env como fallback. | ||
|
|
||
| --- | ||
|
|
||
| ## 📚 Referências | ||
|
|
||
| - `supabase/functions/_shared/credentials.ts` — SSOT de resolução | ||
| - `supabase/migrations/20260429163414_*.sql` — schedule do cron | ||
| - `supabase/migrations/20260429163441_*.sql` — RLS para dev | ||
| - `docs/RUNBOOK.md` — runbook geral | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,9 +1,9 @@ | ||||||||||||||||||
| import { useCallback, useEffect, useRef, useState } from "react"; | ||||||||||||||||||
| import { Loader2, RefreshCw, X } from "lucide-react"; | ||||||||||||||||||
| import { supabase } from "@/integrations/supabase/client"; | ||||||||||||||||||
| import { useSecretsManager } from "@/hooks/useSecretsManager"; | ||||||||||||||||||
| import { Button } from "@/components/ui/button"; | ||||||||||||||||||
| import { toast } from "sonner"; | ||||||||||||||||||
| import { useCallback, useEffect, useRef, useState } from 'react'; | ||||||||||||||||||
| import { Loader2, RefreshCw, X } from 'lucide-react'; | ||||||||||||||||||
| import { supabase } from '@/integrations/supabase/client'; | ||||||||||||||||||
| import { useSecretsManager } from '@/hooks/useSecretsManager'; | ||||||||||||||||||
| import { Button } from '@/components/ui/button'; | ||||||||||||||||||
| import { toast } from 'sonner'; | ||||||||||||||||||
|
|
||||||||||||||||||
| interface CredentialsChangedBannerProps { | ||||||||||||||||||
| /** Callback executed in parallel with cache invalidation + secret list refresh. */ | ||||||||||||||||||
|
|
@@ -12,7 +12,7 @@ interface CredentialsChangedBannerProps { | |||||||||||||||||
|
|
||||||||||||||||||
| interface PendingChange { | ||||||||||||||||||
| count: number; | ||||||||||||||||||
| lastEvent: "INSERT" | "UPDATE" | "DELETE"; | ||||||||||||||||||
| lastEvent: 'INSERT' | 'UPDATE' | 'DELETE'; | ||||||||||||||||||
| lastName: string | null; | ||||||||||||||||||
| lastAt: number; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
@@ -35,16 +35,19 @@ export function CredentialsChangedBanner({ onRefreshed }: CredentialsChangedBann | |||||||||||||||||
|
|
||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||
| const channel = supabase | ||||||||||||||||||
| .channel("admin-conexoes-credentials-changes") | ||||||||||||||||||
| .channel('admin-conexoes-credentials-changes') | ||||||||||||||||||
| .on( | ||||||||||||||||||
| "postgres_changes", | ||||||||||||||||||
| { event: "*", schema: "public", table: "integration_credentials" }, | ||||||||||||||||||
| 'postgres_changes', | ||||||||||||||||||
| { event: '*', schema: 'public', table: 'integration_credentials' }, | ||||||||||||||||||
| (payload) => { | ||||||||||||||||||
| const event = payload.eventType as "INSERT" | "UPDATE" | "DELETE"; | ||||||||||||||||||
| const event = payload.eventType as 'INSERT' | 'UPDATE' | 'DELETE'; | ||||||||||||||||||
| // Coluna real em integration_credentials é `secret_name` — | ||||||||||||||||||
| // typo histórico (`name`) escondia o nome do secret alterado | ||||||||||||||||||
| // no aviso de auto-refresh. | ||||||||||||||||||
| const row = | ||||||||||||||||||
| (payload.new as { name?: string } | null) ?? | ||||||||||||||||||
| (payload.old as { name?: string } | null); | ||||||||||||||||||
| const name = row?.name ?? null; | ||||||||||||||||||
| (payload.new as { secret_name?: string } | null) ?? | ||||||||||||||||||
| (payload.old as { secret_name?: string } | null); | ||||||||||||||||||
| const name = row?.secret_name ?? null; | ||||||||||||||||||
|
Comment on lines
+48
to
+50
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.
Using Suggested fix- const row =
- (payload.new as { secret_name?: string } | null) ??
- (payload.old as { secret_name?: string } | null);
+ const row =
+ event === 'DELETE'
+ ? (payload.old as { secret_name?: string } | null)
+ : ((payload.new as { secret_name?: string } | null) ??
+ (payload.old as { secret_name?: string } | null));📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
| setPending((prev) => ({ | ||||||||||||||||||
| count: (prev?.count ?? 0) + 1, | ||||||||||||||||||
| lastEvent: event, | ||||||||||||||||||
|
|
@@ -71,19 +74,19 @@ export function CredentialsChangedBanner({ onRefreshed }: CredentialsChangedBann | |||||||||||||||||
| Promise.resolve().then(() => onRefreshedRef.current?.()), | ||||||||||||||||||
| ]); | ||||||||||||||||||
| const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1); | ||||||||||||||||||
| const cacheOk = cacheRes.status === "fulfilled" && cacheRes.value.ok; | ||||||||||||||||||
| const listOk = listRes.status === "fulfilled"; | ||||||||||||||||||
| const hookOk = hookRes.status === "fulfilled"; | ||||||||||||||||||
| const cacheOk = cacheRes.status === 'fulfilled' && cacheRes.value.ok; | ||||||||||||||||||
| const listOk = listRes.status === 'fulfilled'; | ||||||||||||||||||
| const hookOk = hookRes.status === 'fulfilled'; | ||||||||||||||||||
| const credCount = listOk && Array.isArray(listRes.value) ? listRes.value.length : 0; | ||||||||||||||||||
| const okCount = [cacheOk, listOk, hookOk].filter(Boolean).length; | ||||||||||||||||||
|
|
||||||||||||||||||
| if (okCount === 3) { | ||||||||||||||||||
| toast.success("Status e cards atualizados", { | ||||||||||||||||||
| toast.success('Status e cards atualizados', { | ||||||||||||||||||
| description: `Cache invalidado · ${credCount} credenciais relidas (${elapsed}s)`, | ||||||||||||||||||
| }); | ||||||||||||||||||
| setPending(null); | ||||||||||||||||||
| } else { | ||||||||||||||||||
| toast.warning("Atualização parcial", { | ||||||||||||||||||
| toast.warning('Atualização parcial', { | ||||||||||||||||||
| description: `Algumas operações falharam (${elapsed}s)`, | ||||||||||||||||||
| }); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
@@ -95,27 +98,30 @@ export function CredentialsChangedBanner({ onRefreshed }: CredentialsChangedBann | |||||||||||||||||
| if (!pending) return null; | ||||||||||||||||||
|
|
||||||||||||||||||
| const eventLabel = | ||||||||||||||||||
| pending.lastEvent === "INSERT" | ||||||||||||||||||
| ? "criada" | ||||||||||||||||||
| : pending.lastEvent === "DELETE" | ||||||||||||||||||
| ? "removida" | ||||||||||||||||||
| : "alterada"; | ||||||||||||||||||
| pending.lastEvent === 'INSERT' | ||||||||||||||||||
| ? 'criada' | ||||||||||||||||||
| : pending.lastEvent === 'DELETE' | ||||||||||||||||||
| ? 'removida' | ||||||||||||||||||
| : 'alterada'; | ||||||||||||||||||
|
|
||||||||||||||||||
| const description = | ||||||||||||||||||
| pending.count === 1 | ||||||||||||||||||
| ? `Credencial ${pending.lastName ? `"${pending.lastName}" ` : ""}${eventLabel} no banco.` | ||||||||||||||||||
| : `${pending.count} alterações detectadas em credenciais (última: ${pending.lastName ?? "—"}, ${eventLabel}).`; | ||||||||||||||||||
| ? `Credencial ${pending.lastName ? `"${pending.lastName}" ` : ''}${eventLabel} no banco.` | ||||||||||||||||||
| : `${pending.count} alterações detectadas em credenciais (última: ${pending.lastName ?? '—'}, ${eventLabel}).`; | ||||||||||||||||||
|
|
||||||||||||||||||
| return ( | ||||||||||||||||||
| <div | ||||||||||||||||||
| role="status" | ||||||||||||||||||
| aria-live="polite" | ||||||||||||||||||
| className="flex items-center gap-3 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-2.5 text-sm" | ||||||||||||||||||
| > | ||||||||||||||||||
| <RefreshCw className="h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" aria-hidden="true" /> | ||||||||||||||||||
| <div className="flex-1 min-w-0"> | ||||||||||||||||||
| <RefreshCw | ||||||||||||||||||
| className="h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" | ||||||||||||||||||
| aria-hidden="true" | ||||||||||||||||||
| /> | ||||||||||||||||||
| <div className="min-w-0 flex-1"> | ||||||||||||||||||
| <p className="font-medium text-foreground">Credenciais alteradas no banco</p> | ||||||||||||||||||
| <p className="text-xs text-muted-foreground truncate">{description}</p> | ||||||||||||||||||
| <p className="truncate text-xs text-muted-foreground">{description}</p> | ||||||||||||||||||
| </div> | ||||||||||||||||||
| <Button | ||||||||||||||||||
| type="button" | ||||||||||||||||||
|
|
@@ -126,11 +132,11 @@ export function CredentialsChangedBanner({ onRefreshed }: CredentialsChangedBann | |||||||||||||||||
| aria-label="Recarregar status e cards agora" | ||||||||||||||||||
| > | ||||||||||||||||||
| {isRefreshing ? ( | ||||||||||||||||||
| <Loader2 className="h-4 w-4 mr-1 animate-spin" /> | ||||||||||||||||||
| <Loader2 className="mr-1 h-4 w-4 animate-spin" /> | ||||||||||||||||||
| ) : ( | ||||||||||||||||||
| <RefreshCw className="h-4 w-4 mr-1" /> | ||||||||||||||||||
| <RefreshCw className="mr-1 h-4 w-4" /> | ||||||||||||||||||
| )} | ||||||||||||||||||
| {isRefreshing ? "Atualizando…" : "Atualizar agora"} | ||||||||||||||||||
| {isRefreshing ? 'Atualizando…' : 'Atualizar agora'} | ||||||||||||||||||
| </Button> | ||||||||||||||||||
| <Button | ||||||||||||||||||
| type="button" | ||||||||||||||||||
|
|
||||||||||||||||||
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.
Add a language tag to the fenced code block.
This block is missing a fence language and can trigger markdown lint failures (MD040).
Suggested fix
Verify each finding against the current code and only fix it if needed.
In
@docs/RUNBOOK_CONNECTIONS.mdaround lines 100 - 105, The fenced code blockcontaining the GET endpoint lines (e.g., "GET
/functions/v1/crm-db-bridge?op=ping", "GET ...?op=diag", "GET
...?op=breaker_status", "GET ...?op=creds_health") is missing a language tag and
triggers MD040; add a language tag (e.g.,
text) to the opening fence so the block becomestext and the linter will accept it.