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
@@ -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();