From 789dfa6fdbccc7aefa74b5ea538816cd4c7443bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Melga=C3=A7o?= Date: Wed, 29 Jul 2026 21:52:34 -0300 Subject: [PATCH] fix(audit): nenhuma acao via MCP era auditada, e o silencio escondia isso MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exercendo as ferramentas MCP nesta VPS e conferindo depois o api_audit_log: zero linhas com action='mcp.tool_called'. O lead criado por MCP existe na tabela crm_leads e nao existe na auditoria. O log do contêiner tinha a causa, repetida a cada chamada: [audit] insert error invalid input syntax for type uuid: "crm_create_lead" [audit] insert error ... violates foreign key constraint "api_audit_log_actor_user_id_fkey" Dois descasamentos com o schema: resourceId: toolName -> resource_id e uuid no banco; ia o nome da tool actorUserId: actor.id -> para token comum, actor.id E O ID DO TOKEN (lib/mcp/auth.ts), nao uma linha de auth.users Agora resource_id vai nulo (o nome da tool ja viaja em metadata.tool_name, que e jsonb) e actorUserId vai nulo (o ator ja esta em actorApiTokenId). Vale para todas as 16 tools. O que deixou isso vivo, porem, foi o terceiro problema: falha de audit so fazia console.error. O CLAUDE.md manda gerar alerta no Sentry justamente para isso — a trilha de auditoria podia parar inteira e o unico sinal ficava dentro do contêiner. Agora reporta. Junto, o inverso do mesmo problema: excesso de ruido. Os crons gravavam uma linha de auditoria por minuto mesmo sem fazer nada. Medido aqui: 1.175 das 1.236 linhas (95%) eram followup.worker_run, followup.silence_sweep_run e conversation.snooze_watcher_run vazios, em ~9h de instalacao PARADA — cerca de 3.100 linhas/dia numa tabela append-only com retencao de 5 anos, no Supabase free de 500MB, afogando as acoes reais na tela de auditoria. Passam a auditar so quando o tick mexeu em alguma coisa; liveness de worker e assunto de log, nao de trilha de auditoria. lib/mcp/audit.test.ts cobre os dois descasamentos. Sabotagem verificada: devolvendo resourceId: toolName o teste falha com "expected 'crm_create_lead' to be null". Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014eDYqCWr7Zrs99vLjqGj8r --- app/api/v1/cron/followup-flow-worker/route.ts | 38 +++++++++----- app/api/v1/cron/snooze-watcher/route.ts | 18 ++++--- lib/audit/index.ts | 26 +++++++++- lib/mcp/audit.test.ts | 51 +++++++++++++++++++ lib/mcp/audit.ts | 11 +++- 5 files changed, 119 insertions(+), 25 deletions(-) create mode 100644 lib/mcp/audit.test.ts diff --git a/app/api/v1/cron/followup-flow-worker/route.ts b/app/api/v1/cron/followup-flow-worker/route.ts index f6636f69c..3b731943e 100644 --- a/app/api/v1/cron/followup-flow-worker/route.ts +++ b/app/api/v1/cron/followup-flow-worker/route.ts @@ -73,13 +73,21 @@ async function handle(req: NextRequest): Promise { return fail("internal_error", detail, 500, { requestId }); } - void audit({ - action: "followup.worker_run", - organizationId: null, - bypassedRls: true, - metadata: { ...summary }, - requestId, - }); + // Só audita tick que MEXEU em alguma coisa. Auditar toda batida enchia o + // api_audit_log — que é append-only e tem retenção de 5 anos — de linhas + // vazias: numa instalação parada, medido nesta VPS, 95% das entradas eram + // heartbeat de cron (1.175 de 1.236 em ~9h), afogando as ações reais na tela + // de auditoria. Liveness de worker é assunto de log/monitoramento, não de + // trilha de auditoria. + if (summary.claimed || summary.advanced || summary.scheduled || summary.failed || summary.dead) { + void audit({ + action: "followup.worker_run", + organizationId: null, + bypassedRls: true, + metadata: { ...summary }, + requestId, + }); + } try { const sweepSummary = await runSilenceSweep({ @@ -87,13 +95,15 @@ async function handle(req: NextRequest): Promise { gateDb: createSupabaseFollowupGateDb(admin), clock: () => new Date(), }); - void audit({ - action: "followup.silence_sweep_run", - organizationId: null, - bypassedRls: true, - metadata: { ...sweepSummary }, - requestId, - }); + if (sweepSummary.enrolled || sweepSummary.pointers_gated_out || sweepSummary.skipped_existing) { + void audit({ + action: "followup.silence_sweep_run", + organizationId: null, + bypassedRls: true, + metadata: { ...sweepSummary }, + requestId, + }); + } } catch (err) { // Sweep falhando NUNCA aborta o tick — a resposta abaixo já reflete o // resultado de runFollowupTick, que rodou (e foi auditado) antes disto. diff --git a/app/api/v1/cron/snooze-watcher/route.ts b/app/api/v1/cron/snooze-watcher/route.ts index c45a8d191..0f5cdcfc5 100644 --- a/app/api/v1/cron/snooze-watcher/route.ts +++ b/app/api/v1/cron/snooze-watcher/route.ts @@ -105,13 +105,17 @@ async function handle(req: NextRequest): Promise { } } - void audit({ - action: "conversation.snooze_watcher_run", - organizationId: null, - bypassedRls: true, - metadata: { scanned: conversations.length, reopened }, - requestId, - }); + // Ver comentário em followup-flow-worker: varredura que não reabriu nada não + // é mutação, e não tem por que ocupar linha na auditoria. + if (reopened > 0) { + void audit({ + action: "conversation.snooze_watcher_run", + organizationId: null, + bypassedRls: true, + metadata: { scanned: conversations.length, reopened }, + requestId, + }); + } return ok({ scanned: conversations.length, reopened }, { requestId }); } diff --git a/lib/audit/index.ts b/lib/audit/index.ts index aef94508f..bf53f296f 100644 --- a/lib/audit/index.ts +++ b/lib/audit/index.ts @@ -57,13 +57,35 @@ export async function audit(entry: AuditEntry): Promise { acting_as_platform_admin: entry.actingAsPlatformAdmin ?? false, }); if (error) { - console.error("[audit] insert error", error.message); + reportAuditFailure(error.message, entry); } } catch (err) { - console.error("[audit] write failed", err); + reportAuditFailure(err instanceof Error ? err.message : String(err), entry); } } +/** + * Falha de audit não bloqueia a mutação (por doutrina), mas TEM que ser + * barulhenta em algum lugar — senão a trilha de auditoria pode parar inteira + * sem ninguém perceber. Foi exatamente o que aconteceu: TODA chamada de + * ferramenta MCP falhava ao auditar ("invalid input syntax for type uuid") e o + * único sinal era um console.error dentro do contêiner. + */ +function reportAuditFailure(message: string, entry: AuditEntry): void { + console.error("[audit] insert error", message, { action: entry.action }); + void import("@sentry/nextjs") + .then((Sentry) => { + Sentry.captureException(new Error(`[audit] write failed: ${message}`), { + level: "error", + tags: { subsystem: "audit", audit_action: entry.action }, + extra: { resource_type: entry.resourceType, organization_id: entry.organizationId }, + }); + }) + .catch(() => { + /* sem Sentry configurado: o console.error acima é o que resta */ + }); +} + /** * Stable sha256 hex of normalized email. Used in audit metadata to correlate * failed logins without storing PII plaintext. diff --git a/lib/mcp/audit.test.ts b/lib/mcp/audit.test.ts new file mode 100644 index 000000000..86341dd00 --- /dev/null +++ b/lib/mcp/audit.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const auditSpy = vi.fn(); +vi.mock("@/lib/audit", () => ({ audit: (e: unknown) => auditSpy(e) })); + +import { auditMcpToolCall } from "./audit"; +import type { McpContext } from "./types"; + +const ctx = { + organizationId: "bcc12320-f555-4fef-8d90-38a0ac5950e0", + apiTokenId: "d7ba0e68-0000-4000-8000-000000000001", + requestId: "req-1", + // Um token comum vira actor.type='user' com id = id do TOKEN (lib/mcp/auth.ts). + actor: { type: "user", id: "d7ba0e68-0000-4000-8000-000000000001", role: "manager" }, +} as unknown as McpContext; + +describe("auditMcpToolCall", () => { + beforeEach(() => auditSpy.mockClear()); + + it("não manda o nome da tool em resource_id (coluna uuid no banco)", async () => { + // Defeito de origem: resourceId recebia "crm_create_lead" e todo insert + // morria com «invalid input syntax for type uuid», em silêncio — nenhuma + // chamada MCP era auditada. + await auditMcpToolCall({ + ctx, toolName: "crm_create_lead", args: {}, durationMs: 12, success: true, + }); + const e = auditSpy.mock.calls[0]![0]; + expect(e.resourceId).toBeNull(); + expect(e.resourceType).toBe("mcp_tool"); + expect(e.metadata.tool_name).toBe("crm_create_lead"); + }); + + it("não manda id de token em actorUserId (FK para auth.users)", async () => { + await auditMcpToolCall({ + ctx, toolName: "crm_list_leads", args: {}, durationMs: 5, success: true, + }); + const e = auditSpy.mock.calls[0]![0]; + expect(e.actorUserId).toBeNull(); + expect(e.actorApiTokenId).toBe(ctx.apiTokenId); + }); + + it("redige segredos nos argumentos", async () => { + await auditMcpToolCall({ + ctx, toolName: "crm_get_contact", args: { cpf: "12345678900", query: "joana" }, + durationMs: 3, success: true, + }); + const e = auditSpy.mock.calls[0]![0]; + expect(e.metadata.args.cpf).toBe("[redacted]"); + expect(e.metadata.args.query).toBe("joana"); + }); +}); diff --git a/lib/mcp/audit.ts b/lib/mcp/audit.ts index f5db93820..d46641c14 100644 --- a/lib/mcp/audit.ts +++ b/lib/mcp/audit.ts @@ -62,11 +62,18 @@ export async function auditMcpToolCall(input: AuditMcpToolCallInput): Promise