- Aufräumpunkt h: writeAuditLog puffert innerhalb von inTransaction (AsyncLocalStorage) und schreibt nach dem Commit; bei Rollback werden die Einträge verworfen, nur „denied" bleibt. Verschachtelte Transaktionen nutzen den äußeren Puffer. - Aufräumpunkt d: mergeCustomers läuft über inTransaction (sequenziell, geschützter Statuswechsel) statt ctx.db.$transaction([...]) und ist damit auch bei RLS_ENFORCED=true atomar und in äußere Transaktionen einbettbar. - Aufräumpunkt e: AuditAction „read" (+ Label im Audit-Viewer de/en); Notdienst-Kunden- und Objektsuche protokollieren als „read" statt „export". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
144 lines
4.7 KiB
TypeScript
144 lines
4.7 KiB
TypeScript
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<RequestContext> {
|
|
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<T>(fn: () => Promise<T>): Promise<T> {
|
|
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<T>(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,
|
|
},
|
|
});
|
|
}
|