mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add request correlation IDs to audit logs and response headers
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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_") ||
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,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 });
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user