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,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