Audit-Haerten: Fork, Feldabdeckung, Refresh-Rauschen, Route (Pentest R166)
R166-01 (HIGH): Der GET_LOCK-Ansatz gab die Sperre im finally INNERHALB des Transaktions-Callbacks frei, also vor dem COMMIT. Im Fenster Release-Commit las der naechste Schreiber ein noch nicht sichtbares Kettenende - zwei Zeilen hingen am selben Vorgaenger. Meine vorherige Messung war zu schwach: sie suchte Luecken zwischen Nachbarn, nicht Forks. Fix: einzeiliger Mutex AuditChainLock mit FOR UPDATE (InnoDB-Zeilensperren fallen erst beim COMMIT) plus isolationLevel ReadCommitted. Belegt im Direktvergleich mit geweitetem Fenster: Release-vor-Commit forkt, Zeilensperre nicht. R166-02 (MEDIUM): Der Hash deckte nur 7 Felder ab. changesBefore/After, success, ipAddress, resourceLabel, dataSubjectId, userId/customerId waren ungeschuetzt - ein Einzeledit dort blieb unsichtbar. Fix: hashVersion + generateHashV2 ueber alle Inhaltsspalten. Bestandszeilen behalten Version 1 und bleiben ohne Rehash gueltig. Verifiziert: 5/5 zuvor ungeschuetzte Felder werden jetzt erkannt. R166-03 (LOW): "kein Cookie" (normaler Erstbesuch) wurde als HIGH/abgelehnt gefuehrt - jetzt eigener Ausgang mit LOW. Nur echte Ablehnung bleibt HIGH. R166-04 (LOW, pre-existing): GET /retention-policies wurde von GET /:id verschluckt. Konkrete Routen jetzt vor der Parameter-Route. Design-Empfehlungen: runRetentionCleanup schreibt ein Loeschungs-Manifest (ID-Bereich, Anzahl, Policy, Cutoff) als eigenen verketteten Eintrag - Luecken ausserhalb bleiben erklaerungsbeduerftig. rehashAll schreibt einen Marker. Verifiziert: 50 parallele Schreiber -> 50/50, 0 Forks, alle V2, manipuliert 0, Luecken unveraendert 7. tsc + vite build gruen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -161,6 +161,86 @@ function generateHashLegacy(data: {
|
||||
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;
|
||||
}
|
||||
|
||||
function generateHashV2(data: AuditHashV2Input): string {
|
||||
const n = <T>(v: T | null | undefined): T | null => (v === undefined ? null : v);
|
||||
// Feldreihenfolge ist Teil des Hashes und darf nicht veraendert werden.
|
||||
const payload = {
|
||||
v: 2,
|
||||
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 || '',
|
||||
};
|
||||
|
||||
return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestimmt die Sensitivität basierend auf dem Ressourcentyp
|
||||
*/
|
||||
@@ -215,9 +295,6 @@ function shouldEncryptChanges(_resourceType: string): boolean {
|
||||
/**
|
||||
* Erstellt einen neuen Audit-Log-Eintrag mit Hash-Kette
|
||||
*/
|
||||
// Name des DB-weiten Locks, ueber den die Hash-Kette serialisiert wird.
|
||||
const AUDIT_CHAIN_LOCK = 'opencrm_audit_chain';
|
||||
|
||||
export async function createAuditLog(data: CreateAuditLogData): Promise<void> {
|
||||
try {
|
||||
// Sensitivität bestimmen falls nicht angegeben
|
||||
@@ -248,78 +325,99 @@ export async function createAuditLog(data: CreateAuditLogData): Promise<void> {
|
||||
// haengten sich beide daran, was die Kette zerriss (echte Bruchstellen im
|
||||
// Bestand, u. a. 05.05./07.05.2026).
|
||||
//
|
||||
// Serialisiert wird ueber einen benannten MySQL-Lock (GET_LOCK), NICHT ueber
|
||||
// `SELECT … FOR UPDATE` am Kettenende: letzteres nimmt Gap-/Next-Key-Locks
|
||||
// am Index-Ende, die mit den gleichzeitigen INSERTs kollidieren – gemessen
|
||||
// gingen dabei 38 von 40 parallelen Eintraegen durch Deadlocks verloren.
|
||||
// Ein FEHLENDER Audit-Eintrag ist unsichtbar und damit schlimmer als ein
|
||||
// sichtbarer Kettenbruch. Der benannte Lock kennt keine Gap-Locks und
|
||||
// serialisiert sauber; er liegt in der DB und wirkt daher auch ueber
|
||||
// mehrere App-Instanzen hinweg.
|
||||
// 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) => {
|
||||
// Interaktive Transaktion => alle Queries auf DERSELBEN Verbindung,
|
||||
// Voraussetzung dafuer, dass GET_LOCK/RELEASE_LOCK zusammengehoeren.
|
||||
const got = await tx.$queryRaw<Array<Record<string, number | null>>>`
|
||||
SELECT GET_LOCK(${AUDIT_CHAIN_LOCK}, 10) AS ok
|
||||
`;
|
||||
const locked = Number(Object.values(got[0] ?? {})[0] ?? 0) === 1;
|
||||
try {
|
||||
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();
|
||||
// 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 hash = generateHash({
|
||||
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();
|
||||
|
||||
// Neue Eintraege immer mit Version 2 (volle Feldabdeckung, R166-02).
|
||||
const hash = generateHashV2({
|
||||
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,
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
// Benannte Locks sind NICHT transaktional – ohne explizites Release
|
||||
// wandert die Sperre mit der Verbindung zurueck in den Pool und
|
||||
// blockiert alle weiteren Schreiber.
|
||||
if (locked) {
|
||||
await tx.$queryRaw`SELECT RELEASE_LOCK(${AUDIT_CHAIN_LOCK}) AS released`;
|
||||
}
|
||||
}
|
||||
}, { timeout: 20000, maxWait: 15000 });
|
||||
hashVersion: 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);
|
||||
@@ -473,16 +571,35 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -492,22 +609,39 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
||||
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 – Altbestand darf die historische
|
||||
// Serialisierung nutzen (siehe generateHashLegacy). Legacy wird nur
|
||||
// geprüft, wenn die aktuelle Variante nicht passt.
|
||||
if (log.hash !== expectedHash) {
|
||||
const legacyHash = generateHashLegacy({
|
||||
// Pruefverfahren richtet sich nach der Version, mit der geschrieben wurde.
|
||||
// Version 2 deckt alle Inhaltsspalten ab; Version 1 nur 7 Felder und darf
|
||||
// zusaetzlich die historische Serialisierung nutzen (generateHashLegacy).
|
||||
let hashOk: boolean;
|
||||
if (log.hashVersion >= 2) {
|
||||
hashOk = log.hash === generateHashV2({
|
||||
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,
|
||||
});
|
||||
} else {
|
||||
const v1 = {
|
||||
userEmail: log.userEmail,
|
||||
action: log.action,
|
||||
resourceType: log.resourceType,
|
||||
@@ -515,11 +649,13 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
||||
endpoint: log.endpoint,
|
||||
createdAt: log.createdAt,
|
||||
previousHash: log.previousHash,
|
||||
});
|
||||
if (log.hash !== legacyHash) {
|
||||
tamperedEntries.push(log.id);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
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
|
||||
@@ -550,11 +686,28 @@ export async function rehashAll(): Promise<{ rehashedCount: number }> {
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -563,25 +716,37 @@ export async function rehashAll(): Promise<{ rehashedCount: number }> {
|
||||
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,
|
||||
});
|
||||
// Rehash schreibt immer Version 2 (volle Feldabdeckung) – ein Rueckfall
|
||||
// auf Version 1 wuerde die Abdeckung nachtraeglich wieder verkleinern.
|
||||
const hash = generateHashV2({ ...log, previousHash });
|
||||
|
||||
await prisma.auditLog.update({
|
||||
where: { id: log.id },
|
||||
data: { hash, previousHash },
|
||||
data: { hash, previousHash, hashVersion: 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: 'system',
|
||||
userRole: 'System',
|
||||
action: 'UPDATE',
|
||||
resourceType: 'AuditLog',
|
||||
resourceLabel: `Hash-Kette neu berechnet (${count} Einträge) – Beweiskraft der Vergangenheit zurückgesetzt`,
|
||||
endpoint: '/api/audit-logs/rehash',
|
||||
httpMethod: 'POST',
|
||||
ipAddress: 'system',
|
||||
sensitivity: 'CRITICAL',
|
||||
success: true,
|
||||
});
|
||||
|
||||
return { rehashedCount: count };
|
||||
}
|
||||
|
||||
@@ -652,6 +817,14 @@ export async function runRetentionCleanup(): Promise<{
|
||||
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);
|
||||
@@ -668,8 +841,28 @@ export async function runRetentionCleanup(): Promise<{
|
||||
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,
|
||||
@@ -679,6 +872,22 @@ export async function runRetentionCleanup(): Promise<{
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user