diff --git a/app/ledger.php b/app/ledger.php index cd942da..cc447a1 100644 --- a/app/ledger.php +++ b/app/ledger.php @@ -496,6 +496,52 @@ function ledger_set_participant_active(PDO $pdo, int $tenantId, int $participant } } +/** + * Anonymizes a participant (GDPR-style erasure) instead of deleting them: + * name and email are replaced with a non-identifying placeholder and the + * participant is deactivated, but their ledger history stays intact for + * the tenant's bookkeeping. For the default tenant, the linked + * kl_Mitarbeiter row is anonymized the same way (its email column is + * NOT NULL UNIQUE, so it gets a placeholder instead of NULL). + */ +function ledger_anonymize_participant(PDO $pdo, int $tenantId, int $participantId): bool +{ + $stmt = $pdo->prepare('SELECT legacy_mitarbeiter_id FROM participants WHERE id = ? AND tenant_id = ?'); + $stmt->execute([$participantId, $tenantId]); + $row = $stmt->fetch(); + if ($row === false) { + return false; + } + $legacyMitarbeiterId = $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_id'] : null; + $placeholderName = 'Gelöschter Teilnehmer #' . $participantId; + $placeholderEmail = "deleted-participant-{$participantId}@invalid.local"; + + try { + $pdo->beginTransaction(); + + if ($legacyMitarbeiterId !== null) { + $pdo->prepare('UPDATE kl_Mitarbeiter SET Name = ?, Email = ?, paypalname = NULL, aktiv = 0 WHERE MitarbeiterID = ?') + ->execute([$placeholderName, $placeholderEmail, $legacyMitarbeiterId]); + } + + $pdo->prepare( + 'UPDATE participants + SET display_name = ?, email = NULL, email_norm = NULL, paypal_name = NULL, active = 0, user_id = NULL + WHERE id = ? AND tenant_id = ?' + )->execute([$placeholderName, $participantId, $tenantId]); + + $pdo->commit(); + + return true; + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + return false; + } +} + function ledger_mirror_legacy_payment(PDO $pdo, int $tenantId, int $legacyPaymentId): void { $stmt = $pdo->prepare( diff --git a/docs/m8-haertung.md b/docs/m8-haertung.md index 42a7eb5..dcee5cb 100644 --- a/docs/m8-haertung.md +++ b/docs/m8-haertung.md @@ -149,17 +149,62 @@ scripts/http-smoke.php geprüft, verifiziert dass keine Passwörter enthalten sind. Testdaten anschließend entfernt. +## Lösch-/Anonymisierungsprozess + +Umgesetzte Dateien: + +```text +app/ledger.php (ledger_anonymize_participant) +mitarbeiterverwalten.php +mandant-loeschen.php +konto.php +scripts/http-smoke.php +``` + +Zwei getrennte Flows, je nachdem was gelöscht werden soll: + +- **Teilnehmer anonymisieren** statt löschen: Name, E-Mail und PayPal-Name + werden durch einen nicht-identifizierenden Platzhalter ersetzt, das + Mitglied wird deaktiviert und vom Login-Konto getrennt + (`ledger_anonymize_participant()`). Die Ledger-Historie (Buchungen) + bleibt für die Kassenführung erhalten – konsistent mit dem + „keine harten Deletes"-Prinzip für Buchungen. Für den Default-Mandanten + wird die verknüpfte `kl_Mitarbeiter`-Zeile mitanonymisiert (`Email` ist + dort NOT NULL UNIQUE, bekommt also einen Platzhalter statt NULL). Der + Inhaber kann nicht anonymisiert werden. Button „Anonymisieren" mit + JS-Bestätigungsdialog in `mitarbeiterverwalten.php`. +- **Mandant vollständig löschen**: neue Seite `mandant-loeschen.php` + (nur Inhaber). Erfordert die exakte Eingabe des Kundenkürzels zur + Bestätigung. Löscht die `tenants`-Zeile; alle tenant-scoped Tabellen + (`participants`, `ledger_entries`, `notices`, `tenant_memberships`, + `audit_log`, `outbound_emails`, `payment_import_batches`/`_rows`, + `tenant_settings`) sind per Fremdschlüssel `ON DELETE CASCADE` + verknüpft und verschwinden automatisch mit. `users` bleiben bestehen, + da ein Login-Konto zu mehreren Mandanten gehören kann. Der migrierte + Default-Mandant ist von dieser Selbstbedienungs-Löschung ausgenommen + (seine `kl_Mitarbeiter`-Historie hat keinen Fremdschlüssel zu `tenants` + und würde verwaisen); dafür verweist die Seite an den Betreiber. +- Beide Aktionen werden auditiert (`participant.anonymized`; die + Mandantenlöschung selbst nicht, da mit ihr auch das Audit-Log dieses + Mandanten verschwindet – das ist beabsichtigt, echte Löschung soll + keine Spuren hinterlassen). + +Live getestet: Mitglied mit Buchungshistorie angelegt, anonymisiert – +Name/E-Mail weg, Jahresstriche unverändert erhalten. Mandantenlöschung mit +falscher Bestätigung abgewiesen, mit korrekter Bestätigung vollständig +durchgeführt; anschließend geprüft, dass wirklich alle tenant-scoped +Tabellen leer sind und die globale `users`-Zeile erhalten bleibt. + ## Prüfstatus - Golden Master: grün mit 104 Assertions. - M4 Ledger-Migration: grün mit 73 Assertions. - M3 Settings-Flow: grün mit 13 Assertions. -- HTTP-Smoke: grün mit 27 geprüften Seiten. +- HTTP-Smoke: grün mit 28 geprüften Seiten. - M8 Mandanten-Isolation: grün mit 10 Assertions. - M8 Rollenmatrix: grün mit 55 Assertions. ## Noch offen in M8 - Content-Security-Policy (braucht Template-Bereinigung der Inline-Styles). -- Lösch-/Anonymisierungsprozess für Teilnehmer und Kunden. - Backup-/Restore-Prozess und Monitoring/Fehlerlogging dokumentieren. diff --git a/docs/saas-umstrukturierungsplan.md b/docs/saas-umstrukturierungsplan.md index ade3c27..8398168 100644 --- a/docs/saas-umstrukturierungsplan.md +++ b/docs/saas-umstrukturierungsplan.md @@ -652,7 +652,12 @@ Schritte: des eigenen Mandanten (Stammdaten, Teilnehmer, Mitglieder/Rollen, Buchungen, Hinweise, Importe, Mail-Log, Admin-Protokoll) ohne Passwörter, protokolliert im Audit-Log. -- Lösch-/Anonymisierungsprozess für Teilnehmer und Kunden. +- Lösch-/Anonymisierungsprozess für Teilnehmer und Kunden: erledigt. + Teilnehmer werden anonymisiert statt gelöscht (Buchungshistorie bleibt + für die Kassenführung erhalten), Mandanten können sich über + `mandant-loeschen.php` mit Bestätigungseingabe vollständig selbst + löschen (Cascade über Fremdschlüssel); der migrierte Default-Mandant + ist davon ausgenommen. - Rate-Limits und Security Headers: erledigt. Globale Security-Headers über `app/bootstrap.php` (ohne CSP, siehe `docs/m8-haertung.md`), DB-gestützte Rate-Limits für Login, Registrierung und Passwort-Reset. diff --git a/konto.php b/konto.php index abe424a..9944c40 100644 --- a/konto.php +++ b/konto.php @@ -67,6 +67,9 @@ include 'nav.php'; diff --git a/mandant-loeschen.php b/mandant-loeschen.php new file mode 100644 index 0000000..d38d49f --- /dev/null +++ b/mandant-loeschen.php @@ -0,0 +1,83 @@ +prepare('DELETE FROM tenants WHERE id = ?')->execute([$tenantId]); + saas_logout(); + header('Location: landing.php?tenant_deleted=1'); + exit; + } +} + +include 'header.php'; +include 'headerline.php'; +include 'nav.php'; +?> + + + + diff --git a/mitarbeiterverwalten.php b/mitarbeiterverwalten.php index e7d0ecd..aa6740e 100644 --- a/mitarbeiterverwalten.php +++ b/mitarbeiterverwalten.php @@ -122,6 +122,24 @@ if($hasAccess){ } else { $fehler = implode(' ', $ergebnis['errors']); } + } elseif ($aktion === 'anonymisieren') { + $participantId = (int)$_POST["mitgliedID"]; + $mitgliedRolle = null; + foreach (ledger_fetch_participants_for_admin($pdo, $tenantId) as $m) { + if ($m['participant_id'] === $participantId) { + $mitgliedRolle = $m['role']; + break; + } + } + + if ($mitgliedRolle === 'owner') { + $fehler = 'Der Inhaber kann nicht anonymisiert werden.'; + } elseif (ledger_anonymize_participant($pdo, $tenantId, $participantId)) { + app_audit_log($pdo, $tenantId, $actorUserId, 'participant.anonymized', 'participant', $participantId); + $meldung = 'Mitglied wurde anonymisiert. Name und E-Mail sind entfernt, Buchungen bleiben für die Kassenführung erhalten.'; + } else { + $fehler = 'Das Mitglied konnte nicht anonymisiert werden.'; + } } } @@ -292,6 +310,16 @@ if($hasAccess){ + +
  • +
    " onsubmit="return confirm('Name und E-Mail dieses Mitglieds unwiderruflich entfernen? Buchungen bleiben erhalten.');"> + + + + +
    +
  • + diff --git a/scripts/http-smoke.php b/scripts/http-smoke.php index 67c026a..8511e46 100644 --- a/scripts/http-smoke.php +++ b/scripts/http-smoke.php @@ -117,6 +117,11 @@ $checks = [ 'path' => 'datenexport.php', 'contains' => ['Login'], ], + [ + 'label' => 'Mandant löschen Login-Schutz', + 'path' => 'mandant-loeschen.php', + 'contains' => ['Login'], + ], [ 'label' => 'Teilnehmerauswertung', 'path' => 'teilnehmerauswertung.php?user_id=1',