cms.c2sgmbh/src/lib/audit/audit-service.ts
Martin Porwoll 2faefdac1e chore: code cleanup, TypeScript fixes, and dependency updates
- Remove unused variables and imports across API routes and workers
- Fix TypeScript errors in ConsentLogs.ts (PayloadRequest header access)
- Fix TypeScript errors in formSubmissionHooks.ts (add ResponseTracking interface)
- Update eslint ignores for coverage, test results, and generated files
- Set push: false in payload.config.ts (schema changes only via migrations)
- Update dependencies to latest versions (Payload 3.68.4, React 19.2.3)
- Add framework update check script and documentation
- Regenerate payload-types.ts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 09:02:58 +00:00

477 lines
12 KiB
TypeScript

/**
* 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<string, unknown>
newValue?: Record<string, unknown>
metadata?: Record<string, unknown>
}
/**
* 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<string, string | string[] | undefined>
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<void> {
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<typeof payload.create>[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<void> {
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<string, unknown>
// 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<void> {
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<void> {
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<string, unknown>,
newValue?: Record<string, unknown>,
req?: PayloadRequest,
): Promise<void> {
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<string, unknown>; newValue?: Record<string, unknown> },
req?: PayloadRequest,
): Promise<void> {
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<void> {
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<void> {
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<void> {
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 }