Files
Laufzettel/docs/issues/028-graph-quelle.md
T
Florian WildeandClaude Opus 5 76a61ec771
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(verzeichnis): Entra ID als Quelle ueber Microsoft Graph (#48)
Ein Haus ohne lokales Active Directory hat kein LDAP, auf das sich
zeigen liesse. Eine Verzeichnisquelle hat jetzt eine **Art** — LDAP oder
GRAPH —, und beide laufen durch **denselben** Abgleich: anlegen,
aktualisieren, deaktivieren, nie loeschen. Nur das Holen der Eintraege
unterscheidet sich; zwei Abgleiche nebeneinander wuerden
auseinanderdriften, und die Zusage „es wird nichts geloescht“ gaelte
dann fuer eine Art und fuer die andere nicht mehr ganz.

Gelesen wird mit dem Client-Credentials-Flow, seitenweise ueber
@odata.nextLink, der Vorgesetzte ueber $expand=manager($select=id). Das
Client-Secret wird verschluesselt gespeichert wie das Bind-Passwort und
erreicht keine Antwort und keinen Protokolleintrag.

Die Art ist **unveraenderlich**, und das steht nicht im Issue: an ihr
haengt, was `externeId` bedeutet. Im AD eine objectGUID, in Entra ID
eine andere GUID — nach einem Wechsel wuerde kein einziger Mitarbeiter
wiedererkannt, der naechste Abgleich legte die ganze Belegschaft neu an
und setzte die alten Zeilen auf aktiv = false, mit allen Laeufen und
Geraeten daran.

Der teuerste Fund beim Bauen: „in Entra ID deaktiviert“ als
*uebersprungen* zu melden haette die Leute **aktiv** gelassen.
Uebersprungen heisst im Abgleich „nicht zuordenbar“, und solche
Eintraege werden seit #016 absichtlich nicht abgeschaltet. Gefiltert
wird deshalb serverseitig mit `$filter=accountEnabled eq true`, als
Gegenstueck zum LDAP-Standardfilter. Gefunden hat es der Test — aber nur,
weil der Doppelgaenger den Filter wirklich auswertet statt ihn bloss zu
empfangen.

{{ou}} bleibt bei einer Graph-Quelle leer: Entra ID hat keine OU, und
etwas hineinzudeuten waere ein Wert, den kein PowerShell-Befehl brauchen
kann — er saehe nur richtig aus. Steht an der Abteilung eine Ziel-OU
gepflegt, greift die.

Gegen einen echten Entra-Mandanten ist das **nicht** gelaufen. Geprueft
ist es gegen einen HTTP-Doppelgaenger mit aufgezeichneten Antworten; die
Pruefliste fuer den ersten echten Lauf steht am Ende von
docs/betrieb/entra-id.md.

Closes #48

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-06 23:36:36 +02:00

7.7 KiB
Raw Blame History

#028 M15 – Quellenarten und Microsoft-Graph-Quelle

Status: erledigt Im Tracker: #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 braucht: Verzeichnisquelle bekommt eine Art. Heute ist jede Quelle implizit LDAP.

Zu entscheiden

  • 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.
  • 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

  • QuellenArt als Enum, Migration mit LDAP für alle bestehenden Zeilen
  • 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
  • Lesen über GET /v1.0/users mit $select, Seitenabruf über @odata.nextLink; der Vorgesetzte über $expand=manager($select=id)
  • Attributzuordnung mit Graph-Vorgaben (givenName, surname, displayName, mail, userPrincipalName, onPremisesSamAccountName, department, jobTitle, accountEnabled)
  • externeId ist die Entra-id (GUID). Sie ist stabil und übersteht eine Umbenennung
  • „Verbindung testen“ wie bei LDAP: die ersten zehn zugeordneten Treffer zeigen
  • Der Abgleich läuft durch denselben Dienst wie LDAP — nur das Holen der Einträge unterscheidet sich. Sonst driften die Regeln auseinander
  • Admin → Verzeichnis zeigt die Art je Quelle und bietet beim Anlegen die Wahl
  • Anleitung Kapitel 06 und docs/betrieb/: was in Entra ID einzurichten ist (App-Registrierung, User.Read.All, Directory.Read.All, Administratoreinwilligung)

Akzeptanzkriterien

  • 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
  • Test: der Seitenabruf folgt @odata.nextLink über mindestens drei Seiten
  • Test: clientSecret erscheint in keiner Antwort und in keinem Protokolleintrag
  • Test: eine Graph-Quelle lässt {{ou}} leer, statt einen Wert zu erfinden
  • Der LDAP-Weg verhält sich unverändert — alle Tests aus #016 bleiben grün
  • Offen und benannt: gegen einen echten Entra-Mandanten ist das nicht gelaufen; die Gegenprobe gehört in eine Prüfliste wie bei #024

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 — acht Punkte, darunter ein Mandant mit mehr als 999 Benutzern für den Seitenabruf. Dieselbe Art offener Punkt wie #024.