Files
breakpilot-compliance/admin-compliance/lib/sdk/loeschfristen-compliance.ts
Benjamin Admin 2a70441eaa feat(sdk): VVT master libraries, process templates, Loeschfristen profiling + document
VVT: Master library tables (7 catalogs), 500+ seed entries, process templates
with instantiation, library API endpoints + 18 tests.
Loeschfristen: Baseline catalog, compliance checks, profiling engine, HTML document
generator, MkDocs documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 11:56:25 +01:00

438 lines
15 KiB
TypeScript

// =============================================================================
// 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<ComplianceIssueSeverity, number>
}
}
// =============================================================================
// 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<string>()
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<ComplianceIssueSeverity, number> = {
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,
},
}
}