Fundament: Audit mit IP/User-Agent, Mail-Absender je Mandant

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:20:41 +02:00
co-authored by Claude Opus 5
parent 2d1c07cf74
commit b8b8bddefe
5 changed files with 83 additions and 36 deletions
@@ -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;
+18 -16
View File
@@ -55,28 +55,28 @@ model Tenant {
// Mandanten-Einstellungen: Unternehmensdaten, Branding, Sicherheits-Policy. // Mandanten-Einstellungen: Unternehmensdaten, Branding, Sicherheits-Policy.
model TenantSettings { model TenantSettings {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String @unique @map("tenant_id") tenantId String @unique @map("tenant_id")
orgName String @map("org_name") orgName String @map("org_name")
orgShort String? @map("org_short") orgShort String? @map("org_short")
address String? address String?
phone String? phone String?
email String? email String?
sector String? sector String?
// Platzhalter für das Mandanten-Logo im Objektspeicher (Upload folgt). // Platzhalter für das Mandanten-Logo im Objektspeicher (Upload folgt).
logoKey String? @map("logo_key") logoKey String? @map("logo_key")
accent String? accent String?
locale String @default("de") locale String @default("de")
timezone String @default("Europe/Berlin") timezone String @default("Europe/Berlin")
securityPolicy Json @default("{}") @map("security_policy") // pw/mfa/session securityPolicy Json @default("{}") @map("security_policy") // pw/mfa/session
smtp Json @default("{}") smtp Json @default("{}")
// Craftvia §33.2 — tenant mail settings (sender address stays the platform domain for SPF/DKIM). // Craftvia §33.2 — tenant mail settings (sender address stays the platform domain for SPF/DKIM).
mailFromName String? @map("mail_from_name") mailFromName String? @map("mail_from_name")
mailReplyTo String? @map("mail_reply_to") mailReplyTo String? @map("mail_reply_to")
emergencyRecipients String[] @default([]) @map("emergency_recipients") emergencyRecipients String[] @default([]) @map("emergency_recipients")
billingRecipients String[] @default([]) @map("billing_recipients") billingRecipients String[] @default([]) @map("billing_recipients")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@ -284,6 +284,8 @@ model AuditLog {
entityId String? @map("entity_id") entityId String? @map("entity_id")
before Json? before Json?
after Json? after Json?
ipAddress String? @map("ip_address")
userAgent String? @map("user_agent")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
tenant Tenant? @relation(fields: [tenantId], references: [id]) tenant Tenant? @relation(fields: [tenantId], references: [id])
+30 -3
View File
@@ -1,20 +1,42 @@
import { prisma } from "./db"; 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 * Uses the raw client on purpose — audit writes must never be silently
* filtered, and tenantId is passed explicitly by the caller. * 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: { export async function writeAuditLog(entry: {
tenantId: string; tenantId: string;
actorId?: string; actorId?: string;
action: "create" | "update" | "delete" | "login" | "logout" | "export" | "import" | "denied"; action: AuditAction;
scope?: "tenant" | "platform"; scope?: "tenant" | "platform";
entity: string; entity: string;
entityId?: string; entityId?: string;
before?: unknown; before?: unknown;
after?: unknown; after?: unknown;
}) { }) {
const ctx = await requestContext();
await prisma.auditLog.create({ await prisma.auditLog.create({
data: { data: {
tenantId: entry.tenantId, tenantId: entry.tenantId,
@@ -25,6 +47,8 @@ export async function writeAuditLog(entry: {
entityId: entry.entityId, entityId: entry.entityId,
before: entry.before as object | undefined, before: entry.before as object | undefined,
after: entry.after 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: { export async function writePlatformAudit(entry: {
actorId?: string; actorId?: string;
action: "create" | "update" | "delete" | "login" | "logout" | "export" | "import" | "denied"; action: AuditAction;
entity: string; entity: string;
entityId?: string; entityId?: string;
before?: unknown; before?: unknown;
after?: unknown; after?: unknown;
}) { }) {
const ctx = await requestContext();
await prisma.auditLog.create({ await prisma.auditLog.create({
data: { data: {
tenantId: null, tenantId: null,
@@ -51,6 +76,8 @@ export async function writePlatformAudit(entry: {
entityId: entry.entityId, entityId: entry.entityId,
before: entry.before as object | undefined, before: entry.before as object | undefined,
after: entry.after as object | undefined, after: entry.after as object | undefined,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
}, },
}); });
} }
+29 -3
View File
@@ -1,5 +1,5 @@
import { prisma } from "@/server/db"; import { prisma } from "@/server/db";
import { getMailConfig, mailFrom } from "./config"; import { getMailConfig, mailFrom, type MailConfig } from "./config";
import { getMailProvider } from "./provider-smtp"; import { getMailProvider } from "./provider-smtp";
import { TransientMailError } from "./provider"; import { TransientMailError } from "./provider";
import { renderTemplate } from "./templates"; 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 rendered = renderTemplate(job.template, job.locale, job.vars);
const sender = await tenantSender(job.mailLogId, config);
try { try {
const { messageId } = await provider.send({ const { messageId } = await provider.send({
from: mailFrom(config), from: sender.from,
to: job.to, to: job.to,
replyTo: config.replyTo, replyTo: sender.replyTo,
subject: rendered.subject, subject: rendered.subject,
html: rendered.html, html: rendered.html,
text: rendered.text, text: rendered.text,
@@ -89,3 +90,28 @@ export async function markMailFailed(mailLogId: string, error: string): Promise<
data: { status: "failed", error: error.slice(0, 500) }, 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;
}
+3 -14
View File
@@ -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<string, unknown>;
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) { export async function getAuditEntry(ctx: ServiceCtx, id: unknown) {
assertCan(ctx, "audit:read"); assertCan(ctx, "audit:read");
const entryId = z.string().min(1).max(64).parse(id); const entryId = z.string().min(1).max(64).parse(id);
@@ -123,6 +110,8 @@ export async function getAuditEntry(ctx: ServiceCtx, id: unknown) {
...row, ...row,
actorName: actor?.name ?? null, actorName: actor?.name ?? null,
diff: diffAudit(row.before ?? undefined, row.after ?? undefined), 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,
}; };
} }