Closes checklist items F-01..F-06, D-01..D-28, S-01..S-10, C-01..C-07, B-01..B-07, R-01..R-02, O-03. Security (28 deletions + 10 auth gates): - Delete 28 unauthenticated debug/cursor/firebase/test routes - Gate ai/chat, ai/conversation, context/summarize, work-completed with withTenantProject/withAuth - Add HMAC-SHA256 signature verification to webhooks/coolify - Switch all admin secret comparisons to timingSafeStringEq Foundations (lib/server/*): - api-handler.ts: withAuth, withTenantProject, withWorkspace, withAdminSecret, withRateLimit - logger.ts: structured request-scoped logging with turnId - audit-log.ts: writeAuditLog helper + audit_log table - rate-limit.ts: Postgres sliding window rate limiter - coolify-webhook.ts: verifyCoolifySignature - timing-safe.ts: timingSafeStringEq Chat hardening (chat/route.ts): - MAX_TOOL_ROUNDS 15 → 8 (C-01) - Loop detection: hard-break at 3 identical fingerprints (was 5) (C-02) - Add 6-consecutive-tool-call hard-break (C-02) - Mode: respond first, act second prompt block (C-03) - SSE heartbeat every 25s via setInterval (C-04) - Per-tool 45s timeout via Promise.race (C-05) - turnId per-turn UUID for log correlation (C-06) - Recovery fires when roundsSinceText >= 4 (C-07) - SSE plan event on plan_task_add/edit (B-05) Beta features: - invites table + GET/POST /api/invites (P4.8) - invites/[token] validate + redeem (P4.8) - fs_project_dev_servers table + lib/server/dev-server-state.ts (P6.B1) - fs_project_secrets table + CRUD routes (P6.D2) - lib/integrations/brief-extract.ts (P3.7) Documentation: - app/api/ROUTES.md: full route map with auth + tenant
146 lines
4.8 KiB
TypeScript
146 lines
4.8 KiB
TypeScript
/**
|
|
* 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<string, unknown> = {};
|
|
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
|
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<string, unknown>;
|
|
error?: string;
|
|
turnId?: string;
|
|
}
|
|
|
|
/** Best-effort: never throw out of the audit path. */
|
|
export async function writeAuditLog(entry: AuditLogEntry): Promise<void> {
|
|
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<unknown[]> {
|
|
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 };
|