184 lines
5.6 KiB
TypeScript
184 lines
5.6 KiB
TypeScript
/**
|
|
* Pure helpers for the audit-export route. Extracted so unit tests can
|
|
* import them without dragging in the route module's server-only deps
|
|
* (PrismaClient, getDbClient, session secret env vars).
|
|
*/
|
|
import { computeAuditHash } from "@toolctrl/db/audit-hash";
|
|
|
|
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
|
|
export const AUDIT_EXPORT_MAX_ROWS = 50_000;
|
|
|
|
export const AUDIT_EXPORT_CSV_COLUMNS = [
|
|
"id",
|
|
"createdAt",
|
|
"tenantId",
|
|
"actorType",
|
|
"actorId",
|
|
"actorEmail",
|
|
"actorIp",
|
|
"actorUserAgent",
|
|
"action",
|
|
"targetType",
|
|
"targetId",
|
|
"requestId",
|
|
"sourceModule",
|
|
"outcome",
|
|
"dataSensitivity",
|
|
"regulatoryScope",
|
|
"metadata",
|
|
"previousHash",
|
|
"eventHash",
|
|
] as const;
|
|
|
|
export type AuditExportFilters = {
|
|
from: Date | null;
|
|
to: Date | null;
|
|
regulatoryScope: string[];
|
|
actorType: string | null;
|
|
outcome: string | null;
|
|
tenantId: string | null;
|
|
};
|
|
|
|
export type AuditExportRow = {
|
|
id: string;
|
|
createdAt: Date;
|
|
tenantId: string | null;
|
|
actorType: string;
|
|
actorId: string | null;
|
|
actorEmail: string | null;
|
|
actorIp: string | null;
|
|
actorUserAgent: string | null;
|
|
action: string;
|
|
targetType: string | null;
|
|
targetId: string | null;
|
|
requestId: string | null;
|
|
sourceModule: string | null;
|
|
outcome: string;
|
|
dataSensitivity: string;
|
|
regulatoryScope: string[];
|
|
metadata: unknown;
|
|
previousHash: string | null;
|
|
eventHash: string | null;
|
|
};
|
|
|
|
export type AuditIntegritySummary = {
|
|
ok: boolean;
|
|
inspected: number;
|
|
firstBreakId: string | null;
|
|
reason: string | null;
|
|
};
|
|
|
|
export function parseAuditExportFilters(url: URL): AuditExportFilters {
|
|
return {
|
|
from: parseIsoDate(url.searchParams.get("from")),
|
|
to: parseIsoDate(url.searchParams.get("to")),
|
|
regulatoryScope: url.searchParams.getAll("regulatoryScope").map((s) => s.trim().toLowerCase()).filter(Boolean),
|
|
actorType: url.searchParams.get("actorType")?.trim() || null,
|
|
outcome: url.searchParams.get("outcome")?.trim() || null,
|
|
tenantId: url.searchParams.get("tenantId")?.trim() || null,
|
|
};
|
|
}
|
|
|
|
function parseIsoDate(raw: string | null): Date | null {
|
|
if (!raw || !ISO_DATE.test(raw)) return null;
|
|
const d = new Date(`${raw}T00:00:00.000Z`);
|
|
return Number.isNaN(d.getTime()) ? null : d;
|
|
}
|
|
|
|
export function buildAuditExportWhere(filters: AuditExportFilters): Record<string, unknown> {
|
|
const where: Record<string, unknown> = {};
|
|
if (filters.tenantId) where.tenantId = filters.tenantId;
|
|
if (filters.actorType) where.actorType = filters.actorType;
|
|
if (filters.outcome) where.outcome = filters.outcome;
|
|
if (filters.regulatoryScope.length > 0) where.regulatoryScope = { hasSome: filters.regulatoryScope };
|
|
if (filters.from || filters.to) {
|
|
const range: Record<string, Date> = {};
|
|
if (filters.from) range.gte = filters.from;
|
|
if (filters.to) {
|
|
const upper = new Date(filters.to);
|
|
upper.setUTCDate(upper.getUTCDate() + 1);
|
|
range.lt = upper;
|
|
}
|
|
where.createdAt = range;
|
|
}
|
|
return where;
|
|
}
|
|
|
|
export function computeAuditIntegritySummary(rows: ReadonlyArray<AuditExportRow>): AuditIntegritySummary {
|
|
const chains = new Map<string, AuditExportRow[]>();
|
|
for (const row of rows) {
|
|
const key = row.tenantId ?? "__platform__";
|
|
const list = chains.get(key) ?? [];
|
|
list.push(row);
|
|
chains.set(key, list);
|
|
}
|
|
let inspected = 0;
|
|
for (const [, list] of chains) {
|
|
list.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
|
let lastHash: string | null = null;
|
|
for (const row of list) {
|
|
if (row.eventHash === null) {
|
|
lastHash = null;
|
|
continue;
|
|
}
|
|
inspected += 1;
|
|
if (row.previousHash !== lastHash) {
|
|
return { ok: false, inspected, firstBreakId: row.id, reason: "previous-hash-mismatch" };
|
|
}
|
|
const recomputed = computeAuditHash({
|
|
previousHash: row.previousHash,
|
|
payload: {
|
|
id: "",
|
|
createdAt: "",
|
|
tenantId: row.tenantId,
|
|
actorType: row.actorType,
|
|
actorId: row.actorId,
|
|
actorEmail: row.actorEmail,
|
|
actorIp: row.actorIp,
|
|
actorUserAgent: row.actorUserAgent,
|
|
action: row.action,
|
|
targetType: row.targetType,
|
|
targetId: row.targetId,
|
|
requestId: row.requestId,
|
|
sourceModule: row.sourceModule,
|
|
outcome: row.outcome,
|
|
dataSensitivity: row.dataSensitivity,
|
|
regulatoryScope: row.regulatoryScope,
|
|
metadata: row.metadata,
|
|
},
|
|
});
|
|
if (recomputed !== row.eventHash) {
|
|
return { ok: false, inspected, firstBreakId: row.id, reason: "hash-mismatch" };
|
|
}
|
|
lastHash = row.eventHash;
|
|
}
|
|
}
|
|
return { ok: true, inspected, firstBreakId: null, reason: null };
|
|
}
|
|
|
|
export function renderAuditExportCsv(rows: ReadonlyArray<AuditExportRow>, integrity: AuditIntegritySummary): string {
|
|
const lines: string[] = [];
|
|
lines.push(`# verifyAuditChain: ok=${integrity.ok} inspected=${integrity.inspected} firstBreakId=${integrity.firstBreakId ?? "-"} reason=${integrity.reason ?? "-"}`);
|
|
lines.push(AUDIT_EXPORT_CSV_COLUMNS.join(","));
|
|
for (const row of rows) {
|
|
lines.push(
|
|
AUDIT_EXPORT_CSV_COLUMNS.map((col) => {
|
|
const value = (row as unknown as Record<string, unknown>)[col];
|
|
return csvCell(value);
|
|
}).join(",")
|
|
);
|
|
}
|
|
return `${lines.join("\n")}\n`;
|
|
}
|
|
|
|
export function csvCell(value: unknown): string {
|
|
if (value === null || value === undefined) return "";
|
|
if (Array.isArray(value)) return csvCell(value.join("|"));
|
|
if (value instanceof Date) return value.toISOString();
|
|
if (typeof value === "object") return csvCell(JSON.stringify(value));
|
|
const s = String(value);
|
|
if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
|
|
return s;
|
|
}
|