Files
craftvia/src/server/audit.ts
T
msolarczekandClaude Opus 5 b8b8bddefe 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>
2026-09-14 12:20:41 +02:00

84 lines
2.5 KiB
TypeScript

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.
*/
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: AuditAction;
scope?: "tenant" | "platform";
entity: string;
entityId?: string;
before?: unknown;
after?: unknown;
}) {
const ctx = await requestContext();
await 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: ctx.ipAddress,
userAgent: ctx.userAgent,
},
});
}
/**
* 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,
},
});
}