# #028 M15 – Quellenarten und Microsoft-Graph-Quelle **Status:** erledigt **Im Tracker:** [#48](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/48) **Meilenstein:** v0.2.0 **Labels:** phase-2, backend, frontend **Geschätzt:** 10 h **Tatsächlich:** 4 h 10 min ## Ziel Ein Haus ohne lokales AD hat kein LDAP, auf das sich zeigen ließe. Für reine Entra-ID-Umgebungen braucht es eine zweite Art von Verzeichnisquelle, die über Microsoft Graph liest — mit denselben Regeln wie der LDAP-Abgleich: anlegen, aktualisieren, deaktivieren, **nie löschen**. Dieser Meilenstein legt dazu die Weiche, die auch [#029](029-scim-endpunkt.md) braucht: `Verzeichnisquelle` bekommt eine **Art**. Heute ist jede Quelle implizit LDAP. ## Zu entscheiden - [x] **Ein Enum `QuellenArt {LDAP GRAPH SCIM}` auf `Verzeichnisquelle`** — Empfehlung. Die Felder `url`, `bindDn` und `basisDn` sind dann je Art unterschiedlich belegt; was eine Art braucht, prüft der Service, nicht die Datenbank. Die Alternative wäre eine Tabelle je Art mit gemeinsamer Oberklasse — mehr Struktur, als drei Arten rechtfertigen. - [x] **Wie `{{ou}}` in Entra ID aussieht.** Es gibt keinen DN und keine OU. Ehrlich ist, den Platzhalter bei einer Graph-Quelle **leer** zu lassen, statt etwas hineinzudeuten — und das in der Anleitung zu sagen. Wer OU-abhängige Befehle fährt, braucht ein lokales AD. ## Aufgaben - [x] `QuellenArt` als Enum, Migration mit `LDAP` für alle bestehenden Zeilen - [x] Graph-Zugang: `tenantId`, `clientId`, `clientSecret` (verschlüsselt wie das Bind-Passwort, nie in einer Antwort), Token über Client-Credentials-Flow, Zwischenspeicher bis kurz vor Ablauf - [x] Lesen über `GET /v1.0/users` mit `$select`, Seitenabruf über `@odata.nextLink`; der Vorgesetzte über `$expand=manager($select=id)` - [x] Attributzuordnung mit Graph-Vorgaben (`givenName`, `surname`, `displayName`, `mail`, `userPrincipalName`, `onPremisesSamAccountName`, `department`, `jobTitle`, `accountEnabled`) - [x] `externeId` ist die Entra-`id` (GUID). Sie ist stabil und übersteht eine Umbenennung - [x] „Verbindung testen“ wie bei LDAP: die ersten zehn zugeordneten Treffer zeigen - [x] Der Abgleich läuft durch **denselben** Dienst wie LDAP — nur das Holen der Einträge unterscheidet sich. Sonst driften die Regeln auseinander - [x] Admin → Verzeichnis zeigt die Art je Quelle und bietet beim Anlegen die Wahl - [x] Anleitung Kapitel 06 und `docs/betrieb/`: was in Entra ID einzurichten ist (App-Registrierung, `User.Read.All`, `Directory.Read.All`, Administratoreinwilligung) ## Akzeptanzkriterien - [x] Test gegen einen **abspielenden HTTP-Doppelgänger** mit aufgezeichneten Graph-Antworten: erster Abgleich legt an, zweiter aktualisiert ohne Dubletten, ein `accountEnabled: false` setzt `aktiv = false` - [x] Test: der Seitenabruf folgt `@odata.nextLink` über mindestens drei Seiten - [x] Test: `clientSecret` erscheint in keiner Antwort und in keinem Protokolleintrag - [x] Test: eine Graph-Quelle lässt `{{ou}}` leer, statt einen Wert zu erfinden - [x] Der LDAP-Weg verhält sich unverändert — alle Tests aus #016 bleiben grün - [x] **Offen und benannt:** gegen einen echten Entra-Mandanten ist das nicht gelaufen; die Gegenprobe gehört in eine Prüfliste wie bei [#024](024-proxmox-echtlauf.md) ## Beim Umsetzen dazugekommen - **„Deaktiviert in Entra ID“ als *übersprungen* zu melden war falsch — und hätte Leute aktiv gelassen.** „Übersprungen“ bedeutet im Abgleich „nicht zuordenbar“, und solche Einträge werden seit #016 absichtlich **nicht** abgeschaltet (ein Konto ohne GUID soll nicht die Person deaktivieren). Ein gesperrtes Entra-Konto wäre damit genau umgekehrt behandelt worden als im AD. Gefiltert wird jetzt serverseitig mit `$filter=accountEnabled eq true`, als Gegenstück zum LDAP-Standardfilter. Gefunden hat es der Test „setzt aktiv = false“ — und nur, weil der Doppelgänger den Filter wirklich auswertet statt ihn bloß zu empfangen. - **`URLSearchParams` kodiert `$` zu `%24`.** Die Abfrage hätte `%24select` und `%28` in `manager($select=id)` geschickt. Graph nimmt das teils an und antwortet teils mit „Invalid expand clause“ — und eine Adresse, die in einem Mitschnitt anders aussieht als in der Dokumentation, kostet bei der Fehlersuche eine Stunde. Die Parameter werden jetzt von Hand zusammengesetzt. - **Die Art einer Quelle ist unveränderlich**, und das steht nicht im Issue. An ihr hängt, was `externeId` bedeutet: im AD eine `objectGUID`, in Entra ID eine andere GUID. Nach einem Wechsel würde **kein einziger Mitarbeiter wiedererkannt** — der nächste Abgleich legte die ganze Belegschaft neu an und setzte die alten Zeilen auf `aktiv = false`, mit allen Läufen und Geräten daran. Der Dienst weist einen Wechsel ab, die Oberfläche bietet ihn nicht an, und die Anleitung sagt, was stattdessen zu tun ist. - **`zuordnungLesen` brauchte die Art als Parameter.** Ohne sie bekäme eine Graph-Quelle mit leerer Zuordnung die AD-Namen und fragte bei Graph nach `sn` und `objectGUID`, die es dort nicht gibt. Der Standardwert `LDAP` hält jeden Aufrufer von vor #028 bei seinem Verhalten. - **Die Zugangs-Helfer mussten in ein eigenes Modul.** `quellenleser.ts` (die Weiche) braucht `zugangAus`, und `services/verzeichnis.ts` braucht die Weiche für „Verbindung testen“ — zusammen ein Import-Zyklus. Der läuft in ESM oft, aber nicht zuverlässig, und ein Modul, das beim Laden halb leer ist, ergibt Fehler, die niemand liest. Jetzt liegt beides in `src/server/verzeichnis/zugang.ts`. - **`SCIM` steht im Enum, wird aber abgewiesen.** Die Weiche braucht #029, und ein Wert, der stillschweigend nichts tut, wäre hier besonders teuer: ein Abgleich, der null Einträge liest und `ERFOLG` meldet, hätte über die Abschaltregel die ganze Belegschaft deaktiviert — genau der Fall, der in #016 einen Reviewbefund gekostet hat. Mit eigenem Test. - **Die Felder je Art hält die Datenbank fest.** Ein CHECK verlangt bei `LDAP` die LDAP-Felder **und** verbietet die Graph-Felder, bei `GRAPH` umgekehrt. Ohne den zweiten Teil wäre eine Graph-Quelle mit `basisDn` möglich — eine Zeile, die zwei Dinge gleichzeitig behauptet. - **Der Vorgesetzte kommt bei Graph direkt**, nicht über eine DN-Auflösung. `RoherEintrag` hat dafür ein zweites Feld (`vorgesetzterDirekt`) und nicht ein Feld mit zwei Bedeutungen: ein DN und eine GUID sehen verschieden aus, aber ein Feld, das mal das eine und mal das andere trägt, verwechselt irgendwann jemand. - **Der Token-Zwischenspeicher hat 60 Sekunden Sicherheitsabstand.** Ein Token, das in zehn Sekunden abläuft, ist für eine Seite, die zwei Sekunden braucht, formal noch gültig — und in der Mitte der nächsten nicht mehr. Der Fehler wäre ein Abgleich, der nach der dritten Seite mit 401 abbricht. - **Eine Reißleine bei 1000 Seiten.** Ein `@odata.nextLink`, der auf sich selbst zeigt, wäre sonst eine Schleife bis zum Speicherende — still, weil jeder einzelne Abruf gelingt. - **Zwei eigene Testfehler**, keine Produktfehler: die Token-Grenze liegt bei +3540 s und nicht bei „so ungefähr einer Stunde“, und die Klickprobe zählte die Felder der Bearbeiten-Formulare der bestehenden Quellen mit. ## Noch offen, und benannt Gegen einen **echten Entra-Mandanten** ist das nicht gelaufen. Geprüft ist es gegen einen HTTP-Doppelgänger mit aufgezeichneten Antworten, und deren Form stammt aus der Dokumentation, nicht aus einem Mitschnitt. Die Prüfliste steht am Ende von [`docs/betrieb/entra-id.md`](../betrieb/entra-id.md) — acht Punkte, darunter ein Mandant mit mehr als 999 Benutzern für den Seitenabruf. Dieselbe Art offener Punkt wie [#024](024-proxmox-echtlauf.md).