Vier Luecken geschlossen, die im Alltag sofort aufgefallen waeren: - Passwort aendern war im eingeloggten Zustand gar nicht moeglich; es gab nur den Reset per Mail-Link. saas_change_password() prueft das aktuelle Passwort, verlangt ein tatsaechlich anderes und erneuert danach die Session-ID. Formular in konto.php. - mandant-auswahl.php war nur direkt nach dem Login erreichbar. Wer bei mehreren Mandanten Mitglied ist, musste sich zum Wechseln abmelden. Die Seite bedient jetzt beide Wege, die Mandantenpruefung bleibt unveraendert ueber saas_identity_for_user_tenant(). Menuepunkt ab zwei Mitgliedschaften. - email_verified_at wurde nirgends geprueft, nur angezeigt - bei offener Selbstregistrierung konnte sich jemand mit fremder Adresse anmelden und alles nutzen. Erzwungen wird jetzt gezielt dort, wo eine Aktion nach aussen wirkt: Einladung, Info-Mail, Jahresabschluss. Der Login selbst bleibt bewusst frei, sonst waeren alle migrierten Bestandsnutzer mit NULL-Verifikation ausgesperrt. Zusaetzlich setzt der Passwort-Reset die Verifikation mit, weil der Mail-Link den Postfachzugriff nachweist - sonst blieben eingeladene Mitglieder dauerhaft unbestaetigt. - Login landete auf konto.php statt auf dem Dashboard. Landingpage: die gruene Vertrauenszeile auf "DSGVO-konform" gekuerzt, die Eintraege zu Paragraf 19 UStG und "Bestehende Ablaeufe bleiben" entfernt. Geprueft: neues scripts/check-konto-und-mandantenwechsel.php mit 18 Assertions gruen, Passwortformular zusaetzlich manuell inkl. CSRF (419). Bestehende Suiten unveraendert gruen: HTTP-Smoke 34 Seiten, Rollenmatrix 55, Mandanten-Isolation 12, M3-Auth 9, M3-Settings 15. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 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.Erledigt indocs/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:
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).