/** * Audit Service * * Zentraler Service für das Logging von Audit-Events. * Verwendet von Hooks und anderen System-Komponenten. */ import type { Payload, PayloadRequest } from 'payload' import { maskString, maskObject, maskError } from '../security/data-masking' export type AuditAction = | 'login_success' | 'login_failed' | 'logout' | 'password_changed' | 'password_reset' | 'create' | 'update' | 'delete' | 'config_changed' | 'email_failed' | 'access_denied' | 'rate_limit' export type AuditSeverity = 'info' | 'warning' | 'error' | 'critical' export type AuditEntityType = | 'users' | 'tenants' | 'pages' | 'posts' | 'media' | 'forms' | 'email' | 'global' | 'system' export interface AuditLogInput { action: AuditAction severity?: AuditSeverity entityType?: AuditEntityType entityId?: string | number userId?: number userEmail?: string tenantId?: number ipAddress?: string userAgent?: string description?: string previousValue?: Record newValue?: Record metadata?: Record } /** * Extrahiert einen Header-Wert aus verschiedenen Header-Formaten * * Unterstützt: * - Express/Node IncomingHttpHeaders (object mit lowercase keys) * - Fetch API Headers (mit .get() Methode) * - Request mit .get() Methode (Express-style) */ function getHeaderValue( req: PayloadRequest, headerName: string, ): string | undefined { const lowerName = headerName.toLowerCase() // 1. Versuche req.get() (Express Request Methode) const reqWithGet = req as unknown as { get?: (name: string) => string | undefined } if (typeof reqWithGet.get === 'function') { const value = reqWithGet.get(lowerName) if (value) return value } // 2. Versuche headers.get() (Fetch API Headers) const headersWithGet = req.headers as unknown as { get?: (name: string) => string | null } if (req.headers && typeof headersWithGet.get === 'function') { const value = headersWithGet.get(lowerName) if (value) return value } // 3. Direkter Zugriff auf headers object (IncomingHttpHeaders) if (req.headers && typeof req.headers === 'object') { const headers = req.headers as unknown as Record const value = headers[lowerName] if (typeof value === 'string') return value if (Array.isArray(value) && value.length > 0) return value[0] } return undefined } /** * Extrahiert Client-Informationen aus dem Request * * Funktioniert mit: * - PayloadRequest (Express-basiert mit IncomingHttpHeaders) * - Next.js Request (Fetch API basiert) */ function getClientInfo(req?: PayloadRequest): { ipAddress?: string; userAgent?: string } { if (!req) return {} // IP-Adresse aus verschiedenen Headern extrahieren const forwarded = getHeaderValue(req, 'x-forwarded-for') const realIp = getHeaderValue(req, 'x-real-ip') let ipAddress: string | undefined if (forwarded) { ipAddress = forwarded.split(',')[0]?.trim() } else if (realIp) { ipAddress = realIp } const userAgent = getHeaderValue(req, 'user-agent') return { ipAddress, userAgent } } /** * Bestimmt den Schweregrad basierend auf der Aktion */ function getDefaultSeverity(action: AuditAction): AuditSeverity { switch (action) { case 'login_failed': case 'access_denied': case 'rate_limit': return 'warning' case 'email_failed': return 'error' case 'delete': return 'warning' default: return 'info' } } /** * Erstellt einen Audit-Log-Eintrag * * Sensible Daten in previousValue, newValue und metadata werden automatisch maskiert. */ export async function createAuditLog( payload: Payload, input: AuditLogInput, req?: PayloadRequest, ): Promise { try { const clientInfo = getClientInfo(req) // Sensible Daten in Objekten maskieren const maskedPreviousValue = input.previousValue ? maskObject(input.previousValue) : undefined const maskedNewValue = input.newValue ? maskObject(input.newValue) : undefined const maskedMetadata = input.metadata ? maskObject(input.metadata) : undefined type CreateArgs = Parameters[0] await payload.create({ collection: 'audit-logs', data: { action: input.action, severity: input.severity || getDefaultSeverity(input.action), entityType: input.entityType, entityId: input.entityId?.toString(), user: input.userId, userEmail: input.userEmail, tenant: input.tenantId, ipAddress: input.ipAddress || clientInfo.ipAddress, userAgent: input.userAgent || clientInfo.userAgent, description: input.description ? maskString(input.description) : undefined, previousValue: maskedPreviousValue, newValue: maskedNewValue, metadata: maskedMetadata, }, // Bypass Access Control für System-Logging overrideAccess: true, } as CreateArgs) } catch (error) { // Fehler beim Audit-Logging sollten die Hauptoperation nicht blockieren // Auch Fehlermeldungen maskieren console.error('[AuditService] Error creating audit log:', maskError(error)) } } /** * Loggt einen erfolgreichen Login */ export async function logLoginSuccess( payload: Payload, userId: number, userEmail: string, req?: PayloadRequest, ): Promise { await createAuditLog( payload, { action: 'login_success', entityType: 'users', entityId: userId, userId, userEmail, description: `Benutzer ${userEmail} hat sich erfolgreich angemeldet`, }, req, ) } /** * Client-Info Interface für direkte Übergabe (wenn kein PayloadRequest verfügbar) */ export interface ClientInfo { ipAddress?: string userAgent?: string } /** * Prüft ob ein Objekt ein Request ist (hat headers property) * im Gegensatz zu einem einfachen ClientInfo-Objekt */ function isRequest(obj: unknown): obj is PayloadRequest { if (!obj || typeof obj !== 'object') return false // Ein Request hat typischerweise headers, method, url etc. // ClientInfo hat nur ipAddress und/oder userAgent const asRecord = obj as Record // Wenn es headers hat, ist es ein Request if ('headers' in asRecord) return true // Wenn es method oder url hat, ist es ein Request if ('method' in asRecord || 'url' in asRecord) return true // Wenn es NUR ipAddress und/oder userAgent hat, ist es ClientInfo const keys = Object.keys(asRecord) const clientInfoKeys = ['ipAddress', 'userAgent'] if (keys.every((key) => clientInfoKeys.includes(key))) return false // Im Zweifel als Request behandeln return true } /** * Extrahiert ClientInfo aus einem Request oder gibt das übergebene ClientInfo zurück */ function extractClientInfo(reqOrClientInfo?: PayloadRequest | ClientInfo): ClientInfo { if (!reqOrClientInfo) return {} if (isRequest(reqOrClientInfo)) { return getClientInfo(reqOrClientInfo) } // Es ist bereits ClientInfo return reqOrClientInfo } /** * Loggt einen fehlgeschlagenen Login * * @param payload - Payload-Instanz * @param email - E-Mail des Login-Versuchs * @param reason - Grund für das Fehlschlagen * @param reqOrClientInfo - PayloadRequest ODER direktes ClientInfo-Objekt */ export async function logLoginFailed( payload: Payload, email: string, reason: string, reqOrClientInfo?: PayloadRequest | ClientInfo, ): Promise { const clientInfo = extractClientInfo(reqOrClientInfo) await createAuditLog( payload, { action: 'login_failed', severity: 'warning', entityType: 'users', userEmail: email, ipAddress: clientInfo.ipAddress, userAgent: clientInfo.userAgent, description: `Fehlgeschlagener Login-Versuch für ${email}: ${reason}`, metadata: { reason }, }, undefined, // Kein req mehr nötig, Client-Info ist bereits im Input ) } /** * Loggt eine Passwort-Reset-Anfrage * * @param payload - Payload-Instanz * @param email - E-Mail für den Reset * @param reqOrClientInfo - PayloadRequest ODER direktes ClientInfo-Objekt */ export async function logPasswordReset( payload: Payload, email: string, reqOrClientInfo?: PayloadRequest | ClientInfo, ): Promise { const clientInfo = extractClientInfo(reqOrClientInfo) await createAuditLog( payload, { action: 'password_reset', severity: 'info', entityType: 'users', userEmail: email, ipAddress: clientInfo.ipAddress, userAgent: clientInfo.userAgent, description: `Passwort-Reset angefordert für ${email}`, }, undefined, ) } /** * Loggt eine Tenant-Änderung */ export async function logTenantChange( payload: Payload, tenantId: number, action: 'create' | 'update' | 'delete', userId: number, userEmail: string, previousValue?: Record, newValue?: Record, req?: PayloadRequest, ): Promise { const actionLabels = { create: 'erstellt', update: 'aktualisiert', delete: 'gelöscht', } await createAuditLog( payload, { action, severity: action === 'delete' ? 'warning' : 'info', entityType: 'tenants', entityId: tenantId, userId, userEmail, tenantId, description: `Tenant ${tenantId} wurde ${actionLabels[action]} von ${userEmail}`, previousValue, newValue, }, req, ) } /** * Loggt eine User-Änderung */ export async function logUserChange( payload: Payload, targetUserId: number, action: 'create' | 'update' | 'delete', performedByUserId: number, performedByEmail: string, changes?: { previousValue?: Record; newValue?: Record }, req?: PayloadRequest, ): Promise { const actionLabels = { create: 'erstellt', update: 'aktualisiert', delete: 'gelöscht', } await createAuditLog( payload, { action, severity: action === 'delete' ? 'warning' : 'info', entityType: 'users', entityId: targetUserId, userId: performedByUserId, userEmail: performedByEmail, description: `Benutzer ${targetUserId} wurde ${actionLabels[action]} von ${performedByEmail}`, previousValue: changes?.previousValue, newValue: changes?.newValue, }, req, ) } /** * Loggt einen E-Mail-Fehler */ export async function logEmailFailed( payload: Payload, tenantId: number, to: string, subject: string, error: string, userId?: number, userEmail?: string, ): Promise { await createAuditLog(payload, { action: 'email_failed', severity: 'error', entityType: 'email', tenantId, userId, userEmail, description: `E-Mail an ${to} fehlgeschlagen: ${subject}`, metadata: { to, subject, // Fehler maskieren um Secrets zu schützen error: maskSensitiveData(error), }, }) } /** * Loggt einen Zugriffsverweigerung */ export async function logAccessDenied( payload: Payload, resource: string, userId?: number, userEmail?: string, req?: PayloadRequest, ): Promise { await createAuditLog( payload, { action: 'access_denied', severity: 'warning', entityType: 'system', userId, userEmail, description: `Zugriff auf ${resource} verweigert`, metadata: { resource }, }, req, ) } /** * Loggt ein Rate-Limit-Ereignis */ export async function logRateLimit( payload: Payload, endpoint: string, userId?: number, userEmail?: string, req?: PayloadRequest, ): Promise { await createAuditLog( payload, { action: 'rate_limit', severity: 'warning', entityType: 'system', userId, userEmail, description: `Rate-Limit erreicht für ${endpoint}`, metadata: { endpoint }, }, req, ) } /** * Maskiert sensible Daten in Fehlermeldungen * Verwendet jetzt den zentralen Data-Masking-Service */ function maskSensitiveData(text: string): string { return maskString(text) } // Re-export für externe Nutzung export { maskError, maskObject, maskString }