579 lines
15 KiB
TypeScript
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,
|
|
});
|
|
}
|