Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/routes/admin/audit-export.helpers.server.ts
T

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;
}