# #029 M16 – SCIM-Endpunkt als Alternative zum Abgleich **Status:** erledigt **Im Tracker:** [#49](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/49) **Meilenstein:** v0.2.0 **Labels:** phase-2, backend **Geschätzt:** 12 h **Tatsächlich:** 4 h 05 min ## Ziel Der Abgleich holt heute. Ein Identitätsanbieter kann auch **schicken** — authentik, Entra ID und Okta sprechen dafür SCIM 2.0. Das ist der Weg für Häuser, die Laufzettel keinen Lesezugriff auf ihr Verzeichnis geben wollen oder aus einem Netz heraus betreiben, das das Verzeichnis nicht erreicht. Setzt [#028](028-graph-quelle.md) voraus: die Quellenart `SCIM` braucht die Weiche. ## Zu entscheiden - [x] **Ein Token je Quelle, als Hash gespeichert** — Empfehlung, dasselbe Verfahren wie beim lokalen Passwort (argon2). Es wird **einmal** angezeigt und ist danach nicht mehr lesbar. Das Token identifiziert die Quelle und damit den Kunden; es ist der einzige Mandantenbeweis, den ein Push mitbringt. - [x] **Was bei `DELETE /Users/{id}` geschieht:** `aktiv = false`, nicht löschen. Das ist die Regel des ganzen Projekts, und ein Identitätsanbieter, der löschen will, bekommt `204` — aber die Zeile bleibt, weil Läufe an ihr hängen. Das muss in der Anleitung stehen, sonst wirkt es wie ein Fehler. ## Aufgaben - [x] Route Handler unter `src/app/api/scim/v2/`: `Users` (GET mit Filter `userName eq`, POST), `Users/{id}` (GET, PUT, PATCH, DELETE), dazu `ServiceProviderConfig`, `Schemas`, `ResourceTypes` — ohne die drei weigern sich manche Anbieter, überhaupt anzufangen - [x] **Jeder dieser Handler prüft selbst**: das Token, und daraus die Quelle und den Kunden. Die Hülle unter `(app)` liegt hier nicht darüber (CLAUDE.md) - [x] Zuordnung: `externalId` → `externeId`, `userName` → `upn`, `name.givenName`/`familyName`, `emails[primary]` → `mail`, `active` → `aktiv`, `urn:ietf:params:scim:schemas:extension:enterprise:2.0:User` für `department` und `manager` - [x] Antwortform nach RFC 7644: `schemas`, `id`, `meta.resourceType`, `meta.location`, bei Listen `totalResults`/`itemsPerPage`/`startIndex`; Fehler als `urn:ietf:params:scim:api:messages:2.0:Error` - [x] `PATCH` mit `op: add|replace|remove` auf den Pfaden, die die Anbieter wirklich schicken — nicht der ganze Standard - [x] Idempotenz: zweimal dasselbe `POST` ergibt `409`, nicht eine zweite Person - [x] Protokolleintrag je Vorgang, mit der Quelle als Urheber (Systemlauf, kein Benutzer) - [x] Admin → Verzeichnis: Token erzeugen, einmal anzeigen, widerrufen; letzte Zustellung sichtbar - [x] Anleitung Kapitel 06: Einrichtung in authentik und in Entra ID ## Akzeptanzkriterien - [x] Test gegen **aufgezeichnete Anfragen** von authentik und Entra ID (Form, nicht erfunden): anlegen, aktualisieren, deaktivieren, wieder aktivieren - [x] Test: ohne Token `401`, mit fremdem Token kein Zugriff auf die Personen einer anderen Quelle - [x] Test: `DELETE` setzt `aktiv = false` und löscht nichts - [x] Test: zweimal dasselbe `POST` ergibt `409` und keine zweite Zeile - [x] Test: das Token steht nur als Hash in der Datenbank und in keiner Antwort - [x] Test: eine Quelle der Art `SCIM` wird vom Zeitplan **nicht** abgeglichen — sie bekommt geschickt ## Beim Umsetzen dazugekommen - **Zwei Gegenproben blieben grün, und beide deckten etwas auf.** Von acht Gegenproben (Regel brechen, Test muss rot werden) schlugen sechs sofort an. Die zwei stillen waren die wertvollen: - **`PATCH remove` wirkte gar nicht** — ein echter Fehler, kein Testloch. `personAendern` hatte einen Schalter `nurGesetzte`, der bei `PATCH` jedes `null` als „nicht mitgeschickt“ las und den alten Wert stehen ließ. Nimmt jemand beim Anbieter die Abteilung aus dem Profil, stand sie in Laufzettel weiter — sichtbar, gepflegt aussehend, falsch. Nötig war der Schalter nie: `patchAnwenden` setzt auf dem vorhandenen Stand auf und liefert deshalb immer einen **vollständigen** Zielzustand. Der Unterschied zwischen `PUT` und `PATCH` liegt vor der Funktion, nicht in ihr. Schalter entfernt, Test ergänzt, Gegenprobe schlägt jetzt an. - **Entra ID schickt `active` als Zeichenkette `"True"`/`"False"`.** Das war richtig umgesetzt, aber nur halb geprüft: der Deaktivierungs-Test lief gegen `"False"`, und mit einem reinen `value === true` kommt dort **zufällig** das Richtige heraus. Nur das Aktivieren wäre kaputt gewesen — ein Konto, das wieder freigeschaltet wird und in Laufzettel für immer inaktiv bleibt. Der Fehler wäre also nur in einer Richtung sichtbar gewesen. Jetzt steht `ENTRA_PATCH_AN` mit `"True"` als eigene aufgezeichnete Form daneben. - **`manager` war im Issue genannt und wäre fast untergegangen.** Die Zuordnung nennt die Enterprise-Erweiterung für `department` **und** `manager`; umgesetzt war zunächst nur die Abteilung. Dazugekommen sind: `vorgesetzterAus` liest alle drei Formen, die die Anbieter schicken (`{value}`, `{$ref}` und die nackte Zeichenkette — letztere nicht standardkonform, aber authentik schickt sie), die Auflösung über die Laufzettel-Kennung, und `manager` im `Schemas`-Endpunkt. Das Letzte ist das Entscheidende: ein Anbieter liest dieses Schema und schickt nur, was darin steht. Fehlte es, bliebe der Vorgesetzte dauerhaft leer — **ohne Fehlermeldung auf einer der beiden Seiten**. - **`manager` wäre ein Weg über die Quellengrenze gewesen.** Eine Kennung aus einer fremden Quelle zeigt auf jemanden, den diese hier nicht sehen darf, und über die Vorgesetzten-Anzeige stünde dessen Name in der Oberfläche. Die Auflösung sucht deshalb nur in der eigenen Quelle; eine Gegenprobe ohne diese Einschränkung ist rot. Dasselbe für den Fall „sein eigener Vorgesetzter“ — die Datenbank weist das ohnehin ab, aber dann wäre die ganze Zuweisung daran gescheitert. - **Ein unbekannter Vorgesetzter lässt die Zuweisung nicht scheitern.** Der Anbieter schickt die Leute in beliebiger Reihenfolge. Wer an einer noch unbekannten Kennung scheiterte, ließe die halbe Belegschaft draußen; nachgezogen wird beim nächsten `PATCH`. - **Ein unverstandener Filter ergibt `400` und nicht „alle Personen“.** Das ist die wichtigste Einzelentscheidung dieses Meilensteins. Ihn zu ignorieren hieße, alle zu liefern — und der Anbieter schlösse daraus, die gesuchte Person gäbe es schon, und legte sie **nie** an. Unterstützt wird genau `userName eq "…"`, die Frage, die jeder Anbieter vor dem Anlegen stellt. - **Ein Fehler im Nachbarcode aus #028**: in `verbindungTestenAktion` stand zweimal dieselbe Bedingung (`ergebnis.treffer.length === 0`), der zweite Zweig war damit unerreichbar. Genau der Zweig war in #028 als Reviewbefund eingebaut worden — „gelesen, aber nichts zuordenbar“ sollte anders klingen als „der Filter findet niemanden“, weil die Ursachen verschiedene sind und den Administrator an verschiedene Stellen schicken. Wirkungslos, seit er geschrieben wurde. Behoben. - **„Noch nie abgeglichen“ ist an einer SCIM-Quelle eine Falschaussage**, und „kein Zeitplan“ liest sich wie ein Mangel. Beides ersetzt: die Pille sagt „nimmt Zustellungen an“, und an der Stelle, an der bei LDAP der Verbindungstest sitzt, steht die letzte Zustellung. Verbindungstest und „Jetzt abgleichen“ gibt es dort nicht — ein Knopf, der nur eine Erklärung auswirft, ist kein Knopf. Wer sie trotzdem über den Dienst ruft, bekommt eine Erklärung statt „unbekannter Fehler“. - **Der Zeitplan lässt SCIM-Quellen aus, und das ist keine Kosmetik.** Ein Abgleich, der an einer SCIM-Quelle nichts liest und `ERFOLG` meldet, hätte über die Abschaltregel die **ganze Belegschaft deaktiviert** — derselbe Fall, der in #016 einen Reviewbefund gekostet hat. Zwei Stellen halten das: `faelligeQuellen` nimmt sie nicht auf, und die Weiche im Quellenleser wirft. - **Das Token in keiner Sicht** — geprüft nicht an einem Feld, sondern an der **ganzen** serialisierten Sicht aus Liste und Detail. Ein später ergänztes Feld fällt damit auch auf. Dazu: nicht im Protokolleintrag, nicht im HTML der Seite nach dem Neuladen (in der Klickprobe am gebauten Stand nachgesehen). - **Dieselbe `401` für „kein Token“ und „falsches Token“.** Die Unterscheidung wäre eine Auskunft darüber, ob ein geratenes Token die richtige Form hat. Und vor der Hash-Prüfung steht eine Vorab-Prüfung auf das Präfix: ohne sie würde ein Token mit falschem Präfix gegen jeden Hash geprüft, eines mit richtigem nur bis zum Treffer — ein Zeitorakel. - **Die Beschreibungs-Endpunkte verlangen ebenfalls ein Token**, und ihre **Verneinungen** sind der wichtigere Teil: `bulk`, `sort`, `filter` und `etag` stehen ausdrücklich auf `false`. Ein Anbieter, der `bulk` für unterstützt hält, schickt Anfragen, die niemand beantwortet, und meldet das als Fehler der Gegenseite. - **Die Klickprobe am gebauten Stand fand keinen Bedienfehler** — zwanzig Prüfungen von „Art SCIM wählbar“ über „Token angezeigt“, eine echte Zustellung mit genau diesem Token, „Person steht in der Liste“, `DELETE` deaktiviert, Widerruf sperrt, bis „390 px ohne horizontales Scrollen“. Die Probe hat ihre Daten danach selbst wieder entfernt; das ist die Lehre aus #027. - **Ein Test aus #028 war überholt**, nicht falsch: er prüfte, dass die Meldung der Weiche „#029“ enthält. Jetzt ist #029 da, und die Antwort bleibt dieselbe — nur ohne Issue-Nummer, denn die Meldung steht auch in der Oberfläche. ## Was offen bleibt - **Gegen echte Mandanten ist das nicht gelaufen.** Die Anfrageformen in `tests/scim.test.ts` stammen aus der Dokumentation von authentik und Entra ID, nicht aus einem Mitschnitt — dieselbe Grenze wie bei #028, und sie ist dort im Kopfkommentar der Testdatei benannt. Stimmt eine Form nicht, stimmen Test und Wirklichkeit nicht. Die Prüflisten für den ersten echten Lauf stehen in [`docs/betrieb/scim.md`](../betrieb/scim.md) unter „Prüfen von Hand“. - **Keine Paginierung über 200 Einträge** bei `GET /Users` ohne Filter. Die Anbieter fragen gefiltert; ein voller Abzug ist keine Betriebsart dieses Endpunkts. - **Kein `/Groups`.** Rollen entstehen beim Anmelden aus den OIDC-Gruppen, nicht aus dem Provisioning. `ResourceTypes` nennt deshalb nur `User`.