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>
211 lines
8.8 KiB
Markdown
211 lines
8.8 KiB
Markdown
# M8 Härtung, Datenschutz und Betrieb
|
||
|
||
Stand: 2026-07-15
|
||
|
||
M8 macht die SaaS-App für den mehrmandantenfähigen Betrieb sicherer und
|
||
nachvollziehbarer: Security-Headers, Rate-Limits, ein zentrales Audit-Log,
|
||
sowie (folgend) Mandanten-Isolation, Rollenmatrix, Datenexport und
|
||
Löschung/Anonymisierung.
|
||
|
||
## Security-Headers
|
||
|
||
Umgesetzte Dateien:
|
||
|
||
```text
|
||
app/bootstrap.php
|
||
landing.php
|
||
```
|
||
|
||
- `app_send_security_headers()` setzt `X-Content-Type-Options: nosniff`,
|
||
`X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`,
|
||
`Permissions-Policy` (Geolocation/Mikrofon/Kamera aus) sowie `Strict-
|
||
Transport-Security` bei HTTPS. Der Aufruf steht am Ende von
|
||
`app/bootstrap.php`, das transitiv von jeder dynamischen Seite geladen
|
||
wird, und läuft damit automatisch einmal pro Request.
|
||
- Bewusst **kein** Content-Security-Policy-Header: Die bestehenden
|
||
Templates nutzen durchgängig `style=""`-Inline-Attribute (Banner,
|
||
Tabellenzellen, Status-Einfärbung). Ein CSP-Lockdown würde diese
|
||
brechen und bräuchte einen eigenen Template-Durchgang – als offener
|
||
Punkt vermerkt, nicht in diesem Schritt umgesetzt.
|
||
- `landing.php` war die einzige Seite ganz ohne PHP (reines HTML). Ein
|
||
minimaler `require_once app/bootstrap.php`-Aufruf am Dateianfang sorgt
|
||
dafür, dass auch sie die Header bekommt, ohne das Markup zu verändern.
|
||
|
||
## Rate-Limits
|
||
|
||
Umgesetzte Dateien:
|
||
|
||
```text
|
||
database/migrations/0010_saas_rate_limits.sql
|
||
app/rate-limit.php
|
||
login.php
|
||
register.php
|
||
passwort-vergessen.php
|
||
```
|
||
|
||
- Neue Tabelle `rate_limit_attempts` (Bucket + Zeitstempel) mit
|
||
`app_rate_limit_check()`: schreibt einen Versuch, räumt alte Einträge
|
||
für denselben Bucket auf und meldet, ob das Limit noch eingehalten ist.
|
||
- Login: 10 Versuche / 15 Minuten je E-Mail-Adresse **und** 20 Versuche /
|
||
15 Minuten je IP (beide müssen greifen, damit ein einzelnes Konto nicht
|
||
gezielt von verteilten IPs aus brute-forced werden kann und eine IP
|
||
nicht viele Konten gleichzeitig durchprobieren kann).
|
||
- Registrierung: 5 Versuche / Stunde je IP (gegen Spam-Registrierungen).
|
||
- Passwort-Reset-Anfrage: 5 / Stunde je E-Mail, 10 / Stunde je IP. Bei
|
||
überschrittenem Limit erscheint bewusst dieselbe generische
|
||
Erfolgsmeldung wie im echten Erfolgsfall, damit ein ausgereiztes Limit
|
||
nicht zusätzlich verrät, ob ein Konto existiert.
|
||
- Live getestet: 11 aufeinanderfolgende Fehlversuche gegen `login.php` –
|
||
die ersten 10 zeigen „ungültig“, der 11. wird korrekt mit „Zu viele
|
||
Anmeldeversuche“ abgewiesen.
|
||
|
||
## Audit-Log
|
||
|
||
Umgesetzte Dateien:
|
||
|
||
```text
|
||
database/migrations/0011_saas_audit_log.sql
|
||
app/audit.php
|
||
mitarbeiterverwalten.php
|
||
letzteneintraege.php
|
||
mandant-einstellungen.php
|
||
hinweise.php
|
||
csvupload.php
|
||
jahresauswertung.php
|
||
mailversenden.php
|
||
```
|
||
|
||
- Neue Tabelle `audit_log` (Mandant, ausführender Nutzer, Aktion,
|
||
betroffener Datensatz, Metadaten als JSON, IP, Zeitpunkt) gemäß
|
||
Kernschema aus dem Umstrukturierungsplan.
|
||
- `app_audit_log()` schreibt einen Eintrag, `app_fetch_audit_log()` liest
|
||
die letzten N Einträge eines Mandanten.
|
||
- Protokollierte Aktionen: Mitglied anlegen/bearbeiten/(de)aktivieren,
|
||
Zugang gewähren/entziehen, Storno von Einzahlung/Strich, Mandant-
|
||
Einstellungen ändern, Hinweis anlegen/löschen, CSV-Import bestätigen,
|
||
Jahresbonus-Verteilung, Live-Mailversand.
|
||
- Sichtbar für Owner/Admin auf `mandant-einstellungen.php` unter
|
||
„Protokoll" (letzte 50 Einträge, mit Namen des ausführenden Nutzers).
|
||
- Live getestet: Hinweis anlegen erzeugt einen Log-Eintrag mit korrekten
|
||
Metadaten; über einen eigens angelegten Test-Mandanten geprüft, dass
|
||
eine Einstellungsänderung im UI-Protokoll mit korrektem Nutzernamen
|
||
erscheint. Testdaten anschließend entfernt.
|
||
|
||
## Mandanten-Isolation (automatisiert)
|
||
|
||
Umgesetzte Datei: `scripts/check-m8-tenant-isolation.php`
|
||
|
||
Legt zwei frische, isolierte Test-Mandanten mit je einem Teilnehmer, einer
|
||
Ledger-Buchung, einem Hinweis und einem Audit-Log-Eintrag an und prüft
|
||
10 Fälle: Teilnehmerlisten, Einzelabruf, letzte Buchungen, aktive Hinweise,
|
||
Schreibversuche (Buchung, Zugangsvergabe, Storno) und Audit-Log sind
|
||
strikt auf den jeweils richtigen Mandanten beschränkt; ein Zugriff über den
|
||
falschen Mandanten liefert nichts beziehungsweise schlägt sauber fehl statt
|
||
fremde Daten zurückzugeben. Räumt die Testdaten am Ende selbst auf.
|
||
|
||
Ergebnis: grün mit 10 Assertions.
|
||
|
||
## Rollenmatrix (automatisiert)
|
||
|
||
Umgesetzte Datei: `scripts/check-m8-role-matrix.php`
|
||
|
||
Legt einen Test-Mandanten mit je einem Nutzer pro Rolle (`owner`, `admin`,
|
||
`treasurer`, `member`, `viewer`) an, loggt sich für jede Rolle per echtem
|
||
HTTP-Request ein (manueller Cookie-Jar über `file_get_contents`, da diese
|
||
PHP-Installation keine curl-Extension hat) und ruft alle rollen-geschützten
|
||
Seiten auf: `kaffeeliste.php`, `mitarbeiterverwalten.php`, `hinweise.php`,
|
||
`stricheintragen.php`, `einzahlung.php`, `letzteneintraege.php`,
|
||
`csvupload.php`, `exportKaffeeliste.php`, `mailversenden.php`,
|
||
`jahresauswertung.php`, `mandant-einstellungen.php`. Für jede
|
||
Rolle-Seite-Kombination wird geprüft, ob der tatsächliche Zugriff (anhand
|
||
der „Kein Zugriff"/„keine Berechtigung"-Marker in der Antwort) mit der
|
||
erwarteten Rollenliste übereinstimmt.
|
||
|
||
Ergebnis: grün mit 55 Assertions (5 Rollen × 11 Seiten). Räumt die
|
||
Testdaten am Ende selbst auf.
|
||
|
||
## Datenexport pro Mandant
|
||
|
||
Umgesetzte Dateien:
|
||
|
||
```text
|
||
app/data-export.php
|
||
datenexport.php
|
||
konto.php
|
||
scripts/http-smoke.php
|
||
```
|
||
|
||
- Neue Seite `datenexport.php` (Owner/Admin, echte SaaS-Session nötig)
|
||
lädt einen vollständigen JSON-Export des eigenen Mandanten herunter:
|
||
Tenant-Stammdaten, Einstellungen, Teilnehmer, Mitglieder mit Rolle,
|
||
alle Ledger-Buchungen, Hinweise, CSV-Importe (Batches und Zeilen),
|
||
Mail-Versandlog und Admin-Protokoll.
|
||
- Passwort-Hashes und Token-Hashes werden bewusst nicht exportiert.
|
||
- Der Export selbst wird im Audit-Log protokolliert
|
||
(`tenant_data.exported`).
|
||
- Link von `konto.php` aus neben „Mandant-Einstellungen".
|
||
- Live getestet: eigens angelegter Test-Mandant, Export heruntergeladen,
|
||
Header (`Content-Type`, `Content-Disposition`) und JSON-Struktur
|
||
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 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).
|
||
- Backup-/Restore-Prozess und Monitoring/Fehlerlogging dokumentieren.
|