Files
kaffeekasse-saas/docs/m8-haertung.md
T
clemensandClaude Opus 5 39f662d541 Kontofunktionen vor dem Go-Live und Landingpage-Kuerzung
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>
2026-08-06 17:03:50 +02:00

12 KiB
Raw Blame History

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() 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:

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:

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:

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:

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:

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).