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
@@ -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();
|
||||
Reference in New Issue
Block a user