feat(verzeichnis): Entra ID als Quelle ueber Microsoft Graph (#48)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

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>
This commit is contained in:
Florian Wilde
2026-10-06 23:36:36 +02:00
co-authored by Claude Opus 5
parent 0158d0292f
commit 76a61ec771
25 changed files with 2446 additions and 382 deletions
+3 -3
View File
@@ -536,12 +536,12 @@ describe.runIf(datenbankVerfuegbar)("Verzeichnisquellen", () => {
// Umbenennen kaputt.
await quelleBearbeiten(quelle.id, { bindPasswort: "", name: "Neu" }, admin);
const nachher = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
expect(entschluesseln(nachher.bindPasswortVerschluesselt)).toBe(LDAP_BIND_PASSWORT);
expect(entschluesseln(nachher.bindPasswortVerschluesselt ?? "")).toBe(LDAP_BIND_PASSWORT);
expect(nachher.bindPasswortVerschluesselt).toBe(vorher.bindPasswortVerschluesselt);
await quelleBearbeiten(quelle.id, { bindPasswort: "ein-neues-geheimnis" }, admin);
const dritte = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
expect(entschluesseln(dritte.bindPasswortVerschluesselt)).toBe("ein-neues-geheimnis");
expect(entschluesseln(dritte.bindPasswortVerschluesselt ?? "")).toBe("ein-neues-geheimnis");
});
it("gibt das Bind-Passwort in keiner Antwort und in keinem Protokoll heraus", async () => {
@@ -562,7 +562,7 @@ describe.runIf(datenbankVerfuegbar)("Verzeichnisquellen", () => {
const zeile = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
expect(zeile.bindPasswortVerschluesselt).not.toContain(LDAP_BIND_PASSWORT);
expect(zeile.bindPasswortVerschluesselt).toMatch(/^v1\./u);
expect(entschluesseln(zeile.bindPasswortVerschluesselt)).toBe(LDAP_BIND_PASSWORT);
expect(entschluesseln(zeile.bindPasswortVerschluesselt ?? "")).toBe(LDAP_BIND_PASSWORT);
});
it("loescht eine Quelle ohne Mitarbeiter und verweigert es sonst", async () => {