// ============================================================================= // Loeschfristen Module - Compliance Check Engine // Prueft Policies auf Vollstaendigkeit, Konsistenz und DSGVO-Konformitaet // ============================================================================= import { LoeschfristPolicy, PolicyStatus, RetentionDriverType, isPolicyOverdue, getActiveLegalHolds, RETENTION_DRIVER_META, } from './loeschfristen-types' // ============================================================================= // TYPES // ============================================================================= export type ComplianceIssueType = | 'MISSING_TRIGGER' | 'MISSING_LEGAL_BASIS' | 'OVERDUE_REVIEW' | 'NO_RESPONSIBLE' | 'LEGAL_HOLD_CONFLICT' | 'STALE_DRAFT' | 'UNCOVERED_VVT_CATEGORY' | 'MISSING_DELETION_METHOD' | 'MISSING_STORAGE_LOCATIONS' | 'EXCESSIVE_RETENTION' | 'MISSING_DATA_CATEGORIES' export type ComplianceIssueSeverity = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL' export interface ComplianceIssue { id: string policyId: string policyName: string type: ComplianceIssueType severity: ComplianceIssueSeverity title: string description: string recommendation: string } export interface ComplianceCheckResult { issues: ComplianceIssue[] score: number // 0-100 stats: { total: number passed: number failed: number bySeverity: Record } } // ============================================================================= // HELPERS // ============================================================================= let issueCounter = 0 function createIssueId(): string { issueCounter++ return `CI-${Date.now()}-${String(issueCounter).padStart(4, '0')}` } function createIssue( policy: LoeschfristPolicy, type: ComplianceIssueType, severity: ComplianceIssueSeverity, title: string, description: string, recommendation: string ): ComplianceIssue { return { id: createIssueId(), policyId: policy.policyId, policyName: policy.dataObjectName || policy.policyId, type, severity, title, description, recommendation, } } function daysBetween(dateStr: string, now: Date): number { const date = new Date(dateStr) const diffMs = now.getTime() - date.getTime() return Math.floor(diffMs / (1000 * 60 * 60 * 24)) } // ============================================================================= // INDIVIDUAL CHECKS // ============================================================================= /** * Check 1: MISSING_TRIGGER (HIGH) * Policy has no deletionTrigger set, or trigger is PURPOSE_END but no startEvent defined. */ function checkMissingTrigger(policy: LoeschfristPolicy): ComplianceIssue | null { if (!policy.deletionTrigger) { return createIssue( policy, 'MISSING_TRIGGER', 'HIGH', 'Kein Loeschtrigger definiert', `Die Policy "${policy.dataObjectName}" hat keinen Loeschtrigger gesetzt. Ohne Trigger ist unklar, wann die Daten geloescht werden.`, 'Definieren Sie einen Loeschtrigger (Zweckende, Aufbewahrungspflicht oder Legal Hold) fuer diese Policy.' ) } if (policy.deletionTrigger === 'PURPOSE_END' && !policy.startEvent.trim()) { return createIssue( policy, 'MISSING_TRIGGER', 'HIGH', 'Zweckende ohne Startereignis', `Die Policy "${policy.dataObjectName}" nutzt "Zweckende" als Trigger, hat aber kein Startereignis definiert. Ohne Startereignis laesst sich der Loeschzeitpunkt nicht berechnen.`, 'Definieren Sie ein konkretes Startereignis (z.B. "Vertragsende", "Abmeldung", "Projektabschluss").' ) } return null } /** * Check 2: MISSING_LEGAL_BASIS (HIGH) * Policy with RETENTION_DRIVER trigger but no retentionDriver set. */ function checkMissingLegalBasis(policy: LoeschfristPolicy): ComplianceIssue | null { if (policy.deletionTrigger === 'RETENTION_DRIVER' && !policy.retentionDriver) { return createIssue( policy, 'MISSING_LEGAL_BASIS', 'HIGH', 'Aufbewahrungspflicht ohne Rechtsgrundlage', `Die Policy "${policy.dataObjectName}" hat "Aufbewahrungspflicht" als Trigger, aber keinen konkreten Aufbewahrungstreiber (z.B. AO 147, HGB 257) zugeordnet.`, 'Waehlen Sie den passenden gesetzlichen Aufbewahrungstreiber aus oder wechseln Sie den Trigger-Typ.' ) } return null } /** * Check 3: OVERDUE_REVIEW (MEDIUM) * Policy where nextReviewDate is in the past. */ function checkOverdueReview(policy: LoeschfristPolicy): ComplianceIssue | null { if (isPolicyOverdue(policy)) { const overdueDays = daysBetween(policy.nextReviewDate, new Date()) return createIssue( policy, 'OVERDUE_REVIEW', 'MEDIUM', 'Ueberfaellige Pruefung', `Die Policy "${policy.dataObjectName}" haette am ${new Date(policy.nextReviewDate).toLocaleDateString('de-DE')} geprueft werden muessen. Die Pruefung ist ${overdueDays} Tag(e) ueberfaellig.`, 'Fuehren Sie umgehend eine Pruefung dieser Policy durch und aktualisieren Sie das naechste Pruefungsdatum.' ) } return null } /** * Check 4: NO_RESPONSIBLE (MEDIUM) * Policy with no responsiblePerson AND no responsibleRole. */ function checkNoResponsible(policy: LoeschfristPolicy): ComplianceIssue | null { if (!policy.responsiblePerson.trim() && !policy.responsibleRole.trim()) { return createIssue( policy, 'NO_RESPONSIBLE', 'MEDIUM', 'Keine verantwortliche Person/Rolle', `Die Policy "${policy.dataObjectName}" hat weder eine verantwortliche Person noch eine verantwortliche Rolle zugewiesen. Ohne Verantwortlichkeit kann die Loeschung nicht zuverlaessig durchgefuehrt werden.`, 'Weisen Sie eine verantwortliche Person oder zumindest eine verantwortliche Rolle (z.B. "Datenschutzbeauftragter", "IT-Leitung") zu.' ) } return null } /** * Check 5: LEGAL_HOLD_CONFLICT (CRITICAL) * Policy has active legal hold but deletionMethod is AUTO_DELETE. */ function checkLegalHoldConflict(policy: LoeschfristPolicy): ComplianceIssue | null { const activeHolds = getActiveLegalHolds(policy) if (activeHolds.length > 0 && policy.deletionMethod === 'AUTO_DELETE') { const holdReasons = activeHolds.map((h) => h.reason).join(', ') return createIssue( policy, 'LEGAL_HOLD_CONFLICT', 'CRITICAL', 'Legal Hold mit automatischer Loeschung', `Die Policy "${policy.dataObjectName}" hat ${activeHolds.length} aktive(n) Legal Hold(s) (${holdReasons}), aber die Loeschmethode ist auf "Automatische Loeschung" gesetzt. Dies kann zu unbeabsichtigter Vernichtung von Beweismitteln fuehren.`, 'Aendern Sie die Loeschmethode auf "Manuelle Pruefung & Loeschung" oder deaktivieren Sie die automatische Loeschung, solange der Legal Hold aktiv ist.' ) } return null } /** * Check 6: STALE_DRAFT (LOW) * Policy in DRAFT status older than 90 days. */ function checkStaleDraft(policy: LoeschfristPolicy): ComplianceIssue | null { if (policy.status === 'DRAFT') { const ageInDays = daysBetween(policy.createdAt, new Date()) if (ageInDays > 90) { return createIssue( policy, 'STALE_DRAFT', 'LOW', 'Veralteter Entwurf', `Die Policy "${policy.dataObjectName}" ist seit ${ageInDays} Tagen im Entwurfsstatus. Entwuerfe, die laenger als 90 Tage nicht finalisiert werden, deuten auf unvollstaendige Dokumentation hin.`, 'Finalisieren Sie den Entwurf und setzen Sie den Status auf "Aktiv", oder archivieren Sie die Policy, falls sie nicht mehr benoetigt wird.' ) } } return null } /** * Check 8: MISSING_DELETION_METHOD (MEDIUM) * Active policy without a deletion method detail description. */ function checkMissingDeletionMethod(policy: LoeschfristPolicy): ComplianceIssue | null { if (policy.status === 'ACTIVE' && !policy.deletionMethodDetail.trim()) { return createIssue( policy, 'MISSING_DELETION_METHOD', 'MEDIUM', 'Keine Loeschmethode beschrieben', `Die aktive Policy "${policy.dataObjectName}" hat keine detaillierte Beschreibung der Loeschmethode. Fuer ein auditfaehiges Loeschkonzept muss dokumentiert sein, wie die Loeschung technisch durchgefuehrt wird.`, 'Ergaenzen Sie eine detaillierte Beschreibung der Loeschmethode (z.B. automatisches Loeschen durch Datenbank-Job, manuelle Pruefung durch Fachabteilung, kryptographische Loeschung).' ) } return null } /** * Check 9: MISSING_STORAGE_LOCATIONS (MEDIUM) * Active policy without any documented storage locations. */ function checkMissingStorageLocations(policy: LoeschfristPolicy): ComplianceIssue | null { if (policy.status === 'ACTIVE' && policy.storageLocations.length === 0) { return createIssue( policy, 'MISSING_STORAGE_LOCATIONS', 'MEDIUM', 'Keine Speicherorte dokumentiert', `Die aktive Policy "${policy.dataObjectName}" hat keine Speicherorte hinterlegt. Ohne Speicherort-Dokumentation ist unklar, wo die Daten gespeichert sind und wo die Loeschung durchgefuehrt werden muss.`, 'Dokumentieren Sie mindestens einen Speicherort (z.B. Datenbank, Cloud-Speicher, E-Mail-System, Papierarchiv).' ) } return null } /** * Check 10: EXCESSIVE_RETENTION (HIGH) * Retention duration exceeds 2x the legal default for the driver. */ function checkExcessiveRetention(policy: LoeschfristPolicy): ComplianceIssue | null { if ( policy.retentionDriver && policy.retentionDriver !== 'CUSTOM' && policy.retentionDuration !== null && policy.retentionUnit !== null ) { const meta = RETENTION_DRIVER_META[policy.retentionDriver] if (meta.defaultDuration !== null && meta.defaultUnit !== null) { // Normalize both to days for comparison const policyDays = toDays(policy.retentionDuration, policy.retentionUnit) const legalDays = toDays(meta.defaultDuration, meta.defaultUnit) if (legalDays > 0 && policyDays > legalDays * 2) { return createIssue( policy, 'EXCESSIVE_RETENTION', 'HIGH', 'Ueberschreitung der gesetzlichen Aufbewahrungsfrist', `Die Policy "${policy.dataObjectName}" hat eine Aufbewahrungsdauer von ${policy.retentionDuration} ${policy.retentionUnit === 'YEARS' ? 'Jahren' : policy.retentionUnit === 'MONTHS' ? 'Monaten' : 'Tagen'}, die mehr als das Doppelte der gesetzlichen Frist (${meta.defaultDuration} ${meta.defaultUnit === 'YEARS' ? 'Jahre' : meta.defaultUnit === 'MONTHS' ? 'Monate' : 'Tage'} nach ${meta.statute}) betraegt. Ueberlange Speicherung widerspricht dem Grundsatz der Speicherbegrenzung (Art. 5 Abs. 1 lit. e DSGVO).`, 'Pruefen Sie, ob die verlaengerte Aufbewahrungsdauer gerechtfertigt ist. Falls nicht, reduzieren Sie sie auf die gesetzliche Mindestfrist.' ) } } } return null } /** * Check 11: MISSING_DATA_CATEGORIES (LOW) * Non-draft policy without any data categories assigned. */ function checkMissingDataCategories(policy: LoeschfristPolicy): ComplianceIssue | null { if (policy.status !== 'DRAFT' && policy.dataCategories.length === 0) { return createIssue( policy, 'MISSING_DATA_CATEGORIES', 'LOW', 'Keine Datenkategorien zugeordnet', `Die Policy "${policy.dataObjectName}" (Status: ${policy.status}) hat keine Datenkategorien zugeordnet. Ohne Datenkategorien ist unklar, welche personenbezogenen Daten von dieser Loeschregel betroffen sind.`, 'Ordnen Sie mindestens eine Datenkategorie zu (z.B. Stammdaten, Kontaktdaten, Finanzdaten, Gesundheitsdaten).' ) } return null } /** * Helper: convert retention duration to days for comparison. */ function toDays(duration: number, unit: string): number { switch (unit) { case 'DAYS': return duration case 'MONTHS': return duration * 30 case 'YEARS': return duration * 365 default: return duration } } // ============================================================================= // MAIN COMPLIANCE CHECK // ============================================================================= /** * Fuehrt einen vollstaendigen Compliance-Check ueber alle Policies durch. * * @param policies - Alle Loeschfrist-Policies * @param vvtDataCategories - Optionale Datenkategorien aus dem VVT (localStorage) * @returns ComplianceCheckResult mit Issues, Score und Statistiken */ export function runComplianceCheck( policies: LoeschfristPolicy[], vvtDataCategories?: string[] ): ComplianceCheckResult { // Reset counter for deterministic IDs within a single check run issueCounter = 0 const issues: ComplianceIssue[] = [] // Run checks 1-6 for each policy for (const policy of policies) { const checks = [ checkMissingTrigger(policy), checkMissingLegalBasis(policy), checkOverdueReview(policy), checkNoResponsible(policy), checkLegalHoldConflict(policy), checkStaleDraft(policy), checkMissingDeletionMethod(policy), checkMissingStorageLocations(policy), checkExcessiveRetention(policy), checkMissingDataCategories(policy), ] for (const issue of checks) { if (issue !== null) { issues.push(issue) } } } // Check 7: UNCOVERED_VVT_CATEGORY (MEDIUM) if (vvtDataCategories && vvtDataCategories.length > 0) { const coveredCategories = new Set() for (const policy of policies) { for (const category of policy.dataCategories) { coveredCategories.add(category.toLowerCase().trim()) } } for (const vvtCategory of vvtDataCategories) { const normalized = vvtCategory.toLowerCase().trim() if (!coveredCategories.has(normalized)) { issues.push({ id: createIssueId(), policyId: '-', policyName: '-', type: 'UNCOVERED_VVT_CATEGORY', severity: 'MEDIUM', title: `Datenkategorie ohne Loeschfrist: "${vvtCategory}"`, description: `Die Datenkategorie "${vvtCategory}" ist im Verzeichnis der Verarbeitungstaetigkeiten (VVT) erfasst, hat aber keine zugehoerige Loeschfrist-Policy. Gemaess DSGVO Art. 5 Abs. 1 lit. e muss fuer jede Datenkategorie eine Speicherbegrenzung definiert sein.`, recommendation: `Erstellen Sie eine neue Loeschfrist-Policy fuer die Datenkategorie "${vvtCategory}" oder ordnen Sie sie einer bestehenden Policy zu.`, }) } } } // Calculate score const bySeverity: Record = { LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0, } for (const issue of issues) { bySeverity[issue.severity]++ } const rawScore = 100 - (bySeverity.CRITICAL * 15 + bySeverity.HIGH * 10 + bySeverity.MEDIUM * 5 + bySeverity.LOW * 2) const score = Math.max(0, rawScore) // Calculate pass/fail per policy const failedPolicyIds = new Set( issues.filter((i) => i.policyId !== '-').map((i) => i.policyId) ) const totalPolicies = policies.length const failedCount = failedPolicyIds.size const passedCount = totalPolicies - failedCount return { issues, score, stats: { total: totalPolicies, passed: passedCount, failed: failedCount, bySeverity, }, } }