121 lines
3.1 KiB
TypeScript
121 lines
3.1 KiB
TypeScript
import { prisma } from "../prisma";
|
|
|
|
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 prisma.adminAuditLog.create({
|
|
data: {
|
|
userId: entry.userId,
|
|
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(),
|
|
},
|
|
});
|
|
}
|
|
|
|
interface GetLogsOptions {
|
|
search?: string;
|
|
page?: number;
|
|
perPage?: number;
|
|
}
|
|
|
|
export async function getAuditLogs(options: GetLogsOptions = {}) {
|
|
const { search, page = 1, perPage = 20 } = options;
|
|
const skip = (page - 1) * perPage;
|
|
|
|
const where = search
|
|
? {
|
|
OR: [
|
|
{ action: { contains: search } },
|
|
{ target: { contains: search } },
|
|
],
|
|
}
|
|
: {};
|
|
|
|
const [rows, total] = await Promise.all([
|
|
prisma.adminAuditLog.findMany({
|
|
where,
|
|
orderBy: { id: "desc" },
|
|
skip,
|
|
take: perPage,
|
|
}),
|
|
prisma.adminAuditLog.count({ where }),
|
|
]);
|
|
|
|
const userIds = [...new Set(rows.map((r) => r.userId))];
|
|
const users = await prisma.user.findMany({
|
|
where: { id: { in: userIds } },
|
|
select: { id: true, username: true },
|
|
});
|
|
const userMap = new Map(users.map((u) => [u.id, u.username]));
|
|
|
|
const enrichedRows = rows.map((r) => ({
|
|
...r,
|
|
username: userMap.get(r.userId) ?? `User #${r.userId}`,
|
|
}));
|
|
|
|
return {
|
|
rows: enrichedRows,
|
|
total,
|
|
page,
|
|
perPage,
|
|
lastPage: Math.ceil(total / perPage),
|
|
};
|
|
}
|