/** * Workspace-scoped audit log of mutating operations. * * Closes BETA_LAUNCH_PLAN P4.7: "Per-workspace audit log of mutating MCP calls". * * Schema: * audit_log * id BIGSERIAL PK * ts TIMESTAMPTZ DEFAULT NOW() * workspace TEXT NOT NULL -- workspace slug * user_email TEXT -- caller; null for runner/system * source TEXT NOT NULL -- 'session' | 'api_key' | 'system' | 'webhook' * action TEXT NOT NULL -- 'apps.create' | 'databases.delete' | … * resource_type TEXT -- 'application' | 'database' | 'project' | … * resource_id TEXT -- coolify uuid / project id / etc. * ok BOOLEAN NOT NULL * params JSONB -- redacted call params * error TEXT * turn_id TEXT -- correlation id (chat turn etc.) * * Read via `SELECT … WHERE workspace = $1 ORDER BY ts DESC LIMIT N`. * * SECRETS: never write raw credentials into `params`. The helper redacts * the standard secret-shaped keys (`api_key`, `password`, `token`, `secret`, * `private_key`, `credential`). Callers are still responsible for not * passing sensitive blobs through unfiltered. */ import { getPool, query } from "@/lib/db-postgres"; import { log } from "@/lib/server/logger"; let tableReady = false; async function ensureTable() { if (tableReady) return; await query(` CREATE TABLE IF NOT EXISTS audit_log ( id BIGSERIAL PRIMARY KEY, ts TIMESTAMPTZ NOT NULL DEFAULT NOW(), workspace TEXT NOT NULL, user_email TEXT, source TEXT NOT NULL, action TEXT NOT NULL, resource_type TEXT, resource_id TEXT, ok BOOLEAN NOT NULL DEFAULT TRUE, params JSONB, error TEXT, turn_id TEXT ) `); await query(`CREATE INDEX IF NOT EXISTS audit_log_workspace_ts_idx ON audit_log (workspace, ts DESC)`); await query(`CREATE INDEX IF NOT EXISTS audit_log_action_ts_idx ON audit_log (action, ts DESC)`); tableReady = true; } const SECRET_KEYS = new Set([ "api_key", "apiKey", "password", "token", "access_token", "refresh_token", "secret", "private_key", "privateKey", "credential", "credentials", "authorization", ]); function redact(obj: unknown, depth = 0): unknown { if (depth > 4) return "[deep]"; if (obj == null) return obj; if (Array.isArray(obj)) return obj.map((x) => redact(x, depth + 1)); if (typeof obj === "object") { const out: Record = {}; for (const [k, v] of Object.entries(obj as Record)) { out[k] = SECRET_KEYS.has(k) ? "[redacted]" : redact(v, depth + 1); } return out; } if (typeof obj === "string" && obj.length > 2048) return obj.slice(0, 2048) + "…"; return obj; } export interface AuditLogEntry { workspace: string; userEmail?: string | null; source: "session" | "api_key" | "system" | "webhook"; action: string; resourceType?: string; resourceId?: string; ok: boolean; params?: Record; error?: string; turnId?: string; } /** Best-effort: never throw out of the audit path. */ export async function writeAuditLog(entry: AuditLogEntry): Promise { try { await ensureTable(); await query( `INSERT INTO audit_log (workspace, user_email, source, action, resource_type, resource_id, ok, params, error, turn_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10)`, [ entry.workspace, entry.userEmail ?? null, entry.source, entry.action, entry.resourceType ?? null, entry.resourceId ?? null, entry.ok, entry.params ? JSON.stringify(redact(entry.params)) : null, entry.error ?? null, entry.turnId ?? null, ], ); } catch (err) { log.warn("audit-log write failed (non-fatal)", { action: entry.action, err: err instanceof Error ? err.message : String(err), }); } } export async function listAuditLog(opts: { workspace: string; limit?: number; action?: string; }): Promise { await ensureTable(); const limit = Math.min(500, Math.max(1, opts.limit ?? 100)); if (opts.action) { return query( `SELECT id, ts, user_email, source, action, resource_type, resource_id, ok, params, error, turn_id FROM audit_log WHERE workspace = $1 AND action = $2 ORDER BY ts DESC LIMIT $3`, [opts.workspace, opts.action, limit], ); } return query( `SELECT id, ts, user_email, source, action, resource_type, resource_id, ok, params, error, turn_id FROM audit_log WHERE workspace = $1 ORDER BY ts DESC LIMIT $2`, [opts.workspace, limit], ); } // Re-export for tests / migration scripts export { getPool };