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>
295 lines
12 KiB
Markdown
295 lines
12 KiB
Markdown
# 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).
|