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:
co-authored by
Claude Opus 5
parent
2b53556175
commit
ff40437a1a
+113
-16
@@ -107,6 +107,21 @@ enum FahrzeugArt {
|
||||
}
|
||||
|
||||
/// Dienstgrad nach bayerischer Feuerwehr-Dienstgradverordnung (Auswahl).
|
||||
/// Wie jemand zu einer Wehr gehört.
|
||||
///
|
||||
/// MITGLIED ist der Regelfall: das Konto hängt an einer Personenakte in dieser
|
||||
/// Wehr und bringt deren Rollen mit.
|
||||
///
|
||||
/// AUFSICHT ist die Kreisebene — Kreisbrandrat, Kreisbrandinspektion. Sie
|
||||
/// gehört der Wehr nicht an, hat dort also auch keine Personenakte, und darf
|
||||
/// ausschließlich lesen. Ein Kreisbrandinspektor, der zugleich Mitglied seiner
|
||||
/// Heimatwehr ist, hat schlicht zwei Mitgliedschaften: dort MITGLIED, in den
|
||||
/// betreuten Wehren AUFSICHT.
|
||||
enum MitgliedschaftArt {
|
||||
MITGLIED
|
||||
AUFSICHT
|
||||
}
|
||||
|
||||
enum Dienstgrad {
|
||||
FEUERWEHRANWAERTER
|
||||
FEUERWEHRMANN
|
||||
@@ -153,6 +168,7 @@ model Organisation {
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
personen Person[]
|
||||
mitgliedschaften Mitgliedschaft[]
|
||||
rollen Rolle[]
|
||||
personRollen PersonRolle[]
|
||||
rolleRechte RolleBerechtigung[]
|
||||
@@ -280,18 +296,13 @@ model Person {
|
||||
/// Aktiver Dienst, passiv, Jugendfeuerwehr, Ehrenmitglied.
|
||||
abteilung String? @db.VarChar(60)
|
||||
|
||||
/// bcrypt-Hash für den lokalen Login. Null, wenn nur OIDC genutzt wird.
|
||||
passwortHash String? @db.VarChar(120)
|
||||
passwortGeaendertAm DateTime? @db.Timestamptz(3)
|
||||
/// "sub"-Claim aus authentik. Verknüpft die Person mit dem OIDC-Konto.
|
||||
oidcSubject String? @db.VarChar(255)
|
||||
/// Gruppen aus dem letzten OIDC-Login — nur zur Nachvollziehbarkeit.
|
||||
oidcGruppen String[] @default([])
|
||||
|
||||
letzteAnmeldung DateTime? @db.Timestamptz(3)
|
||||
fehlversuche Int @default(0)
|
||||
gesperrtBis DateTime? @db.Timestamptz(3)
|
||||
aktiv Boolean @default(true)
|
||||
/// Die Anmeldedaten liegen seit Phase 10 am `Benutzer`, nicht mehr hier.
|
||||
///
|
||||
/// Der Grund ist die Doppelmitgliedschaft: wer in zwei Wehren geführt wird,
|
||||
/// hat dort zwei Akten — aber ein Passwort. Lägen Hash und OIDC-Subject
|
||||
/// weiterhin an der Akte, gäbe es zwei davon, die auseinanderlaufen können,
|
||||
/// und die Anmeldung per E-Mail wäre mehrdeutig.
|
||||
aktiv Boolean @default(true)
|
||||
|
||||
/// Profilbild. Liegt wie alles andere in MinIO.
|
||||
avatarDateiId String? @db.Uuid
|
||||
@@ -301,6 +312,7 @@ model Person {
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
mitgliedschaft Mitgliedschaft? @relation("MitgliedschaftPerson")
|
||||
avatar Datei? @relation("PersonAvatar", fields: [avatarDateiId], references: [id], onDelete: SetNull)
|
||||
rollen PersonRolle[] @relation("PersonRollenInhaber")
|
||||
vergebeneRollen PersonRolle[] @relation("PersonRollenVergeber")
|
||||
@@ -375,19 +387,104 @@ model Person {
|
||||
zweitgepruefteAbschluesse Kassenabschluss[] @relation("AbschlussZweitpruefer")
|
||||
erstellteRechnungen Rechnung[] @relation("RechnungErsteller")
|
||||
|
||||
/// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe
|
||||
/// Person kann in zwei Wehren Mitglied sein.
|
||||
/// Die E-Mail ist je Wehr eindeutig, nicht global: dieselbe Person kann in
|
||||
/// zwei Wehren eine Akte haben. Angemeldet wird sich nicht damit, sondern
|
||||
/// mit der E-Mail des `Benutzer` — die ist global eindeutig.
|
||||
@@unique([organisationId, email])
|
||||
@@unique([organisationId, oidcSubject])
|
||||
@@unique([organisationId, mitgliedsnummer])
|
||||
@@index([organisationId, nachname, vorname])
|
||||
@@index([organisationId, aktiv])
|
||||
@@index([oidcSubject])
|
||||
@@map("person")
|
||||
}
|
||||
|
||||
/// Eine Rolle innerhalb einer Wehr. Die Systemrollen legt der Seed an; eigene
|
||||
/// Rollen kann der Kommandant ergänzen.
|
||||
/// Ein Anmeldekonto.
|
||||
///
|
||||
/// **Die einzige Tabelle neben `Berechtigung` ohne `organisationId`** — und
|
||||
/// zwar notwendigerweise: sie ist der Angelpunkt, an dem ein Mensch zu
|
||||
/// mehreren Wehren gehören kann. Ein Konto mit Mandantenkennung könnte genau
|
||||
/// das nicht.
|
||||
///
|
||||
/// Sie ist deshalb auch nicht über Row Level Security geschützt, sondern über
|
||||
/// Rechte: die Anwendungsrolle darf sie nur lesen und die Anmeldefelder
|
||||
/// fortschreiben, angelegt und verändert werden Konten über den
|
||||
/// Systemzugang. Was ein Konto in einer Wehr sehen darf, entscheidet nicht es
|
||||
/// selbst, sondern seine `Mitgliedschaft` — und die trägt `organisationId`
|
||||
/// wie jede andere Tabelle.
|
||||
model Benutzer {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
|
||||
/// Global eindeutig. Das ist der Unterschied zur E-Mail an der Personenakte:
|
||||
/// die ist eine Kontaktangabe je Wehr, diese hier ist die Anmeldung.
|
||||
email String @unique @db.VarChar(200)
|
||||
|
||||
/// bcrypt-Hash für den lokalen Login. Null, wenn nur OIDC genutzt wird.
|
||||
passwortHash String? @db.VarChar(120)
|
||||
passwortGeaendertAm DateTime? @db.Timestamptz(3)
|
||||
/// "sub"-Claim aus authentik.
|
||||
oidcSubject String? @unique @db.VarChar(255)
|
||||
/// Gruppen aus dem letzten OIDC-Login — nur zur Nachvollziehbarkeit.
|
||||
oidcGruppen String[] @default([])
|
||||
|
||||
letzteAnmeldung DateTime? @db.Timestamptz(3)
|
||||
fehlversuche Int @default(0)
|
||||
gesperrtBis DateTime? @db.Timestamptz(3)
|
||||
aktiv Boolean @default(true)
|
||||
|
||||
/// Verwaltet die Installation: Wehren anlegen und in jede hineinschalten.
|
||||
///
|
||||
/// Bewusst ein Merkmal des Kontos und keine Rolle innerhalb einer Wehr. Eine
|
||||
/// Rolle gilt je Organisation — wer Wehren anlegt, steht aber über allen,
|
||||
/// und ein Recht, das in Wehr A vergeben wird und in Wehr B wirkt, wäre
|
||||
/// genau die Art Hintertür, die die Mandantentrennung aushöhlt.
|
||||
istVerwalter Boolean @default(false)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
mitgliedschaften Mitgliedschaft[]
|
||||
|
||||
@@index([oidcSubject])
|
||||
@@map("benutzer")
|
||||
}
|
||||
|
||||
/// Die Zugehörigkeit eines Kontos zu einer Wehr.
|
||||
///
|
||||
/// Sie ist das, was eine Sitzung ausmacht: erst die Wahl einer Mitgliedschaft
|
||||
/// entscheidet, welche Wehr in `app.current_organisation` steht und welche
|
||||
/// Rollen gelten. Wer nur eine hat — die allermeisten —, merkt davon nichts.
|
||||
model Mitgliedschaft {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
benutzerId String @db.Uuid
|
||||
|
||||
/// Die Personenakte in dieser Wehr. Bei AUFSICHT leer: die Kreisebene ist
|
||||
/// dort nicht Mitglied und soll auch in keiner Mitgliederliste auftauchen.
|
||||
personId String? @db.Uuid
|
||||
|
||||
art MitgliedschaftArt @default(MITGLIED)
|
||||
aktiv Boolean @default(true)
|
||||
|
||||
/// Wer sie eingerichtet hat. Bei der Umstellung aus Phase 10 leer.
|
||||
vergebenVonId String? @db.Uuid
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
|
||||
person Person? @relation("MitgliedschaftPerson", fields: [personId], references: [id], onDelete: Cascade)
|
||||
|
||||
/// Ein Konto gehört einer Wehr höchstens einmal an.
|
||||
@@unique([benutzerId, organisationId])
|
||||
/// Und eine Personenakte hängt an höchstens einem Konto — sonst wäre nicht
|
||||
/// entscheidbar, wer unter ihrem Namen gehandelt hat.
|
||||
@@unique([personId])
|
||||
@@index([organisationId, art])
|
||||
@@map("mitgliedschaft")
|
||||
}
|
||||
|
||||
model Rolle {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
|
||||
Reference in New Issue
Block a user