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
Als kbi@… anmelden. Du landest in FF Musterhausen — der eigenen Wehr, nicht der betreuten.
Auf den Wehrnamen in der Seitenleiste klicken. Beide Wehren stehen da, mit Klartext, welche Art von Zugehörigkeit.
In die Nachbarwehr wechseln. Der Hinweis oben bleibt stehen, die Mitgliederliste zeigt Sailer, Kraus und Ostermeier — und der Menüpunkt Kasse ist weg.
/kasse von Hand aufrufen — es geht nicht.
Zurückwechseln. Alles ist wieder da.
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.
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)
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>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Ein Server, mehrere Wehren. Ein Konto, mehrere Zugehörigkeiten. Eine Kreisebene, die zusieht. Version 0.11.0.
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, undpersongehört genau einer Wehr. Daraus folgten zwei Dinge:Der Aufbau
BenutzerMitgliedschaftorganisationIdPersonorganisationIdBenutzerträ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 beiBerechtigungaus Phase 0. Was ein Konto in einer Wehr darf, entscheidet nicht es selbst, sondern seineMitgliedschaft.Die Sitzung behält ihre Form und entsteht nur anders — aus der aktiven Mitgliedschaft statt aus der Person.
sitzung.organisationIdundsitzung.personIdwerden an 414 Stellen in 61 Dateien gelesen; keine davon musste angefasst werden. Die eine Ausnahme ist Absicht:personIdist 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
mitgliedschaftwie 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 benutzerfür die Anwendungsrolle.MITGLIEDbraucht eine Akte,AUFSICHTdarf keine haben; ein Konto braucht einen Anmeldeweg; eine E-Mail ohne @ ist keine.Die Kreisebene
Ihre Rechte werden gefiltert, nicht aufgezählt: alles, was
.lesenheiß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
.lesenenden: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-resetließ 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.ki-datenbank.test.tslegte die Wiki-Seite ohneupsertan).Nachweise
npm run typecheck— ohne Ausgabenpm run lint— ohne Befundnpm 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 neunpm run build— die neue Route/wehrenZum Testen
Nach
npm run db:seedgibt es zwei Wehren und ein Konto, das zu beiden gehört:admin@beispielwehr.deFeuerwehr2026kbi@ff-musterhausen.deFeuerwehr2026kbi@…anmelden. Du landest in FF Musterhausen — der eigenen Wehr, nicht der betreuten./kassevon Hand aufrufen — es geht nicht.admin@…anmelden, dann Wehren und Konten. Dort eine Wehr anlegen, ein Konto anlegen und beides zuordnen. Der Menüpunkt fehlt beimkbi@…— 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