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
+56 -3
View File
@@ -76,6 +76,10 @@ export type QuellenSicht = {
readonly tenantId: string | null;
readonly clientId: string | null;
readonly clientSecretVorhanden: boolean;
/** Nur bei `art = SCIM`. Das Token **nie**, nur ob eines gilt und seit wann. */
readonly scimTokenVorhanden: boolean;
readonly scimTokenGesetztAm: Date | null;
readonly letzteScimZustellungAm: Date | null;
readonly attributZuordnung: AttributZuordnung;
readonly caZertifikatVorhanden: boolean;
readonly abgleichIntervallMinuten: number;
@@ -187,6 +191,15 @@ function alsSicht(zeile: Verzeichnisquelle): QuellenSicht {
* nicht in eine Antwort schafft, kann auch nicht versehentlich hinausgehen.
*/
clientSecretVorhanden: Boolean(zeile.clientSecretVerschluesselt?.trim()),
/*
* Dasselbe fuer das SCIM-Token (#029), und hier waere es noch teurer: es ist
* der einzige Mandantenbeweis, den ein Push mitbringt. Es steht als Hash in
* der Datenbank und geht aus dem Dienst genau einmal heraus — in der
* Antwort von `scimTokenErzeugen`, nie in einer Sicht.
*/
scimTokenVorhanden: Boolean(zeile.scimTokenHash?.trim()),
scimTokenGesetztAm: zeile.scimTokenGesetztAm,
letzteScimZustellungAm: zeile.letzteScimZustellungAm,
attributZuordnung: zuordnungLesen(zeile.attributZuordnung, zuordnungsArt(zeile.art)),
// Der Inhalt bleibt hier: die Oberflaeche muss nur wissen, ob eines
// hinterlegt ist, und braucht dafuer keinen Zertifikatstext.
@@ -254,9 +267,34 @@ function feldernJeArt(art: QuellenArt, eingabe: NeueQuelle) {
};
}
throw new VerzeichnisFehler(
`Quellen der Art ${art} lassen sich noch nicht anlegen. Der SCIM-Endpunkt kommt mit #029.`,
);
if (art === "SCIM") {
/*
* **Eine SCIM-Quelle hat keine Zugangsdaten — sie hat ein Token, und das
* entsteht danach.** Erst steht die Quelle, dann erzeugt der Administrator
* das Token und traegt es beim Anbieter ein. Eine Quelle ohne Token nimmt
* nichts an und antwortet mit 401; das ist der richtige Anfangszustand.
*/
if (
eingabe.url?.trim() ||
eingabe.bindDn?.trim() ||
eingabe.basisDn?.trim() ||
eingabe.tenantId?.trim() ||
eingabe.clientId?.trim()
) {
throw new VerzeichnisFehler(
"Eine SCIM-Quelle hat weder LDAP- noch Graph-Zugangsdaten: Laufzettel verbindet sich nicht, " +
"sondern wird beliefert. Sie braucht nur einen Namen — das Token kommt danach.",
);
}
return {};
}
/*
* Unerreichbar, solange `QuellenArt` drei Werte hat — und genau deshalb hier:
* eine vierte Art faellt beim Uebersetzen auf und nicht im Betrieb.
*/
const unbekannt: never = art;
throw new VerzeichnisFehler(`Quellen der Art ${String(unbekannt)} lassen sich nicht anlegen.`);
}
export async function quelleAnlegen(
@@ -564,6 +602,21 @@ export async function verbindungTesten(
await nurIntern();
rechtPruefen(benutzer);
const quelle = await quelleZeileHolen(quelleId);
/*
* **Bei SCIM gibt es nichts zu testen, und das ist keine Lücke** (#029):
* Laufzettel baut keine Verbindung auf, sondern nimmt eine an. Ob sie steht,
* zeigt die erste Zustellung — deshalb steht in der Oberfläche „Letzte
* Zustellung“ dort, wo bei LDAP der Verbindungstest sitzt.
*/
if (quelle.art === "SCIM") {
throw new VerzeichnisFehler(
`„${quelle.name}“ ist eine SCIM-Quelle: es gibt keine Verbindung, die sich von hier aus ` +
"testen ließe. Trag das Token beim Anbieter ein und lass ihn eine Zuweisung schicken — " +
"danach steht hier, wann die letzte angekommen ist.",
);
}
const wieViele = Math.min(Math.max(Math.trunc(grenze), 1), PROBE_GRENZE_HOECHSTENS);
/*