import { AsyncLocalStorage } from "node:async_hooks"; import { prisma } from "./db"; /** * Audit trail (spec §26): every writing action creates an entry. * Uses the raw client on purpose — audit writes must never be silently * filtered, and tenantId is passed explicitly by the caller. */ /** `read` (L10b): sensitive read access that must be traceable (e.g. emergency customer search). */ export type AuditAction = "create" | "update" | "delete" | "login" | "logout" | "export" | "import" | "denied" | "read"; type RequestContext = { ipAddress: string | null; userAgent: string | null }; type AuditEntry = { tenantId: string; actorId?: string; action: AuditAction; scope?: "tenant" | "platform"; entity: string; entityId?: string; before?: unknown; after?: unknown; }; /** * IP address and user agent of the current request, if there is one. * Outside a request scope (workers, scripts, tests) `headers()` throws → both null. * Behind the Coolify/Traefik proxy the client IP is the first X-Forwarded-For hop. */ async function requestContext(): Promise { try { const { headers } = await import("next/headers"); const h = await headers(); const forwarded = h.get("x-forwarded-for")?.split(",")[0]?.trim(); const ip = forwarded || h.get("x-real-ip")?.trim() || null; const ua = h.get("user-agent"); return { ipAddress: ip ? ip.slice(0, 64) : null, userAgent: ua ? ua.slice(0, 512) : null }; } catch { return { ipAddress: null, userAgent: null }; } } function insertAudit(entry: AuditEntry, request: RequestContext) { return prisma.auditLog.create({ data: { tenantId: entry.tenantId, scope: entry.scope ?? "tenant", actorId: entry.actorId, action: entry.action, entity: entry.entity, entityId: entry.entityId, before: entry.before as object | undefined, after: entry.after as object | undefined, ipAddress: request.ipAddress, userAgent: request.userAgent, }, }); } /** * L10b (ARCHITEKTUR §4.8): audit entries written inside `inTransaction` are buffered and flushed * after the commit. The audit insert uses the owner client (outside the tenant transaction), so * without the buffer a rolled-back transaction would leave "create/update" entries for changes * that never happened. On rollback only `denied` entries are kept (security relevant, independent * of the business change). */ const deferredAudit = new AsyncLocalStorage<{ entries: { entry: AuditEntry; request: RequestContext }[] }>(); export async function writeAuditLog(entry: AuditEntry) { const request = await requestContext(); const buffer = deferredAudit.getStore(); if (buffer) { // snapshot before/after now — callers may mutate the objects after the call buffer.entries.push({ entry: structuredCloneSafe(entry), request }); return; } await insertAudit(entry, request); } /** Run `fn` with deferred audit writes (see above). Nested calls join the outer buffer. */ export async function withDeferredAudit(fn: () => Promise): Promise { if (deferredAudit.getStore()) return fn(); const buffer: { entries: { entry: AuditEntry; request: RequestContext }[] } = { entries: [] }; let result: T; try { result = await deferredAudit.run(buffer, fn); } catch (err) { await flush(buffer.entries.filter((e) => e.entry.action === "denied")); throw err; } await flush(buffer.entries); return result; } async function flush(entries: { entry: AuditEntry; request: RequestContext }[]) { for (const { entry, request } of entries) { try { await insertAudit(entry, request); } catch (err) { // the business change is already committed — never turn it into an error for the caller console.error("[audit] deferred write failed:", (err as Error).message); } } } /** Deep copy for JSON-like audit payloads; falls back to the original for non-cloneable values. */ function structuredCloneSafe(value: T): T { try { return structuredClone(value); } catch { return value; } } /** * Audit-Eintrag der Plattform-Ebene (kein Mandantenbezug, scope="platform"). * Für Superadmin-Anmeldungen und -Aktionen (Phase-1-Härtung Paket 2). */ export async function writePlatformAudit(entry: { actorId?: string; action: AuditAction; entity: string; entityId?: string; before?: unknown; after?: unknown; }) { const ctx = await requestContext(); await prisma.auditLog.create({ data: { tenantId: null, scope: "platform", actorId: entry.actorId, action: entry.action, entity: entry.entity, entityId: entry.entityId, before: entry.before as object | undefined, after: entry.after as object | undefined, ipAddress: ctx.ipAddress, userAgent: ctx.userAgent, }, }); }