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

295 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).