Files
opencrm/backend/src/services/audit.service.ts
T
2026-03-21 18:23:54 +01:00

579 lines
15 KiB
TypeScript

import { AuditAction, AuditSensitivity, Prisma } from '@prisma/client';
import crypto from 'crypto';
import { encrypt, decrypt } from '../utils/encryption.js';
import prisma from '../lib/prisma.js';
/**
* Vereinfachte Audit-Log-Funktion für gezielte Änderungsprotokolle.
* Wird direkt in Controllern aufgerufen mit aussagekräftigen Details.
*/
export async function logChange(opts: {
req: any; // Express Request (für userId, email, IP)
action: AuditAction;
resourceType: string;
resourceId?: string;
label: string; // Menschenlesbares Label z.B. "Vollmacht für Stefan Hacker widerrufen"
details?: Record<string, unknown>; // Zusätzliche Details z.B. { vorher: 'erteilt', nachher: 'widerrufen' }
customerId?: number;
}) {
try {
const user = opts.req?.user;
await createAuditLog({
userId: user?.userId,
userEmail: user?.email || 'system',
userRole: user?.isCustomerPortal ? 'Kundenportal' : 'Mitarbeiter',
customerId: user?.customerId,
isCustomerPortal: user?.isCustomerPortal,
action: opts.action,
resourceType: opts.resourceType,
resourceId: opts.resourceId,
resourceLabel: opts.label,
endpoint: opts.req?.path || '',
httpMethod: opts.req?.method || '',
ipAddress: opts.req?.socket?.remoteAddress || opts.req?.headers?.['x-forwarded-for'] || 'unknown',
dataSubjectId: opts.customerId,
changesAfter: opts.details,
});
} catch (error) {
console.error('[logChange] Fehler:', error);
}
}
export interface CreateAuditLogData {
userId?: number;
userEmail: string;
userRole?: string;
customerId?: number;
isCustomerPortal?: boolean;
action: AuditAction;
sensitivity?: AuditSensitivity;
resourceType: string;
resourceId?: string;
resourceLabel?: string;
endpoint: string;
httpMethod: string;
ipAddress: string;
userAgent?: string;
changesBefore?: Record<string, unknown>;
changesAfter?: Record<string, unknown>;
dataSubjectId?: number;
legalBasis?: string;
success?: boolean;
errorMessage?: string;
durationMs?: number;
}
export interface AuditLogSearchParams {
userId?: number;
customerId?: number;
dataSubjectId?: number;
action?: AuditAction;
sensitivity?: AuditSensitivity;
resourceType?: string;
resourceId?: string;
startDate?: Date;
endDate?: Date;
success?: boolean;
search?: string;
page?: number;
limit?: number;
}
/**
* Generiert einen SHA-256 Hash für einen Audit-Log-Eintrag
*/
function generateHash(data: {
userEmail: string;
action: AuditAction;
resourceType: string;
resourceId?: string | null;
endpoint: string;
createdAt: Date;
previousHash?: string | null;
}): string {
const content = JSON.stringify({
userEmail: data.userEmail,
action: data.action,
resourceType: data.resourceType,
resourceId: data.resourceId,
endpoint: data.endpoint,
createdAt: data.createdAt.toISOString(),
previousHash: data.previousHash || '',
});
return crypto.createHash('sha256').update(content).digest('hex');
}
/**
* Bestimmt die Sensitivität basierend auf dem Ressourcentyp
*/
function determineSensitivity(resourceType: string): AuditSensitivity {
const sensitivityMap: Record<string, AuditSensitivity> = {
// CRITICAL
Authentication: 'CRITICAL',
BankCard: 'CRITICAL',
IdentityDocument: 'CRITICAL',
// HIGH
Customer: 'HIGH',
User: 'HIGH',
CustomerConsent: 'HIGH',
DataDeletionRequest: 'HIGH',
// MEDIUM
Contract: 'MEDIUM',
Address: 'MEDIUM',
Meter: 'MEDIUM',
MeterReading: 'MEDIUM',
StressfreiEmail: 'MEDIUM',
CachedEmail: 'MEDIUM',
// LOW
Provider: 'LOW',
Tariff: 'LOW',
SalesPlatform: 'LOW',
AppSetting: 'LOW',
ContractCategory: 'LOW',
CancellationPeriod: 'LOW',
ContractDuration: 'LOW',
};
return sensitivityMap[resourceType] || 'MEDIUM';
}
/**
* Prüft ob Änderungen verschlüsselt werden sollen.
* Deaktiviert - sensible Felder werden bereits von der Prisma-Middleware als [REDACTED] gefiltert.
*/
function shouldEncryptChanges(_resourceType: string): boolean {
return false;
}
/**
* Erstellt einen neuen Audit-Log-Eintrag mit Hash-Kette
*/
export async function createAuditLog(data: CreateAuditLogData): Promise<void> {
try {
// Letzten Hash abrufen für die Kette
const lastLog = await prisma.auditLog.findFirst({
orderBy: { id: 'desc' },
select: { hash: true },
});
const previousHash = lastLog?.hash || null;
const createdAt = new Date();
// Sensitivität bestimmen falls nicht angegeben
const sensitivity = data.sensitivity || determineSensitivity(data.resourceType);
// Änderungen serialisieren und ggf. verschlüsseln
let changesBefore: string | null = null;
let changesAfter: string | null = null;
let changesEncrypted = false;
if (data.changesBefore || data.changesAfter) {
changesEncrypted = shouldEncryptChanges(data.resourceType);
if (data.changesBefore) {
const json = JSON.stringify(data.changesBefore);
changesBefore = changesEncrypted ? encrypt(json) : json;
}
if (data.changesAfter) {
const json = JSON.stringify(data.changesAfter);
changesAfter = changesEncrypted ? encrypt(json) : json;
}
}
// Hash generieren
const hash = generateHash({
userEmail: data.userEmail,
action: data.action,
resourceType: data.resourceType,
resourceId: data.resourceId,
endpoint: data.endpoint,
createdAt,
previousHash,
});
// Eintrag erstellen
await prisma.auditLog.create({
data: {
userId: data.userId,
userEmail: data.userEmail,
userRole: data.userRole,
customerId: data.customerId,
isCustomerPortal: data.isCustomerPortal || false,
action: data.action,
sensitivity,
resourceType: data.resourceType,
resourceId: data.resourceId,
resourceLabel: data.resourceLabel,
endpoint: data.endpoint,
httpMethod: data.httpMethod,
ipAddress: data.ipAddress,
userAgent: data.userAgent,
changesBefore,
changesAfter,
changesEncrypted,
dataSubjectId: data.dataSubjectId,
legalBasis: data.legalBasis,
success: data.success ?? true,
errorMessage: data.errorMessage,
durationMs: data.durationMs,
createdAt,
hash,
previousHash,
},
});
} catch (error) {
// Audit-Logging darf niemals die Hauptoperation blockieren
console.error('[AuditService] Fehler beim Erstellen des Audit-Logs:', error);
}
}
/**
* Sucht Audit-Logs mit Filtern und Paginierung
*/
export async function searchAuditLogs(params: AuditLogSearchParams) {
const {
userId,
customerId,
dataSubjectId,
action,
sensitivity,
resourceType,
resourceId,
startDate,
endDate,
success,
search,
page = 1,
limit = 50,
} = params;
const where: Prisma.AuditLogWhereInput = {};
if (userId !== undefined) where.userId = userId;
if (customerId !== undefined) where.customerId = customerId;
if (dataSubjectId !== undefined) where.dataSubjectId = dataSubjectId;
if (action) where.action = action;
if (sensitivity) where.sensitivity = sensitivity;
if (resourceType) where.resourceType = resourceType;
if (resourceId) where.resourceId = resourceId;
if (success !== undefined) where.success = success;
if (startDate || endDate) {
where.createdAt = {};
if (startDate) where.createdAt.gte = startDate;
if (endDate) where.createdAt.lte = endDate;
}
if (search) {
where.OR = [
{ userEmail: { contains: search } },
{ resourceLabel: { contains: search } },
{ endpoint: { contains: search } },
];
}
const [logs, total] = await Promise.all([
prisma.auditLog.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.auditLog.count({ where }),
]);
// Entschlüsselung der Änderungen wenn nötig
const decryptedLogs = logs.map((log) => ({
...log,
changesBefore: log.changesBefore && log.changesEncrypted
? JSON.parse(decrypt(log.changesBefore))
: log.changesBefore ? JSON.parse(log.changesBefore) : null,
changesAfter: log.changesAfter && log.changesEncrypted
? JSON.parse(decrypt(log.changesAfter))
: log.changesAfter ? JSON.parse(log.changesAfter) : null,
}));
return {
data: decryptedLogs,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Holt einen einzelnen Audit-Log-Eintrag
*/
export async function getAuditLogById(id: number) {
const log = await prisma.auditLog.findUnique({
where: { id },
});
if (!log) return null;
return {
...log,
changesBefore: log.changesBefore && log.changesEncrypted
? JSON.parse(decrypt(log.changesBefore))
: log.changesBefore ? JSON.parse(log.changesBefore) : null,
changesAfter: log.changesAfter && log.changesEncrypted
? JSON.parse(decrypt(log.changesAfter))
: log.changesAfter ? JSON.parse(log.changesAfter) : null,
};
}
/**
* Holt alle Audit-Logs für eine betroffene Person (DSGVO)
*/
export async function getAuditLogsByDataSubject(customerId: number) {
const logs = await prisma.auditLog.findMany({
where: { dataSubjectId: customerId },
orderBy: { createdAt: 'desc' },
});
return logs.map((log) => ({
...log,
changesBefore: log.changesBefore && log.changesEncrypted
? JSON.parse(decrypt(log.changesBefore))
: log.changesBefore ? JSON.parse(log.changesBefore) : null,
changesAfter: log.changesAfter && log.changesEncrypted
? JSON.parse(decrypt(log.changesAfter))
: log.changesAfter ? JSON.parse(log.changesAfter) : null,
}));
}
/**
* Verifiziert die Integrität der Hash-Kette
*/
export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
valid: boolean;
checkedCount: number;
invalidEntries: number[];
}> {
const where: Prisma.AuditLogWhereInput = {};
if (fromId !== undefined) where.id = { gte: fromId };
if (toId !== undefined) where.id = { ...(where.id as object || {}), lte: toId };
const logs = await prisma.auditLog.findMany({
where,
orderBy: { id: 'asc' },
select: {
id: true,
userEmail: true,
action: true,
resourceType: true,
resourceId: true,
endpoint: true,
createdAt: true,
hash: true,
previousHash: true,
},
});
const invalidEntries: number[] = [];
for (let i = 0; i < logs.length; i++) {
const log = logs[i];
// Hash neu berechnen
const expectedHash = generateHash({
userEmail: log.userEmail,
action: log.action,
resourceType: log.resourceType,
resourceId: log.resourceId,
endpoint: log.endpoint,
createdAt: log.createdAt,
previousHash: log.previousHash,
});
// Prüfen ob Hash übereinstimmt
if (log.hash !== expectedHash) {
invalidEntries.push(log.id);
continue;
}
// Prüfen ob previousHash mit dem Hash des vorherigen Eintrags übereinstimmt
if (i > 0) {
const previousLog = logs[i - 1];
if (log.previousHash !== previousLog.hash) {
invalidEntries.push(log.id);
}
}
}
return {
valid: invalidEntries.length === 0,
checkedCount: logs.length,
invalidEntries,
};
}
/**
* Hash-Kette komplett neu berechnen (Reparatur)
*/
export async function rehashAll(): Promise<{ rehashedCount: number }> {
const logs = await prisma.auditLog.findMany({
orderBy: { id: 'asc' },
select: {
id: true,
userEmail: true,
action: true,
resourceType: true,
resourceId: true,
endpoint: true,
createdAt: true,
},
});
let previousHash: string | null = null;
let count = 0;
for (const log of logs) {
const hash = generateHash({
userEmail: log.userEmail,
action: log.action,
resourceType: log.resourceType,
resourceId: log.resourceId,
endpoint: log.endpoint,
createdAt: log.createdAt,
previousHash,
});
await prisma.auditLog.update({
where: { id: log.id },
data: { hash, previousHash },
});
previousHash = hash;
count++;
}
return { rehashedCount: count };
}
/**
* Exportiert Audit-Logs als JSON oder CSV
*/
export async function exportAuditLogs(
params: AuditLogSearchParams,
format: 'json' | 'csv' = 'json'
): Promise<string> {
// Alle Logs ohne Paginierung
const result = await searchAuditLogs({ ...params, limit: 100000, page: 1 });
const logs = result.data;
if (format === 'json') {
return JSON.stringify(logs, null, 2);
}
// CSV Export
const headers = [
'ID',
'Zeitstempel',
'Benutzer',
'Aktion',
'Ressource',
'Ressource-ID',
'Bezeichnung',
'Endpoint',
'IP-Adresse',
'Erfolg',
'Sensitivität',
];
const rows = logs.map((log) => [
log.id.toString(),
log.createdAt.toISOString(),
log.userEmail,
log.action,
log.resourceType,
log.resourceId || '',
log.resourceLabel || '',
log.endpoint,
log.ipAddress,
log.success ? 'Ja' : 'Nein',
log.sensitivity,
]);
const csvContent = [
headers.join(';'),
...rows.map((row) => row.map((cell) => `"${cell.replace(/"/g, '""')}"`).join(';')),
].join('\n');
return csvContent;
}
/**
* Löscht alte Audit-Logs basierend auf Retention-Policies
* Hinweis: Diese Funktion sollte nur von einem autorisierten Admin-Prozess aufgerufen werden
*/
export async function runRetentionCleanup(): Promise<{
deletedCount: number;
policies: Array<{ resourceType: string; sensitivity: string | null; deletedCount: number }>;
}> {
const policies = await prisma.auditRetentionPolicy.findMany({
where: { isActive: true },
});
const results: Array<{ resourceType: string; sensitivity: string | null; deletedCount: number }> = [];
let totalDeleted = 0;
for (const policy of policies) {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - policy.retentionDays);
const where: Prisma.AuditLogWhereInput = {
createdAt: { lt: cutoffDate },
};
if (policy.resourceType !== '*') {
where.resourceType = policy.resourceType;
}
if (policy.sensitivity) {
where.sensitivity = policy.sensitivity;
}
const deleted = await prisma.auditLog.deleteMany({ where });
results.push({
resourceType: policy.resourceType,
sensitivity: policy.sensitivity,
deletedCount: deleted.count,
});
totalDeleted += deleted.count;
}
return {
deletedCount: totalDeleted,
policies: results,
};
}
/**
* Holt die Retention-Policies
*/
export async function getRetentionPolicies() {
return prisma.auditRetentionPolicy.findMany({
orderBy: [{ resourceType: 'asc' }, { sensitivity: 'asc' }],
});
}
/**
* Aktualisiert eine Retention-Policy
*/
export async function updateRetentionPolicy(
id: number,
data: { retentionDays?: number; description?: string; legalBasis?: string; isActive?: boolean }
) {
return prisma.auditRetentionPolicy.update({
where: { id },
data,
});
}