From 8aedc642ca75d9864c369a17d060635b8920bc6a Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 18:19:19 +0200 Subject: [PATCH] =?UTF-8?q?L10b=20Betrieb=20&=20Aufr=C3=A4umen:=20Audit=20?= =?UTF-8?q?nach=20Commit,=20mergeCustomers=20atomar,=20Audit-Aktion=20read?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- messages/de/notifications.json | 1 + messages/en/notifications.json | 1 + src/server/audit.ts | 92 ++++++++++++++++++++----- src/server/services/context.ts | 5 +- src/server/services/customers/merge.ts | 83 ++++++++++++---------- src/server/services/emergency/lookup.ts | 4 +- 6 files changed, 129 insertions(+), 57 deletions(-) diff --git a/messages/de/notifications.json b/messages/de/notifications.json index 4262b25..3689c31 100644 --- a/messages/de/notifications.json +++ b/messages/de/notifications.json @@ -147,6 +147,7 @@ "logout": "Abmeldung", "denied": "Abgelehnt", "export": "Export", + "read": "Lesezugriff", "import": "Import", "provision": "Eingerichtet", "approve": "Freigegeben", diff --git a/messages/en/notifications.json b/messages/en/notifications.json index 2bd8e15..3206fdf 100644 --- a/messages/en/notifications.json +++ b/messages/en/notifications.json @@ -147,6 +147,7 @@ "logout": "Sign-out", "denied": "Denied", "export": "Export", + "read": "Read access", "import": "Import", "provision": "Provisioned", "approve": "Approved", diff --git a/src/server/audit.ts b/src/server/audit.ts index 75d82a7..05ab060 100644 --- a/src/server/audit.ts +++ b/src/server/audit.ts @@ -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 { 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(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). diff --git a/src/server/services/context.ts b/src/server/services/context.ts index 4e4eaa1..05c2d84 100644 --- a/src/server/services/context.ts +++ b/src/server/services/context.ts @@ -1,5 +1,6 @@ import type { Session } from "next-auth"; import { tenantTransaction, type TenantDb } from "@/server/db"; +import { withDeferredAudit } from "@/server/audit"; /** * Context passed to every domain service. Created by server actions (from moduleGuard) @@ -27,9 +28,11 @@ export function ctxFromGuard(g: { session: Session; db: TenantDb; permissions: R * Run a multi-step write atomically (ARCHITEKTUR §4.8). `fn` receives a ctx whose `db` * is bound to the transaction; nested calls join the outer transaction. * Never use `ctx.db.$transaction(...)` directly — it is not atomic with RLS_ENFORCED=true. + * Audit entries written inside `fn` are flushed after the commit and dropped on rollback + * (except `denied`), see audit.ts#withDeferredAudit. */ export function inTransaction(ctx: ServiceCtx, fn: (ctx: ServiceCtx) => Promise): Promise { - return tenantTransaction(ctx.db, ctx.tenantId, (tx) => fn({ ...ctx, db: tx })); + return withDeferredAudit(() => tenantTransaction(ctx.db, ctx.tenantId, (tx) => fn({ ...ctx, db: tx }))); } export function can(ctx: ServiceCtx, permission: string): boolean { diff --git a/src/server/services/customers/merge.ts b/src/server/services/customers/merge.ts index 206018e..481fb83 100644 --- a/src/server/services/customers/merge.ts +++ b/src/server/services/customers/merge.ts @@ -1,5 +1,5 @@ import { writeAuditLog } from "@/server/audit"; -import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context"; import { mergeSchema, type MergeInput } from "@/server/services/customers/schemas"; /** @@ -7,51 +7,58 @@ import { mergeSchema, type MergeInput } from "@/server/services/customers/schema * Contacts, sites, work orders and documents of the source are moved to the target; the source * becomes status `merged` with `mergedIntoId`. Both records must belong to the caller's tenant * (dbForTenant) — ids of another tenant are "not found". Never triggered automatically. + * Runs in `inTransaction` (L10b): atomic with RLS_ENFORCED=true and joinable by callers that + * already hold a transaction (e.g. the emergency review). */ export async function mergeCustomers(ctx: ServiceCtx, input: MergeInput) { assertCan(ctx, "customer:merge"); const { sourceId, targetId } = mergeSchema.parse(input); - const [source, target] = await Promise.all([ - ctx.db.customer.findFirst({ where: { id: sourceId, deletedAt: null } }), - ctx.db.customer.findFirst({ where: { id: targetId, deletedAt: null } }), - ]); - if (!source) throw new ServiceError("not_found", "source customer not found", { field: "sourceId", reason: "not_found" }); - if (!target) throw new ServiceError("not_found", "target customer not found", { field: "targetId", reason: "not_found" }); - if (source.status === "merged" || target.status === "merged") { - throw new ServiceError("conflict", "customer already merged", { reason: "already_merged" }); - } + return inTransaction(ctx, async (tx) => { + const [source, target] = await Promise.all([ + tx.db.customer.findFirst({ where: { id: sourceId, deletedAt: null } }), + tx.db.customer.findFirst({ where: { id: targetId, deletedAt: null } }), + ]); + if (!source) throw new ServiceError("not_found", "source customer not found", { field: "sourceId", reason: "not_found" }); + if (!target) throw new ServiceError("not_found", "target customer not found", { field: "targetId", reason: "not_found" }); + if (source.status === "merged" || target.status === "merged") { + throw new ServiceError("conflict", "customer already merged", { reason: "already_merged" }); + } - const [contacts, sites, workOrders, documents, mergedSource] = await ctx.db.$transaction([ - ctx.db.contact.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId } }), - ctx.db.site.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId } }), + // sequential on purpose: one interactive transaction, no parallel queries on the tx client + const contacts = await tx.db.contact.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId } }); + const sites = await tx.db.site.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId } }); // version bump: offline clients must not overwrite the re-parented order with stale data - ctx.db.workOrder.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId, version: { increment: 1 } } }), - ctx.db.document.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId } }), - ctx.db.customer.update({ - where: { id: sourceId }, + const workOrders = await tx.db.workOrder.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId, version: { increment: 1 } } }); + const documents = await tx.db.document.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId } }); + // guarded update: a concurrent merge of the same source loses instead of merging twice + const flipped = await tx.db.customer.updateMany({ + where: { id: sourceId, status: { not: "merged" } }, data: { status: "merged", mergedIntoId: targetId, isProvisional: false }, - }), - ]); + }); + if (flipped.count !== 1) throw new ServiceError("conflict", "customer already merged", { reason: "already_merged" }); + const mergedSource = await tx.db.customer.findFirstOrThrow({ where: { id: sourceId } }); - const moved = { contacts: contacts.count, sites: sites.count, workOrders: workOrders.count, documents: documents.count }; - await writeAuditLog({ - tenantId: ctx.tenantId, - actorId: ctx.userId, - action: "update", - entity: "customer", - entityId: sourceId, - before: source, - after: { ...mergedSource, merge: { role: "source", targetId, moved } }, + const moved = { contacts: contacts.count, sites: sites.count, workOrders: workOrders.count, documents: documents.count }; + // deferred until commit by inTransaction (audit.ts#withDeferredAudit) + await writeAuditLog({ + tenantId: tx.tenantId, + actorId: tx.userId, + action: "update", + entity: "customer", + entityId: sourceId, + before: source, + after: { ...mergedSource, merge: { role: "source", targetId, moved } }, + }); + await writeAuditLog({ + tenantId: tx.tenantId, + actorId: tx.userId, + action: "update", + entity: "customer", + entityId: targetId, + before: target, + after: { merge: { role: "target", sourceId, moved } }, + }); + return { sourceId, targetId, moved }; }); - await writeAuditLog({ - tenantId: ctx.tenantId, - actorId: ctx.userId, - action: "update", - entity: "customer", - entityId: targetId, - before: target, - after: { merge: { role: "target", sourceId, moved } }, - }); - return { sourceId, targetId, moved }; } diff --git a/src/server/services/emergency/lookup.ts b/src/server/services/emergency/lookup.ts index 3970ad1..c2fbdfc 100644 --- a/src/server/services/emergency/lookup.ts +++ b/src/server/services/emergency/lookup.ts @@ -35,7 +35,7 @@ export async function searchCustomersForEmergency(ctx: ServiceCtx, rawQuery: str await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, - action: "export", + action: "read", entity: "emergency_customer_search", after: { query: q, resultCount: rows.length, customerIds: rows.map((r) => r.id) }, }); @@ -64,7 +64,7 @@ export async function listSitesForEmergency(ctx: ServiceCtx, customerId: string) await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, - action: "export", + action: "read", entity: "emergency_site_lookup", entityId: customerId, after: { resultCount: sites.length, siteIds: sites.map((s) => s.id) },