feat(verzeichnis): SCIM-Endpunkt als dritte Quellenart (#029)
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:
co-authored by
Claude Opus 5
parent
76a61ec771
commit
fcd67018b8
@@ -1,10 +1,10 @@
|
||||
# #029 M16 – SCIM-Endpunkt als Alternative zum Abgleich
|
||||
|
||||
**Status:** offen
|
||||
**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:** –
|
||||
**Geschätzt:** 12 h **Tatsächlich:** 4 h 05 min
|
||||
|
||||
## Ziel
|
||||
|
||||
@@ -16,41 +16,137 @@ Setzt [#028](028-graph-quelle.md) voraus: die Quellenart `SCIM` braucht die Weic
|
||||
|
||||
## Zu entscheiden
|
||||
|
||||
- [ ] **Ein Token je Quelle, als Hash gespeichert** — Empfehlung, dasselbe Verfahren wie beim
|
||||
- [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.
|
||||
- [ ] **Was bei `DELETE /Users/{id}` geschieht:** `aktiv = false`, nicht löschen. Das ist die Regel
|
||||
- [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
|
||||
|
||||
- [ ] Route Handler unter `src/app/api/scim/v2/`: `Users` (GET mit Filter `userName eq`, POST),
|
||||
- [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
|
||||
- [ ] **Jeder dieser Handler prüft selbst**: das Token, und daraus die Quelle und den Kunden. Die
|
||||
- [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)
|
||||
- [ ] Zuordnung: `externalId` → `externeId`, `userName` → `upn`, `name.givenName`/`familyName`,
|
||||
- [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`
|
||||
- [ ] Antwortform nach RFC 7644: `schemas`, `id`, `meta.resourceType`, `meta.location`, bei Listen
|
||||
- [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`
|
||||
- [ ] `PATCH` mit `op: add|replace|remove` auf den Pfaden, die die Anbieter wirklich schicken —
|
||||
- [x] `PATCH` mit `op: add|replace|remove` auf den Pfaden, die die Anbieter wirklich schicken —
|
||||
nicht der ganze Standard
|
||||
- [ ] Idempotenz: zweimal dasselbe `POST` ergibt `409`, nicht eine zweite Person
|
||||
- [ ] Protokolleintrag je Vorgang, mit der Quelle als Urheber (Systemlauf, kein Benutzer)
|
||||
- [ ] Admin → Verzeichnis: Token erzeugen, einmal anzeigen, widerrufen; letzte Zustellung sichtbar
|
||||
- [ ] Anleitung Kapitel 06: Einrichtung in authentik und in Entra ID
|
||||
- [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
|
||||
|
||||
- [ ] Test gegen **aufgezeichnete Anfragen** von authentik und Entra ID (Form, nicht erfunden):
|
||||
- [x] Test gegen **aufgezeichnete Anfragen** von authentik und Entra ID (Form, nicht erfunden):
|
||||
anlegen, aktualisieren, deaktivieren, wieder aktivieren
|
||||
- [ ] Test: ohne Token `401`, mit fremdem Token kein Zugriff auf die Personen einer anderen Quelle
|
||||
- [ ] Test: `DELETE` setzt `aktiv = false` und löscht nichts
|
||||
- [ ] Test: zweimal dasselbe `POST` ergibt `409` und keine zweite Zeile
|
||||
- [ ] Test: das Token steht nur als Hash in der Datenbank und in keiner Antwort
|
||||
- [ ] Test: eine Quelle der Art `SCIM` wird vom Zeitplan **nicht** abgeglichen — sie bekommt
|
||||
- [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`.
|
||||
|
||||
@@ -33,11 +33,11 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
|
||||
| #026 | [Zwei Konventionen präzisieren, die strenger klingen als sie gemeint sind](026-konventionen-praezisieren.md) | erledigt | v0.2.0 | 1 h | 20 min |
|
||||
| #027 | [Die Editor-Abläufe lassen ihre Vorlagen stehen](027-e2e-reste-vorlagen.md) | offen | v0.2.0 | 1 h | – |
|
||||
| #028 | [M15 – Quellenarten und Microsoft-Graph-Quelle](028-graph-quelle.md) | erledigt | v0.2.0 | 10 h | 4 h 10 min |
|
||||
| #029 | [M16 – SCIM-Endpunkt als Alternative zum Abgleich](029-scim-endpunkt.md) | offen | v0.2.0 | 12 h | – |
|
||||
| #029 | [M16 – SCIM-Endpunkt als Alternative zum Abgleich](029-scim-endpunkt.md) | erledigt | v0.2.0 | 12 h | 4 h 05 min |
|
||||
| #030 | [M17 – Wiederverwendbare Bausteine](030-bausteine.md) | erledigt | v0.2.0 | 12 h | 2 h 25 min |
|
||||
| #031 | [M18 – Abteilungsspezifische Zusatzabschnitte](031-abteilungsabschnitte.md) | erledigt | v0.2.0 | 8 h | 1 h 35 min |
|
||||
| #032 | [M19 – Anhänge und Fotos an Schritten](032-anhaenge.md) | erledigt | v0.2.0 | 10 h | 3 h 05 min |
|
||||
| #033 | [M20 – Live-Aktualisierung per SSE statt Polling](033-sse-live.md) | erledigt | v0.2.0 | 6 h | 1 h 40 min |
|
||||
| #034 | [M21 – Benachrichtigungen per Mail und Webhook](034-benachrichtigungen.md) | erledigt | v0.2.0 | 10 h | 3 h 30 min |
|
||||
| #035 | [M22 – Mitarbeiter-Selbstauskunft](035-selbstauskunft.md) | erledigt | v0.2.0 | 8 h | 2 h 50 min |
|
||||
| | **Summe** | | | **229 h** | **60 h 35 min** |
|
||||
| | **Summe** | | | **229 h** | **64 h 40 min** |
|
||||
|
||||
Reference in New Issue
Block a user