mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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:
co-authored by
Claude Opus 5
parent
6d420cff34
commit
789dfa6fdb
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user