214 lines
7.5 KiB
TypeScript
214 lines
7.5 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/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';
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
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);
|
|
const resourceId = mapping.extractId?.(req);
|
|
const dataSubjectId = extractDataSubjectId(req);
|
|
|
|
// Audit-Kontext abrufen (enthält Before/After-Werte von Prisma Middleware)
|
|
const auditContext = getAuditContext();
|
|
|
|
// Label für bessere Lesbarkeit generieren
|
|
let resourceLabel: string | undefined;
|
|
if (responseBody && typeof responseBody === 'object' && 'data' in responseBody) {
|
|
const data = (responseBody as { data: Record<string, unknown> }).data;
|
|
if (data) {
|
|
// Versuche verschiedene Label-Felder
|
|
resourceLabel =
|
|
(data.contractNumber as string) ||
|
|
(data.customerNumber as string) ||
|
|
(data.name as string) ||
|
|
(data.email as string) ||
|
|
(data.firstName && data.lastName
|
|
? `${data.firstName} ${data.lastName}`
|
|
: undefined);
|
|
}
|
|
}
|
|
|
|
await createAuditLog({
|
|
userId: req.user?.userId,
|
|
userEmail: req.user?.email || 'anonymous',
|
|
userRole: req.user?.permissions?.join(', '),
|
|
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();
|
|
}
|