M8: Loesch-/Anonymisierungsprozess fuer Teilnehmer und Mandanten
Zwei getrennte Flows: - Teilnehmer anonymisieren statt loeschen (ledger_anonymize_participant): Name/E-Mail/PayPal-Name werden durch einen Platzhalter ersetzt, das Mitglied deaktiviert und vom Login-Konto getrennt. Buchungshistorie bleibt fuer die Kassenfuehrung erhalten, konsistent mit dem Storno-statt-Delete-Prinzip. Default-Mandant spiegelt die Anonymisierung in kl_Mitarbeiter (Email dort NOT NULL UNIQUE, bekommt Platzhalter statt NULL). Inhaber kann nicht anonymisiert werden. - Mandant vollstaendig loeschen (mandant-loeschen.php, nur Inhaber): erfordert exakte Eingabe des Kundenkuerzels, loescht die tenants-Zeile; alle tenant-scoped Tabellen kaskadieren per Fremdschluessel. users bleiben bestehen (koennen zu mehreren Mandanten gehoeren). Der migrierte Default-Mandant ist ausgenommen, da seine kl_Mitarbeiter- Historie sonst verwaisen wuerde. Live getestet: Mitglied mit Buchungshistorie anonymisiert (Historie blieb erhalten), Mandantenloeschung mit falscher/richtiger Bestaetigung geprueft, vollstaendiger Cascade-Delete ueber alle tenant-scoped Tabellen verifiziert, globale users-Zeile bleibt korrekt erhalten. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
function ledger_mirror_legacy_payment(PDO $pdo, int $tenantId, int $legacyPaymentId): void
|
||||||
{
|
{
|
||||||
$stmt = $pdo->prepare(
|
$stmt = $pdo->prepare(
|
||||||
|
|||||||
+47
-2
@@ -149,17 +149,62 @@ scripts/http-smoke.php
|
|||||||
geprüft, verifiziert dass keine Passwörter enthalten sind. Testdaten
|
geprüft, verifiziert dass keine Passwörter enthalten sind. Testdaten
|
||||||
anschließend entfernt.
|
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
|
## Prüfstatus
|
||||||
|
|
||||||
- Golden Master: grün mit 104 Assertions.
|
- Golden Master: grün mit 104 Assertions.
|
||||||
- M4 Ledger-Migration: grün mit 73 Assertions.
|
- M4 Ledger-Migration: grün mit 73 Assertions.
|
||||||
- M3 Settings-Flow: grün mit 13 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 Mandanten-Isolation: grün mit 10 Assertions.
|
||||||
- M8 Rollenmatrix: grün mit 55 Assertions.
|
- M8 Rollenmatrix: grün mit 55 Assertions.
|
||||||
|
|
||||||
## Noch offen in M8
|
## Noch offen in M8
|
||||||
|
|
||||||
- Content-Security-Policy (braucht Template-Bereinigung der Inline-Styles).
|
- Content-Security-Policy (braucht Template-Bereinigung der Inline-Styles).
|
||||||
- Lösch-/Anonymisierungsprozess für Teilnehmer und Kunden.
|
|
||||||
- Backup-/Restore-Prozess und Monitoring/Fehlerlogging dokumentieren.
|
- Backup-/Restore-Prozess und Monitoring/Fehlerlogging dokumentieren.
|
||||||
|
|||||||
@@ -652,7 +652,12 @@ Schritte:
|
|||||||
des eigenen Mandanten (Stammdaten, Teilnehmer, Mitglieder/Rollen,
|
des eigenen Mandanten (Stammdaten, Teilnehmer, Mitglieder/Rollen,
|
||||||
Buchungen, Hinweise, Importe, Mail-Log, Admin-Protokoll) ohne
|
Buchungen, Hinweise, Importe, Mail-Log, Admin-Protokoll) ohne
|
||||||
Passwörter, protokolliert im Audit-Log.
|
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
|
- Rate-Limits und Security Headers: erledigt. Globale Security-Headers
|
||||||
über `app/bootstrap.php` (ohne CSP, siehe `docs/m8-haertung.md`),
|
über `app/bootstrap.php` (ohne CSP, siehe `docs/m8-haertung.md`),
|
||||||
DB-gestützte Rate-Limits für Login, Registrierung und Passwort-Reset.
|
DB-gestützte Rate-Limits für Login, Registrierung und Passwort-Reset.
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ include 'nav.php';
|
|||||||
<ul class="actions">
|
<ul class="actions">
|
||||||
<li><a href="mandant-einstellungen.php" class="button">Mandant-Einstellungen</a></li>
|
<li><a href="mandant-einstellungen.php" class="button">Mandant-Einstellungen</a></li>
|
||||||
<li><a href="datenexport.php" class="button">Datenexport</a></li>
|
<li><a href="datenexport.php" class="button">Datenexport</a></li>
|
||||||
|
<?php if (saas_user_has_role('owner', $user)): ?>
|
||||||
|
<li><a href="mandant-loeschen.php" class="button">Mandant löschen</a></li>
|
||||||
|
<?php endif; ?>
|
||||||
</ul>
|
</ul>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/functions.php';
|
||||||
|
require_once __DIR__ . '/app/ledger.php';
|
||||||
|
|
||||||
|
$pdo = app_db_pdo();
|
||||||
|
$user = saas_require_login();
|
||||||
|
|
||||||
|
if (!saas_user_has_role('owner', $user)) {
|
||||||
|
http_response_code(403);
|
||||||
|
exit('Nur der Inhaber kann den Mandanten löschen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantId = (int)$user['tenant_id'];
|
||||||
|
$isDefaultTenant = ledger_is_default_tenant($pdo, $tenantId);
|
||||||
|
$errors = [];
|
||||||
|
$confirmInput = '';
|
||||||
|
|
||||||
|
if (!$isDefaultTenant && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
app_require_csrf();
|
||||||
|
$confirmInput = trim((string)($_POST['confirm_slug'] ?? ''));
|
||||||
|
|
||||||
|
if ($confirmInput !== $user['tenant_slug']) {
|
||||||
|
$errors[] = 'Bitte das Kundenkürzel exakt eingeben, um die Löschung zu bestätigen.';
|
||||||
|
} else {
|
||||||
|
// Loeschen der tenants-Zeile kaskadiert per FK auf alles
|
||||||
|
// tenant-scoped (participants, ledger_entries, notices,
|
||||||
|
// tenant_memberships, audit_log, outbound_emails,
|
||||||
|
// payment_import_batches/rows, tenant_settings). users bleiben
|
||||||
|
// erhalten, da ein Login-Konto zu mehreren Mandanten gehoeren kann.
|
||||||
|
$pdo->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';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<section id="banner">
|
||||||
|
<div class="content">
|
||||||
|
<h2>Mandant löschen</h2>
|
||||||
|
|
||||||
|
<?php if ($isDefaultTenant): ?>
|
||||||
|
<div class="hint-box error">
|
||||||
|
<p>Dieser Mandant ist der migrierte Bestandsmandant und kann hier nicht
|
||||||
|
selbstständig gelöscht werden. Bitte wende dich für diesen Fall an den
|
||||||
|
Betreiber.</p>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="hint-box error">
|
||||||
|
<p><b>Diese Aktion kann nicht rückgängig gemacht werden.</b> Alle Daten von
|
||||||
|
„<?php echo saas_html($user['tenant_name']); ?>" werden unwiderruflich gelöscht:
|
||||||
|
Mitglieder, alle Buchungen, Hinweise, Importe, Mail-Log und das
|
||||||
|
Admin-Protokoll. Ziehe vorher gegebenenfalls einen
|
||||||
|
<a href="datenexport.php">Datenexport</a>.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($errors !== []): ?>
|
||||||
|
<div class="hint-box error">
|
||||||
|
<?php foreach ($errors as $error): ?>
|
||||||
|
<p><?php echo saas_html($error); ?></p>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="post" action="mandant-loeschen.php">
|
||||||
|
<?php echo app_csrf_field(); ?>
|
||||||
|
<label for="confirm_slug">Zum Bestätigen das Kundenkürzel eingeben: <b><?php echo saas_html($user['tenant_slug']); ?></b></label>
|
||||||
|
<input type="text" name="confirm_slug" id="confirm_slug" value="<?php echo saas_html($confirmInput); ?>" required>
|
||||||
|
<ul class="actions">
|
||||||
|
<li><button type="submit">Mandant endgültig löschen</button></li>
|
||||||
|
<li><a href="konto.php" class="button alt">Abbrechen</a></li>
|
||||||
|
</ul>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<?php include 'footer.php'; ?>
|
||||||
@@ -122,6 +122,24 @@ if($hasAccess){
|
|||||||
} else {
|
} else {
|
||||||
$fehler = implode(' ', $ergebnis['errors']);
|
$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){
|
|||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</li>
|
</li>
|
||||||
|
<?php if ($mitglied['role'] !== 'owner'): ?>
|
||||||
|
<li>
|
||||||
|
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" onsubmit="return confirm('Name und E-Mail dieses Mitglieds unwiderruflich entfernen? Buchungen bleiben erhalten.');">
|
||||||
|
<input type="hidden" name="aktion" value="anonymisieren">
|
||||||
|
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
|
||||||
|
<?php echo app_csrf_field(); ?>
|
||||||
|
<button type="submit">Anonymisieren</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
<?php endif; ?>
|
||||||
</ul>
|
</ul>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -117,6 +117,11 @@ $checks = [
|
|||||||
'path' => 'datenexport.php',
|
'path' => 'datenexport.php',
|
||||||
'contains' => ['Login'],
|
'contains' => ['Login'],
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Mandant löschen Login-Schutz',
|
||||||
|
'path' => 'mandant-loeschen.php',
|
||||||
|
'contains' => ['Login'],
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'label' => 'Teilnehmerauswertung',
|
'label' => 'Teilnehmerauswertung',
|
||||||
'path' => 'teilnehmerauswertung.php?user_id=1',
|
'path' => 'teilnehmerauswertung.php?user_id=1',
|
||||||
|
|||||||
Reference in New Issue
Block a user