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