mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 17:24:12 +00:00
- 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>
477 lines
12 KiB
TypeScript
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 }
|