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>
8.8 KiB
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:
app/bootstrap.php
landing.php
app_send_security_headers()setztX-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: strict-origin-when-cross-origin,Permissions-Policy(Geolocation/Mikrofon/Kamera aus) sowieStrict- Transport-Securitybei HTTPS. Der Aufruf steht am Ende vonapp/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.phpwar die einzige Seite ganz ohne PHP (reines HTML). Ein minimalerrequire_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:
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) mitapp_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:
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.phpunter „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:
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.phpaus 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:
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üpftekl_Mitarbeiter-Zeile mitanonymisiert (Emailist dort NOT NULL UNIQUE, bekommt also einen Platzhalter statt NULL). Der Inhaber kann nicht anonymisiert werden. Button „Anonymisieren" mit JS-Bestätigungsdialog inmitarbeiterverwalten.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 dietenants-Zeile; alle tenant-scoped Tabellen (participants,ledger_entries,notices,tenant_memberships,audit_log,outbound_emails,payment_import_batches/_rows,tenant_settings) sind per FremdschlüsselON DELETE CASCADEverknüpft und verschwinden automatisch mit.usersbleiben bestehen, da ein Login-Konto zu mehreren Mandanten gehören kann. Der migrierte Default-Mandant ist von dieser Selbstbedienungs-Löschung ausgenommen (seinekl_Mitarbeiter-Historie hat keinen Fremdschlüssel zutenantsund 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.