Phase 10: Mandantenfähigkeit #15

Merged
florian.wilde merged 1 commits from feat/phase-10-mandantenfaehigkeit into main 2026-09-05 13:47:47 +00:00
Owner

Ein Server, mehrere Wehren. Ein Konto, mehrere Zugehörigkeiten. Eine Kreisebene, die zusieht. Version 0.11.0.

Diese Änderung fasst die Anmeldung an. Die Migration ist datenerhaltend und bricht ab, statt einen halben Zustand zu hinterlassen — aber es ist der Teil, den du beim Testen zuerst ansehen solltest.

Warum das mehr war als eine Oberfläche

Die Roadmap sagte „technisch bereits möglich, Oberfläche fehlt". Das stimmte zur Hälfte: Datenmodell und RLS trugen seit Phase 0 mehrere Wehren — aber die Anmeldung nicht. Passwort-Hash und OIDC-Subject hingen an person, und person gehört genau einer Wehr. Daraus folgten zwei Dinge:

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

Der Aufbau

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 organisationId

Benutzer trägt bewusst keine Mandantenkennung und kann keine tragen: sie ist der Angelpunkt, an dem ein Mensch zu mehreren Wehren gehört. Geschützt wird sie deshalb über Rechte statt über RLS — dieselbe Lösung wie bei Berechtigung aus Phase 0. Was ein Konto in einer Wehr darf, entscheidet nicht es selbst, sondern seine Mitgliedschaft.

Die Sitzung behält ihre Form und entsteht nur anders — aus der aktiven Mitgliedschaft statt aus der Person. sitzung.organisationId und sitzung.personId werden an 414 Stellen in 61 Dateien gelesen; keine davon musste angefasst werden. Die eine Ausnahme ist Absicht: personId ist nullbar geworden, weil eine Aufsichtssitzung keine Akte hat. Der Typechecker hat daraus genau die fünf Stellen gemacht, an denen die Kreisaufsicht nichts verloren hat — Anmeldemarke, Dienstausweis, Prüfungswizard, Dateiupload, Atemschutz.

Was die Datenbank durchsetzt

  • RLS für mitgliedschaft wie für jede andere Tabelle. Eine Wehr sieht damit auch nicht, in welchen anderen ein Konto noch geführt wird.
  • mitgliedschaft_akte_gleiche_wehr — eine Zuordnung, deren Personenakte in einer anderen Wehr liegt, wird abgelehnt. Ein Fremdschlüssel prüft nur, dass es die Akte gibt, nicht wo sie liegt.
  • benutzer_kennung_unveraenderlich — E-Mail, OIDC-Subject und das Verwaltermerkmal lassen sich nicht aus einer Wehr heraus ändern.
  • REVOKE INSERT, DELETE ON benutzer für die Anwendungsrolle.
  • CHECKs: MITGLIED braucht eine Akte, AUFSICHT darf keine haben; ein Konto braucht einen Anmeldeweg; eine E-Mail ohne @ ist keine.

Die Kreisebene

Ihre Rechte werden gefiltert, nicht aufgezählt: alles, was .lesen heißt, und sonst nichts. Eine Liste von Hand wäre bei Phase 11 veraltet, und zwar unbemerkt. So bekommt sie neue Leserechte automatisch und neue Schreibrechte nie.

Vier Ausnahmen, obwohl sie auf .lesen enden: kasse.lesen, person.export, system.auditlog, ki.protokoll.

Die Kasse ist eine Entscheidung, die dir gehört. Ich habe sie ausgenommen, weil sie dem Verein oder der Kommune gehört und nicht der Kreisaufsicht — sie wäre nur deshalb dabei gewesen, weil das Recht zufällig richtig heißt. Wenn das bei euch anders gehandhabt wird, ist es eine Zeile in lib/authz.ts.

Nebenbei behoben

  • db:test-reset ließ die Nachbarwehr stehen. Der Seed hielt daraufhin alles für erledigt und legte die Mitgliedschaft in der neu angelegten Testwehr nie wieder an — der Wehrwähler zeigte danach nur noch die betreute Wehr. Gefunden hat das ein E2E-Test.
  • Der Fehlversuch-Test sperrte das gemeinsame Testkonto. Fünf Fehlversuche sperren ein Konto fünf Minuten; im vollen Lauf kam das zusammen und riss fremde Testdateien mit. Er nimmt jetzt eine Adresse, die es nicht gibt.
  • Ein Testaufbau war nicht wiederholbar (ki-datenbank.test.ts legte die Wiki-Seite ohne upsert an).
  • Die Wartelogik der Bildschirmfotos konnte auslösen, bevor eine Seite geladen war — sie wartete darauf, dass keine Ladeanzeige da ist, und das trifft auch zu, bevor sie erscheint. Betraf potenziell jedes Bild.

Nachweise

  • npm run typecheck — ohne Ausgabe
  • npm run lint — ohne Befund
  • npm run test — 581 Tests grün, davon 19 neu (11 gegen eine echte Datenbank)
  • npm run test:e2e — 266 Tests grün, kein Fehlschlag, davon 26 neu
  • npm run build — die neue Route /wehren
  • Anmeldung nach der Umstellung von Hand geprüft

Zum Testen

Nach npm run db:seed gibt es zwei Wehren und ein Konto, das zu beiden gehört:

Konto Passwort Wo
admin@beispielwehr.de Feuerwehr2026 FF Musterhausen, verwaltet die Installation
kbi@ff-musterhausen.de Feuerwehr2026 Mitglied in FF Musterhausen, Kreisaufsicht in der Nachbarwehr
  1. Als kbi@… anmelden. Du landest in FF Musterhausen — der eigenen Wehr, nicht der betreuten.
  2. Auf den Wehrnamen in der Seitenleiste klicken. Beide Wehren stehen da, mit Klartext, welche Art von Zugehörigkeit.
  3. In die Nachbarwehr wechseln. Der Hinweis oben bleibt stehen, die Mitgliederliste zeigt Sailer, Kraus und Ostermeier — und der Menüpunkt Kasse ist weg.
  4. /kasse von Hand aufrufen — es geht nicht.
  5. Zurückwechseln. Alles ist wieder da.
  6. Als admin@… anmelden, dann Wehren und Konten. Dort eine Wehr anlegen, ein Konto anlegen und beides zuordnen. Der Menüpunkt fehlt beim kbi@… — er führt seine Wehr, verwaltet aber nicht die Installation.

Ein Hinweis zum Bildschirmfoto: in der Entwicklungsdatenbank liegen Beispiel- und Testwehr in einer Installation, deshalb sieht der Verwalter dort vier Wehren. Im Echtbetrieb wären es nur die eigenen.

🤖 Generated with Claude Code

Ein Server, mehrere Wehren. Ein Konto, mehrere Zugehörigkeiten. Eine Kreisebene, die zusieht. Version **0.11.0**. > **Diese Änderung fasst die Anmeldung an.** Die Migration ist datenerhaltend und bricht ab, statt einen halben Zustand zu hinterlassen — aber es ist der Teil, den du beim Testen zuerst ansehen solltest. ## Warum das mehr war als eine Oberfläche Die Roadmap sagte „technisch bereits möglich, Oberfläche fehlt". Das stimmte zur Hälfte: Datenmodell und RLS trugen seit Phase 0 mehrere Wehren — aber die Anmeldung nicht. Passwort-Hash und OIDC-Subject hingen an `person`, und `person` gehört genau einer Wehr. Daraus folgten zwei Dinge: - 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. ## Der Aufbau | 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 | `organisationId` | **`Benutzer` trägt bewusst keine Mandantenkennung** und kann keine tragen: sie ist der Angelpunkt, an dem ein Mensch zu mehreren Wehren gehört. Geschützt wird sie deshalb über Rechte statt über RLS — dieselbe Lösung wie bei `Berechtigung` aus Phase 0. Was ein Konto in einer Wehr darf, entscheidet nicht es selbst, sondern seine `Mitgliedschaft`. **Die Sitzung behält ihre Form und entsteht nur anders** — aus der aktiven Mitgliedschaft statt aus der Person. `sitzung.organisationId` und `sitzung.personId` werden an 414 Stellen in 61 Dateien gelesen; keine davon musste angefasst werden. Die eine Ausnahme ist Absicht: `personId` ist nullbar geworden, weil eine Aufsichtssitzung keine Akte hat. Der Typechecker hat daraus genau die fünf Stellen gemacht, an denen die Kreisaufsicht nichts verloren hat — Anmeldemarke, Dienstausweis, Prüfungswizard, Dateiupload, Atemschutz. ## Was die Datenbank durchsetzt - RLS für `mitgliedschaft` wie für jede andere Tabelle. Eine Wehr sieht damit auch nicht, in welchen anderen ein Konto noch geführt wird. - `mitgliedschaft_akte_gleiche_wehr` — eine Zuordnung, deren Personenakte in einer **anderen** Wehr liegt, wird abgelehnt. Ein Fremdschlüssel prüft nur, *dass* es die Akte gibt, nicht *wo* sie liegt. - `benutzer_kennung_unveraenderlich` — E-Mail, OIDC-Subject und das Verwaltermerkmal lassen sich nicht aus einer Wehr heraus ändern. - `REVOKE INSERT, DELETE ON benutzer` für die Anwendungsrolle. - CHECKs: `MITGLIED` braucht eine Akte, `AUFSICHT` darf keine haben; ein Konto braucht einen Anmeldeweg; eine E-Mail ohne @ ist keine. ## Die Kreisebene Ihre Rechte werden **gefiltert, nicht aufgezählt**: alles, was `.lesen` heißt, und sonst nichts. Eine Liste von Hand wäre bei Phase 11 veraltet, und zwar unbemerkt. So bekommt sie neue Leserechte automatisch und neue Schreibrechte nie. Vier Ausnahmen, obwohl sie auf `.lesen` enden: `kasse.lesen`, `person.export`, `system.auditlog`, `ki.protokoll`. **Die Kasse ist eine Entscheidung, die dir gehört.** Ich habe sie ausgenommen, weil sie dem Verein oder der Kommune gehört und nicht der Kreisaufsicht — sie wäre nur deshalb dabei gewesen, weil das Recht zufällig richtig heißt. Wenn das bei euch anders gehandhabt wird, ist es eine Zeile in `lib/authz.ts`. ## Nebenbei behoben - **`db:test-reset` ließ die Nachbarwehr stehen.** Der Seed hielt daraufhin alles für erledigt und legte die Mitgliedschaft in der neu angelegten Testwehr nie wieder an — der Wehrwähler zeigte danach nur noch die betreute Wehr. Gefunden hat das ein E2E-Test. - **Der Fehlversuch-Test sperrte das gemeinsame Testkonto.** Fünf Fehlversuche sperren ein Konto fünf Minuten; im vollen Lauf kam das zusammen und riss fremde Testdateien mit. Er nimmt jetzt eine Adresse, die es nicht gibt. - **Ein Testaufbau war nicht wiederholbar** (`ki-datenbank.test.ts` legte die Wiki-Seite ohne `upsert` an). - **Die Wartelogik der Bildschirmfotos konnte auslösen, bevor eine Seite geladen war** — sie wartete darauf, dass *keine* Ladeanzeige da ist, und das trifft auch zu, bevor sie erscheint. Betraf potenziell jedes Bild. ## Nachweise - `npm run typecheck` — ohne Ausgabe - `npm run lint` — ohne Befund - `npm run test` — **581 Tests grün**, davon 19 neu (11 gegen eine echte Datenbank) - `npm run test:e2e` — **266 Tests grün**, kein Fehlschlag, davon 26 neu - `npm run build` — die neue Route `/wehren` - Anmeldung nach der Umstellung von Hand geprüft ## Zum Testen Nach `npm run db:seed` gibt es zwei Wehren und ein Konto, das zu beiden gehört: | Konto | Passwort | Wo | |---|---|---| | `admin@beispielwehr.de` | `Feuerwehr2026` | FF Musterhausen, verwaltet die Installation | | `kbi@ff-musterhausen.de` | `Feuerwehr2026` | Mitglied in FF Musterhausen, **Kreisaufsicht** in der Nachbarwehr | 1. **Als `kbi@…` anmelden.** Du landest in FF Musterhausen — der eigenen Wehr, nicht der betreuten. 2. **Auf den Wehrnamen in der Seitenleiste klicken.** Beide Wehren stehen da, mit Klartext, welche Art von Zugehörigkeit. 3. **In die Nachbarwehr wechseln.** Der Hinweis oben bleibt stehen, die Mitgliederliste zeigt Sailer, Kraus und Ostermeier — und der Menüpunkt *Kasse* ist weg. 4. **`/kasse` von Hand aufrufen** — es geht nicht. 5. **Zurückwechseln.** Alles ist wieder da. 6. **Als `admin@…` anmelden, dann *Wehren und Konten*.** Dort eine Wehr anlegen, ein Konto anlegen und beides zuordnen. Der Menüpunkt fehlt beim `kbi@…` — er führt seine Wehr, verwaltet aber nicht die Installation. Ein Hinweis zum Bildschirmfoto: in der Entwicklungsdatenbank liegen Beispiel- und Testwehr in **einer** Installation, deshalb sieht der Verwalter dort vier Wehren. Im Echtbetrieb wären es nur die eigenen. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
florian.wilde added 1 commit 2026-09-05 11:44:39 +00:00
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>
florian.wilde merged commit 91742a0dd7 into main 2026-09-05 13:47:47 +00:00
florian.wilde deleted branch feat/phase-10-mandantenfaehigkeit 2026-09-05 13:47:48 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: florian.wilde/FireAdministration#15