Audit log
Source: src/security/audit-log.service.ts
Table: audit_log (src/db/schema.ts)
Append-only security event trail. SOC 2 evidence. GDPR-aware (no PII).
Tamper-evidence
The mcp_app Postgres role has INSERT on audit_log only. UPDATE and DELETE are explicitly REVOKEd in src/db/roles.sql. An attacker who compromises the application cannot rewrite the trail from inside the app — they would need to escalate to the mcp_admin role (separate password, never used by the running service).
Event taxonomy
The event types are a closed union — adding one means updating both AuditEventType and any dashboard/log-shipping consumer. Phase 1 produces:
| Family | Events |
|---|---|
| API key auth | api_key.auth_success, api_key.auth_failure, api_key.created, api_key.rotated, api_key.revoked |
| OAuth (Phase 2+) | oauth.flow_started, oauth.flow_completed, oauth.flow_failed, oauth.token_refreshed, oauth.token_revoked |
| MCP | mcp.tool_called, mcp.tool_failed |
| Lifecycle | tenant.created, tenant.deleted |
| Throttle | rate_limit.exceeded, auth.blocked_ip |
| Sync (Phase 4) | sync.exhausted |
PII handling
The metadata column is JSONB — flexible, but a footgun for GDPR if callers throw arbitrary context in. writeAuditEvent strips a closed list of known PII keys defensively:
email, name, firstName, lastName, phone, address, fullNameThis is a defence in depth layer. The primary control is that callers should pass PII-free context in the first place. The strip is what catches the case where an adapter SDK echoes a user’s email into an error message.
Retention
| Window | Action |
|---|---|
| 0 – 12 months | Live in audit_log. |
| 12 months+ | Archived (not deleted) into a cold table by an Inngest cron (Phase 4). SOC 2 wants the trail preserved; GDPR data minimisation is satisfied because the trail carries no PII. |
GDPR right-to-erasure on a tenant doesn’t delete that tenant’s audit rows — it sets tenant_id to NULL (anonymise) and strips identifying metadata fields. See deneva-mcp-tool-architecture.md §9.
Calling pattern
import { writeAuditEvent } from '../security/audit-log.service.js';
await writeAuditEvent('api_key.auth_failure', 'failure', {
ip: req.ip,
requestId: req.id,
reason: 'invalid',
});Always use await for events on the failure path (so 4xx responses guarantee the row landed). For success paths and high-throughput events, fire-and-forget (void writeAuditEvent(...)) is acceptable — Phase 1 awaits everything for simplicity; Phase 5 may relax some hot-path writes.
Tests
tests/integration/audit.test.ts:
mcp_appcannot UPDATEaudit_logmcp_appcannot DELETEaudit_logmcp_appCAN INSERT- PII keys are stripped from
metadata