2026-04-22 18:10:04 +08:00
|
|
|
import { randomUUID } from "node:crypto";
|
2026-06-13 16:31:49 +08:00
|
|
|
import { eq } from "drizzle-orm";
|
2026-06-13 17:04:27 +08:00
|
|
|
import type { FastifyBaseLogger, FastifyRequest } from "fastify";
|
2026-06-13 16:48:09 +08:00
|
|
|
import { env } from "../config.js";
|
2026-04-22 18:10:04 +08:00
|
|
|
import { db, schema } from "../db/index.js";
|
2026-06-13 16:48:09 +08:00
|
|
|
import { computeHmac } from "./audit-integrity.js";
|
|
|
|
|
import { deriveAuditHmacKey } from "./encryption.js";
|
2026-03-28 11:19:09 +08:00
|
|
|
|
2026-06-07 21:54:27 +08:00
|
|
|
const MAX_AUDIT_INPUT_LENGTH = 200;
|
|
|
|
|
|
2026-06-13 16:31:49 +08:00
|
|
|
/**
|
|
|
|
|
* Check whether tool operation audit logging is enabled.
|
|
|
|
|
*
|
|
|
|
|
* Two paths can enable it:
|
|
|
|
|
* 1. The `auditToolOperations` admin setting is explicitly "true".
|
|
|
|
|
* 2. An active enterprise license enables the `audit_export` feature.
|
|
|
|
|
*
|
|
|
|
|
* Returns false on any error so a broken check never blocks tool execution.
|
|
|
|
|
*/
|
|
|
|
|
export async function isToolAuditEnabled(): Promise<boolean> {
|
|
|
|
|
try {
|
|
|
|
|
const result = await db
|
|
|
|
|
.select({ value: schema.settings.value })
|
|
|
|
|
.from(schema.settings)
|
|
|
|
|
.where(eq(schema.settings.key, "auditToolOperations"))
|
|
|
|
|
.limit(1);
|
|
|
|
|
if (result.length > 0 && result[0].value === "true") return true;
|
|
|
|
|
} catch {
|
|
|
|
|
// fall through to enterprise check
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const enterprise = await import("@snapotter/enterprise");
|
|
|
|
|
return enterprise.isFeatureEnabled("audit_export");
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-07 21:54:27 +08:00
|
|
|
export function sanitizeAuditInput(raw: string): string {
|
|
|
|
|
return raw.replace(/[<>&"']/g, "").slice(0, MAX_AUDIT_INPUT_LENGTH) || "(empty)";
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 11:19:09 +08:00
|
|
|
/**
|
|
|
|
|
* Emit a structured audit log entry for security-relevant events.
|
|
|
|
|
*
|
2026-06-13 16:27:53 +08:00
|
|
|
* Dual-writes: structured stdout log (for aggregators) + DB row.
|
2026-03-28 11:19:09 +08:00
|
|
|
*/
|
2026-06-13 10:15:23 +08:00
|
|
|
export async function auditLog(
|
2026-03-28 11:19:09 +08:00
|
|
|
logger: FastifyBaseLogger,
|
2026-06-13 16:27:53 +08:00
|
|
|
event: string,
|
2026-03-28 11:19:09 +08:00
|
|
|
details: Record<string, unknown> = {},
|
2026-06-13 16:25:58 +08:00
|
|
|
ip: string | null = null,
|
2026-06-13 17:04:27 +08:00
|
|
|
requestId: string | null = null,
|
2026-06-13 10:15:23 +08:00
|
|
|
): Promise<void> {
|
2026-06-13 17:04:27 +08:00
|
|
|
logger.info({ audit: true, event, ip, requestId, ...details }, `[AUDIT] ${event}`);
|
2026-04-22 18:10:04 +08:00
|
|
|
|
|
|
|
|
const actorId = (details.userId as string) ?? (details.adminId as string) ?? null;
|
|
|
|
|
const actorUsername = (details.username as string) ?? (details.newUsername as string) ?? "system";
|
|
|
|
|
const targetId = (details.targetUserId as string) ?? (details.keyId as string) ?? null;
|
|
|
|
|
const targetType = deriveTargetType(event);
|
|
|
|
|
|
2026-06-13 16:48:09 +08:00
|
|
|
const id = randomUUID();
|
2026-04-22 18:10:04 +08:00
|
|
|
try {
|
2026-06-13 10:15:23 +08:00
|
|
|
await db.insert(schema.auditLog).values({
|
2026-06-13 16:48:09 +08:00
|
|
|
id,
|
2026-06-13 10:15:23 +08:00
|
|
|
actorId,
|
|
|
|
|
actorUsername,
|
|
|
|
|
action: event,
|
|
|
|
|
targetType,
|
|
|
|
|
targetId,
|
|
|
|
|
details,
|
2026-06-13 16:25:58 +08:00
|
|
|
ipAddress: ip,
|
2026-06-13 17:04:27 +08:00
|
|
|
requestId,
|
2026-06-13 10:15:23 +08:00
|
|
|
});
|
2026-04-22 18:10:04 +08:00
|
|
|
} catch {
|
|
|
|
|
logger.warn({ event }, "Failed to write audit log to DB");
|
2026-06-13 16:48:09 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Compute HMAC for tamper-resistant mode
|
|
|
|
|
if (env.DATA_ENCRYPTION_KEY) {
|
|
|
|
|
try {
|
|
|
|
|
const tamperResult = await db
|
|
|
|
|
.select({ value: schema.settings.value })
|
|
|
|
|
.from(schema.settings)
|
|
|
|
|
.where(eq(schema.settings.key, "tamperResistantAudit"))
|
|
|
|
|
.limit(1);
|
|
|
|
|
|
|
|
|
|
if (tamperResult.length > 0 && tamperResult[0].value === "true") {
|
|
|
|
|
const hmacKey = await deriveAuditHmacKey(env.DATA_ENCRYPTION_KEY);
|
|
|
|
|
const rowData = {
|
|
|
|
|
actorId,
|
|
|
|
|
actorUsername,
|
|
|
|
|
action: event,
|
|
|
|
|
targetType,
|
|
|
|
|
targetId,
|
|
|
|
|
details,
|
|
|
|
|
ipAddress: ip,
|
2026-06-13 17:04:27 +08:00
|
|
|
requestId,
|
2026-06-13 16:48:09 +08:00
|
|
|
};
|
|
|
|
|
const integrity = computeHmac(rowData, hmacKey);
|
|
|
|
|
await db
|
|
|
|
|
.update(schema.auditLog)
|
|
|
|
|
.set({ integrity })
|
|
|
|
|
.where(eq(schema.auditLog.id, id));
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
logger.warn({ event }, "Failed to compute audit HMAC");
|
|
|
|
|
}
|
2026-04-22 18:10:04 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-13 17:04:27 +08:00
|
|
|
/**
|
|
|
|
|
* Create a bound audit logger from a Fastify request.
|
|
|
|
|
* Captures request.ip and request.id so call sites only need event + details.
|
|
|
|
|
*/
|
|
|
|
|
export function auditFromRequest(request: FastifyRequest) {
|
|
|
|
|
return (event: string, details: Record<string, unknown> = {}) =>
|
|
|
|
|
auditLog(request.log, event, details, request.ip, request.id);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-13 16:27:53 +08:00
|
|
|
function deriveTargetType(event: string): string | null {
|
2026-04-22 18:10:04 +08:00
|
|
|
if (
|
|
|
|
|
event.startsWith("USER_") ||
|
|
|
|
|
event.startsWith("LOGIN") ||
|
|
|
|
|
event.startsWith("PASSWORD") ||
|
2026-05-13 18:52:39 +08:00
|
|
|
event.startsWith("OIDC_") ||
|
2026-06-13 16:27:53 +08:00
|
|
|
event.startsWith("SAML_") ||
|
|
|
|
|
event.startsWith("SCIM_") ||
|
|
|
|
|
event.startsWith("MFA_") ||
|
2026-04-22 18:10:04 +08:00
|
|
|
event === "LOGOUT"
|
|
|
|
|
)
|
|
|
|
|
return "user";
|
|
|
|
|
if (event.startsWith("API_KEY")) return "api_key";
|
|
|
|
|
if (event.startsWith("FILE")) return "file";
|
|
|
|
|
if (event.startsWith("ROLE")) return "role";
|
2026-06-13 16:27:53 +08:00
|
|
|
if (event === "SETTINGS_UPDATED" || event === "IP_ALLOWLIST_UPDATED") return "setting";
|
|
|
|
|
if (event.startsWith("TOOL_") || event.startsWith("BATCH_") || event.startsWith("PIPELINE_"))
|
|
|
|
|
return "tool";
|
|
|
|
|
if (event.startsWith("LEGAL_HOLD")) return "compliance";
|
|
|
|
|
if (event.startsWith("SIEM_") || event.startsWith("WEBHOOK_")) return "integration";
|
2026-04-22 18:10:04 +08:00
|
|
|
return null;
|
2026-03-28 11:19:09 +08:00
|
|
|
}
|