# 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.~~ Erledigt in `docs/betrieb-backup-monitoring.md`. ## Nachtrag: Kontofunktionen vor dem Go-Live (2026-08-06) Vier Lücken, die im Alltag sofort aufgefallen wären, geschlossen. Umgesetzte Dateien: ```text app/saas-auth.php (saas_change_password, saas_email_verified, saas_require_verified_email) konto.php (Formular "Passwort ändern") mandant-auswahl.php(Wechsel aus bestehender Sitzung) footer.php ("Mandant wechseln" ab zwei Mandanten) header.php (Hinweisbanner bei unbestätigter Adresse) login.php (Ziel nach Login) mitarbeiterverwalten.php, mailversenden.php, jahresauswertung.php (Verifikationspflicht) scripts/check-konto-und-mandantenwechsel.php ``` ### Passwortwechsel im eingeloggten Zustand Bisher gab es ausschließlich den Reset über einen Mail-Link; wer sein Passwort ändern wollte, musste sich abmelden und „Passwort vergessen" benutzen. `saas_change_password()` verlangt das aktuelle Passwort als Identitätsnachweis, erzwingt dieselbe Mindestlänge wie der Reset und verlangt ein tatsächlich anderes Passwort. Nach dem Wechsel wird die Session-ID erneuert. **Bewusst nicht umgesetzt:** ein Abmelden aller anderen Sitzungen. Die Sessions liegen als Dateien ohne Zuordnung zur User-ID; das bräuchte einen eigenen Session-Store und lohnt beim aktuellen Umfang nicht. ### Mandantenwechsel ohne Logout `mandant-auswahl.php` war nur direkt nach dem Login erreichbar (die Seite stieg ohne `saas_pending_tenant_user_id()` sofort aus). Wer bei mehreren Mandanten Mitglied ist, musste sich zum Wechseln abmelden. Die Seite bedient jetzt beide Wege; die Mandantenprüfung läuft unverändert über `saas_identity_for_user_tenant()`, ein fremder Mandant wird also auch aus einer bestehenden Sitzung heraus abgewiesen. Der Menüpunkt erscheint nur ab zwei Mitgliedschaften. ### E-Mail-Verifikation wird erzwungen `email_verified_at` wurde bisher nur angezeigt, nie geprüft — bei öffentlicher Selbstregistrierung konnte sich also jemand mit einer fremden Adresse anmelden und das Produkt voll nutzen. Erzwungen wird jetzt gezielt dort, wo eine Aktion **nach außen** wirkt: Mitglieder einladen, Info-Mail an alle, Jahresabschluss mit Benachrichtigung. Bei den beiden Mailflows bleibt der Dry-Run erlaubt, die Einladung wird ganz abgewiesen. Bewusst **nicht** erzwungen wird der Login selbst: die eigene Kaffeeliste darf man auch unbestätigt führen. Der Grund ist der migrierte Bestand — eine harte Loginsperre hätte alle Nutzer ausgesperrt, deren `email_verified_at` aus der Legacy-Migration `NULL` ist. Ergänzend setzt `saas_reset_password_with_token()` jetzt `email_verified_at` mit, denn wer den zugestellten Link öffnen konnte, hat den Zugriff auf das Postfach nachgewiesen. Das betrifft vor allem eingeladene Mitglieder: deren Zugangsvergabe läuft über genau diesen Link, ohne dass je eine separate Verifikationsmail verschickt wird — ohne diese Ergänzung wären eingeladene Admins dauerhaft als unbestätigt geführt worden. ### Login-Ziel Nach dem Login ging es auf `konto.php`, also auf eine Stammdatentabelle. Ziel ist jetzt `index.php`. ### Prüfstatus `scripts/check-konto-und-mandantenwechsel.php` deckt alle vier Punkte ab (18 Assertions, grün): Login-Ziel, abgewiesene und erlaubte Einladung, Mandantenwechsel inklusive Negativfall fremder Mandant, alle vier Fehlerfälle des Passwortwechsels plus echter Login mit altem und neuem Passwort, und die Verifikation über den Reset-Link. Zusätzlich manuell über das Formular in `konto.php` geprüft: Erfolgs- und Fehlermeldung, CSRF-Schutz (419 ohne Token). Alle bestehenden Suiten bleiben grün (HTTP-Smoke 34 Seiten, Rollenmatrix 55, Mandanten-Isolation 12, M3-Auth 9, M3-Settings 15).