feat: add request correlation IDs to audit logs and response headers

This commit is contained in:
SnapOtter
2026-06-13 17:04:27 +08:00
parent c1dc27f248
commit 1cf1f47d6f
10 changed files with 81 additions and 68 deletions
+2
View File
@@ -182,6 +182,7 @@ function parseTrustProxy(value: string): boolean | number | string {
}
const app = Fastify({
genReqId: (req) => (req.headers["x-request-id"] as string) ?? randomUUID(),
logger: {
level: env.LOG_LEVEL,
transport: {
@@ -255,6 +256,7 @@ await app.register(cors, {
// HTTP so it is safe (and desirable) to send it in dev/staging too. CSP catches
// injection issues early when applied during development.
app.addHook("onSend", async (_request, reply) => {
reply.header("x-request-id", _request.id);
reply.header("X-Content-Type-Options", "nosniff");
reply.header("X-Frame-Options", "DENY");
reply.header("X-XSS-Protection", "0");
+14 -2
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import type { FastifyBaseLogger } from "fastify";
import type { FastifyBaseLogger, FastifyRequest } from "fastify";
import { env } from "../config.js";
import { db, schema } from "../db/index.js";
import { computeHmac } from "./audit-integrity.js";
@@ -51,8 +51,9 @@ export async function auditLog(
event: string,
details: Record<string, unknown> = {},
ip: string | null = null,
requestId: string | null = null,
): Promise<void> {
logger.info({ audit: true, event, ip, ...details }, `[AUDIT] ${event}`);
logger.info({ audit: true, event, ip, requestId, ...details }, `[AUDIT] ${event}`);
const actorId = (details.userId as string) ?? (details.adminId as string) ?? null;
const actorUsername = (details.username as string) ?? (details.newUsername as string) ?? "system";
@@ -70,6 +71,7 @@ export async function auditLog(
targetId,
details,
ipAddress: ip,
requestId,
});
} catch {
logger.warn({ event }, "Failed to write audit log to DB");
@@ -95,6 +97,7 @@ export async function auditLog(
targetId,
details,
ipAddress: ip,
requestId,
};
const integrity = computeHmac(rowData, hmacKey);
await db
@@ -108,6 +111,15 @@ export async function auditLog(
}
}
/**
* 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);
}
function deriveTargetType(event: string): string | null {
if (
event.startsWith("USER_") ||
+19 -17
View File
@@ -5,7 +5,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { env } from "../config.js";
import { db, schema } from "../db/index.js";
import { auditLog, sanitizeAuditInput } from "../lib/audit.js";
import { auditFromRequest, sanitizeAuditInput } from "../lib/audit.js";
import { getPermissions, requirePermission } from "../permissions.js";
const scryptAsync = promisify(scrypt);
@@ -290,20 +290,22 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
.from(schema.users)
.where(eq(schema.users.username, body.username));
const audit = auditFromRequest(request);
if (!user || !user.passwordHash) {
await auditLog(request.log, "LOGIN_FAILED", {
await audit("LOGIN_FAILED", {
username: sanitizeAuditInput(body.username),
reason: "unknown_user",
}, request.ip);
});
return reply.status(401).send({ error: "Invalid credentials" });
}
const valid = await verifyPassword(body.password, user.passwordHash);
if (!valid) {
await auditLog(request.log, "LOGIN_FAILED", {
await audit("LOGIN_FAILED", {
username: sanitizeAuditInput(body.username),
reason: "bad_password",
}, request.ip);
});
return reply.status(401).send({ error: "Invalid credentials" });
}
@@ -317,7 +319,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
expiresAt,
});
await auditLog(request.log, "LOGIN_SUCCESS", { userId: user.id, username: user.username }, request.ip);
await audit("LOGIN_SUCCESS", { userId: user.id, username: user.username });
const [teamRow] = await db.select().from(schema.teams).where(eq(schema.teams.id, user.team));
@@ -378,7 +380,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
cookieReply.clearCookie("snapotter-session", { path: "/" });
}
await auditLog(request.log, "LOGOUT", { userId: user?.id }, request.ip);
await auditFromRequest(request)("LOGOUT", { userId: user?.id });
return reply.send({ ok: true, ...(logoutUrl && { logoutUrl }) });
});
@@ -500,10 +502,10 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
// Revoke all API keys - if credentials were compromised, keys must be rotated too
await db.delete(schema.apiKeys).where(eq(schema.apiKeys.userId, authUser.id));
await auditLog(request.log, "PASSWORD_CHANGED", {
await auditFromRequest(request)("PASSWORD_CHANGED", {
userId: authUser.id,
username: authUser.username,
}, request.ip);
});
return reply.send({ ok: true });
});
@@ -669,12 +671,12 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
mustChangePassword: true,
});
await auditLog(request.log, "USER_CREATED", {
await auditFromRequest(request)("USER_CREATED", {
adminId: admin.id,
newUserId: id,
newUsername: body.username,
role,
}, request.ip);
});
return reply.status(201).send({
id,
@@ -784,11 +786,11 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
);
}
await auditLog(request.log, "USER_UPDATED", {
await auditFromRequest(request)("USER_UPDATED", {
adminId: admin.id,
targetUserId: id,
changes: { role: updates.role, team: updates.team },
}, request.ip);
});
return reply.send({ ok: true });
},
@@ -845,11 +847,11 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
// Revoke all API keys
await db.delete(schema.apiKeys).where(eq(schema.apiKeys.userId, id));
await auditLog(request.log, "PASSWORD_RESET", {
await auditFromRequest(request)("PASSWORD_RESET", {
adminId: admin.id,
targetUserId: id,
targetUsername: user.username,
}, request.ip);
});
return reply.send({ ok: true });
},
@@ -883,11 +885,11 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
// Delete the user (cascades to api_keys via FK)
await db.delete(schema.users).where(eq(schema.users.id, id));
await auditLog(request.log, "USER_DELETED", {
await auditFromRequest(request)("USER_DELETED", {
adminId: admin.id,
deletedUserId: id,
deletedUsername: user.username,
}, request.ip);
});
return reply.send({ ok: true });
},
+15 -13
View File
@@ -5,7 +5,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import * as oidc from "openid-client";
import { env } from "../config.js";
import { db, schema } from "../db/index.js";
import { auditLog, sanitizeAuditInput } from "../lib/audit.js";
import { auditFromRequest, sanitizeAuditInput } from "../lib/audit.js";
import { createSessionToken } from "./auth.js";
// ── Types ─────────────────────────────────────────────────────────
@@ -221,15 +221,17 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
return redirectToLogin(reply, "oidc_session_expired");
}
const audit = auditFromRequest(request);
// Check for error response from the IdP
if (query.error) {
request.log.warn(
{ error: query.error, description: query.error_description },
"OIDC IdP returned error",
);
await auditLog(request.log, "OIDC_LOGIN_FAILED", {
await audit("OIDC_LOGIN_FAILED", {
reason: sanitizeAuditInput(String(query.error)),
}, request.ip);
});
return redirectToLogin(reply, "oidc_auth_failed");
}
@@ -257,7 +259,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
});
} catch (err) {
request.log.error({ err }, "OIDC token exchange failed");
await auditLog(request.log, "OIDC_LOGIN_FAILED", { reason: "token_exchange_failed" }, request.ip);
await audit("OIDC_LOGIN_FAILED", { reason: "token_exchange_failed" });
return redirectToLogin(reply, "oidc_auth_failed");
}
@@ -265,7 +267,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
const claims = tokenResponse.claims();
if (!claims) {
request.log.error("OIDC callback: no ID token claims");
await auditLog(request.log, "OIDC_LOGIN_FAILED", { reason: "no_id_token" }, request.ip);
await audit("OIDC_LOGIN_FAILED", { reason: "no_id_token" });
return redirectToLogin(reply, "oidc_auth_failed");
}
@@ -314,11 +316,11 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
})
.where(eq(schema.users.id, existingByEmail.id));
userId = existingByEmail.id;
await auditLog(request.log, "OIDC_USER_LINKED", {
await audit("OIDC_USER_LINKED", {
userId: existingByEmail.id,
username: existingByEmail.username,
email,
}, request.ip);
});
}
}
@@ -356,21 +358,21 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
});
userId = newUserId;
await auditLog(request.log, "OIDC_USER_CREATED", {
await audit("OIDC_USER_CREATED", {
userId: newUserId,
username: uniqueUsername,
email,
role: env.OIDC_DEFAULT_ROLE,
}, request.ip);
});
}
// 4d. No user found and no auto-create
if (!userId) {
request.log.warn({ sub, email }, "OIDC user not authorized");
await auditLog(request.log, "OIDC_LOGIN_FAILED", {
await audit("OIDC_LOGIN_FAILED", {
reason: "user_not_authorized",
sub: sanitizeAuditInput(String(sub)),
}, request.ip);
});
return redirectToLogin(reply, "oidc_user_not_authorized");
}
@@ -388,10 +390,10 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
// Fetch the user for audit logging
const [user] = await db.select().from(schema.users).where(eq(schema.users.id, userId));
await auditLog(request.log, "OIDC_LOGIN_SUCCESS", {
await audit("OIDC_LOGIN_SUCCESS", {
userId,
username: user?.username ?? username,
}, request.ip);
});
// 6. Set session cookie
reply.setCookie("snapotter-session", token, {
+3 -3
View File
@@ -10,7 +10,7 @@ import { and, eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db, schema } from "../db/index.js";
import { auditLog } from "../lib/audit.js";
import { auditFromRequest } from "../lib/audit.js";
import { getPermissions, hasEffectivePermission } from "../permissions.js";
import { computeKeyPrefix, hashPassword, requireAuth } from "../plugins/auth.js";
@@ -86,7 +86,7 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
return reply.status(409).send({ error: "Failed to create API key" });
}
await auditLog(request.log, "API_KEY_CREATED", { userId: user.id, keyId: id, keyName: name }, request.ip);
await auditFromRequest(request)("API_KEY_CREATED", { userId: user.id, keyId: id, keyName: name });
// Return the raw key ONCE — it cannot be retrieved again
return reply.status(201).send({
@@ -155,7 +155,7 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
await db.delete(schema.apiKeys).where(eq(schema.apiKeys.id, id));
await auditLog(request.log, "API_KEY_DELETED", { userId: user.id, keyId: id }, request.ip);
await auditFromRequest(request)("API_KEY_DELETED", { userId: user.id, keyId: id });
return reply.send({ ok: true });
},
+3 -3
View File
@@ -3,7 +3,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { env } from "../../config.js";
import { db, schema } from "../../db/index.js";
import { auditLog } from "../../lib/audit.js";
import { auditFromRequest } from "../../lib/audit.js";
import { encrypt, isEncrypted } from "../../lib/encryption.js";
import { requirePermission } from "../../permissions.js";
@@ -115,11 +115,11 @@ export async function registerSiemRoutes(app: FastifyInstance): Promise<void> {
await db.insert(schema.settings).values({ key: SETTINGS_KEY, value });
}
await auditLog(request.log, "SETTINGS_UPDATED", {
await auditFromRequest(request)("SETTINGS_UPDATED", {
adminId: user.id,
username: user.username,
keys: [SETTINGS_KEY],
}, request.ip);
});
return reply.send({ ok: true });
},
+5 -5
View File
@@ -4,7 +4,7 @@ import { eq, sql } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db, schema } from "../db/index.js";
import { auditLog } from "../lib/audit.js";
import { auditFromRequest } from "../lib/audit.js";
import { requirePermission } from "../permissions.js";
const ALL_PERMISSIONS: Permission[] = [
@@ -116,7 +116,7 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
createdBy: user.id,
});
await auditLog(request.log, "ROLE_CREATED", { adminId: user.id, roleId: id, roleName: name }, request.ip);
await auditFromRequest(request)("ROLE_CREATED", { adminId: user.id, roleId: id, roleName: name });
return reply.status(201).send({
id,
@@ -185,7 +185,7 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
}
await tx.update(schema.roles).set(updates).where(eq(schema.roles.id, id));
});
await auditLog(request.log, "ROLE_UPDATED", { adminId: user.id, roleId: id }, request.ip);
await auditFromRequest(request)("ROLE_UPDATED", { adminId: user.id, roleId: id });
return reply.send({ ok: true });
},
@@ -216,11 +216,11 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
.where(eq(schema.users.role, role.name));
await tx.delete(schema.roles).where(eq(schema.roles.id, id));
});
await auditLog(request.log, "ROLE_DELETED", {
await auditFromRequest(request)("ROLE_DELETED", {
adminId: user.id,
roleId: id,
roleName: role.name,
}, request.ip);
});
return reply.send({ ok: true });
},
+3 -3
View File
@@ -10,7 +10,7 @@ import { eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db, schema } from "../db/index.js";
import { auditLog } from "../lib/audit.js";
import { auditFromRequest } from "../lib/audit.js";
import { env } from "../config.js";
import { encrypt, decrypt, isEncrypted } from "../lib/encryption.js";
import { requirePermission } from "../permissions.js";
@@ -115,11 +115,11 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
}
if (entries.length > 0) {
await auditLog(request.log, "SETTINGS_UPDATED", {
await auditFromRequest(request)("SETTINGS_UPDATED", {
adminId: admin.id,
username: admin.username,
keys: entries.map((e) => e.key),
}, request.ip);
});
}
return reply.send({ ok: true, updatedCount: entries.length });
+11 -16
View File
@@ -471,25 +471,20 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
// Fire-and-forget: audit log must never block the response
import("../lib/audit.js")
.then(({ isToolAuditEnabled, auditLog }) =>
.then(({ isToolAuditEnabled, auditFromRequest }) =>
isToolAuditEnabled().then((enabled) => {
if (!enabled) return;
const user = getAuthUser(request);
return auditLog(
request.log,
"TOOL_EXECUTED",
{
userId: user?.id,
username: user?.username,
toolId: config.toolId,
inputFileCount: received.length,
totalInputSize: received.reduce((sum, r) => sum + r.size, 0),
outputFormat: (settings as Record<string, unknown>)?.format ?? null,
status: "success",
durationMs: Date.now() - startTime,
},
request.ip,
);
return auditFromRequest(request)("TOOL_EXECUTED", {
userId: user?.id,
username: user?.username,
toolId: config.toolId,
inputFileCount: received.length,
totalInputSize: received.reduce((sum, r) => sum + r.size, 0),
outputFormat: (settings as Record<string, unknown>)?.format ?? null,
status: "success",
durationMs: Date.now() - startTime,
});
}),
)
.catch(() => {});
+6 -6
View File
@@ -17,7 +17,7 @@ import sharp from "sharp";
import { z } from "zod";
import { env } from "../config.js";
import { db, schema } from "../db/index.js";
import { auditLog } from "../lib/audit.js";
import { auditFromRequest } from "../lib/audit.js";
import {
deleteStoredFile,
deleteThumbnail,
@@ -259,11 +259,11 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
return reply.status(400).send({ error: "No valid files uploaded" });
}
await auditLog(request.log, "FILE_UPLOADED", {
await auditFromRequest(request)("FILE_UPLOADED", {
userId,
count: created.length,
files: created.map((f) => f.originalName),
}, request.ip);
});
return reply.status(201).send({ files: created });
},
@@ -495,7 +495,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
.map((f) => f.id);
if (validIds.length === 0) {
await auditLog(request.log, "FILE_DELETED", { userId: user.id, count: 0, ids }, request.ip);
await auditFromRequest(request)("FILE_DELETED", { userId: user.id, count: 0, ids });
return reply.send({ deleted: 0 });
}
@@ -542,11 +542,11 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
await db.delete(schema.userFiles).where(inArray(schema.userFiles.id, chainIds));
}
await auditLog(request.log, "FILE_DELETED", {
await auditFromRequest(request)("FILE_DELETED", {
userId: user.id,
count: chainRows.length,
ids,
}, request.ip);
});
return reply.send({ deleted: chainRows.length });
});