From b8b8bddefe59ee713d6847b79fefb0f4ec61be3b Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 12:20:41 +0200 Subject: [PATCH] Fundament: Audit mit IP/User-Agent, Mail-Absender je Mandant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration audit_request_context: ip_address, user_agent an audit_logs (Spec §26) - writeAuditLog/writePlatformAudit erfassen IP (X-Forwarded-For) und User-Agent aus dem Request; außerhalb eines Requests (Worker/Skripte) null - Audit-Viewer liest die neuen Spalten statt Heuristik aus before/after - deliverMail nutzt Anzeigename und Reply-To aus TenantSettings (Spec §33.2); Absenderadresse bleibt Plattform-Domain (SPF/DKIM), Header-Injection bereinigt Gate: tsc, lint, build, 24/24 Tests grün. Co-Authored-By: Claude Opus 5 --- .../migration.sql | 3 ++ prisma/schema.prisma | 34 ++++++++++--------- src/server/audit.ts | 33 ++++++++++++++++-- src/server/mail/deliver.ts | 32 +++++++++++++++-- src/server/services/audit/viewer.ts | 17 ++-------- 5 files changed, 83 insertions(+), 36 deletions(-) create mode 100644 prisma/migrations/20260914150000_audit_request_context/migration.sql diff --git a/prisma/migrations/20260914150000_audit_request_context/migration.sql b/prisma/migrations/20260914150000_audit_request_context/migration.sql new file mode 100644 index 0000000..d0de6d5 --- /dev/null +++ b/prisma/migrations/20260914150000_audit_request_context/migration.sql @@ -0,0 +1,3 @@ +-- Spec §26: audit entries carry IP address, user agent and outcome. +ALTER TABLE "audit_logs" ADD COLUMN "ip_address" TEXT; +ALTER TABLE "audit_logs" ADD COLUMN "user_agent" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 640dcbe..cf52408 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -55,28 +55,28 @@ model Tenant { // Mandanten-Einstellungen: Unternehmensdaten, Branding, Sicherheits-Policy. model TenantSettings { - id String @id @default(cuid()) - tenantId String @unique @map("tenant_id") - orgName String @map("org_name") - orgShort String? @map("org_short") - address String? - phone String? - email String? - sector String? + id String @id @default(cuid()) + tenantId String @unique @map("tenant_id") + orgName String @map("org_name") + orgShort String? @map("org_short") + address String? + phone String? + email String? + sector String? // Platzhalter für das Mandanten-Logo im Objektspeicher (Upload folgt). - logoKey String? @map("logo_key") - accent String? - locale String @default("de") - timezone String @default("Europe/Berlin") - securityPolicy Json @default("{}") @map("security_policy") // pw/mfa/session - smtp Json @default("{}") + logoKey String? @map("logo_key") + accent String? + locale String @default("de") + timezone String @default("Europe/Berlin") + securityPolicy Json @default("{}") @map("security_policy") // pw/mfa/session + smtp Json @default("{}") // Craftvia §33.2 — tenant mail settings (sender address stays the platform domain for SPF/DKIM). mailFromName String? @map("mail_from_name") mailReplyTo String? @map("mail_reply_to") emergencyRecipients String[] @default([]) @map("emergency_recipients") billingRecipients String[] @default([]) @map("billing_recipients") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) @@ -284,6 +284,8 @@ model AuditLog { entityId String? @map("entity_id") before Json? after Json? + ipAddress String? @map("ip_address") + userAgent String? @map("user_agent") createdAt DateTime @default(now()) @map("created_at") tenant Tenant? @relation(fields: [tenantId], references: [id]) diff --git a/src/server/audit.ts b/src/server/audit.ts index d22fc91..75d82a7 100644 --- a/src/server/audit.ts +++ b/src/server/audit.ts @@ -1,20 +1,42 @@ import { prisma } from "./db"; /** - * Audit trail (SPEC §5 AuditLog, §10): every writing action creates an entry. + * 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. */ + +type AuditAction = "create" | "update" | "delete" | "login" | "logout" | "export" | "import" | "denied"; + +/** + * 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 }> { + 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 }; + } +} + export async function writeAuditLog(entry: { tenantId: string; actorId?: string; - action: "create" | "update" | "delete" | "login" | "logout" | "export" | "import" | "denied"; + action: AuditAction; scope?: "tenant" | "platform"; entity: string; entityId?: string; before?: unknown; after?: unknown; }) { + const ctx = await requestContext(); await prisma.auditLog.create({ data: { tenantId: entry.tenantId, @@ -25,6 +47,8 @@ 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, }, }); } @@ -35,12 +59,13 @@ export async function writeAuditLog(entry: { */ export async function writePlatformAudit(entry: { actorId?: string; - action: "create" | "update" | "delete" | "login" | "logout" | "export" | "import" | "denied"; + action: AuditAction; entity: string; entityId?: string; before?: unknown; after?: unknown; }) { + const ctx = await requestContext(); await prisma.auditLog.create({ data: { tenantId: null, @@ -51,6 +76,8 @@ export async function writePlatformAudit(entry: { entityId: entry.entityId, before: entry.before as object | undefined, after: entry.after as object | undefined, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, }, }); } diff --git a/src/server/mail/deliver.ts b/src/server/mail/deliver.ts index 1f1286f..60b4e4e 100644 --- a/src/server/mail/deliver.ts +++ b/src/server/mail/deliver.ts @@ -1,5 +1,5 @@ import { prisma } from "@/server/db"; -import { getMailConfig, mailFrom } from "./config"; +import { getMailConfig, mailFrom, type MailConfig } from "./config"; import { getMailProvider } from "./provider-smtp"; import { TransientMailError } from "./provider"; import { renderTemplate } from "./templates"; @@ -42,12 +42,13 @@ export async function deliverMail(job: MailJob): Promise<{ messageId: string }> } const rendered = renderTemplate(job.template, job.locale, job.vars); + const sender = await tenantSender(job.mailLogId, config); try { const { messageId } = await provider.send({ - from: mailFrom(config), + from: sender.from, to: job.to, - replyTo: config.replyTo, + replyTo: sender.replyTo, subject: rendered.subject, html: rendered.html, text: rendered.text, @@ -89,3 +90,28 @@ export async function markMailFailed(mailLogId: string, error: string): Promise< data: { status: "failed", error: error.slice(0, 500) }, }); } + +/** + * Tenant-specific display name and reply-to (spec §33.2). The sender ADDRESS always stays + * the platform address (SPF/DKIM alignment); only the display name and Reply-To vary. + * Raw client on purpose: the worker has no tenant context; the MailLog row carries the tenant. + */ +async function tenantSender(mailLogId: string, config: MailConfig): Promise<{ from: string; replyTo?: string }> { + const log = await prisma.mailLog.findUnique({ where: { id: mailLogId }, select: { tenantId: true } }); + if (!log?.tenantId) return { from: mailFrom(config), replyTo: config.replyTo }; + const settings = await prisma.tenantSettings.findUnique({ + where: { tenantId: log.tenantId }, + select: { mailFromName: true, mailReplyTo: true }, + }); + const name = sanitizeDisplayName(settings?.mailFromName); + return { + from: name ? mailFrom({ ...config, fromName: name }) : mailFrom(config), + replyTo: settings?.mailReplyTo?.trim() || config.replyTo, + }; +} + +/** Strip characters that could break the From header (quotes, angle brackets, CR/LF). */ +function sanitizeDisplayName(v: string | null | undefined): string | null { + const cleaned = (v ?? "").replace(/[\r\n"<>]/g, "").trim().slice(0, 80); + return cleaned || null; +} diff --git a/src/server/services/audit/viewer.ts b/src/server/services/audit/viewer.ts index e91d53d..3128fa8 100644 --- a/src/server/services/audit/viewer.ts +++ b/src/server/services/audit/viewer.ts @@ -100,19 +100,6 @@ export function diffAudit(before: unknown, after: unknown): DiffRow[] { }); } -/** Extract request metadata if a writer stored it (writeAuditLog does not capture it yet). */ -function requestMeta(...sources: unknown[]): { ip: string | null; userAgent: string | null } { - for (const s of sources) { - if (s && typeof s === "object") { - const o = s as Record; - const ip = typeof o.ip === "string" ? o.ip : null; - const userAgent = typeof o.userAgent === "string" ? o.userAgent : null; - if (ip || userAgent) return { ip, userAgent }; - } - } - return { ip: null, userAgent: null }; -} - export async function getAuditEntry(ctx: ServiceCtx, id: unknown) { assertCan(ctx, "audit:read"); const entryId = z.string().min(1).max(64).parse(id); @@ -123,6 +110,8 @@ export async function getAuditEntry(ctx: ServiceCtx, id: unknown) { ...row, actorName: actor?.name ?? null, diff: diffAudit(row.before ?? undefined, row.after ?? undefined), - ...requestMeta(row.after, row.before), + // captured by writeAuditLog from the request (spec §26); null for worker/script writes + ip: row.ipAddress, + userAgent: row.userAgent, }; }