Files
EpicNext-Cms/src/lib/services/audit.ts
T
Simo a2954e4408
CI / check (push) Successful in 56s
CI / deploy (push) Successful in 19s
CI / publish-container (push) Successful in 1m15s
feat(diagnostics): correlate staff operations errors and audit records
2026-09-11 00:34:49 +02:00

175 lines
5.1 KiB
TypeScript

import { and, count, desc, eq, gte, inArray, like, lt, or } from "drizzle-orm";
import { AdminAuditLog, db, User } from "@/lib/db";
import { getOperationContext } from "@/lib/foundation/request-context";
import { readAuditChanges } from "./audit-diff";
import { type AuditFilters, normalizeAuditFilters } from "./audit-filters";
interface AuditEntry {
userId: number;
action: string;
target: string;
targetId?: number;
before?: Record<string, unknown>;
after?: Record<string, unknown>;
}
const SENSITIVE_KEY_RE =
/password|secret|token|otp|recovery|authTicket|two_factor|two_factor_secret|api_key/i;
const REDACTED = "[Redacted]";
function sanitizeAuditPayload(value: unknown, depth = 0): unknown {
if (depth > 6 || value == null) return value;
if (Array.isArray(value))
return value.map((v) => sanitizeAuditPayload(v, depth + 1));
if (typeof value !== "object") return value;
const out: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
out[key] = SENSITIVE_KEY_RE.test(key)
? REDACTED
: sanitizeAuditPayload(val, depth + 1);
}
return out;
}
function computeDiff(
before?: Record<string, unknown>,
after?: Record<string, unknown>,
): Record<string, { from: unknown; to: unknown }> | null {
if (!before || !after) return null;
const diff: Record<string, { from: unknown; to: unknown }> = {};
const allKeys = new Set([...Object.keys(before), ...Object.keys(after)]);
for (const key of allKeys) {
if (JSON.stringify(before[key]) !== JSON.stringify(after[key])) {
diff[key] = { from: before[key], to: after[key] };
}
}
return Object.keys(diff).length > 0 ? diff : null;
}
export async function logAudit(entry: AuditEntry): Promise<void> {
const sanitizedBefore = entry.before
? (sanitizeAuditPayload(entry.before) as Record<string, unknown>)
: undefined;
const sanitizedAfter = entry.after
? (sanitizeAuditPayload(entry.after) as Record<string, unknown>)
: undefined;
const diff = computeDiff(sanitizedBefore, sanitizedAfter);
await db.insert(AdminAuditLog).values({
userId: entry.userId,
details: JSON.stringify(getOperationContext()),
action: entry.action,
target: entry.target,
targetId: entry.targetId,
before: sanitizedBefore ? JSON.stringify(sanitizedBefore) : null,
after: sanitizedAfter ? JSON.stringify(sanitizedAfter) : null,
diff: diff ? JSON.stringify(diff) : null,
createdAt: new Date().toISOString(),
});
}
export async function getAuditLogs(options: AuditFilters = {}) {
const { search, actor, action, from, until, page, perPage } =
normalizeAuditFilters(options);
const skip = (page - 1) * perPage;
const where = and(
search
? or(
like(AdminAuditLog.action, `%${search}%`),
like(AdminAuditLog.target, `%${search}%`),
like(AdminAuditLog.details, `%${search}%`),
)
: undefined,
action ? eq(AdminAuditLog.action, action) : undefined,
actor
? /^[1-9]\d*$/.test(actor) && Number.isSafeInteger(Number(actor))
? eq(AdminAuditLog.userId, Number(actor))
: inArray(
AdminAuditLog.userId,
db
.select({ id: User.id })
.from(User)
.where(eq(User.username, actor)),
)
: undefined,
from ? gte(AdminAuditLog.createdAt, from) : undefined,
until ? lt(AdminAuditLog.createdAt, until) : undefined,
);
const [rows, totalResult] = await Promise.all([
db
.select({
id: AdminAuditLog.id,
userId: AdminAuditLog.userId,
action: AdminAuditLog.action,
target: AdminAuditLog.target,
targetId: AdminAuditLog.targetId,
diff: AdminAuditLog.diff,
operationDetails: AdminAuditLog.details,
before: AdminAuditLog.before,
after: AdminAuditLog.after,
createdAt: AdminAuditLog.createdAt,
})
.from(AdminAuditLog)
.where(where)
.orderBy(desc(AdminAuditLog.id))
.limit(perPage)
.offset(skip),
db.select({ value: count() }).from(AdminAuditLog).where(where),
]);
const total = Number(totalResult[0]?.value ?? 0);
const userIds = [...new Set(rows.map((r) => r.userId))];
const users =
userIds.length > 0
? await db
.select({ id: User.id, username: User.username })
.from(User)
.where(inArray(User.id, userIds))
: [];
const userMap = new Map(users.map((u) => [u.id, u.username]));
const enrichedRows = rows.map((r) => {
const details = readAuditChanges(r.diff, r.before, r.after);
let operationId: string | null = null;
try {
const meta = JSON.parse(r.operationDetails ?? "{}");
if (
typeof meta.operationId === "string" &&
/^[a-zA-Z0-9-]{1,100}$/.test(meta.operationId)
)
operationId = meta.operationId;
} catch {}
const { operationDetails: _privateDetails, ...safeRow } = r;
return {
...safeRow,
operationId,
// Only sanitized changes may cross the server/client boundary.
before: null,
after: null,
diff: details.invalid
? "[Unavailable legacy details]"
: details.changes.length
? JSON.stringify(
Object.fromEntries(
details.changes.map(({ key, from, to }) => [key, { from, to }]),
),
)
: null,
username: userMap.get(r.userId) ?? `User #${r.userId}`,
};
});
return {
rows: enrichedRows,
total,
page,
perPage,
lastPage: Math.ceil(total / perPage),
};
}