R175-01 (HIGH): Die erste Fassung signierte zwar, prueft aber nie. Sie las ihre Wahrheit per readFileSync aus der lokalen Arbeitsdatei, nirgends gab es ein git verify-commit - das -S war write-only ohne Konsument. Live reproduziert: DB-Tail abgeschnitten und die lokale Ledger-Zeile angepasst -> "OK, Checkpoint beglaubigt", exit 0, kein Alarm. Fix: Wahrheitsquelle ist der signierte Commit-Baum (bevorzugt der Remote-Kopf); jeder Commit mit Gegenbuch-Aenderung muss eine gueltige Signatur tragen; weicht die Arbeitsdatei vom signierten Stand ab, wird abgebrochen; lokale, nie gepushte Commits gelten nicht als beglaubigt; die Signatur des frisch erzeugten Commits wird gegengeprueft. Dazu ein Pruefmodus --check fuer Auditoren. R175-02 (MEDIUM): /checkpoint fuhr je Aufruf ein volles verifyIntegrity (O(n), 0,85 s bei 16k Zeilen) - authentifizierte DoS-Verstaerkung. Gebraucht wurde nur die Siegel-Wurzel. Jetzt Kopf-Hash aus der Kopfzeile, Wurzel aus dem juengsten gueltigen Marker, sealLeafCount statt des teuren Status. R175-03: writeFileSync lief vor dem Commit, eine verwaiste Zeile wurde vom Folgelauf zementiert. Jetzt Ruecknahme bei Fehlschlag, und NOTARY_SIGN=false verlangt zusaetzlich NOTARY_INSECURE_ACK. Verifiziert mit echtem SSH-Signaturschluessel: stilles Waschen -> Alarm; erfundene Zeile -> Alarm; unsignierter Commit -> Alarm; Commit-Fehlschlag -> zurueckgerollt; Reflex-Schalter verweigert; Pruefmodus haengt nichts an. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1541 lines
55 KiB
TypeScript
1541 lines
55 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 {
|
||
// WICHTIG – Pentest R121 (Verify-Fehlalarm, ~73% "manipuliert"):
|
||
// Beim Schreiben war `resourceId` bei middleware-generierten Einträgen
|
||
// oft `undefined`. JSON.stringify LÄSST einen undefined-Wert weg – der
|
||
// Hash wurde also OHNE `resourceId`-Key gebildet. In der DB landet der
|
||
// Wert aber als `NULL`. verifyIntegrity/rehashAll lasen ihn als `null`
|
||
// zurück, und `JSON.stringify({resourceId: null})` schreibt
|
||
// `"resourceId":null` REIN → anderer Hash → falscher Tamper-Alarm für
|
||
// jede Zeile mit leerem resourceId.
|
||
//
|
||
// Fix: nullish resourceId weglassen – reproduziert exakt das historische
|
||
// Schreibverhalten (undefined → Key weg). Kein Caller hat je `null`
|
||
// explizit übergeben (verifiziert), daher matchen ALLE Bestands-Hashes
|
||
// ohne Rehash. Feldreihenfolge bleibt identisch zur Alt-Serialisierung.
|
||
const payload: Record<string, unknown> = {
|
||
userEmail: data.userEmail,
|
||
action: data.action,
|
||
resourceType: data.resourceType,
|
||
};
|
||
if (data.resourceId !== null && data.resourceId !== undefined) {
|
||
payload.resourceId = data.resourceId;
|
||
}
|
||
payload.endpoint = data.endpoint;
|
||
payload.createdAt = data.createdAt.toISOString();
|
||
payload.previousHash = data.previousHash || '';
|
||
|
||
return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
||
}
|
||
|
||
/**
|
||
* Historische Hash-Variante (Bestandsdaten ~08.02.–01.05.2026).
|
||
*
|
||
* Der R121-Fix ging davon aus, dass `resourceId` beim Schreiben immer
|
||
* `undefined` war (→ Key faellt bei JSON.stringify weg) und daher ALLE
|
||
* Bestands-Hashes ohne Rehash matchen. Das stimmt erst ab ~01.05.2026:
|
||
* aeltere Zeilen wurden mit explizitem `null` serialisiert, der Key war also
|
||
* DRIN. Ergebnis war ein Dauer-Fehlalarm ueber ~3100 Zeilen (67 % des Logs) –
|
||
* und ein Alarm, der staendig grundlos ausloest, verdeckt echte Manipulation.
|
||
*
|
||
* Diese Funktion reproduziert exakt das alte Schreibverhalten, damit
|
||
* verifyIntegrity Altbestand als gueltig erkennt – OHNE Rehash. Ein Rehash
|
||
* waere der naheliegende Schnellfix, wuerde die Manipulations-Beweiskraft der
|
||
* Vergangenheit aber unwiederbringlich zerstoeren.
|
||
*
|
||
* Sicherheit: Beide Varianten hashen dieselben Feldwerte, nur die
|
||
* Serialisierung des nullish `resourceId` unterscheidet sich. Ein Angreifer
|
||
* gewinnt dadurch keinen Spielraum, Inhalte zu aendern – nur die Kodierung
|
||
* eines leeren Feldes ist doppelt zulaessig.
|
||
*/
|
||
function generateHashLegacy(data: {
|
||
userEmail: string;
|
||
action: AuditAction;
|
||
resourceType: string;
|
||
resourceId?: string | null;
|
||
endpoint: string;
|
||
createdAt: Date;
|
||
previousHash?: string | null;
|
||
}): string {
|
||
const payload: Record<string, unknown> = {
|
||
userEmail: data.userEmail,
|
||
action: data.action,
|
||
resourceType: data.resourceType,
|
||
resourceId: data.resourceId ?? null,
|
||
endpoint: data.endpoint,
|
||
createdAt: data.createdAt.toISOString(),
|
||
previousHash: data.previousHash || '',
|
||
};
|
||
|
||
return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
||
}
|
||
|
||
/**
|
||
* Hash-Version 2 (Pentest R166-02): deckt ALLE Inhaltsspalten ab.
|
||
*
|
||
* Version 1 hashte nur 7 Felder (userEmail, action, resourceType, resourceId,
|
||
* endpoint, createdAt, previousHash). Nicht abgedeckt waren u. a.
|
||
* `changesBefore`/`changesAfter` – also die eigentliche Nutzlast –, dazu
|
||
* `success`, `ipAddress`, `resourceLabel`, `dataSubjectId`, `userId`,
|
||
* `customerId`. Ein nachtraeglicher Einzeledit an genau diesen Feldern
|
||
* (z. B. `success` false→true oder das Umschreiben von `changesAfter`) war
|
||
* damit unsichtbar – exakt der Angriff, gegen den die Kette schuetzen soll.
|
||
*
|
||
* Bestandszeilen behalten `hashVersion = 1` und werden weiter mit dem alten
|
||
* Verfahren geprueft; ein Rehash waere nicht noetig und wuerde die
|
||
* Beweiskraft der Vergangenheit zerstoeren.
|
||
*
|
||
* `undefined` wird bewusst zu `null` normalisiert, damit die Serialisierung
|
||
* deterministisch ist (die Uneindeutigkeit an dieser Stelle war die Ursache
|
||
* des Dauer-Fehlalarms bei Version 1).
|
||
*/
|
||
export interface AuditHashV2Input {
|
||
userId?: number | null;
|
||
userEmail: string;
|
||
userRole?: string | null;
|
||
customerId?: number | null;
|
||
isCustomerPortal?: boolean | null;
|
||
action: AuditAction;
|
||
sensitivity?: AuditSensitivity | null;
|
||
resourceType: string;
|
||
resourceId?: string | null;
|
||
resourceLabel?: string | null;
|
||
endpoint: string;
|
||
httpMethod?: string | null;
|
||
ipAddress?: string | null;
|
||
userAgent?: string | null;
|
||
changesBefore?: string | null;
|
||
changesAfter?: string | null;
|
||
changesEncrypted?: boolean | null;
|
||
dataSubjectId?: number | null;
|
||
legalBasis?: string | null;
|
||
success?: boolean | null;
|
||
errorMessage?: string | null;
|
||
durationMs?: number | null;
|
||
createdAt: Date;
|
||
previousHash?: string | null;
|
||
}
|
||
|
||
/**
|
||
* Externer Anker (Pentest R166/R167, Design-Empfehlung).
|
||
*
|
||
* Bis Version 2 war die Kette selbsttragend: Wer die DB schreiben kann, kann
|
||
* jede Zeile aendern und saemtliche Folgehashes konsistent nachziehen – die
|
||
* Pruefung meldet dann sauber "gueltig". Version 3 signiert denselben Inhalt
|
||
* stattdessen per HMAC-SHA256 mit einem Schluessel, der NICHT in der Datenbank
|
||
* liegt (`AUDIT_HMAC_KEY`). Ohne diesen Schluessel laesst sich keine gueltige
|
||
* Signatur erzeugen – reiner DB-Schreibzugriff genuegt damit nicht mehr.
|
||
*
|
||
* Fail-safe: Ohne konfigurierten Schluessel wird weiterhin Version 2
|
||
* geschrieben. Audit-Logging darf nie an fehlender Konfiguration scheitern.
|
||
* `AUDIT_HMAC_KEY_OLD` erlaubt einen Schluesselwechsel ohne Rehash – bei der
|
||
* Pruefung wird zusaetzlich gegen den alten Schluessel getestet.
|
||
*/
|
||
/**
|
||
* Ein nicht ersetzter Platzhalter aus `.env.example` (z. B.
|
||
* `<hier-eigenen-wert-eintragen>`) darf NICHT als Schluessel gelten. Sonst
|
||
* wuerde mit einem oeffentlich im Repository stehenden Wert gesiegelt – das
|
||
* waere schlechter als gar kein Siegel, weil es Sicherheit vortaeuscht.
|
||
*/
|
||
function istPlatzhalter(k: string): boolean {
|
||
return k.includes('<') || k.includes('>') || /^(change|dein|your|hier)/i.test(k);
|
||
}
|
||
|
||
let platzhalterGemeldet = false;
|
||
|
||
function auditHmacKey(): string | null {
|
||
const k = process.env.AUDIT_HMAC_KEY?.trim();
|
||
if (!k) return null;
|
||
if (istPlatzhalter(k)) {
|
||
if (!platzhalterGemeldet) {
|
||
platzhalterGemeldet = true;
|
||
console.warn(
|
||
'[Audit] AUDIT_HMAC_KEY enthält noch den Platzhalter aus .env.example – ' +
|
||
'das Audit-Siegel bleibt deaktiviert. Eigenen Wert erzeugen: openssl rand -hex 32',
|
||
);
|
||
}
|
||
return null;
|
||
}
|
||
return k;
|
||
}
|
||
|
||
/**
|
||
* Frueher verwendete Schluessel. Kommagetrennt, damit auch ein ZWEITER Wechsel
|
||
* moeglich ist, ohne die zuerst signierten Eintraege zu verlieren - mit nur
|
||
* einem Platz wuerden die aeltesten sonst als "manipuliert" gemeldet.
|
||
*/
|
||
function auditHmacKeysOld(): string[] {
|
||
return (process.env.AUDIT_HMAC_KEY_OLD || '')
|
||
.split(',')
|
||
.map((k) => k.trim())
|
||
.filter((k) => k.length > 0 && !istPlatzhalter(k));
|
||
}
|
||
|
||
function generateHashV3(data: AuditHashV2Input, key: string): string {
|
||
return crypto.createHmac('sha256', key)
|
||
.update(JSON.stringify(buildContentPayload(data, 3)))
|
||
.digest('hex');
|
||
}
|
||
|
||
function generateHashV2(data: AuditHashV2Input): string {
|
||
return crypto.createHash('sha256')
|
||
.update(JSON.stringify(buildContentPayload(data, 2)))
|
||
.digest('hex');
|
||
}
|
||
|
||
/** Gemeinsamer Inhalts-Payload fuer Version 2 (SHA-256) und 3 (HMAC). */
|
||
function buildContentPayload(data: AuditHashV2Input, version: 2 | 3): Record<string, unknown> {
|
||
const n = <T>(v: T | null | undefined): T | null => (v === undefined ? null : v);
|
||
// Feldreihenfolge ist Teil des Hashes und darf nicht veraendert werden.
|
||
return {
|
||
v: version,
|
||
userId: n(data.userId),
|
||
userEmail: data.userEmail,
|
||
userRole: n(data.userRole),
|
||
customerId: n(data.customerId),
|
||
isCustomerPortal: n(data.isCustomerPortal) ?? false,
|
||
action: data.action,
|
||
sensitivity: n(data.sensitivity),
|
||
resourceType: data.resourceType,
|
||
resourceId: n(data.resourceId),
|
||
resourceLabel: n(data.resourceLabel),
|
||
endpoint: data.endpoint,
|
||
httpMethod: n(data.httpMethod),
|
||
ipAddress: n(data.ipAddress),
|
||
userAgent: n(data.userAgent),
|
||
changesBefore: n(data.changesBefore),
|
||
changesAfter: n(data.changesAfter),
|
||
changesEncrypted: n(data.changesEncrypted) ?? false,
|
||
dataSubjectId: n(data.dataSubjectId),
|
||
legalBasis: n(data.legalBasis),
|
||
success: n(data.success) ?? true,
|
||
errorMessage: n(data.errorMessage),
|
||
durationMs: n(data.durationMs),
|
||
createdAt: data.createdAt.toISOString(),
|
||
previousHash: data.previousHash || '',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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',
|
||
// Klartext-Passwort-Reads – jeder Decrypt-Vorgang muss nachvollziehbar sein
|
||
PortalPassword: 'CRITICAL',
|
||
ContractPassword: 'CRITICAL',
|
||
SimCardCredentials: 'CRITICAL',
|
||
InternetCredentials: 'CRITICAL',
|
||
SipCredentials: 'CRITICAL',
|
||
MailboxCredentials: 'CRITICAL',
|
||
// HIGH
|
||
Customer: 'HIGH',
|
||
User: 'HIGH',
|
||
CustomerConsent: 'HIGH',
|
||
DataDeletionRequest: 'HIGH',
|
||
// Abgewehrter IDOR-/Cross-Boundary-Zugriffsversuch (canAccess*-403).
|
||
// HIGH, weil ein Treffer auf gezielte Fremddaten-Enumeration hindeutet.
|
||
AccessDenied: '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 {
|
||
// 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;
|
||
}
|
||
}
|
||
|
||
// Kette atomar fortschreiben: Vorgaenger-Hash lesen UND neuen Eintrag
|
||
// schreiben muessen eine Einheit sein. Vorher lagen beide Schritte offen
|
||
// nebeneinander – zwei parallele Requests lasen denselben letzten Hash und
|
||
// haengten sich beide daran, was die Kette zerriss (echte Bruchstellen im
|
||
// Bestand, u. a. 05.05./07.05.2026).
|
||
//
|
||
// Serialisiert wird ueber eine Zeilensperre auf dem Einzeiler-Mutex
|
||
// `AuditChainLock`. Zwei Alternativen wurden verworfen:
|
||
// - `SELECT … FOR UPDATE` am Kettenende von AuditLog nimmt Gap-/Next-Key-
|
||
// Locks, die mit den gleichzeitigen INSERTs kollidieren – gemessen gingen
|
||
// 38 von 40 parallelen Eintraegen durch Deadlocks verloren. Ein FEHLENDER
|
||
// Audit-Eintrag ist unsichtbar und damit schlimmer als ein sichtbarer
|
||
// Kettenbruch.
|
||
// - GET_LOCK muss auf derselben Verbindung freigegeben werden, das Release
|
||
// fiel damit VOR den COMMIT. In diesem Fenster las der naechste Schreiber
|
||
// ein noch nicht sichtbares Kettenende und hing sich an denselben
|
||
// Vorgaenger (Fork, Pentest R166-01).
|
||
// Die Zeilensperre faellt erst beim COMMIT und liegt in der DB – wirkt also
|
||
// auch ueber mehrere App-Instanzen hinweg.
|
||
// Alles Rechenintensive (Serialisieren/Verschluesseln) passiert bewusst
|
||
// VOR der Transaktion, damit die Sperre so kurz wie moeglich gehalten wird.
|
||
await prisma.$transaction(async (tx) => {
|
||
// Exklusive Zeilensperre auf den Einzeiler-Mutex. Sie faellt erst beim
|
||
// COMMIT – dadurch sieht der naechste Schreiber die Vorgaengerzeile
|
||
// garantiert bereits festgeschrieben (Pentest R166-01).
|
||
await tx.$queryRaw`SELECT id FROM AuditChainLock WHERE id = 1 FOR UPDATE`;
|
||
|
||
const lastRows = await tx.$queryRaw<Array<{ hash: string }>>`
|
||
SELECT hash FROM AuditLog ORDER BY id DESC LIMIT 1
|
||
`;
|
||
const previousHash = lastRows[0]?.hash || null;
|
||
const createdAt = new Date();
|
||
|
||
// Version 3 (HMAC-signiert) sobald ein Schluessel konfiguriert ist,
|
||
// sonst Version 2 – Audit-Logging darf an fehlender Konfiguration nicht
|
||
// scheitern (fail-safe, siehe generateHashV3).
|
||
const hmacKey = auditHmacKey();
|
||
const inhalt = {
|
||
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,
|
||
previousHash,
|
||
};
|
||
const hash = hmacKey ? generateHashV3(inhalt, hmacKey) : generateHashV2(inhalt);
|
||
|
||
await tx.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,
|
||
hashVersion: hmacKey ? 3 : 2,
|
||
},
|
||
});
|
||
}, {
|
||
// READ COMMITTED: der Lesevorgang nach der Sperre muss den gerade
|
||
// festgeschriebenen Stand sehen. Unter REPEATABLE READ koennte ein
|
||
// Snapshot greifen, der die Vorgaengerzeile noch nicht enthaelt.
|
||
isolationLevel: 'ReadCommitted',
|
||
timeout: 20000,
|
||
maxWait: 15000,
|
||
});
|
||
} 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
|
||
*/
|
||
/**
|
||
* Blattwert einer Altbestands-Zeile fuer das Bestandssiegel (Pentest R171-02).
|
||
*
|
||
* Bewusst ueber den VOLLEN Zeileninhalt inkl. `id` und `hashVersion` – nicht
|
||
* ueber den gespeicherten 7-Feld-Hash der Version 1. Sonst lebte die Luecke im
|
||
* Siegel weiter: genau die 17 ungehashten Spalten waeren wieder frei
|
||
* editierbar. `hashVersion` gehoert mit hinein, damit ein Hochstufen der Zeile
|
||
* (V1→V2, ohne Schluessel nachrechenbar) das Blatt bricht, statt sie klammheimlich
|
||
* aus dem Siegelbereich zu schieben.
|
||
*/
|
||
function backlogLeaf(row: Record<string, unknown>): string {
|
||
const feld = (v: unknown) =>
|
||
v instanceof Date ? v.toISOString() : v === undefined ? null : v;
|
||
// Feste Reihenfolge – Teil des Blattwerts.
|
||
const payload = [
|
||
'id', 'userId', 'userEmail', 'userRole', 'customerId', 'isCustomerPortal',
|
||
'action', 'sensitivity', 'resourceType', 'resourceId', 'resourceLabel',
|
||
'endpoint', 'httpMethod', 'ipAddress', 'userAgent', 'changesBefore',
|
||
'changesAfter', 'changesEncrypted', 'dataSubjectId', 'legalBasis', 'success',
|
||
'errorMessage', 'durationMs', 'createdAt', 'hash', 'previousHash',
|
||
'hashVersion',
|
||
].map((k) => [k, feld(row[k])]);
|
||
|
||
return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
||
}
|
||
|
||
/** Wurzel ueber die Blaetter in id-Reihenfolge. */
|
||
function backlogRoot(leaves: Array<{ auditLogId: number; leafHash: string }>): string {
|
||
const sortiert = [...leaves].sort((a, b) => a.auditLogId - b.auditLogId);
|
||
return crypto
|
||
.createHash('sha256')
|
||
.update(JSON.stringify(sortiert.map((l) => [l.auditLogId, l.leafHash])))
|
||
.digest('hex');
|
||
}
|
||
|
||
/** Endpoint-Kennung des Siegel-Markers. */
|
||
const BACKLOG_SEAL_ENDPOINT = '/api/audit-logs/seal-backlog';
|
||
|
||
/**
|
||
* Eigener Ressourcentyp fuer den Siegel-Marker (Pentest R173-01).
|
||
*
|
||
* Der Marker wurde urspruenglich als `AuditLog` / `CREATE` /
|
||
* `endpoint=/api/audit-logs/seal-backlog` gesucht. Genau diese Signatur
|
||
* erzeugt aber die generische Audit-Middleware, wenn jemand den Endpunkt
|
||
* aufruft – als Protokollzeile des Requests selbst, mit hoeherer id und ohne
|
||
* `changesAfter`. Der Selektor griff dann diese Zeile statt des echten
|
||
* Markers, `root` war `undefined`, und die Pruefung meldete dauerhaft
|
||
* „gebrochen“. Ein Ressourcentyp, den die Middleware nie vergibt (sie leitet
|
||
* ihn aus dem Pfad ab → `AuditLog`), macht den Marker eindeutig.
|
||
*/
|
||
const BACKLOG_SEAL_RESOURCE = 'AuditBacklogSeal';
|
||
|
||
/**
|
||
* Versiegelt den Altbestand einmalig.
|
||
*
|
||
* Der Bereich ist FIX auf `[1 … v3FromId-1]` gebunden, nicht auf eine
|
||
* Live-Abfrage `hashVersion < 3`. Sonst koennte ein Angreifer die Grenzzeile
|
||
* hochstufen und sie damit aus der geprueften Menge draengen – sie erschiene
|
||
* dann nur als harmlose Luecke. Zeilen ab `v3FromId` deckt die Versionsgrenze
|
||
* ab (sie MUESSEN V3 sein), es bleibt also keine Naht dazwischen.
|
||
*/
|
||
/**
|
||
* Kontrollwert fuer die externe Notarisierung (Gegenbuch).
|
||
*
|
||
* Alle bisherigen Anker leben IM System: Hash, Versionsgrenze, Manifest,
|
||
* Bestandssiegel, Siegel-Marker. Wer Schreibzugriff auf die Datenbank hat,
|
||
* sitzt am Ende immer schon auf der Ebene, die beweisen soll – zuletzt gezeigt
|
||
* in R174-01. Ein Gegenbuch an einem fremden Ort durchbricht das.
|
||
*
|
||
* Der Wert enthaelt bewusst `maxId`: Ein blosser Kopf-Hash erkennt zwar
|
||
* Umschreiben, aber kein ABSCHNEIDEN am Ende. Mit ankertem `maxId` ist
|
||
* „juengster beglaubigter maxId > aktueller maxId“ der Beweis, dass Zeilen
|
||
* verschwunden sind.
|
||
*
|
||
* `atId` erlaubt der Gegenstelle, einen FRUEHER beglaubigten Kopf erneut
|
||
* abzufragen und zu reproduzieren – ohne das koennte sie nur den aktuellen
|
||
* Stand sehen und nichts nachrechnen.
|
||
*
|
||
* Enthaelt keine Geheimnisse: nur Hashes und Zaehlstaende.
|
||
*/
|
||
export async function getCheckpoint(atId?: number): Promise<{
|
||
ts: string;
|
||
maxId: number | null;
|
||
chainHead: string | null;
|
||
sealRoot: string | null;
|
||
sealLeafCount: number;
|
||
atId?: number;
|
||
atHash?: string | null;
|
||
}> {
|
||
const kopf = await prisma.auditLog.findFirst({
|
||
orderBy: { id: 'desc' },
|
||
select: { id: true, hash: true },
|
||
});
|
||
|
||
// BEWUSST KEIN verifyIntegrity() hier (Pentest R175-02): das laeuft ueber
|
||
// alle Zeilen und kostet bei grossen Logs Sekunden je Aufruf – ein
|
||
// authentifizierter Leser koennte damit die Datenbank in die Knie zwingen.
|
||
// Gebraucht wird von der Vollpruefung ohnehin nur die Siegel-Wurzel, und die
|
||
// steht im juengsten gueltigen Marker. Der Kopf-Hash kommt direkt aus der
|
||
// Kopfzeile.
|
||
const c3 = await prisma.auditLog.aggregate({
|
||
where: { hashVersion: { gte: 3 } },
|
||
_min: { id: true },
|
||
});
|
||
const v3FromId = c3._min.id;
|
||
const schluessel = [auditHmacKey(), ...auditHmacKeysOld()].filter(
|
||
(k): k is string => !!k,
|
||
);
|
||
const markerKandidaten = await prisma.auditLog.findMany({
|
||
where: { resourceType: BACKLOG_SEAL_RESOURCE, endpoint: BACKLOG_SEAL_ENDPOINT },
|
||
orderBy: { id: 'desc' },
|
||
take: 20,
|
||
});
|
||
let sealRoot: string | null = null;
|
||
if (schluessel.length && v3FromId !== null) {
|
||
for (const r of markerKandidaten) {
|
||
if (r.id < v3FromId || r.hashVersion < 3) continue;
|
||
if (!schluessel.some((k) => r.hash === generateHashV3(r as unknown as AuditHashV2Input, k))) continue;
|
||
try {
|
||
const m = JSON.parse(r.changesAfter || '{}');
|
||
if (typeof m.root === 'string' && m.root.length > 0) {
|
||
sealRoot = m.root;
|
||
break;
|
||
}
|
||
} catch {
|
||
/* naechster Kandidat */
|
||
}
|
||
}
|
||
}
|
||
const blattAnzahl = await prisma.auditBacklogSeal.count();
|
||
|
||
const ergebnis: {
|
||
ts: string;
|
||
maxId: number | null;
|
||
chainHead: string | null;
|
||
sealRoot: string | null;
|
||
sealLeafCount: number;
|
||
atId?: number;
|
||
atHash?: string | null;
|
||
} = {
|
||
ts: new Date().toISOString(),
|
||
maxId: kopf?.id ?? null,
|
||
chainHead: kopf?.hash ?? null,
|
||
sealRoot,
|
||
// Reicht der Gegenstelle, um das Verschwinden des Siegels zu bemerken:
|
||
// Blaetter ohne Wurzel = Marker entfernt.
|
||
sealLeafCount: blattAnzahl,
|
||
};
|
||
|
||
if (atId !== undefined && Number.isFinite(atId)) {
|
||
const zeile = await prisma.auditLog.findUnique({
|
||
where: { id: atId },
|
||
select: { hash: true },
|
||
});
|
||
ergebnis.atId = atId;
|
||
// null bedeutet: die beglaubigte Zeile existiert nicht mehr.
|
||
ergebnis.atHash = zeile?.hash ?? null;
|
||
}
|
||
|
||
return ergebnis;
|
||
}
|
||
|
||
export async function sealBacklog(
|
||
ausgeloestVon?: { userEmail?: string; ipAddress?: string },
|
||
): Promise<{ sealedCount: number; fromId: number; toId: number; root: string }> {
|
||
if (!auditHmacKey()) {
|
||
throw new Error(
|
||
'Bestandssiegel erfordert einen konfigurierten AUDIT_HMAC_KEY – ohne ihn liesse sich die Wurzel nachrechnen.',
|
||
);
|
||
}
|
||
const c3 = await prisma.auditLog.aggregate({
|
||
where: { hashVersion: { gte: 3 } },
|
||
_min: { id: true },
|
||
});
|
||
const v3FromId = c3._min.id;
|
||
if (v3FromId === null) {
|
||
throw new Error(
|
||
'Noch keine signierten Einträge vorhanden – bitte zuerst AUDIT_HMAC_KEY setzen und das Backend neu starten.',
|
||
);
|
||
}
|
||
const toId = v3FromId - 1;
|
||
if (toId < 1) {
|
||
throw new Error('Kein Altbestand vorhanden – es gibt nichts zu versiegeln.');
|
||
}
|
||
|
||
// Zustand VOR dem Siegeln festhalten (R173-03).
|
||
const vorher = await verifyIntegrity();
|
||
const vorbefund = {
|
||
vorherigesSiegel:
|
||
vorher.backlogSealStatus === 'kein_siegel'
|
||
? null
|
||
: { status: vorher.backlogSealStatus, root: vorher.backlogSealRoot },
|
||
befund: {
|
||
manipuliert: vorher.tamperedEntries,
|
||
altbestand_veraendert: vorher.backlogTampered,
|
||
altbestand_fehlend: vorher.backlogMissing,
|
||
ketten_luecken: vorher.chainGaps,
|
||
},
|
||
};
|
||
|
||
const rows = await prisma.auditLog.findMany({
|
||
where: { id: { lte: toId } },
|
||
orderBy: { id: 'asc' },
|
||
});
|
||
const leaves = rows.map((r) => ({
|
||
auditLogId: r.id,
|
||
leafHash: backlogLeaf(r as unknown as Record<string, unknown>),
|
||
}));
|
||
const root = backlogRoot(leaves);
|
||
|
||
await prisma.$transaction(async (tx) => {
|
||
await tx.auditBacklogSeal.deleteMany({});
|
||
for (let i = 0; i < leaves.length; i += 500) {
|
||
await tx.auditBacklogSeal.createMany({ data: leaves.slice(i, i + 500) });
|
||
}
|
||
}, { timeout: 60000, maxWait: 20000 });
|
||
|
||
// Marker ist selbst HMAC-signiert (Version 3) – damit ist die Wurzel
|
||
// signiert, ohne dass es einen zweiten Signaturpfad braucht.
|
||
//
|
||
// Vorzustand mitschreiben (Pentest R173-03): Ein erneutes Siegeln wuerde
|
||
// sonst eine zwischenzeitliche Manipulation absorbieren und die gefaelschte
|
||
// Zeile als echt zertifizieren – mit noch weniger Spur als ein Rehash. Der
|
||
// Marker haelt deshalb fest, was VOR dem Siegeln galt: der Befund und die
|
||
// Wurzel des vorherigen Siegels.
|
||
await createAuditLog({
|
||
userEmail: ausgeloestVon?.userEmail || 'system',
|
||
userRole: 'System',
|
||
action: 'CREATE',
|
||
resourceType: BACKLOG_SEAL_RESOURCE,
|
||
resourceLabel:
|
||
`Bestandssiegel erstellt: ${leaves.length} Alteinträge (id 1–${toId})` +
|
||
(vorbefund.vorherigesSiegel
|
||
? ' – ERNEUTES Siegeln, vorheriges Siegel wird ersetzt'
|
||
: ''),
|
||
endpoint: BACKLOG_SEAL_ENDPOINT,
|
||
httpMethod: 'POST',
|
||
ipAddress: ausgeloestVon?.ipAddress || 'system',
|
||
sensitivity: 'CRITICAL',
|
||
changesBefore: vorbefund,
|
||
changesAfter: { fromId: 1, toId, count: leaves.length, root },
|
||
success: true,
|
||
});
|
||
|
||
return { sealedCount: leaves.length, fromId: 1, toId, root };
|
||
}
|
||
|
||
export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
||
valid: boolean;
|
||
checkedCount: number;
|
||
/** Alle beanstandeten Zeilen (tampered + chainGaps) – Abwaertskompatibilitaet. */
|
||
invalidEntries: number[];
|
||
/**
|
||
* ERNST: Der Inhalt der Zeile passt nicht mehr zu ihrem Hash – jemand hat
|
||
* einen bestehenden Eintrag nachtraeglich veraendert.
|
||
*/
|
||
tamperedEntries: number[];
|
||
/**
|
||
* MEIST HARMLOS: Der Inhalt stimmt, aber die Verkettung zur Vorgaengerzeile
|
||
* passt nicht. Ursachen: parallel geschriebene Eintraege (bis zum Fix der
|
||
* Race-Condition) oder geloeschte Zeilen (Retention-Cleanup). Kein Hinweis
|
||
* auf Manipulation – die Zeilen selbst sind unveraendert.
|
||
*/
|
||
chainGaps: number[];
|
||
/**
|
||
* Teilmenge von `chainGaps`, die NICHT durch ein protokolliertes
|
||
* Loeschungs-Manifest erklaert ist. Nur diese sind erklaerungsbeduerftig.
|
||
*/
|
||
unexplainedGaps: number[];
|
||
/**
|
||
* HMAC-signierte Zeilen, die mangels konfiguriertem `AUDIT_HMAC_KEY` nicht
|
||
* geprueft werden konnten. Bewusst KEIN Manipulationsverdacht – sonst waere
|
||
* eine fehlende Konfiguration ein Fehlalarm ueber das gesamte Log.
|
||
*/
|
||
unverifiableEntries: number[];
|
||
/**
|
||
* Zustand des Bestandssiegels ueber den nicht signierbaren Altbestand.
|
||
* `kein_siegel` = nie erstellt. `entfernt` = Blaetter vorhanden, aber kein
|
||
* gueltiger Marker mehr – der Anker wurde herausgeloest (Pentest R174-01).
|
||
*/
|
||
backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen' | 'entfernt';
|
||
/** Altbestands-Zeilen, deren Inhalt vom Siegel abweicht. */
|
||
backlogTampered: number[];
|
||
/** Gesiegelte Zeilen, die nicht mehr existieren – Beweismaterial entfernt. */
|
||
backlogMissing: number[];
|
||
/** Anzahl gueltiger Siegel-Marker; >1 bedeutet erneutes Siegeln. */
|
||
backlogSealCount: number;
|
||
/** Wurzel des aktuell gueltigen Siegels. */
|
||
backlogSealRoot: string | null;
|
||
}> {
|
||
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' },
|
||
// Version 2 hasht alle Inhaltsspalten – daher vollstaendig laden.
|
||
select: {
|
||
id: true,
|
||
userId: true,
|
||
userEmail: true,
|
||
userRole: true,
|
||
customerId: true,
|
||
isCustomerPortal: true,
|
||
action: true,
|
||
sensitivity: true,
|
||
resourceType: true,
|
||
resourceId: true,
|
||
resourceLabel: true,
|
||
endpoint: true,
|
||
httpMethod: true,
|
||
ipAddress: true,
|
||
userAgent: true,
|
||
changesBefore: true,
|
||
changesAfter: true,
|
||
changesEncrypted: true,
|
||
dataSubjectId: true,
|
||
legalBasis: true,
|
||
success: true,
|
||
errorMessage: true,
|
||
durationMs: true,
|
||
createdAt: true,
|
||
hash: true,
|
||
previousHash: true,
|
||
hashVersion: true,
|
||
},
|
||
});
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Version-Floor (Pentest R167-01)
|
||
//
|
||
// Vorher bestimmte die Zeile SELBST ueber `hashVersion`, wie streng sie
|
||
// geprueft wird – und `hashVersion` ist nicht gehasht. Ein Angreifer mit
|
||
// Schreibzugriff konnte also 2→1 zuruecksetzen, die Felder aendern, die nur
|
||
// V2 abdeckt (z. B. `success` false→true), und den schwachen V1-Hash ueber
|
||
// die 7 unveraenderten Felder nachziehen: Die Pruefung meldete "gueltig".
|
||
//
|
||
// Jetzt leitet sich die ERWARTETE Version aus der Kette ab: ab der ersten
|
||
// jemals mit V2 geschriebenen Zeile muss jede weitere Zeile V2 sein. Die
|
||
// Grenze laesst sich durch das Herabstufen einer einzelnen Zeile nicht
|
||
// verschieben (das Minimum bleibt), ein Angreifer muesste saemtliche
|
||
// V2-Zeilen ab der Grenze herabstufen und die komplette Kette neu rechnen –
|
||
// das entspricht einem vollstaendigen Rehash und damit dem bekannten
|
||
// Grenzfall "vollstaendig kompromittierter DB-Zugang".
|
||
//
|
||
// Betriebshinweis: Bei einem rollierenden Deploy, bei dem kurzzeitig alte und
|
||
// neue Instanz parallel schreiben, koennen echte V1-Zeilen nach der Grenze
|
||
// entstehen und werden dann angezeigt. Beim hier ueblichen Deploy
|
||
// (pull + rebuild + restart einer Instanz) tritt das nicht auf.
|
||
// Grenze pro Stufe: ab der ersten je mit Version >= v geschriebenen Zeile
|
||
// muss jede weitere Zeile mindestens Version v haben. Verallgemeinert auf
|
||
// Version 3 (HMAC-Anker), damit auch ein Ruecksprung 3→2 auffliegt.
|
||
const [c2, c3] = await Promise.all([
|
||
prisma.auditLog.aggregate({ where: { hashVersion: { gte: 2 } }, _min: { id: true } }),
|
||
prisma.auditLog.aggregate({ where: { hashVersion: { gte: 3 } }, _min: { id: true } }),
|
||
]);
|
||
const v2FromId = c2._min.id;
|
||
const v3FromId = c3._min.id;
|
||
const erwarteteVersion = (id: number): 1 | 2 | 3 => {
|
||
if (v3FromId !== null && id >= v3FromId) return 3;
|
||
if (v2FromId !== null && id >= v2FromId) return 2;
|
||
return 1;
|
||
};
|
||
|
||
// Dokumentierte Loeschungen einlesen – NUR aus signierten, selbst gueltigen
|
||
// Traegerzeilen (Pentest R171-01).
|
||
//
|
||
// Vorher wurde jedem Manifest bedingungslos geglaubt. Das Manifest steht in
|
||
// `changesAfter`, und dieses Feld ist erst ab Hash-Version 2 mitgehasht – auf
|
||
// V1-Altzeilen also voellig unauthentifiziert. Ein Angreifer konnte daher in
|
||
// eine BELIEBIGE V1-Zeile ein erfundenes Manifest schreiben, ohne deren Hash
|
||
// zu veraendern, und damit eigene Loeschungen und Luecken als "erklaert"
|
||
// ausweisen. Das hebelte zugleich die Eskalation an signierten Zeilen aus –
|
||
// also Anker UND Versionsgrenze.
|
||
//
|
||
// Deshalb zaehlt ein Manifest nur, wenn seine Traegerzeile
|
||
// 1. laut Versionsgrenze Stufe 3 sein MUSS und dies auch deklariert, und
|
||
// 2. ihre HMAC-Signatur mit einem konfigurierten Schluessel aufgeht.
|
||
// Ohne Schluessel gibt es keine gueltigen Traeger – dann bleiben Luecken
|
||
// unerklaert, was die sichere Richtung ist.
|
||
const deletionRanges: Array<{ from: number; to: number }> = [];
|
||
const manifestKandidaten = await prisma.auditLog.findMany({
|
||
where: { resourceType: 'AuditLog', action: 'DELETE', endpoint: '/api/audit-logs/cleanup' },
|
||
});
|
||
const manifestSchluessel = [auditHmacKey(), ...auditHmacKeysOld()].filter(
|
||
(k): k is string => !!k,
|
||
);
|
||
for (const row of manifestKandidaten) {
|
||
if (erwarteteVersion(row.id) !== 3 || row.hashVersion < 3) continue;
|
||
if (manifestSchluessel.length === 0) continue;
|
||
const signaturOk = manifestSchluessel.some(
|
||
(k) => row.hash === generateHashV3(row as unknown as AuditHashV2Input, k),
|
||
);
|
||
if (!signaturOk) continue;
|
||
try {
|
||
const parsed = JSON.parse(row.changesAfter || '{}');
|
||
for (const m of parsed.manifest || []) {
|
||
if (typeof m.fromId === 'number' && typeof m.toId === 'number') {
|
||
deletionRanges.push({ from: m.fromId, to: m.toId });
|
||
}
|
||
}
|
||
} catch {
|
||
// Unlesbares Manifest = nicht erklaerend; Luecke bleibt unerklaert.
|
||
}
|
||
}
|
||
const gapErklaert = (prevId: number, curId: number) =>
|
||
deletionRanges.some((r) => r.from <= curId && r.to >= prevId);
|
||
|
||
const tamperedEntries: number[] = [];
|
||
const chainGaps: number[] = [];
|
||
const unexplainedGaps: number[] = [];
|
||
const unverifiableEntries: number[] = [];
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Bestandssiegel pruefen (Pentest R171-02)
|
||
//
|
||
// Der Altbestand ist nicht signierbar: bei Version 1 sind nur 7 von 24
|
||
// Spalten gehasht. Wurde er versiegelt, wird hier je Zeile geprueft:
|
||
// vorhanden, immer noch Altbestand (hashVersion < 3) und Inhalt unveraendert.
|
||
// Der Marker muss selbst signiert und gueltig sein - sonst waere das Siegel
|
||
// ueber denselben Weg faelschbar wie zuvor die Manifeste (R171-01).
|
||
const backlogTampered: number[] = [];
|
||
const backlogMissing: number[] = [];
|
||
let backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen' | 'entfernt' = 'kein_siegel';
|
||
let backlogSealRoot: string | null = null;
|
||
|
||
const siegelKandidaten = await prisma.auditLog.findMany({
|
||
where: { resourceType: BACKLOG_SEAL_RESOURCE, endpoint: BACKLOG_SEAL_ENDPOINT },
|
||
orderBy: { id: 'desc' },
|
||
});
|
||
const siegelSchluessel = [auditHmacKey(), ...auditHmacKeysOld()].filter(
|
||
(k): k is string => !!k,
|
||
);
|
||
// Nur signierte Marker MIT auswertbarem Inhalt zaehlen. Der zweite Teil ist
|
||
// die Absicherung gegen Zeilen, die zufaellig dieselbe Signatur tragen, aber
|
||
// gar kein Siegel beschreiben (Pentest R173-01).
|
||
const hatSiegelInhalt = (r: { changesAfter: string | null }) => {
|
||
try {
|
||
const m = JSON.parse(r.changesAfter || '{}');
|
||
return typeof m.toId === 'number' && typeof m.root === 'string' && m.root.length > 0;
|
||
} catch {
|
||
return false;
|
||
}
|
||
};
|
||
const gueltigeSiegel = siegelSchluessel.length
|
||
? siegelKandidaten.filter(
|
||
(r) =>
|
||
erwarteteVersion(r.id) === 3 &&
|
||
r.hashVersion >= 3 &&
|
||
hatSiegelInhalt(r) &&
|
||
siegelSchluessel.some((k) => r.hash === generateHashV3(r as unknown as AuditHashV2Input, k)),
|
||
)
|
||
: [];
|
||
const siegel = gueltigeSiegel[0];
|
||
const backlogSealCount = gueltigeSiegel.length;
|
||
|
||
// Gegen-Check (Pentest R174-01): Der Siegelzustand hing ausschliesslich am
|
||
// Marker – und den kann ein DB-Schreiber OHNE Schluessel loeschen. Danach
|
||
// meldete die Pruefung `kein_siegel`, also Entwarnung, ununterscheidbar von
|
||
// „nie versiegelt“. Steht der Marker am Ketten-Ende (genau der Zustand
|
||
// direkt nach dem einmaligen Siegeln beim Deploy), riss dabei nicht einmal
|
||
// eine Luecke: das Ergebnis war `valid: true` trotz gefaelschter gesiegelter
|
||
// Zeile. Die Blaetter blieben verwaist zurueck und wurden nie konsultiert.
|
||
//
|
||
// Deshalb gilt jetzt: Blaetter vorhanden, aber kein gueltiger Marker =
|
||
// Siegel ENTFERNT und damit ein Befund – nicht „nie versiegelt“.
|
||
const blattAnzahl = await prisma.auditBacklogSeal.count();
|
||
if (!siegel && blattAnzahl > 0 && siegelSchluessel.length > 0) {
|
||
backlogSealStatus = 'entfernt';
|
||
}
|
||
|
||
if (siegel) {
|
||
try {
|
||
const meta = JSON.parse(siegel.changesAfter || '{}');
|
||
const bis: number = meta.toId;
|
||
const blaetter = await prisma.auditBacklogSeal.findMany();
|
||
const erwartet = new Map(blaetter.map((b) => [b.auditLogId, b.leafHash]));
|
||
|
||
const altzeilen = await prisma.auditLog.findMany({
|
||
where: { id: { lte: bis } },
|
||
orderBy: { id: 'asc' },
|
||
});
|
||
const vorhanden = new Map(altzeilen.map((r) => [r.id, r]));
|
||
|
||
for (const [id, blatt] of erwartet) {
|
||
const zeile = vorhanden.get(id);
|
||
if (!zeile) {
|
||
// FEHLENDE gesiegelte Zeile ist ein Siegelbruch, keine blosse Luecke
|
||
// (Pentest R173-02). Vorher wurde hier uebersprungen: das Loeschen
|
||
// eines Einbruchsbelegs erschien nur als "unerklaerte Luecke" –
|
||
// ununterscheidbar von den als meist harmlos dokumentierten
|
||
// Race-Luecken –, waehrend der dedizierte Indikator "intakt" meldete.
|
||
backlogMissing.push(id);
|
||
continue;
|
||
}
|
||
if (zeile.hashVersion >= 3 || backlogLeaf(zeile as unknown as Record<string, unknown>) !== blatt) {
|
||
backlogTampered.push(id);
|
||
}
|
||
}
|
||
|
||
// Wurzel gegenrechnen: faengt auch Zeilen ab, die dem Siegel
|
||
// nachtraeglich hinzugefuegt oder daraus entfernt wurden.
|
||
const wurzelJetzt = backlogRoot(blaetter);
|
||
backlogSealRoot = meta.root;
|
||
backlogSealStatus =
|
||
backlogTampered.length === 0 && backlogMissing.length === 0 && wurzelJetzt === meta.root
|
||
? 'intakt'
|
||
: 'gebrochen';
|
||
} catch {
|
||
backlogSealStatus = 'gebrochen';
|
||
}
|
||
}
|
||
tamperedEntries.push(...backlogTampered, ...backlogMissing);
|
||
|
||
for (let i = 0; i < logs.length; i++) {
|
||
const log = logs[i];
|
||
|
||
// Erwartete Pruefstaerke aus der Kette, NICHT aus der Selbstauskunft.
|
||
const erwartet = erwarteteVersion(log.id);
|
||
if (log.hashVersion !== erwartet) {
|
||
// Deklarierte Version passt nicht zur Position in der Kette →
|
||
// Downgrade-Versuch (oder manipulierte Version).
|
||
tamperedEntries.push(log.id);
|
||
continue;
|
||
}
|
||
|
||
// Pruefverfahren richtet sich nach der erwarteten Version:
|
||
// 3 = HMAC-signiert (Schluessel ausserhalb der DB)
|
||
// 2 = SHA-256 ueber alle Inhaltsspalten
|
||
// 1 = SHA-256 ueber 7 Felder, zusaetzlich historische Serialisierung
|
||
let hashOk: boolean;
|
||
if (erwartet >= 2) {
|
||
const inhalt = {
|
||
userId: log.userId,
|
||
userEmail: log.userEmail,
|
||
userRole: log.userRole,
|
||
customerId: log.customerId,
|
||
isCustomerPortal: log.isCustomerPortal,
|
||
action: log.action,
|
||
sensitivity: log.sensitivity,
|
||
resourceType: log.resourceType,
|
||
resourceId: log.resourceId,
|
||
resourceLabel: log.resourceLabel,
|
||
endpoint: log.endpoint,
|
||
httpMethod: log.httpMethod,
|
||
ipAddress: log.ipAddress,
|
||
userAgent: log.userAgent,
|
||
changesBefore: log.changesBefore,
|
||
changesAfter: log.changesAfter,
|
||
changesEncrypted: log.changesEncrypted,
|
||
dataSubjectId: log.dataSubjectId,
|
||
legalBasis: log.legalBasis,
|
||
success: log.success,
|
||
errorMessage: log.errorMessage,
|
||
durationMs: log.durationMs,
|
||
createdAt: log.createdAt,
|
||
previousHash: log.previousHash,
|
||
};
|
||
|
||
if (erwartet === 3) {
|
||
// HMAC-signiert: ohne JEDEN Schluessel ist die Zeile nicht pruefbar.
|
||
// Das als "manipuliert" zu melden waere ein Fehlalarm – daher eigener
|
||
// Topf. Ein FALSCHER Schluessel ist dagegen nicht von einer Faelschung
|
||
// zu unterscheiden und wird bewusst als Befund gemeldet.
|
||
const kandidaten = [auditHmacKey(), ...auditHmacKeysOld()].filter(
|
||
(k): k is string => !!k,
|
||
);
|
||
if (kandidaten.length === 0) {
|
||
unverifiableEntries.push(log.id);
|
||
continue;
|
||
}
|
||
hashOk = kandidaten.some((k) => log.hash === generateHashV3(inhalt, k));
|
||
} else {
|
||
hashOk = log.hash === generateHashV2(inhalt);
|
||
}
|
||
} else {
|
||
const v1 = {
|
||
userEmail: log.userEmail,
|
||
action: log.action,
|
||
resourceType: log.resourceType,
|
||
resourceId: log.resourceId,
|
||
endpoint: log.endpoint,
|
||
createdAt: log.createdAt,
|
||
previousHash: log.previousHash,
|
||
};
|
||
hashOk = log.hash === generateHash(v1) || log.hash === generateHashLegacy(v1);
|
||
}
|
||
|
||
if (!hashOk) {
|
||
tamperedEntries.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) {
|
||
chainGaps.push(log.id);
|
||
if (!gapErklaert(previousLog.id, log.id)) {
|
||
unexplainedGaps.push(log.id);
|
||
// Ist die AKTUELLE Zeile HMAC-signiert, laesst sich ihr
|
||
// `previousHash` ohne Schluessel nicht nachziehen. Eine unerklaerte
|
||
// Luecke davor bedeutet dann zwingend: der Vorgaenger wurde
|
||
// veraendert oder entfernt. Das ist kein struktureller Zufall,
|
||
// sondern ein Befund.
|
||
//
|
||
// Dieser Pfad schliesst zugleich die Restluecke der Versionsgrenze:
|
||
// Wird die FRUEHESTE V3-Zeile herabgestuft, wandert `MIN(id)` mit und
|
||
// die Grenze allein wuerde den Downgrade durchwinken – der Nachfolger
|
||
// erzeugt aber eine nicht faelschbare Luecke.
|
||
if (erwartet === 3) {
|
||
tamperedEntries.push(log.id);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Eskalierte Luecken stehen sowohl in tamperedEntries als auch in chainGaps;
|
||
// ohne Entdopplung zaehlte dieselbe Zeile zweimal (Pentest R171-03).
|
||
const invalidEntries = [...new Set([...tamperedEntries, ...chainGaps])].sort((a, b) => a - b);
|
||
|
||
// Ein gebrochenes oder entferntes Siegel muss `valid` kippen, auch wenn keine
|
||
// einzelne Zeile beanstandet ist – sonst bliebe der stille Anker-Verlust
|
||
// unsichtbar (R174-01).
|
||
const siegelInOrdnung =
|
||
backlogSealStatus === 'intakt' || backlogSealStatus === 'kein_siegel';
|
||
|
||
return {
|
||
valid: invalidEntries.length === 0 && siegelInOrdnung,
|
||
checkedCount: logs.length,
|
||
invalidEntries,
|
||
tamperedEntries,
|
||
chainGaps,
|
||
unexplainedGaps,
|
||
unverifiableEntries,
|
||
backlogSealStatus,
|
||
backlogTampered,
|
||
backlogMissing,
|
||
backlogSealCount,
|
||
backlogSealRoot,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Hash-Kette komplett neu berechnen (Reparatur)
|
||
*/
|
||
export async function rehashAll(
|
||
ausgeloestVon?: { userEmail?: string; ipAddress?: string },
|
||
): Promise<{ rehashedCount: number }> {
|
||
// ZUSTAND VOR DEM REHASH SICHERN (Pentest R170-01)
|
||
//
|
||
// Ein Rehash macht die Kette rechnerisch wieder stimmig – auch dann, wenn sie
|
||
// vorher berechtigte Beanstandungen enthielt. Der bisherige Marker hielt nur
|
||
// fest, DASS rehasht wurde, nicht WAS dabei verschwand. Wer `audit:admin`
|
||
// besitzt, konnte damit Spuren glattziehen, ohne dass hinterher erkennbar
|
||
// war, welche.
|
||
//
|
||
// Deshalb wird der Befund samt Kettenkopf VOR dem Rehash erhoben und im
|
||
// Marker mitgeschrieben. Der Marker entsteht nach dem Rehash, ist selbst
|
||
// Teil der neuen Kette und signiert; entfernen liesse er sich nur unter
|
||
// Hinterlassung einer Luecke.
|
||
const vorher = await verifyIntegrity();
|
||
const kopf = await prisma.auditLog.findFirst({
|
||
orderBy: { id: 'desc' },
|
||
select: { id: true, hash: true, hashVersion: true, createdAt: true },
|
||
});
|
||
|
||
const logs = await prisma.auditLog.findMany({
|
||
orderBy: { id: 'asc' },
|
||
select: {
|
||
id: true,
|
||
userId: true,
|
||
userEmail: true,
|
||
userRole: true,
|
||
customerId: true,
|
||
isCustomerPortal: true,
|
||
action: true,
|
||
sensitivity: true,
|
||
resourceType: true,
|
||
resourceId: true,
|
||
resourceLabel: true,
|
||
endpoint: true,
|
||
httpMethod: true,
|
||
ipAddress: true,
|
||
userAgent: true,
|
||
changesBefore: true,
|
||
changesAfter: true,
|
||
changesEncrypted: true,
|
||
dataSubjectId: true,
|
||
legalBasis: true,
|
||
success: true,
|
||
errorMessage: true,
|
||
durationMs: true,
|
||
createdAt: true,
|
||
},
|
||
});
|
||
|
||
const rehashKey = auditHmacKey();
|
||
let previousHash: string | null = null;
|
||
let count = 0;
|
||
|
||
for (const log of logs) {
|
||
// Rehash schreibt die hoechste verfuegbare Stufe – ein Rueckfall auf eine
|
||
// schwaechere Version wuerde die Abdeckung nachtraeglich verkleinern.
|
||
const inhalt: AuditHashV2Input = { ...log, previousHash };
|
||
const hash: string = rehashKey ? generateHashV3(inhalt, rehashKey) : generateHashV2(inhalt);
|
||
|
||
await prisma.auditLog.update({
|
||
where: { id: log.id },
|
||
data: { hash, previousHash, hashVersion: rehashKey ? 3 : 2 },
|
||
});
|
||
|
||
previousHash = hash;
|
||
count++;
|
||
}
|
||
|
||
// Marker: Ein Rehash setzt die Beweiskraft der Vergangenheit zurueck (jede
|
||
// vorhandene Faelschung wuerde mitbesiegelt). Der Vorgang muss deshalb im
|
||
// Log selbst sichtbar sein. Der Eintrag wird NACH dem Rehash geschrieben und
|
||
// haengt sich an die neu berechnete Kette; entfernen liesse er sich nur unter
|
||
// Hinterlassung einer Luecke.
|
||
await createAuditLog({
|
||
userEmail: ausgeloestVon?.userEmail || 'system',
|
||
userRole: 'System',
|
||
action: 'UPDATE',
|
||
resourceType: 'AuditLog',
|
||
resourceLabel:
|
||
`Hash-Kette neu berechnet (${count} Einträge) – Beweiskraft der Vergangenheit zurückgesetzt` +
|
||
(vorher.valid
|
||
? ' – Kette war vorher unbeanstandet'
|
||
: ` – vorher beanstandet: ${vorher.tamperedEntries.length} manipuliert, ` +
|
||
`${vorher.chainGaps.length} Lücken`),
|
||
endpoint: '/api/audit-logs/rehash',
|
||
httpMethod: 'POST',
|
||
ipAddress: ausgeloestVon?.ipAddress || 'system',
|
||
sensitivity: 'CRITICAL',
|
||
// Befund VOR dem Rehash – ohne das waere nach dem Rehash nicht mehr
|
||
// nachvollziehbar, was uebertuencht wurde.
|
||
changesBefore: {
|
||
geprueft: vorher.checkedCount,
|
||
manipuliert: vorher.tamperedEntries,
|
||
ketten_luecken: vorher.chainGaps,
|
||
luecken_ohne_dokumentierte_loeschung: vorher.unexplainedGaps,
|
||
nicht_pruefbar: vorher.unverifiableEntries,
|
||
kettenkopf: kopf
|
||
? { id: kopf.id, hash: kopf.hash, hashVersion: kopf.hashVersion, createdAt: kopf.createdAt }
|
||
: null,
|
||
},
|
||
changesAfter: { neuBerechnet: count },
|
||
success: true,
|
||
});
|
||
|
||
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;
|
||
|
||
// Loeschungs-Manifest (Pentest R166, Design): Jede geloeschte Zeile reisst die
|
||
// Hash-Kette auf. Ohne Nachweis, WELCHE Bereiche legitim entfernt wurden,
|
||
// koennte sich eine boeswillige Loeschung als "harmloser Gap" tarnen. Deshalb
|
||
// halten wir Bereich + Anzahl fest und schreiben sie als eigenen, selbst
|
||
// wieder verketteten Audit-Eintrag. Luecken ausserhalb dieser Bereiche
|
||
// bleiben damit erklaerungsbeduerftig.
|
||
const manifest: Array<Record<string, unknown>> = [];
|
||
|
||
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;
|
||
}
|
||
|
||
// Betroffenen ID-Bereich VOR dem Loeschen festhalten.
|
||
const range = await prisma.auditLog.aggregate({
|
||
where,
|
||
_count: true,
|
||
_min: { id: true },
|
||
_max: { id: true },
|
||
});
|
||
|
||
const deleted = await prisma.auditLog.deleteMany({ where });
|
||
|
||
if (deleted.count > 0) {
|
||
manifest.push({
|
||
resourceType: policy.resourceType,
|
||
sensitivity: policy.sensitivity,
|
||
retentionDays: policy.retentionDays,
|
||
cutoff: cutoffDate.toISOString(),
|
||
deletedCount: deleted.count,
|
||
fromId: range._min.id,
|
||
toId: range._max.id,
|
||
});
|
||
}
|
||
|
||
results.push({
|
||
resourceType: policy.resourceType,
|
||
sensitivity: policy.sensitivity,
|
||
deletedCount: deleted.count,
|
||
});
|
||
|
||
totalDeleted += deleted.count;
|
||
}
|
||
|
||
if (totalDeleted > 0) {
|
||
await createAuditLog({
|
||
userEmail: 'system',
|
||
userRole: 'System',
|
||
action: 'DELETE',
|
||
resourceType: 'AuditLog',
|
||
resourceLabel: `Retention-Cleanup: ${totalDeleted} Audit-Einträge gelöscht`,
|
||
endpoint: '/api/audit-logs/cleanup',
|
||
httpMethod: 'POST',
|
||
ipAddress: 'system',
|
||
sensitivity: 'CRITICAL',
|
||
changesAfter: { manifest },
|
||
success: true,
|
||
});
|
||
}
|
||
|
||
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,
|
||
});
|
||
}
|