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