feat(verzeichnis): SCIM-Endpunkt als dritte Quellenart (#029)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

Bisher holt Laufzettel Personen aus dem Verzeichnis. Das setzt Lesezugriff und
einen Netzweg dorthin voraus, und beides ist nicht ueberall gegeben. Mit der Art
SCIM ruft stattdessen der Anbieter an — sofort, nicht beim naechsten Intervall.

Umgesetzt ist, was authentik, Entra ID und Okta wirklich schicken: Users mit dem
Filter userName eq, POST/GET/PUT/PATCH/DELETE, dazu ServiceProviderConfig,
Schemas und ResourceTypes. Jeder Handler prueft sein Token selbst.

Ein Token je Quelle, argon2-gehasht, einmal angezeigt — es ist der einzige
Mandantenbeweis, den eine Zustellung mitbringt. DELETE deaktiviert und loescht
nichts. Eine SCIM-Quelle wird nicht abgeglichen, an zwei Stellen gehalten: ein
Abgleich, der dort nichts liest und ERFOLG meldet, haette ueber die
Abschaltregel die ganze Belegschaft deaktiviert.

Zwei Befunde kamen aus Gegenproben, nicht aus Tests:

- PATCH remove wirkte gar nicht. Der Schalter nurGesetzte las bei PATCH jedes
  null als "nicht mitgeschickt"; eine beim Anbieter entfernte Abteilung stand
  hier weiter. Noetig war er nie — patchAnwenden liefert immer den
  vollstaendigen Zielzustand.
- Entra ID schickt active als "True"/"False". Beim Deaktivieren kommt mit einem
  reinen value === true zufaellig das Richtige heraus, beim Aktivieren nicht:
  ein wieder freigeschaltetes Konto waere fuer immer inaktiv geblieben.

Dazu ein Fehler aus #028: in verbindungTestenAktion stand zweimal dieselbe
Bedingung, der zweite Zweig war seit dem ersten Tag unerreichbar.

1432 Tests (vorher 1373). Klickprobe am gebauten Stand mit einer echten
Zustellung. Gegen echte Mandanten ist nichts gelaufen — die Anfrageformen
stammen aus der Dokumentation, und das steht so in der Testdatei.

Closes #49

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-10 19:50:54 +02:00
co-authored by Claude Opus 5
parent 76a61ec771
commit fcd67018b8
32 changed files with 3672 additions and 141 deletions
@@ -0,0 +1,95 @@
-- ---------------------------------------------------------------------------
-- HANDGESCHRIEBEN. Prisma erzeugt hier zusaetzlich Loeschbefehle fuer die
-- zusammengesetzten Fremdschluessel (Mandantengrenze), die Trigramm-Indizes
-- (Archivsuche), die partiellen eindeutigen Indizes und alle Trigger: es kennt
-- sie nicht, weil sie sich im Schema nicht ausdruecken lassen. Diese
-- Loeschbefehle sind bewusst gestrichen.
--
-- tests/datenbankregeln.test.ts prueft, dass alle diese Objekte vorhanden sind –
-- ein versehentlich stehengelassener Loeschbefehl faellt dort sofort auf.
-- ---------------------------------------------------------------------------
-- SCIM-Endpunkt als Alternative zum Abgleich (#029).
ALTER TABLE "Verzeichnisquelle"
ADD COLUMN "scimTokenHash" TEXT,
ADD COLUMN "scimTokenGesetztAm" TIMESTAMP(3),
ADD COLUMN "letzteScimZustellungAm" TIMESTAMP(3);
/*
* **Der CHECK je Art wird ersetzt, nicht ergaenzt.**
*
* Bis #028 liess er `SCIM` ohne jede Bedingung durch — die Art hatte noch keine
* Felder. Jetzt hat sie welche, und dieselbe Regel gilt in beide Richtungen: was
* eine Art braucht, und was sie **nicht** haben darf. Ohne den zweiten Teil
* waere eine SCIM-Quelle mit `basisDn` moeglich, und bei der koennte niemand
* mehr sagen, was gilt.
*
* **Das Token steht nicht in der Bedingung.** Eine SCIM-Quelle entsteht ohne
* Token und bekommt es danach — der Administrator legt die Quelle an, erzeugt
* das Token und traegt es beim Anbieter ein. Eine Quelle ohne Token nimmt
* nichts an: der Handler findet keinen Hash zum Vergleichen und antwortet mit
* 401. Das ist der richtige Zustand, nicht ein verbotener.
*/
ALTER TABLE "Verzeichnisquelle" DROP CONSTRAINT "Verzeichnisquelle_felder_je_art";
ALTER TABLE "Verzeichnisquelle"
ADD CONSTRAINT "Verzeichnisquelle_felder_je_art"
CHECK (
("art" = 'LDAP' AND "url" IS NOT NULL AND "bindDn" IS NOT NULL
AND "bindPasswortVerschluesselt" IS NOT NULL AND "basisDn" IS NOT NULL
AND "filter" IS NOT NULL
AND "tenantId" IS NULL AND "clientId" IS NULL AND "clientSecretVerschluesselt" IS NULL
AND "scimTokenHash" IS NULL)
OR
("art" = 'GRAPH' AND "tenantId" IS NOT NULL AND "clientId" IS NOT NULL
AND "clientSecretVerschluesselt" IS NOT NULL
AND "url" IS NULL AND "bindDn" IS NULL AND "bindPasswortVerschluesselt" IS NULL
AND "basisDn" IS NULL AND "filter" IS NULL
AND "scimTokenHash" IS NULL)
OR
("art" = 'SCIM'
AND "url" IS NULL AND "bindDn" IS NULL AND "bindPasswortVerschluesselt" IS NULL
AND "basisDn" IS NULL AND "filter" IS NULL
AND "tenantId" IS NULL AND "clientId" IS NULL AND "clientSecretVerschluesselt" IS NULL)
);
-- Das Token als argon2-Hash, nicht im Klartext. `$argon2` ist das Praefix der
-- Bibliothek; ein Token, das versehentlich roh gespeichert wird, faellt hier auf.
ALTER TABLE "Verzeichnisquelle"
ADD CONSTRAINT "Verzeichnisquelle_scimtoken_gehasht"
CHECK ("scimTokenHash" IS NULL OR "scimTokenHash" LIKE '$argon2%');
-- Ein Token gehoert zu einem Zeitpunkt, in beide Richtungen: ohne ihn liesse
-- sich nicht sagen, seit wann es gilt, und ein Zeitpunkt ohne Token behauptete
-- ein Token, das es nicht gibt.
ALTER TABLE "Verzeichnisquelle"
ADD CONSTRAINT "Verzeichnisquelle_scimtoken_mit_zeitpunkt"
CHECK (("scimTokenHash" IS NULL) = ("scimTokenGesetztAm" IS NULL));
/*
* Der Index, auf dem der SCIM-Handler arbeitet.
*
* Jede Anfrage bringt ein Token mit und muss daraus die Quelle finden. Ein Hash
* laesst sich nicht nachschlagen — argon2 ist je Aufruf anders —, also werden
* die SCIM-Quellen gelesen und einzeln geprueft. Bei zwei Quellen ist das
* gleichgueltig; der Index haelt es auch bei zwanzig schnell und sagt zugleich,
* dass hier nach `art` gesucht wird.
*/
CREATE INDEX "Verzeichnisquelle_art_aktiv_idx" ON "Verzeichnisquelle"("art", "aktiv");
/*
* Gegenprobe: der ersetzte CHECK muss die bestehenden Zeilen noch zulassen.
* `ALTER TABLE ... ADD CONSTRAINT` prueft das von sich aus — aber eine
* Migration, die ihre eigene Annahme nicht nachrechnet, ist genau die Art
* Zusage, die dieses Projekt teuer bezahlt hat.
*/
DO $$
DECLARE
anzahl INTEGER;
BEGIN
SELECT count(*) INTO anzahl FROM "Verzeichnisquelle" WHERE "art" NOT IN ('LDAP', 'GRAPH', 'SCIM');
IF anzahl > 0 THEN
RAISE EXCEPTION 'Unerwartete Quellenart in % Zeilen.', anzahl;
END IF;
END $$;