fix(audit): nenhuma acao via MCP era auditada, e o silencio escondia isso

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014eDYqCWr7Zrs99vLjqGj8r
This commit is contained in:
Rafael Melgaço
2026-07-29 21:52:34 -03:00
co-authored by Claude Opus 5
parent 6d420cff34
commit 789dfa6fdb
5 changed files with 119 additions and 25 deletions
+24 -14
View File
@@ -73,13 +73,21 @@ async function handle(req: NextRequest): Promise<Response> {
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<Response> {
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.
+11 -7
View File
@@ -105,13 +105,17 @@ async function handle(req: NextRequest): Promise<Response> {
}
}
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 });
}
+24 -2
View File
@@ -57,13 +57,35 @@ export async function audit(entry: AuditEntry): Promise<void> {
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.
+51
View File
@@ -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");
});
});
+9 -2
View File
@@ -62,11 +62,18 @@ export async function auditMcpToolCall(input: AuditMcpToolCallInput): Promise<vo
await audit({
action: "mcp.tool_called",
actorUserId: ctx.actor.type === "user" ? ctx.actor.id : null,
// Quem age via MCP é um TOKEN, nunca uma linha de auth.users: para um token
// comum, ctx.actor.id é o id do próprio token (lib/mcp/auth.ts), e mandá-lo
// como actorUserId estourava a FK api_audit_log_actor_user_id_fkey. O ator
// já fica registrado em actorApiTokenId e em metadata.actor_id.
actorUserId: null,
actorApiTokenId: ctx.apiTokenId,
organizationId: ctx.organizationId,
resourceType: "mcp_tool",
resourceId: toolName,
// `resource_id` é uuid no banco; o nome da tool ia aqui como texto e o
// insert morria com "invalid input syntax for type uuid: crm_create_lead".
// O nome já viaja em metadata.tool_name, que é jsonb.
resourceId: null,
requestId: ctx.requestId,
metadata,
});