L10b Betrieb & Aufräumen: Audit nach Commit, mergeCustomers atomar, Audit-Aktion read

- 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>
This commit is contained in:
2026-09-14 18:19:19 +02:00
co-authored by Claude Opus 5
parent 85bae832d0
commit 8aedc642ca
6 changed files with 129 additions and 57 deletions
+76 -16
View File
@@ -1,3 +1,4 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { prisma } from "./db";
/**
@@ -6,14 +7,28 @@ import { prisma } from "./db";
* filtered, and tenantId is passed explicitly by the caller.
*/
type AuditAction = "create" | "update" | "delete" | "login" | "logout" | "export" | "import" | "denied";
/** `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<{ ipAddress: string | null; userAgent: string | null }> {
async function requestContext(): Promise<RequestContext> {
try {
const { headers } = await import("next/headers");
const h = await headers();
@@ -26,18 +41,8 @@ async function requestContext(): Promise<{ ipAddress: string | null; userAgent:
}
}
export async function writeAuditLog(entry: {
tenantId: string;
actorId?: string;
action: AuditAction;
scope?: "tenant" | "platform";
entity: string;
entityId?: string;
before?: unknown;
after?: unknown;
}) {
const ctx = await requestContext();
await prisma.auditLog.create({
function insertAudit(entry: AuditEntry, request: RequestContext) {
return prisma.auditLog.create({
data: {
tenantId: entry.tenantId,
scope: entry.scope ?? "tenant",
@@ -47,12 +52,67 @@ export async function writeAuditLog(entry: {
entityId: entry.entityId,
before: entry.before as object | undefined,
after: entry.after as object | undefined,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
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).