Phase 10: Mandantenfähigkeit (#15)

This commit was merged in pull request #15.
This commit is contained in:
2026-09-05 13:47:46 +00:00
parent 2b53556175
commit 91742a0dd7
98 changed files with 3810 additions and 201 deletions
+113 -16
View File
@@ -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