Phase 10: Mandantenfaehigkeit

Ein Server, mehrere Wehren. Ein Konto, mehrere Zugehoerigkeiten. Eine
Kreisebene, die zusieht.

Datenmodell und RLS trugen seit Phase 0 mehrere Wehren. Was fehlte, war
alles darueber: bis hierher war die Personenakte zugleich das Anmeldekonto,
und die gehoert genau einer Wehr. Wer in zweien gefuehrt wurde, brauchte
zwei Passwoerter, und die Anmeldung per E-Mail war mehrdeutig.

Neu sind `Benutzer` (das Konto) und `Mitgliedschaft` (die Zugehoerigkeit).
Das Konto traegt bewusst keine Mandantenkennung und kann keine tragen - es
ist der Angelpunkt, an dem ein Mensch zu mehreren Wehren gehoert. Geschuetzt
wird es deshalb ueber Rechte statt ueber RLS, wie `Berechtigung` seit Phase
0. Was es in einer Wehr darf, entscheidet seine Mitgliedschaft.

Die Sitzung behaelt ihre Form und entsteht nur anders - aus der aktiven
Mitgliedschaft statt aus der Person. Von 414 Fundstellen in 61 Dateien
musste dadurch keine angefasst werden. Die eine Ausnahme ist Absicht:
`personId` ist nullbar geworden, weil eine Aufsichtssitzung keine Akte hat,
und der Typechecker hat daraus genau die fuenf Stellen gemacht, an denen
die Kreisaufsicht nichts verloren hat.

Ihre Rechte werden gefiltert statt aufgezaehlt: alles, was `.lesen` heisst,
minus Kasse, Audit-Log, KI-Protokoll und Personenexport. So bekommt sie
neue Leserechte automatisch und neue Schreibrechte nie.

Die Migration ist datenerhaltend und bricht ab, statt einen halben Zustand
zu hinterlassen.

Nebenbei behoben: `db:test-reset` liess die Nachbarwehr stehen, der
Fehlversuch-Test sperrte das gemeinsame Testkonto, ein Testaufbau war
nicht wiederholbar, und die Wartelogik der Bildschirmfotos konnte
ausloesen, bevor eine Seite ueberhaupt geladen war.

581 Unit-Tests und 266 E2E-Tests gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-05 13:43:24 +02:00
co-authored by Claude Opus 5
parent 2b53556175
commit ff40437a1a
98 changed files with 3810 additions and 201 deletions
+142
View File
@@ -33,6 +33,7 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env`
| Phase 7 — Benachrichtigungen und Termine | fertig |
| Phase 8 — Lokale KI | fertig |
| Phase 9 — Kassenverwaltung | fertig |
| Phase 10 — Mandantenfähigkeit | fertig |
Vollständige Planung mit Aufwänden: [docs/ROADMAP.md](docs/ROADMAP.md) und
[docs/AUFWANDSSCHAETZUNG.md](docs/AUFWANDSSCHAETZUNG.md).
@@ -1305,6 +1306,147 @@ läuft: Postgres, MinIO für die Belege und Gotenberg für die PDFs.
---
## Phase 10 — Mandantenfähigkeit
Ein Server, mehrere Wehren. Ein Konto, mehrere Zugehörigkeiten. Eine
Kreisebene, die zusieht.
Datenmodell und Row Level Security tragen seit Phase 0 mehrere Wehren — jede
Tabelle hat `organisationId`, jede Abfrage läuft im Mandantenkontext. Was
fehlte, war alles darüber: eine Anmeldung, die zu mehr als einer Wehr gehören
kann, ein Weg zum Umschalten, und eine Verwaltung für den, der den Server
betreibt.
### Konto und Akte sind nicht dasselbe
Bis Phase 9 war die Personenakte zugleich das Anmeldekonto: Passwort-Hash und
OIDC-Subject hingen an `person`, und `person` gehört genau einer Wehr. Daraus
folgten zwei Dinge, die niemand wollte:
- Wer in zwei Wehren geführt wurde, brauchte **zwei Passwörter**, die
auseinanderlaufen können.
- Die Anmeldung per E-Mail war **mehrdeutig**, sobald dieselbe Adresse in zwei
Wehren stand — die Eindeutigkeit galt nur je Organisation, und gefunden
wurde, was die Datenbank zuerst hergab.
Seit Phase 10:
| Tabelle | Was sie ist | Mandant |
|---|---|---|
| `Benutzer` | das Anmeldekonto — E-Mail, Passwort, OIDC | **keiner** |
| `Mitgliedschaft` | Zugehörigkeit eines Kontos zu einer Wehr | `organisationId` |
| `Person` | die Akte in einer Wehr — Dienstgrad, Qualifikationen, Einsätze | `organisationId` |
**`Benutzer` trägt bewusst keine Mandantenkennung** und kann keine tragen: sie
ist ja der Angelpunkt, an dem ein Mensch zu mehreren Wehren gehört. RLS greift
dort deshalb nicht. Geschützt wird sie stattdessen über Rechte — dieselbe
Lösung wie bei `Berechtigung` aus Phase 0 und aus demselben Grund: eine
Tabelle, die über allen Mandanten steht, gehört nicht in die Hand der
Anwendungsrolle. Was ein Konto in einer Wehr darf, entscheidet nicht es
selbst, sondern seine `Mitgliedschaft`.
Ein angenehmer Nebeneffekt: der Passwort-Hash kann aus einer Personenabfrage
gar nicht mehr herausfallen. Die drei Stellen, die ihn bisher vorsichtshalber
abgeschnitten haben, sind entfallen — eine Sorgfaltsmaßnahme ist durch eine
Struktur ersetzt.
### Die Wehr wechseln
Der Wähler steht in der Seitenleiste unter dem Namen der Wehr — **aber nur,
wenn es etwas zu wählen gibt.** Für die allermeisten gibt es das nie, und ein
Auswahlfeld mit genau einem Eintrag ist keine Hilfe, sondern eine Frage ohne
Antwortmöglichkeit.
Nach der Anmeldung öffnet die **eigene** Wehr, nicht die betreute: wer eine
Wehr führt und acht andere beaufsichtigt, will in seiner landen.
Technisch: die Wehr steht im Sitzungstoken, also wird beim Wechsel ein neues
ausgestellt. Was der Browser dabei schickt, ist ein **Wunsch** — geprüft wird
serverseitig gegen die Mitgliedschaften des Kontos. Eine fremde Kennung zu
nennen bringt nichts, weil es dazu keine Mitgliedschaft gibt.
### Die Kreisebene
Kreisbrandrat und Kreisbrandinspektion gehören den betreuten Wehren nicht an.
Sie bekommen deshalb dort **keine Personenakte**, tauchen in keiner
Mitgliederliste auf und können nichts ändern.
Ihre Rechte werden nicht aufgezählt, sondern **gefiltert**: alles, was
`.lesen` heißt, und sonst nichts. Eine von Hand gepflegte Liste wäre bei der
nächsten Phase veraltet, und zwar unbemerkt — ein neues Modul brächte Rechte
mit, die niemand nachträgt. So bekommt die Aufsicht neue Leserechte
automatisch und neue Schreibrechte nie.
Vier Rechte sind ausdrücklich ausgenommen, obwohl sie auf `.lesen` enden:
| Recht | Warum nicht |
|---|---|
| `kasse.lesen` | Die Kasse gehört dem Verein oder der Kommune, nicht der Kreisaufsicht. |
| `person.export` | Das Auskunftsrecht nach DSGVO erfüllt die Wehr, die die Daten führt. |
| `system.auditlog` | Wer wann was getan hat, bleibt innerhalb der Wehr. |
| `ki.protokoll` | Dasselbe. |
Der **Atemschutz** steht dagegen bewusst drin: ob ein Träger tauglich ist, ist
genau die Frage, für die es eine Kreisebene gibt.
Wer als Aufsicht unterwegs ist, sieht dauerhaft einen Hinweis darauf — nicht
als kurz aufblitzende Meldung, sondern als Zeile über der Ansicht. Man soll
nicht erst merken, in welcher Rolle man ist, wenn ein Knopf fehlt.
### Doppelmitgliedschaft
Ein Konto, mehrere Zugehörigkeiten. In Bayern der Regelfall bei allem, was
über die eigene Wehr hinausgeht: aktiv in A, Ehrenmitglied in B, oder
Kommandant der einen und Kreisbrandinspektor für sechs weitere.
Zwei Zusicherungen setzt die Datenbank durch:
- **Ein Konto gehört einer Wehr höchstens einmal an.**
- **Eine Personenakte hängt an höchstens einem Konto.** Sonst wäre nicht
entscheidbar, wer unter ihrem Namen gehandelt hat.
Und eine dritte, die weniger offensichtlich ist: die Akte muss zu **derselben
Wehr** gehören wie die Zuordnung. Ein Fremdschlüssel prüft nur, *dass* es die
Akte gibt, nicht *wo* sie liegt — ohne diese Prüfung ließe sich ein Konto über
eine Akte aus Wehr A in Wehr B hängen, und die Sitzung stünde dort mit einer
Personenkennung da, die niemandem gehört. Ein Trigger lehnt das ab.
### Wehren und Konten verwalten
Unter **`/wehren`**, und nur für den, der die Installation betreibt.
Der Zugang hängt an einem Merkmal des Kontos (`istVerwalter`), **nicht an
einem Recht**. Rechte gelten je Wehr; wer Wehren anlegt, steht über allen. Ein
Recht, das in Wehr A vergeben wird und in Wehr B wirkt, wäre genau die
Hintertür, die diese Phase vermeiden soll.
Was dort geht:
- Eine Wehr anlegen — sie bekommt sofort ihre Systemrollen samt Rechten. Ein
Konto bekommt sie nicht: eine Wehr anzulegen und jemanden hineinzusetzen
sind zwei Entscheidungen, und die zweite trifft meist jemand anders.
- Ein Konto anlegen — ohne Wehr. Die kommt über die Zuordnung.
- Zuordnen: als Mitglied mit Personenakte oder als Kreisaufsicht ohne.
**Jede Zuordnung wird in der betroffenen Wehr protokolliert**, nicht beim
Verwalter. Sonst hätte der Betreiber einen Weg, sich still in fremde Daten zu
setzen.
**Keine zusätzlichen Umgebungsvariablen.**
### Was bewusst fehlt
**Wehrübergreifende Auswertungen.** Eine Zahl über mehrere Wehren hinweg
braucht eine abgestimmte Definition — was zählt als Einsatz, was als Übung,
wie wird eine Doppelmitgliedschaft gezählt. Ohne die ist eine solche Zahl
schlimmer als keine, weil sie benutzt wird. Die Aufsicht sieht jede Wehr
einzeln, und das reicht für die Fragen, die tatsächlich gestellt werden.
**Ein Einladungsverfahren.** Passwörter werden vom Verwalter vergeben und
weitergegeben. FLORIAN verschickt keine.
---
## Befehle
```bash