464 lines
18 KiB
TypeScript
464 lines
18 KiB
TypeScript
import { Response, NextFunction } from 'express';
|
|
import { AuditAction } from '@prisma/client';
|
|
import { AuthRequest } from '../types/index.js';
|
|
import { createAuditLog } from '../services/audit.service.js';
|
|
import { getAuditContext, AuditContext } from './auditContext.js';
|
|
|
|
// Resource-Typ-Mapping basierend auf Route-Patterns
|
|
const RESOURCE_MAPPING: Record<string, { type: string; extractId?: (req: AuthRequest) => string | undefined }> = {
|
|
'/api/customers': { type: 'Customer', extractId: (req) => req.params.id || req.params.customerId },
|
|
'/api/customers/*/bank-cards': { type: 'BankCard', extractId: (req) => req.params.bankCardId },
|
|
'/api/customers/*/documents': { type: 'IdentityDocument', extractId: (req) => req.params.documentId },
|
|
'/api/customers/*/addresses': { type: 'Address', extractId: (req) => req.params.addressId },
|
|
'/api/customers/*/meters': { type: 'Meter', extractId: (req) => req.params.meterId },
|
|
'/api/customers/*/consents': { type: 'CustomerConsent', extractId: (req) => req.params.type },
|
|
'/api/contracts': { type: 'Contract', extractId: (req) => req.params.id },
|
|
'/api/contracts/*/history': { type: 'ContractHistoryEntry', extractId: (req) => req.params.entryId },
|
|
'/api/contracts/*/tasks': { type: 'ContractTask', extractId: (req) => req.params.taskId },
|
|
'/api/users': { type: 'User', extractId: (req) => req.params.id },
|
|
'/api/providers': { type: 'Provider', extractId: (req) => req.params.id },
|
|
'/api/tariffs': { type: 'Tariff', extractId: (req) => req.params.id },
|
|
'/api/platforms': { type: 'SalesPlatform', extractId: (req) => req.params.id },
|
|
'/api/contract-categories': { type: 'ContractCategory', extractId: (req) => req.params.id },
|
|
'/api/cancellation-periods': { type: 'CancellationPeriod', extractId: (req) => req.params.id },
|
|
'/api/contract-durations': { type: 'ContractDuration', extractId: (req) => req.params.id },
|
|
'/api/settings': { type: 'AppSetting', extractId: (req) => req.params.key },
|
|
'/api/email-providers': { type: 'EmailProviderConfig', extractId: (req) => req.params.id },
|
|
'/api/meters': { type: 'Meter', extractId: (req) => req.params.id || req.params.meterId },
|
|
'/api/upload': { type: 'Upload' },
|
|
'/api/email-logs': { type: 'EmailLog' },
|
|
'/api/auth': { type: 'Authentication' },
|
|
'/api/audit-logs': { type: 'AuditLog', extractId: (req) => req.params.id },
|
|
'/api/gdpr': { type: 'GDPR' },
|
|
};
|
|
|
|
// Routen die nicht geloggt werden sollen
|
|
const EXCLUDED_ROUTES = [
|
|
'/api/health',
|
|
'/api/uploads',
|
|
];
|
|
|
|
/**
|
|
* Bestimmt die Aktion basierend auf HTTP-Methode und Erfolg
|
|
*/
|
|
function determineAction(method: string, path: string, success: boolean): AuditAction {
|
|
// Spezielle Auth-Aktionen
|
|
if (path.includes('/auth/login')) {
|
|
return success ? 'LOGIN' : 'LOGIN_FAILED';
|
|
}
|
|
if (path.includes('/auth/logout')) {
|
|
return 'LOGOUT';
|
|
}
|
|
|
|
// Standard CRUD-Aktionen
|
|
switch (method.toUpperCase()) {
|
|
case 'GET':
|
|
return 'READ';
|
|
case 'POST':
|
|
return 'CREATE';
|
|
case 'PUT':
|
|
case 'PATCH':
|
|
return 'UPDATE';
|
|
case 'DELETE':
|
|
return 'DELETE';
|
|
default:
|
|
return 'READ';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Findet den passenden Resource-Typ für einen Pfad
|
|
*/
|
|
function findResourceMapping(path: string): { type: string; extractId?: (req: AuthRequest) => string | undefined } | null {
|
|
// Exakte Matches zuerst prüfen
|
|
for (const [pattern, mapping] of Object.entries(RESOURCE_MAPPING)) {
|
|
// Konvertiere Pattern zu Regex
|
|
const regexPattern = pattern
|
|
.replace(/\*/g, '[^/]+')
|
|
.replace(/\//g, '\\/');
|
|
const regex = new RegExp(`^${regexPattern}(?:/|$)`);
|
|
|
|
if (regex.test(path)) {
|
|
return mapping;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Extrahiert die betroffene Kunden-ID für DSGVO-Tracking
|
|
*/
|
|
function extractDataSubjectId(req: AuthRequest): number | undefined {
|
|
// Aus Route-Parameter
|
|
const customerId = req.params.customerId || req.params.id;
|
|
if (customerId && req.path.includes('/customers')) {
|
|
return parseInt(customerId);
|
|
}
|
|
|
|
// Aus Request-Body (bei Create)
|
|
if (req.body?.customerId) {
|
|
return parseInt(req.body.customerId);
|
|
}
|
|
|
|
// Bei Kundenportal-Zugriff
|
|
if (req.user?.customerId) {
|
|
return req.user.customerId;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Extrahiert die IP-Adresse des Clients
|
|
*/
|
|
function getClientIp(req: AuthRequest): string {
|
|
const forwarded = req.headers['x-forwarded-for'];
|
|
if (typeof forwarded === 'string') {
|
|
return forwarded.split(',')[0].trim();
|
|
}
|
|
return req.socket.remoteAddress || 'unknown';
|
|
}
|
|
|
|
// Menschenlesbare Bezeichnungen für Resource-Typen
|
|
const RESOURCE_TYPE_LABELS: Record<string, string> = {
|
|
Customer: 'Kunde',
|
|
Contract: 'Vertrag',
|
|
BankCard: 'Bankverbindung',
|
|
IdentityDocument: 'Ausweis',
|
|
Address: 'Adresse',
|
|
Meter: 'Zähler',
|
|
MeterReading: 'Zählerstand',
|
|
User: 'Benutzer',
|
|
Provider: 'Anbieter',
|
|
Tariff: 'Tarif',
|
|
SalesPlatform: 'Vertriebsplattform',
|
|
ContractCategory: 'Vertragskategorie',
|
|
CancellationPeriod: 'Kündigungsfrist',
|
|
ContractDuration: 'Vertragslaufzeit',
|
|
EmailProviderConfig: 'E-Mail-Provider',
|
|
AppSetting: 'Einstellung',
|
|
CustomerConsent: 'Einwilligung',
|
|
ContractTask: 'Aufgabe',
|
|
ContractHistoryEntry: 'Vertragshistorie',
|
|
GDPR: 'Datenschutz',
|
|
Authentication: 'Anmeldung',
|
|
AuditLog: 'Audit-Protokoll',
|
|
StressfreiEmail: 'Stressfrei-E-Mail',
|
|
CachedEmail: 'E-Mail',
|
|
};
|
|
|
|
const ACTION_LABELS: Record<string, string> = {
|
|
CREATE: 'erstellt',
|
|
READ: 'aufgerufen',
|
|
UPDATE: 'aktualisiert',
|
|
DELETE: 'gelöscht',
|
|
EXPORT: 'exportiert',
|
|
ANONYMIZE: 'anonymisiert',
|
|
LOGIN: 'angemeldet',
|
|
LOGOUT: 'abgemeldet',
|
|
LOGIN_FAILED: 'Anmeldung fehlgeschlagen',
|
|
};
|
|
|
|
/**
|
|
* Erzeugt ein menschenlesbares Label für den Audit-Log-Eintrag
|
|
*/
|
|
function generateHumanLabel(
|
|
action: AuditAction,
|
|
resourceType: string,
|
|
req: AuthRequest,
|
|
responseBody: unknown
|
|
): string {
|
|
const typeName = RESOURCE_TYPE_LABELS[resourceType] || resourceType;
|
|
const actionName = ACTION_LABELS[action] || action;
|
|
|
|
// Identifikator aus Response oder Request extrahieren
|
|
let identifier = '';
|
|
if (responseBody && typeof responseBody === 'object' && 'data' in responseBody) {
|
|
const data = (responseBody as { data: Record<string, unknown> }).data;
|
|
if (data) {
|
|
identifier =
|
|
(data.contractNumber as string) ||
|
|
(data.customerNumber as string) ||
|
|
(data.meterNumber as string) ||
|
|
(data.name as string) ||
|
|
(data.email as string) ||
|
|
(data.firstName && data.lastName ? `${data.firstName} ${data.lastName}` : '') ||
|
|
'';
|
|
}
|
|
}
|
|
|
|
// Spezial-Labels für bestimmte Endpunkte
|
|
const path = req.path;
|
|
|
|
// Auth
|
|
if (path.includes('/auth/login') || path.includes('/auth/customer-login')) {
|
|
const email = req.body?.email || '';
|
|
return action === 'LOGIN'
|
|
? `Benutzer ${email} hat sich angemeldet`
|
|
: `Anmeldung fehlgeschlagen für ${email}`;
|
|
}
|
|
if (path.includes('/auth/logout')) return 'Benutzer hat sich abgemeldet';
|
|
|
|
// Kunden-Operationen
|
|
if (resourceType === 'Customer') {
|
|
if (action === 'CREATE') return `Kunde ${identifier} angelegt`;
|
|
if (action === 'UPDATE') return `Kundendaten ${identifier} aktualisiert`;
|
|
if (action === 'DELETE') return `Kunde ${identifier} gelöscht`;
|
|
if (action === 'READ' && req.params.id) return `Kundendaten ${identifier} aufgerufen`;
|
|
if (action === 'READ') return 'Kundenliste aufgerufen';
|
|
}
|
|
|
|
// Verträge
|
|
if (resourceType === 'Contract') {
|
|
if (path.includes('/cockpit')) return 'Vertrags-Cockpit aufgerufen';
|
|
if (path.includes('/follow-up')) return `Folgevertrag für ${identifier} erstellt`;
|
|
if (path.includes('/snooze')) return `Vertrag ${identifier} zurückgestellt`;
|
|
if (path.includes('/password')) return `Passwort für Vertrag ${identifier} abgerufen`;
|
|
if (path.includes('/sip-credentials')) return 'SIP-Zugangsdaten abgerufen';
|
|
if (path.includes('/internet-credentials')) return `Internet-Zugangsdaten für Vertrag ${identifier} abgerufen`;
|
|
if (path.includes('/successor-meter')) return `Folgezähler zu Vertrag ${identifier} hinzugefügt`;
|
|
if (action === 'CREATE') return `Vertrag ${identifier} angelegt`;
|
|
if (action === 'UPDATE') return `Vertrag ${identifier} aktualisiert`;
|
|
if (action === 'DELETE') return `Vertrag ${identifier} gelöscht`;
|
|
if (action === 'READ' && req.params.id) return `Vertrag ${identifier} aufgerufen`;
|
|
if (action === 'READ') return 'Vertragsliste aufgerufen';
|
|
}
|
|
|
|
// Bankverbindungen
|
|
if (resourceType === 'BankCard') {
|
|
if (action === 'CREATE') return `Bankverbindung hinzugefügt`;
|
|
if (action === 'UPDATE') return `Bankverbindung aktualisiert`;
|
|
if (action === 'DELETE') return `Bankverbindung gelöscht`;
|
|
}
|
|
|
|
// Ausweise
|
|
if (resourceType === 'IdentityDocument') {
|
|
if (action === 'CREATE') return `Ausweis ${identifier} hinzugefügt`;
|
|
if (action === 'UPDATE') return `Ausweis ${identifier} aktualisiert`;
|
|
if (action === 'DELETE') return `Ausweis gelöscht`;
|
|
}
|
|
|
|
// Adressen
|
|
if (resourceType === 'Address') {
|
|
if (action === 'CREATE') return `Adresse hinzugefügt`;
|
|
if (action === 'UPDATE') return `Adresse aktualisiert`;
|
|
if (action === 'DELETE') return `Adresse gelöscht`;
|
|
}
|
|
|
|
// Zähler
|
|
if (resourceType === 'Meter') {
|
|
if (action === 'CREATE') return `Zähler ${identifier} angelegt`;
|
|
if (action === 'UPDATE') return `Zähler ${identifier} aktualisiert`;
|
|
if (action === 'DELETE') return `Zähler gelöscht`;
|
|
}
|
|
|
|
// Einwilligungen
|
|
if (resourceType === 'CustomerConsent') {
|
|
const consentType = req.params.consentType || '';
|
|
const consentLabels: Record<string, string> = {
|
|
DATA_PROCESSING: 'Datenverarbeitung',
|
|
MARKETING_EMAIL: 'E-Mail-Marketing',
|
|
MARKETING_PHONE: 'Telefonmarketing',
|
|
DATA_SHARING_PARTNER: 'Datenweitergabe',
|
|
};
|
|
const consentName = consentLabels[consentType] || consentType;
|
|
if (action === 'UPDATE') {
|
|
const status = req.body?.status;
|
|
return status === 'GRANTED'
|
|
? `Einwilligung "${consentName}" erteilt`
|
|
: `Einwilligung "${consentName}" widerrufen`;
|
|
}
|
|
if (action === 'READ') return 'Einwilligungen abgerufen';
|
|
}
|
|
|
|
// Benutzer
|
|
if (resourceType === 'User') {
|
|
if (action === 'CREATE') return `Benutzer ${identifier} angelegt`;
|
|
if (action === 'UPDATE') return `Benutzer ${identifier} aktualisiert`;
|
|
if (action === 'DELETE') return `Benutzer ${identifier} gelöscht`;
|
|
if (action === 'READ' && req.params.id) return `Benutzerdaten ${identifier} aufgerufen`;
|
|
if (action === 'READ') return 'Benutzerliste aufgerufen';
|
|
}
|
|
|
|
// Aufgaben
|
|
if (resourceType === 'ContractTask') {
|
|
if (path.includes('/complete')) return `Aufgabe als erledigt markiert`;
|
|
if (action === 'CREATE') return `Aufgabe erstellt`;
|
|
if (action === 'UPDATE') return `Aufgabe aktualisiert`;
|
|
if (action === 'DELETE') return `Aufgabe gelöscht`;
|
|
}
|
|
|
|
// E-Mail-Provider
|
|
if (resourceType === 'EmailProviderConfig') {
|
|
if (path.includes('/test-connection')) return `E-Mail-Provider Verbindungstest`;
|
|
if (path.includes('/provision')) return `E-Mail-Adresse provisioniert`;
|
|
if (action === 'CREATE') return `E-Mail-Provider ${identifier} angelegt`;
|
|
if (action === 'UPDATE') return `E-Mail-Provider ${identifier} aktualisiert`;
|
|
if (action === 'DELETE') return `E-Mail-Provider ${identifier} gelöscht`;
|
|
}
|
|
|
|
// GDPR
|
|
if (resourceType === 'GDPR') {
|
|
if (path.includes('/dashboard')) return 'DSGVO-Dashboard aufgerufen';
|
|
if (path.includes('/export')) return 'Kundendaten exportiert (DSGVO Art. 15)';
|
|
if (path.includes('/privacy-policy')) {
|
|
return action === 'UPDATE' ? 'Datenschutzerklärung aktualisiert' : 'Datenschutzerklärung aufgerufen';
|
|
}
|
|
if (path.includes('/authorization-template')) {
|
|
return action === 'UPDATE' ? 'Vollmacht-Vorlage aktualisiert' : 'Vollmacht-Vorlage aufgerufen';
|
|
}
|
|
if (path.includes('/send-consent-link')) return 'Datenschutz-Link versendet';
|
|
if (path.includes('/authorizations') && path.includes('/send')) return 'Vollmacht-Anfrage versendet';
|
|
if (path.includes('/authorizations') && path.includes('/grant')) return 'Vollmacht erteilt';
|
|
if (path.includes('/authorizations') && path.includes('/withdraw')) return 'Vollmacht widerrufen';
|
|
if (path.includes('/authorizations') && path.includes('/upload')) return 'Vollmacht-PDF hochgeladen';
|
|
if (path.includes('/authorizations') && path.includes('/document') && action === 'DELETE') return 'Vollmacht-PDF gelöscht';
|
|
if (path.includes('/my-privacy')) return 'Eigene Datenschutzseite aufgerufen';
|
|
if (path.includes('/my-consent-status')) return 'Eigener Einwilligungsstatus geprüft';
|
|
if (path.includes('/my-authorizations')) return 'Eigene Vollmachten aufgerufen';
|
|
if (path.includes('/deletions')) {
|
|
if (action === 'CREATE') return 'Löschanfrage erstellt';
|
|
if (path.includes('/process')) return 'Löschanfrage bearbeitet';
|
|
return 'Löschanfragen aufgerufen';
|
|
}
|
|
if (path.includes('/consent-status')) return 'Einwilligungsstatus geprüft';
|
|
if (path.includes('/consents/overview')) return 'Einwilligungsübersicht aufgerufen';
|
|
}
|
|
|
|
// Einstellungen
|
|
if (resourceType === 'AppSetting') {
|
|
if (action === 'UPDATE') return `Einstellung "${req.params.key || ''}" geändert`;
|
|
if (action === 'READ') return 'Einstellungen aufgerufen';
|
|
}
|
|
|
|
// Zähler-Readings
|
|
if (path.includes('/readings')) {
|
|
if (path.includes('/report')) return 'Zählerstand vom Kunden gemeldet';
|
|
if (path.includes('/transfer')) return 'Zählerstand als übertragen markiert';
|
|
if (action === 'CREATE') return 'Zählerstand erfasst';
|
|
if (action === 'UPDATE') return 'Zählerstand aktualisiert';
|
|
if (action === 'DELETE') return 'Zählerstand gelöscht';
|
|
}
|
|
|
|
// Upload-Operationen
|
|
if (path.includes('/upload') || path.includes('/privacy-policy')) {
|
|
if (path.includes('/privacy-policy') && action === 'DELETE') return 'Datenschutzerklärung-PDF gelöscht';
|
|
if (path.includes('/privacy-policy')) return 'Datenschutzerklärung-PDF hochgeladen';
|
|
}
|
|
|
|
// Standard-Fallback
|
|
if (identifier) {
|
|
return `${typeName} ${identifier} ${actionName}`;
|
|
}
|
|
return `${typeName} ${actionName}`;
|
|
}
|
|
|
|
/**
|
|
* Audit Middleware - loggt alle API-Aufrufe asynchron
|
|
*/
|
|
export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunction): void {
|
|
const startTime = Date.now();
|
|
|
|
// Ausgeschlossene Routen überspringen
|
|
if (EXCLUDED_ROUTES.some((route) => req.path.startsWith(route))) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
// Resource-Mapping finden
|
|
const mapping = findResourceMapping(req.path);
|
|
if (!mapping) {
|
|
// Unbekannte Route - trotzdem loggen mit generischem Typ
|
|
next();
|
|
return;
|
|
}
|
|
|
|
// Original res.json überschreiben um Response zu capturen
|
|
const originalJson = res.json.bind(res);
|
|
let responseBody: unknown = null;
|
|
let responseSuccess = true;
|
|
|
|
res.json = function (body: unknown) {
|
|
responseBody = body;
|
|
if (typeof body === 'object' && body !== null && 'success' in body) {
|
|
responseSuccess = (body as { success: boolean }).success;
|
|
}
|
|
return originalJson(body);
|
|
};
|
|
|
|
// Response-Ende abfangen für Logging
|
|
// Audit-Kontext hier erfassen (bevor AsyncLocalStorage den Kontext verliert)
|
|
let capturedAuditContext: ReturnType<typeof getAuditContext> | undefined;
|
|
|
|
const origEnd = res.end;
|
|
(res as any).end = function(chunk?: any, encoding?: any, cb?: any) {
|
|
// Kontext VOR dem Ende erfassen
|
|
capturedAuditContext = getAuditContext();
|
|
return origEnd.call(this, chunk, encoding, cb);
|
|
};
|
|
|
|
res.on('finish', () => {
|
|
// Async Logging - blockiert nicht die Response
|
|
setImmediate(async () => {
|
|
try {
|
|
const durationMs = Date.now() - startTime;
|
|
const action = determineAction(req.method, req.path, responseSuccess);
|
|
|
|
// READ-Aktionen nicht loggen (nur Änderungen, Logins und Exporte)
|
|
if (action === 'READ') return;
|
|
|
|
// Routen die bereits gezielt via logChange() geloggt werden → nicht doppelt loggen
|
|
const manuallyLoggedPaths = [
|
|
'/api/customers',
|
|
'/api/contracts',
|
|
'/api/meters',
|
|
'/api/gdpr',
|
|
'/api/upload',
|
|
];
|
|
// Login/Logout immer loggen
|
|
if (action !== 'LOGIN' && action !== 'LOGOUT' && action !== 'LOGIN_FAILED') {
|
|
if (manuallyLoggedPaths.some(p => req.originalUrl?.startsWith(p) || req.baseUrl?.startsWith(p))) return;
|
|
}
|
|
|
|
const resourceId = mapping.extractId?.(req);
|
|
const dataSubjectId = extractDataSubjectId(req);
|
|
|
|
// Audit-Kontext nutzen (wurde vor Response-Ende erfasst)
|
|
const auditContext = capturedAuditContext;
|
|
|
|
// Menschenlesbares Label generieren
|
|
const resourceLabel = generateHumanLabel(action, mapping.type, req, responseBody);
|
|
|
|
await createAuditLog({
|
|
userId: req.user?.userId,
|
|
userEmail: req.user?.email || 'anonymous',
|
|
userRole: req.user?.isCustomerPortal ? 'Kundenportal' : (req.user as any)?.roleName || 'Mitarbeiter',
|
|
customerId: req.user?.customerId,
|
|
isCustomerPortal: req.user?.isCustomerPortal,
|
|
action,
|
|
resourceType: mapping.type,
|
|
resourceId,
|
|
resourceLabel,
|
|
endpoint: req.path,
|
|
httpMethod: req.method,
|
|
ipAddress: getClientIp(req),
|
|
userAgent: req.headers['user-agent'],
|
|
changesBefore: auditContext?.before,
|
|
changesAfter: auditContext?.after,
|
|
dataSubjectId,
|
|
success: responseSuccess,
|
|
errorMessage: !responseSuccess && responseBody && typeof responseBody === 'object' && 'error' in responseBody
|
|
? (responseBody as { error: string }).error
|
|
: undefined,
|
|
durationMs,
|
|
});
|
|
} catch (error) {
|
|
// Audit-Logging darf niemals die Anwendung beeinträchtigen
|
|
console.error('[AuditMiddleware] Fehler beim Logging:', error);
|
|
}
|
|
});
|
|
});
|
|
|
|
next();
|
|
}
|