Phase 10: Mandantenfähigkeit (#15)
This commit was merged in pull request #15.
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
-- =============================================================================
|
||||
-- Phase 10 — Mandantenfähigkeit: Konto und Mitgliedschaft
|
||||
--
|
||||
-- Bis hierher war die Personenakte zugleich das Anmeldekonto: Passwort-Hash
|
||||
-- und OIDC-Subject hingen an `person`, und `person` gehört genau einer Wehr.
|
||||
-- Ein Mensch konnte damit nicht zu zwei Wehren gehören, ohne zwei Passwörter
|
||||
-- zu haben — und die Anmeldung per E-Mail war mehrdeutig, sobald dieselbe
|
||||
-- Adresse in zwei Wehren stand (die Eindeutigkeit galt nur je Organisation).
|
||||
--
|
||||
-- Ab jetzt:
|
||||
--
|
||||
-- benutzer das Konto. Global eindeutige E-Mail, ein Passwort.
|
||||
-- mitgliedschaft die Zugehörigkeit zu einer Wehr, mit oder ohne Akte.
|
||||
-- person bleibt die Akte je Wehr — ohne Anmeldedaten.
|
||||
--
|
||||
-- DIESE MIGRATION IST DATENERHALTEND. Jede Person mit Anmeldedaten bekommt
|
||||
-- ein Konto und eine Mitgliedschaft, bevor die Spalten fallen. Wer schon
|
||||
-- heute in zwei Wehren mit derselben E-Mail steht, bekommt EIN Konto mit ZWEI
|
||||
-- Mitgliedschaften — also genau das, was vorher nicht möglich war.
|
||||
--
|
||||
-- HINWEIS zu den entfernten DropIndex-Bloecken: Prisma schlaegt bei jeder
|
||||
-- Migration erneut vor, die von Hand angelegten GiST- und Trigram-Indizes aus
|
||||
-- Phase 1 abzuraeumen. Es kennt sie nicht, weil sie nicht aus dem Schema
|
||||
-- stammen. Zehnte Migration in Folge, in der sie herausgenommen werden.
|
||||
-- =============================================================================
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "MitgliedschaftArt" AS ENUM ('MITGLIED', 'AUFSICHT');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "benutzer" (
|
||||
"id" UUID NOT NULL,
|
||||
"email" VARCHAR(200) NOT NULL,
|
||||
"passwortHash" VARCHAR(120),
|
||||
"passwortGeaendertAm" TIMESTAMPTZ(3),
|
||||
"oidcSubject" VARCHAR(255),
|
||||
"oidcGruppen" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"letzteAnmeldung" TIMESTAMPTZ(3),
|
||||
"fehlversuche" INTEGER NOT NULL DEFAULT 0,
|
||||
"gesperrtBis" TIMESTAMPTZ(3),
|
||||
"aktiv" BOOLEAN NOT NULL DEFAULT true,
|
||||
"istVerwalter" BOOLEAN NOT NULL DEFAULT false,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "benutzer_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "mitgliedschaft" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"benutzerId" UUID NOT NULL,
|
||||
"personId" UUID,
|
||||
"art" "MitgliedschaftArt" NOT NULL DEFAULT 'MITGLIED',
|
||||
"aktiv" BOOLEAN NOT NULL DEFAULT true,
|
||||
"vergebenVonId" UUID,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "mitgliedschaft_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "benutzer_email_key" ON "benutzer"("email");
|
||||
CREATE UNIQUE INDEX "benutzer_oidcSubject_key" ON "benutzer"("oidcSubject");
|
||||
CREATE INDEX "benutzer_oidcSubject_idx" ON "benutzer"("oidcSubject");
|
||||
|
||||
CREATE UNIQUE INDEX "mitgliedschaft_benutzerId_organisationId_key"
|
||||
ON "mitgliedschaft"("benutzerId", "organisationId");
|
||||
CREATE UNIQUE INDEX "mitgliedschaft_personId_key" ON "mitgliedschaft"("personId");
|
||||
CREATE INDEX "mitgliedschaft_organisationId_art_idx"
|
||||
ON "mitgliedschaft"("organisationId", "art");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mitgliedschaft" ADD CONSTRAINT "mitgliedschaft_organisationId_fkey"
|
||||
FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "mitgliedschaft" ADD CONSTRAINT "mitgliedschaft_benutzerId_fkey"
|
||||
FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "mitgliedschaft" ADD CONSTRAINT "mitgliedschaft_personId_fkey"
|
||||
FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Die Umstellung der vorhandenen Anmeldungen
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
-- Ein Konto je E-Mail-Adresse, über alle Wehren hinweg.
|
||||
--
|
||||
-- Angelegt wird nur für Personen, die sich überhaupt anmelden konnten: mit
|
||||
-- Passwort-Hash oder OIDC-Subject. Eine Akte ohne beides ist eine Akte und
|
||||
-- kein Konto — dabei bleibt es.
|
||||
--
|
||||
-- `DISTINCT ON (lower(email))` entscheidet bei mehreren Akten mit derselben
|
||||
-- Adresse: die älteste gewinnt und stellt die Anmeldedaten. Die anderen
|
||||
-- werden gleich darunter zu weiteren Mitgliedschaften desselben Kontos.
|
||||
INSERT INTO "benutzer" (
|
||||
"id", "email", "passwortHash", "passwortGeaendertAm",
|
||||
"oidcSubject", "oidcGruppen", "letzteAnmeldung", "fehlversuche",
|
||||
"gesperrtBis", "aktiv", "istVerwalter", "erstelltAm", "geaendertAm"
|
||||
)
|
||||
SELECT DISTINCT ON (lower(p."email"))
|
||||
gen_random_uuid(),
|
||||
lower(p."email"),
|
||||
p."passwortHash",
|
||||
p."passwortGeaendertAm",
|
||||
p."oidcSubject",
|
||||
p."oidcGruppen",
|
||||
p."letzteAnmeldung",
|
||||
p."fehlversuche",
|
||||
p."gesperrtBis",
|
||||
p."aktiv",
|
||||
-- Wer bislang SUPERADMIN war, verwaltet ab jetzt die Installation. Das
|
||||
-- ist der einzige Weg, auf dem dieses Merkmal je gesetzt wird, ohne dass
|
||||
-- ein Mensch es vergibt.
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM "person_rolle" pr
|
||||
JOIN "rolle" r ON r."id" = pr."rolleId"
|
||||
WHERE pr."personId" = p."id"
|
||||
AND r."schluessel" = 'SUPERADMIN'
|
||||
),
|
||||
p."erstelltAm",
|
||||
now()
|
||||
FROM "person" p
|
||||
WHERE p."email" IS NOT NULL
|
||||
AND p."geloeschtAm" IS NULL
|
||||
AND (p."passwortHash" IS NOT NULL OR p."oidcSubject" IS NOT NULL)
|
||||
ORDER BY lower(p."email"), p."erstelltAm" ASC;
|
||||
|
||||
-- Je Akte eine Mitgliedschaft am passenden Konto.
|
||||
--
|
||||
-- Auch für die Akten, die beim Anlegen der Konten übergangen wurden: genau
|
||||
-- sie sind die Doppelmitgliedschaften, um die es in dieser Phase geht.
|
||||
INSERT INTO "mitgliedschaft" (
|
||||
"id", "organisationId", "benutzerId", "personId", "art", "aktiv",
|
||||
"erstelltAm", "geaendertAm"
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
p."organisationId",
|
||||
b."id",
|
||||
p."id",
|
||||
'MITGLIED',
|
||||
p."aktiv",
|
||||
p."erstelltAm",
|
||||
now()
|
||||
FROM "person" p
|
||||
JOIN "benutzer" b ON b."email" = lower(p."email")
|
||||
WHERE p."email" IS NOT NULL
|
||||
AND p."geloeschtAm" IS NULL
|
||||
AND (p."passwortHash" IS NOT NULL OR p."oidcSubject" IS NOT NULL);
|
||||
|
||||
-- Ein zweites Konto in derselben Wehr kann es nach der Umstellung nicht
|
||||
-- geben — der eindeutige Index oben hätte es verhindert. Diese Prüfung ist
|
||||
-- die Gegenprobe: schlägt sie an, ist etwas an den Annahmen falsch, und die
|
||||
-- Migration bricht ab, statt einen halben Zustand zu hinterlassen.
|
||||
DO $$
|
||||
DECLARE
|
||||
ohne_konto INTEGER;
|
||||
BEGIN
|
||||
SELECT count(*) INTO ohne_konto
|
||||
FROM "person" p
|
||||
WHERE p."geloeschtAm" IS NULL
|
||||
AND (p."passwortHash" IS NOT NULL OR p."oidcSubject" IS NOT NULL)
|
||||
AND NOT EXISTS (SELECT 1 FROM "mitgliedschaft" m WHERE m."personId" = p."id");
|
||||
|
||||
IF ohne_konto > 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'Umstellung unvollstaendig: % Personen mit Anmeldedaten haben kein Konto '
|
||||
'bekommen. Vermutlich fehlt ihnen die E-Mail-Adresse.', ohne_konto;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Erst jetzt fallen die alten Spalten
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
DROP INDEX IF EXISTS "person_organisationId_oidcSubject_key";
|
||||
DROP INDEX IF EXISTS "person_oidcSubject_idx";
|
||||
|
||||
ALTER TABLE "person"
|
||||
DROP COLUMN "passwortHash",
|
||||
DROP COLUMN "passwortGeaendertAm",
|
||||
DROP COLUMN "oidcSubject",
|
||||
DROP COLUMN "oidcGruppen",
|
||||
DROP COLUMN "letzteAnmeldung",
|
||||
DROP COLUMN "fehlversuche",
|
||||
DROP COLUMN "gesperrtBis";
|
||||
@@ -0,0 +1,153 @@
|
||||
-- =============================================================================
|
||||
-- Phase 10 — Mandantenfähigkeit: Absicherung
|
||||
--
|
||||
-- Zwei Tabellen, zwei sehr verschiedene Schutzbedürfnisse.
|
||||
--
|
||||
-- `mitgliedschaft` trägt `organisationId` und wird behandelt wie jede andere
|
||||
-- Tabelle: Row Level Security, Policy auf die aktuelle Organisation. Wer in
|
||||
-- Wehr A angemeldet ist, sieht die Mitgliedschaften von Wehr A — und damit
|
||||
-- übrigens auch nicht, in welchen anderen Wehren ein Kollege noch geführt
|
||||
-- wird. Das ist Absicht.
|
||||
--
|
||||
-- `benutzer` trägt bewusst KEINE `organisationId` und kann es nicht: sie ist
|
||||
-- der Angelpunkt, an dem ein Mensch zu mehreren Wehren gehört. RLS greift hier
|
||||
-- also nicht. Stattdessen wird sie über Rechte geschützt — 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.
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Row Level Security für die Mitgliedschaft
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE "mitgliedschaft" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "mitgliedschaft" FORCE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "mitgliedschaft_mandant" ON "mitgliedschaft";
|
||||
CREATE POLICY "mitgliedschaft_mandant" ON "mitgliedschaft" FOR ALL
|
||||
USING ("organisationId" = aktuelle_organisation())
|
||||
WITH CHECK ("organisationId" = aktuelle_organisation());
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Prüfbedingungen
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
-- Eine Mitgliedschaft als MITGLIED braucht die Personenakte — sie ist ja
|
||||
-- genau die Verbindung zwischen Konto und Wehr. Die Aufsicht darf keine
|
||||
-- haben: die Kreisebene gehört der Wehr nicht an und hat dort nichts in der
|
||||
-- Mitgliederliste zu suchen.
|
||||
ALTER TABLE "mitgliedschaft" ADD CONSTRAINT "mitgliedschaft_akte_passend" CHECK (
|
||||
("art" = 'MITGLIED' AND "personId" IS NOT NULL)
|
||||
OR ("art" = 'AUFSICHT' AND "personId" IS NULL)
|
||||
);
|
||||
|
||||
-- Die Akte muss zu derselben Wehr gehören wie die Mitgliedschaft. Ein
|
||||
-- Fremdschlüssel allein sagt darüber nichts — er prüft nur, dass es die Akte
|
||||
-- gibt, nicht, wo sie liegt. Ohne diese Bedingung ließe sich ein Konto über
|
||||
-- eine Akte aus Wehr A in Wehr B hineinhängen, und die Sitzung stünde mit
|
||||
-- einer personId da, die dort niemandem gehört.
|
||||
CREATE OR REPLACE FUNCTION mitgliedschaft_akte_gleiche_wehr()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
akte_wehr uuid;
|
||||
BEGIN
|
||||
IF NEW."personId" IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT "organisationId" INTO akte_wehr FROM "person" WHERE "id" = NEW."personId";
|
||||
|
||||
IF akte_wehr IS DISTINCT FROM NEW."organisationId" THEN
|
||||
RAISE EXCEPTION
|
||||
'Die Personenakte gehoert zu einer anderen Wehr als die Mitgliedschaft. '
|
||||
'Ein Konto wird je Wehr an die dortige Akte gehaengt.';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS mitgliedschaft_akte_gleiche_wehr_trigger ON "mitgliedschaft";
|
||||
CREATE TRIGGER mitgliedschaft_akte_gleiche_wehr_trigger
|
||||
BEFORE INSERT OR UPDATE OF "personId", "organisationId" ON "mitgliedschaft"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION mitgliedschaft_akte_gleiche_wehr();
|
||||
|
||||
-- Eine E-Mail-Adresse ohne @ ist keine. Die Anmeldung hängt daran, und ein
|
||||
-- Konto, mit dem sich niemand anmelden kann, fällt sonst erst auf, wenn es
|
||||
-- jemand versucht.
|
||||
ALTER TABLE "benutzer" ADD CONSTRAINT "benutzer_email_plausibel" CHECK (
|
||||
"email" LIKE '%@%.%' AND length("email") >= 6
|
||||
);
|
||||
|
||||
-- Ein Konto ohne jeden Anmeldeweg wäre eine Karteileiche. Entweder ein
|
||||
-- Passwort oder ein OIDC-Subject — sonst ist es keines.
|
||||
ALTER TABLE "benutzer" ADD CONSTRAINT "benutzer_anmeldeweg_vorhanden" CHECK (
|
||||
"passwortHash" IS NOT NULL OR "oidcSubject" IS NOT NULL
|
||||
);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Rechte
|
||||
-- -----------------------------------------------------------------------------
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'florian_app') THEN
|
||||
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO florian_app';
|
||||
EXECUTE 'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO florian_app';
|
||||
|
||||
EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app';
|
||||
EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app';
|
||||
EXECUTE 'REVOKE UPDATE, DELETE ON "kiosk_vorgang" FROM florian_app';
|
||||
EXECUTE 'REVOKE UPDATE, DELETE ON "druckmessung" FROM florian_app';
|
||||
EXECUTE 'REVOKE UPDATE, DELETE ON "atemschutz_ereignis" FROM florian_app';
|
||||
EXECUTE 'REVOKE DELETE ON "benachrichtigung" FROM florian_app';
|
||||
EXECUTE 'REVOKE DELETE ON "ki_vorgang" FROM florian_app';
|
||||
EXECUTE 'REVOKE UPDATE, DELETE ON "wikiversion" FROM florian_app';
|
||||
EXECUTE 'REVOKE DELETE ON "kassenabschluss" FROM florian_app';
|
||||
|
||||
-- Konten legt die Anwendungsrolle nicht an und löscht sie nicht.
|
||||
--
|
||||
-- Beides geht über den Systemzugang, also über denselben Weg, über
|
||||
-- den sich schon heute die Anmeldung eine Person sucht (RLS kennt vor
|
||||
-- der Anmeldung ja noch keine Organisation). UPDATE bleibt erlaubt,
|
||||
-- weil jede Anmeldung eines schreibt: letzte Anmeldung, Fehlversuche,
|
||||
-- Sperre. Ein REVOKE darauf hiesse, dass niemand sich mehr anmelden
|
||||
-- kann.
|
||||
EXECUTE 'REVOKE INSERT, DELETE ON "benutzer" FROM florian_app';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Ein Konto behält seine Kennung
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
-- Was die Anwendungsrolle am Konto ändern darf, ist genau das, was bei jeder
|
||||
-- Anmeldung anfällt. Alles Übrige — E-Mail, Passwort, Verwalterrecht —
|
||||
-- gehört in die Kontenverwaltung und damit an den Systemzugang.
|
||||
--
|
||||
-- Ohne diese Sperre wäre `istVerwalter` über jeden Weg erreichbar, der ein
|
||||
-- Konto schreibt. Und ein Merkmal, das die Mandantentrennung aufhebt, darf
|
||||
-- nicht dort gesetzt werden können, wo eine Wehr ihre eigenen Daten pflegt.
|
||||
CREATE OR REPLACE FUNCTION benutzer_kennung_unveraenderlich()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF current_user <> 'florian_app' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF NEW."email" IS DISTINCT FROM OLD."email"
|
||||
OR NEW."istVerwalter" IS DISTINCT FROM OLD."istVerwalter"
|
||||
OR NEW."oidcSubject" IS DISTINCT FROM OLD."oidcSubject" THEN
|
||||
RAISE EXCEPTION
|
||||
'E-Mail, OIDC-Subject und das Verwalterrecht eines Kontos werden ueber die '
|
||||
'Kontenverwaltung geaendert, nicht aus einer Wehr heraus.';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS benutzer_kennung_unveraenderlich_trigger ON "benutzer";
|
||||
CREATE TRIGGER benutzer_kennung_unveraenderlich_trigger
|
||||
BEFORE UPDATE ON "benutzer"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION benutzer_kennung_unveraenderlich();
|
||||
+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
|
||||
|
||||
+245
-3
@@ -57,6 +57,7 @@ import {
|
||||
LebensdauerBezug,
|
||||
LebensdauerKategorie,
|
||||
MangelPrioritaet,
|
||||
MitgliedschaftArt,
|
||||
MangelStatus,
|
||||
PrismaClient,
|
||||
PruefErgebnis,
|
||||
@@ -198,7 +199,13 @@ async function rollenAnlegen(organisationId: string, katalog: Map<string, string
|
||||
|
||||
const rollenKarte = new Map<string, string>();
|
||||
|
||||
for (const schluessel of ROLLEN) {
|
||||
// AUFSICHT ist keine vergebbare Rolle und steht deshalb auch nicht im
|
||||
// Prisma-Enum. Sie ergibt sich seit Phase 10 daraus, dass ein Konto einer
|
||||
// Wehr als Kreisaufsicht zugeordnet ist — sie hier anzulegen hiesse, dass
|
||||
// jemand sie innerhalb einer Wehr vergeben könnte.
|
||||
const vergebbar = ROLLEN.filter((schluessel) => schluessel !== "AUFSICHT");
|
||||
|
||||
for (const schluessel of vergebbar) {
|
||||
const rolle = await prisma.rolle.upsert({
|
||||
where: {
|
||||
organisationId_schluessel: {
|
||||
@@ -499,12 +506,31 @@ async function adminAnlegen(organisationId: string, rollenKarte: Map<string, str
|
||||
nachname: "Administrator",
|
||||
email,
|
||||
dienstgrad: Dienstgrad.KEIN,
|
||||
passwortHash: await bcrypt.hash(passwort, RUNDEN),
|
||||
passwortGeaendertAm: new Date(),
|
||||
abteilung: "Verwaltung",
|
||||
},
|
||||
});
|
||||
|
||||
// Das Konto seit Phase 10 getrennt von der Akte. Die E-Mail ist global
|
||||
// eindeutig — wird derselbe Seed für eine zweite Wehr gefahren, entsteht
|
||||
// deshalb kein zweites Konto, sondern eine zweite Mitgliedschaft. Genau so
|
||||
// sieht Doppelmitgliedschaft in echt aus.
|
||||
const konto = await prisma.benutzer.upsert({
|
||||
where: { email },
|
||||
update: {},
|
||||
create: {
|
||||
email,
|
||||
passwortHash: await bcrypt.hash(passwort, RUNDEN),
|
||||
passwortGeaendertAm: new Date(),
|
||||
istVerwalter: true,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.mitgliedschaft.upsert({
|
||||
where: { benutzerId_organisationId: { benutzerId: konto.id, organisationId } },
|
||||
update: {},
|
||||
create: { organisationId, benutzerId: konto.id, personId: admin.id },
|
||||
});
|
||||
|
||||
const rolleId = rollenKarte.get(RolleSchluessel.SUPERADMIN);
|
||||
if (rolleId) {
|
||||
await prisma.personRolle.upsert({
|
||||
@@ -3897,6 +3923,219 @@ async function kassendatenAnlegen(organisationId: string): Promise<void> {
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Phase 10 — Mandantenfähigkeit
|
||||
//
|
||||
// Eine zweite, kleine Wehr und ein Konto, das beide sieht.
|
||||
//
|
||||
// Ohne die zweite Wehr lässt sich die ganze Phase nicht ausprobieren: der
|
||||
// Wehrwähler erscheint erst, wenn es etwas zu wählen gibt, und eine
|
||||
// Mandantentrennung, die nie zwei Mandanten hatte, ist keine geprüfte.
|
||||
//
|
||||
// Die Geschichte ist die übliche in Bayern: der Kommandant von Musterhausen
|
||||
// ist zugleich Kreisbrandinspektor und betreut die Nachbarwehr. In seiner
|
||||
// eigenen Wehr führt er alles, in der betreuten sieht er zu — mehr nicht.
|
||||
//
|
||||
// Nachbarshausen bleibt bewusst dünn: drei Leute, ein Fahrzeug. Es soll
|
||||
// zeigen, dass die Trennung hält, und nicht die Beispieldaten verdoppeln.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Die Nachbarwehr leitet sich aus der Wehr ab, die gerade angelegt wird.
|
||||
*
|
||||
* Fest verdrahtet wäre sie das, was Phase 8 gerade beseitigt hat: der
|
||||
* Testlauf und die Beispielwehr griffen auf denselben Datensatz zu. So bekommt
|
||||
* jeder Mandant sein eigenes Paar — und die Trennung gilt auch für das, womit
|
||||
* sie geprüft wird.
|
||||
*/
|
||||
const NACHBAR_SLUG = `${ORGANISATION_SLUG}-nachbar`;
|
||||
const NACHBAR_NAME = `${ORGANISATION_NAME} — Nachbarwehr`;
|
||||
const NACHBAR_KURZNAME = `${ORGANISATION_KURZNAME} Nachbar`;
|
||||
const KBI_EMAIL = process.env.SEED_KBI_EMAIL ?? `kbi@${ORGANISATION_SLUG}.de`;
|
||||
|
||||
async function kreisebeneAnlegen(
|
||||
heimatOrganisationId: string,
|
||||
katalog: Map<string, string>,
|
||||
): Promise<void> {
|
||||
/**
|
||||
* Zwei Teile, zwei Prüfungen — und das ist wichtiger, als es aussieht.
|
||||
*
|
||||
* Ein gemeinsamer Ausstieg am Anfang hätte heißen: steht die Nachbarwehr,
|
||||
* geschieht gar nichts mehr. Genau das ist beim Zurücksetzen der Testwehr
|
||||
* passiert — die Nachbarwehr blieb stehen, die Mitgliedschaft in der neuen
|
||||
* Testwehr wurde nie wieder angelegt, und der Wehrwähler zeigte nur noch
|
||||
* die betreute Wehr. Gefunden hat das ein E2E-Test.
|
||||
*/
|
||||
const nachbar =
|
||||
(await prisma.organisation.findUnique({ where: { slug: NACHBAR_SLUG } })) ??
|
||||
(await nachbarwehrAnlegen(katalog));
|
||||
|
||||
// --- Das Konto, das beide Wehren sieht ------------------------------------
|
||||
|
||||
await kreiskontoAnlegen(heimatOrganisationId, nachbar.id);
|
||||
}
|
||||
|
||||
/** Die Nachbarwehr: dünn besetzt, ein Fahrzeug. Sie soll zeigen, dass die
|
||||
* Trennung hält, und nicht die Beispieldaten verdoppeln. */
|
||||
async function nachbarwehrAnlegen(katalog: Map<string, string>) {
|
||||
const nachbar = await prisma.organisation.create({
|
||||
data: {
|
||||
slug: NACHBAR_SLUG,
|
||||
name: NACHBAR_NAME,
|
||||
kurzname: NACHBAR_KURZNAME,
|
||||
strasse: "Dorfstraße 3",
|
||||
plz: "91316",
|
||||
ort: "Nachbarshausen",
|
||||
landkreis: "Musterkreis",
|
||||
funkPraefix: "Florian Nachbarshausen",
|
||||
},
|
||||
});
|
||||
|
||||
const nachbarRollen = await rollenAnlegen(nachbar.id, katalog);
|
||||
|
||||
const besetzung: Array<{
|
||||
vorname: string;
|
||||
nachname: string;
|
||||
nummer: string;
|
||||
dienstgrad: Dienstgrad;
|
||||
rolle: RolleSchluessel;
|
||||
}> = [
|
||||
{
|
||||
vorname: "Georg",
|
||||
nachname: "Sailer",
|
||||
nummer: "0001",
|
||||
dienstgrad: Dienstgrad.BRANDMEISTER,
|
||||
rolle: RolleSchluessel.KOMMANDANT,
|
||||
},
|
||||
{
|
||||
vorname: "Marianne",
|
||||
nachname: "Kraus",
|
||||
nummer: "0002",
|
||||
dienstgrad: Dienstgrad.LOESCHMEISTER,
|
||||
rolle: RolleSchluessel.GERAETEWART,
|
||||
},
|
||||
{
|
||||
vorname: "Tobias",
|
||||
nachname: "Ostermeier",
|
||||
nummer: "0003",
|
||||
dienstgrad: Dienstgrad.FEUERWEHRMANN,
|
||||
rolle: RolleSchluessel.MITGLIED,
|
||||
},
|
||||
];
|
||||
|
||||
for (const eintrag of besetzung) {
|
||||
const person = await prisma.person.create({
|
||||
data: {
|
||||
organisationId: nachbar.id,
|
||||
vorname: eintrag.vorname,
|
||||
nachname: eintrag.nachname,
|
||||
mitgliedsnummer: eintrag.nummer,
|
||||
dienstgrad: eintrag.dienstgrad,
|
||||
abteilung: "Aktive",
|
||||
eintrittsdatum: datum(2012, 4, 1),
|
||||
},
|
||||
});
|
||||
|
||||
const rolleId = nachbarRollen.get(eintrag.rolle);
|
||||
if (rolleId) {
|
||||
await prisma.personRolle.create({
|
||||
data: { organisationId: nachbar.id, personId: person.id, rolleId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const standort = await prisma.standort.create({
|
||||
data: {
|
||||
organisationId: nachbar.id,
|
||||
name: "Gerätehaus Nachbarshausen",
|
||||
art: StandortArt.GERAETEHAUS,
|
||||
strasse: "Dorfstraße 3",
|
||||
plz: "91316",
|
||||
ort: "Nachbarshausen",
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.fahrzeug.create({
|
||||
data: {
|
||||
organisationId: nachbar.id,
|
||||
funkrufname: "Florian Nachbarshausen 42/1",
|
||||
bezeichnung: "TSF-W",
|
||||
art: FahrzeugArt.LOESCHFAHRZEUG,
|
||||
kennzeichen: "MK-FW 421",
|
||||
baujahr: 2009,
|
||||
standortId: standort.id,
|
||||
},
|
||||
});
|
||||
|
||||
log(`Nachbarwehr angelegt: ${besetzung.length} Personen, 1 Fahrzeug.`);
|
||||
return nachbar;
|
||||
}
|
||||
|
||||
/** Das Konto der Kreisebene: Mitglied in der Heimatwehr, Aufsicht nebenan. */
|
||||
async function kreiskontoAnlegen(heimatOrganisationId: string, nachbarId: string) {
|
||||
const kommandant = await prisma.person.findFirst({
|
||||
where: {
|
||||
organisationId: heimatOrganisationId,
|
||||
rollen: { some: { rolle: { schluessel: RolleSchluessel.KOMMANDANT } } },
|
||||
},
|
||||
orderBy: { mitgliedsnummer: "asc" },
|
||||
});
|
||||
|
||||
if (!kommandant) {
|
||||
log("Kein Kommandant gefunden — die Kreisebene wird übersprungen.");
|
||||
return;
|
||||
}
|
||||
|
||||
const email = KBI_EMAIL;
|
||||
const passwort = process.env.SEED_KBI_PASSWORT ?? "Feuerwehr2026";
|
||||
|
||||
const konto = await prisma.benutzer.upsert({
|
||||
where: { email },
|
||||
update: {},
|
||||
create: {
|
||||
email,
|
||||
passwortHash: await bcrypt.hash(passwort, RUNDEN),
|
||||
passwortGeaendertAm: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// In der eigenen Wehr: Mitglied mit allem, was seine Rollen hergeben.
|
||||
await prisma.mitgliedschaft.upsert({
|
||||
where: {
|
||||
benutzerId_organisationId: {
|
||||
benutzerId: konto.id,
|
||||
organisationId: heimatOrganisationId,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
organisationId: heimatOrganisationId,
|
||||
benutzerId: konto.id,
|
||||
personId: kommandant.id,
|
||||
art: MitgliedschaftArt.MITGLIED,
|
||||
},
|
||||
});
|
||||
|
||||
// In der Nachbarwehr: Aufsicht. Ohne Akte — er gehört ihr nicht an und
|
||||
// steht deshalb auch in keiner ihrer Mitgliederlisten.
|
||||
await prisma.mitgliedschaft.upsert({
|
||||
where: {
|
||||
benutzerId_organisationId: { benutzerId: konto.id, organisationId: nachbarId },
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
organisationId: nachbarId,
|
||||
benutzerId: konto.id,
|
||||
art: MitgliedschaftArt.AUFSICHT,
|
||||
},
|
||||
});
|
||||
|
||||
log(
|
||||
`Kreisebene: ${email} — Mitglied in ${ORGANISATION_KURZNAME}, ` +
|
||||
`Aufsicht in ${NACHBAR_KURZNAME}.`,
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Ablauf
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -3946,6 +4185,9 @@ async function main(): Promise<void> {
|
||||
// Phase 9 — Kassenverwaltung
|
||||
await kassendatenAnlegen(organisation.id);
|
||||
|
||||
// Phase 10 — Mandantenfähigkeit: eine zweite Wehr und die Kreisebene
|
||||
await kreisebeneAnlegen(organisation.id, katalog);
|
||||
|
||||
log("Fertig.");
|
||||
log("");
|
||||
log(`Wehr: ${ORGANISATION_NAME} (${ORGANISATION_SLUG})`);
|
||||
|
||||
Reference in New Issue
Block a user