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
+26
View File
@@ -16,6 +16,32 @@ Zwei Dinge vorweg:
Wer im Verzeichnis verschwindet oder dort deaktiviert wird, wird hier **inaktiv**. Gelöscht wird
nie, sonst verlöre die Historie ihre Namen.
## Zwei Arten von Quelle
Laufzettel liest die Personen aus einem Verzeichnis. Dafür gibt es zwei Wege, und du wählst ihn beim
Anlegen der Quelle:
| Art | Für wen |
|---|---|
| **LDAP** | ein lokales Active Directory (oder ein anderes LDAP-Verzeichnis) |
| **Microsoft Graph** | Entra ID, also Microsoft 365 — für Häuser ohne lokales AD |
Beide verhalten sich danach gleich: anlegen, aktualisieren, deaktivieren, **nie löschen**. Was sie
unterscheidet, sind die Zugangsdaten und zwei Platzhalter:
- **`{{ou}}` bleibt bei Graph leer.** Entra ID hat keine OU. Steht an der Abteilung eine Ziel-OU
gepflegt, nimmt Laufzettel die; sonst bleibt der Platzhalter stehen.
- **`{{sam}}` ist bei Graph leer**, wenn der Mandant kein lokales AD synchronisiert — dort gibt es
keinen Anmeldenamen in diesem Sinn.
**Die Art lässt sich nachträglich nicht ändern.** An ihr hängt, was die dauerhafte Kennung einer
Person bedeutet; nach einem Wechsel würde niemand mehr wiedererkannt, und der nächste Abgleich legte
alle neu an. Wer wechseln muss, legt eine zweite Quelle an und lässt die erste stehen.
Was in Entra ID einzurichten ist — App-Registrierung, Berechtigung, Einwilligung — steht in
[`docs/betrieb/entra-id.md`](../betrieb/entra-id.md).
## Eine Quelle anlegen
![Die Seite Verzeichnis mit Quelle, Status und Formular](bilder/verzeichnis-desktop.png)
+2
View File
@@ -56,6 +56,8 @@ Sitzungen ungültig *und* die verschlüsselt gespeicherten LDAP-Bind-Passwörter
- **[Anmeldung über authentik](authentik.md)** — OIDC-Provider, Gruppen auf Rollen, Notfallzugang
- **[Sichern und Zurückspielen](sicherung.md)** — auf beiden Wegen, was in die Sicherung gehört und
was nicht, und wie du prüfst, dass das Zurückspielen wirklich funktioniert
- **[Entra ID als Verzeichnisquelle](entra-id.md)** — App-Registrierung, `User.Read.All`,
Administratoreinwilligung, und was anders ist als bei einem lokalen AD
- **[Benachrichtigungen](benachrichtigungen.md)** — SMTP einrichten, den Webhook prüfen, die
Signatur nachrechnen, und wo man nachsieht, wenn keine Mail mehr ankommt
+126
View File
@@ -0,0 +1,126 @@
# Entra ID als Verzeichnisquelle
Ein Haus ohne lokales Active Directory hat kein LDAP, auf das sich zeigen ließe. Für reine
Microsoft-365-Umgebungen liest Laufzettel über **Microsoft Graph** — mit denselben Regeln wie der
LDAP-Abgleich: anlegen, aktualisieren, deaktivieren, **nie löschen**.
> **Dieser Weg ist nicht gegen einen echten Mandanten gelaufen.** Geprüft ist er gegen einen
> HTTP-Doppelgänger mit aufgezeichneten Graph-Antworten (`tests/graph.test.ts`), und die Form dieser
> Antworten stammt aus der Dokumentation, nicht aus einem Mitschnitt. Die Prüfliste am Ende dieser
> Seite gehört einmal abgearbeitet, so wie bei Proxmox (#024).
## Was in Entra ID einzurichten ist
### 1. App-Registrierung anlegen
Im Entra-Portal unter **Identität → Anwendungen → App-Registrierungen → Neue Registrierung**:
| Feld | Wert |
|---|---|
| Name | `Laufzettel Verzeichnisabgleich` |
| Unterstützte Kontotypen | **Nur Konten in diesem Organisationsverzeichnis** |
| Umleitungs-URI | **keiner** — Laufzettel meldet sich als Anwendung an, nicht im Namen eines Menschen |
Notiere von der Übersichtsseite die **Anwendungs-ID (Client)** und die **Verzeichnis-ID (Mandant)**.
### 2. Berechtigung erteilen
Unter **API-Berechtigungen → Berechtigung hinzufügen → Microsoft Graph →
Anwendungsberechtigungen**:
| Berechtigung | Wofür |
|---|---|
| `User.Read.All` | die Benutzer lesen |
Dann **Administratoreinwilligung erteilen**. Ohne diesen Klick antwortet Graph mit `403` und
`Authorization_RequestDenied` — Laufzettel nennt den Grund in der Fehlermeldung, weil ihn sonst
niemand errät.
> **Anwendungsberechtigung, nicht delegierte.** Der Abgleich läuft nachts, wenn niemand angemeldet
> ist; eine delegierte Berechtigung braucht einen angemeldeten Menschen und funktioniert dort nicht.
`Directory.Read.All` wird **nicht** gebraucht. `User.Read.All` deckt die Benutzer und über
`$expand=manager` auch den Vorgesetzten ab — die weitergehende Berechtigung wäre mehr Zugriff, als
die Aufgabe verlangt.
### 3. Geheimnis erzeugen
Unter **Zertifikate & Geheimnisse → Neuer geheimer Clientschlüssel**. Die Laufzeit ist eine
Entscheidung mit Folgen: **läuft das Geheimnis ab, hört der Abgleich auf, und niemand merkt es**,
weil jeder Lauf weiter funktioniert. Setz dir eine Erinnerung, und sieh unter **Verwaltung →
Verzeichnis** nach dem Status, wenn Personen nicht mehr aktualisiert werden.
Der Wert ist **nur beim Anlegen sichtbar**. Kopier ihn sofort.
## In Laufzettel einrichten
Unter **Verwaltung → Verzeichnis → Quelle anlegen**, Art **Microsoft Graph**:
| Feld | Wert |
|---|---|
| Mandanten-ID | die Verzeichnis-ID oder der Domänenname (`contoso.onmicrosoft.com`) |
| Anwendungs-ID | die Anwendungs-ID (Client) aus Schritt 1 |
| Client-Secret | der Wert aus Schritt 3 — wird verschlüsselt gespeichert und nie wieder angezeigt |
Dann **Verbindung testen**: es erscheinen die ersten zehn zugeordneten Personen. Steht dort nichts,
hilft die Tabelle unter „Wenn es klemmt“.
## Was anders ist als bei einem lokalen AD
| | LDAP (AD) | Microsoft Graph (Entra ID) |
|---|---|---|
| Dauerhafte Kennung | `objectGUID` (binär, umsortiert) | `id` (GUID als Text) |
| Nachname | `sn` | `surname` |
| Funktion | `title` | `jobTitle` |
| Anmeldename | `sAMAccountName` | `onPremisesSamAccountName` |
| Vorgesetzter | `manager` als DN, danach aufgelöst | `manager` direkt als Kennung |
| Deaktivierte Konten | über den Filter ausgeschlossen | über `$filter=accountEnabled eq true` |
| `{{ou}}` | aus dem DN | **leer** |
### `{{ou}}` bleibt leer, und das ist Absicht
Entra ID hat keinen DN und keine OU. Etwas hineinzudeuten — den Abteilungsnamen, eine erfundene
Struktur — wäre ein Wert, den kein PowerShell-Befehl brauchen kann, und er sähe richtig aus.
Steht an der **Abteilung** eine Ziel-OU gepflegt, nimmt Laufzettel die (`{{ou}}` hat zwei Quellen,
siehe `docs/architektur.md`). Wer OU-abhängige Befehle fährt, braucht ein lokales AD — oder pflegt
die Ziel-OU je Abteilung von Hand.
### `{{sam}}` ist leer ohne AD-Synchronisierung
`onPremisesSamAccountName` kommt aus einem lokalen AD. Ein Mandant, der nie eines hatte, liefert dort
nichts — eine reine Cloud-Umgebung hat keinen Anmeldenamen in diesem Sinn. Vorlagen, die `{{sam}}`
benutzen, zeigen dann einen leeren Wert; gemeint ist meist ohnehin der UPN.
### Die Art einer Quelle lässt sich nicht ändern
An ihr hängt, was die dauerhafte Kennung bedeutet. Nach einem Wechsel von LDAP auf Graph 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.
Wer wechselt, legt eine **zweite** Quelle an und lässt die erste stehen, bis die Personen übernommen
sind. Laufzettel übernimmt dabei von Hand angelegte und bereits vorhandene Personen über Name und
Mailadresse, statt Dubletten anzulegen (#016).
## Wenn es klemmt
| Meldung | Ursache |
|---|---|
| `401` beim Anmelden | Mandant, Anwendungs-ID oder Geheimnis stimmen nicht — oder das Geheimnis ist abgelaufen. Die ausführliche Antwort von Entra ID steht im Protokoll des Dienstes, nicht in der Oberfläche: sie nennt Mandant und Anwendung. |
| `403 Authorization_RequestDenied` | Die Administratoreinwilligung für `User.Read.All` fehlt. Erteilen und erneut testen. |
| `Invalid expand clause` | Sollte nicht vorkommen — Laufzettel baut `$expand=manager($select=id)` mit unkodierten Klammern. Tritt es auf, steht ein Proxy dazwischen, der die Adresse umschreibt. |
| Verbindung testen zeigt niemanden | Der Mandant hat keine Benutzer mit `accountEnabled = true`, oder alle wurden übersprungen. Die Liste der Übersprungenen steht unter dem Ergebnis. |
| Personen werden nicht mehr aktualisiert | Zuerst den Status der Quelle ansehen. Fast immer ist das Client-Secret abgelaufen. |
## Prüfliste für den ersten echten Lauf
Diese Punkte sind **nicht** automatisiert geprüft — sie brauchen einen echten Mandanten:
- [ ] App-Registrierung angelegt, `User.Read.All` als **Anwendungs**berechtigung, Einwilligung erteilt
- [ ] „Verbindung testen“ zeigt die ersten zehn Personen mit richtigen Namen und Abteilungen
- [ ] Der Vorgesetzte steht bei jemandem, der einen hat (`manager` über `$expand`)
- [ ] Erster Abgleich legt an; die Zahl passt zur Zahl der aktiven Konten im Mandanten
- [ ] Zweiter Abgleich legt **nichts** an und meldet nur Änderungen
- [ ] Ein Konto in Entra ID sperren → nächster Abgleich setzt `aktiv = false`, löscht aber nichts
- [ ] Ein Mandant mit mehr als 999 Benutzern: der Seitenabruf über `@odata.nextLink` läuft durch
- [ ] Nach einem abgelaufenen Geheimnis steht der Grund an der Quelle und nicht nur im Log
+67 -2
View File
@@ -34,12 +34,13 @@ als Unterpunkte mit Aufgabe und Dauer.
| #033 | M20 – Live-Aktualisierung per SSE statt Polling | 1 h 40 min |
| #034 | M21 – Benachrichtigungen per Mail und Webhook | 3 h 30 min |
| #035 | M22 – Mitarbeiter-Selbstauskunft | 2 h 50 min |
| | **Summe** | **56 h 25 min** |
| #028 | M15 – Quellenarten und Microsoft-Graph-Quelle | 4 h 10 min |
| | **Summe** | **60 h 35 min** |
| Meilenstein | Tatsächlich | Status |
|---|---|---|
| v0.1.0 | 35 h 20 min | veröffentlicht |
| v0.2.0 | 21 h 05 min | in Arbeit |
| v0.2.0 | 25 h 15 min | in Arbeit |
---
@@ -1370,3 +1371,67 @@ und nicht nach jeder einzelnen — das ist der Grund, warum er alles auf einmal
- Vor beiden wäre ein unabhängiger Review über die sechs Meilensteine dieser Phase angebracht —
M17 bis M22 liegen ohne fremden Blick auf `main`.
- Weiter offen und nicht von hier aus machbar: **#024** Proxmox-Einzeiler auf einem echten PVE-Host.
---
## 2026-10-06 · Sitzung 21 · 09:00–13:10
**Issue:** #028 (M15)
**Version:** 0.2.0 (unveröffentlicht)
### Erledigt
- **Eine Verzeichnisquelle hat eine Art**: `LDAP`, `GRAPH` oder `SCIM`. Bestehende Zeilen sind
`LDAP` — das war vor #028 die einzige.
- **Entra ID über Microsoft Graph**: Client-Credentials-Flow mit Token-Zwischenspeicher, Seitenabruf
über `@odata.nextLink`, Vorgesetzter über `$expand`.
- **Derselbe Abgleich für beide Arten.** Die Weiche sitzt in
`src/server/verzeichnis/quellenleser.ts` und ist die einzige Stelle, die die Art kennt.
- 1373 Vitest-Tests (vorher 1342), davon 31 neue gegen einen HTTP-Doppelgänger mit aufgezeichneten
Graph-Antworten.
- Im gebauten Stand eine Graph-Quelle wirklich angelegt: Art gespeichert, Geheimnis verschlüsselt,
nicht im Quelltext der Seite.
### Entscheidungen
- **Ein Enum auf der Quelle, keine Tabelle je Art.** Drei Arten rechtfertigen keine Vererbung; was
eine Art braucht, hält ein CHECK fest — in **beide** Richtungen, sonst wäre eine Graph-Quelle mit
`basisDn` möglich.
- **Die Art ist unveränderlich.** An ihr hängt, was `externeId` bedeutet; nach einem Wechsel würde
kein Mitarbeiter wiedererkannt. Steht so nicht im Issue und ist die wichtigste Zusage dieses
Meilensteins.
- **`{{ou}}` bleibt bei Graph leer.** Etwas hineinzudeuten wäre ein Wert, den kein PowerShell-Befehl
brauchen kann — und er sähe richtig aus.
- **`SCIM` steht im Enum, wird aber abgewiesen.** Ein Wert, der stillschweigend nichts tut, hätte
hier die ganze Belegschaft deaktiviert.
### Probleme
- **Der teuerste Fund: „deaktiviert“ als „übersprungen“ zu melden hätte Leute aktiv gelassen.**
„Übersprungen“ heißt im Abgleich „nicht zuordenbar“, und solche Einträge werden seit #016
absichtlich nicht abgeschaltet. Ein gesperrtes Entra-Konto wäre damit genau umgekehrt behandelt
worden als ein gesperrtes AD-Konto. Jetzt filtert die Abfrage serverseitig, als Gegenstück zum
LDAP-Standardfilter.
**Gefunden hat es der Test — aber nur, weil der Doppelgänger den Filter wirklich auswertet.** Hätte
er den Filter bloß empfangen und ignoriert, wäre der Test grün gewesen und der Fehler im Betrieb
aufgefallen. Ein Doppelgänger, der nur antwortet, prüft die Anfrage nicht.
- **`URLSearchParams` kodiert `$` zu `%24`** und die Klammern in `manager($select=id)`. Graph nimmt
das teils an und antwortet teils mit „Invalid expand clause“. Jetzt von Hand zusammengesetzt.
- **Ein Import-Zyklus drohte**: die Weiche braucht `zugangAus`, und der Dienst braucht die Weiche.
Beides aufgelöst durch ein eigenes Modul. In ESM läuft so ein Zyklus oft — aber nicht zuverlässig.
- **Zwei eigene Testfehler**: die Token-Grenze liegt bei +3540 s und nicht „ungefähr eine Stunde“,
und die Klickprobe zählte die Felder der Bearbeiten-Formulare mit.
### Was offen bleibt, und das ist benannt
Gegen einen **echten Entra-Mandanten** ist nichts davon gelaufen. Die Form der Antworten im
Doppelgänger stammt aus der Dokumentation, nicht aus einem Mitschnitt — stimmt sie nicht, stimmen
Test und Wirklichkeit nicht. Die Prüfliste steht am Ende von `docs/betrieb/entra-id.md`, acht
Punkte, darunter ein Mandant mit mehr als 999 Benutzern. Dieselbe Art offener Punkt wie #024.
### Nächster Schritt
- **M16 (#029)** SCIM-Endpunkt ist der letzte Meilenstein von Phase 2. Die Weiche dafür steht.
- Vor #029 wäre ein unabhängiger Review angebracht: **sieben Meilensteine** dieser Phase liegen ohne
fremden Blick auf `main`, und diese Sitzung hat wieder einen Fehler gezeigt, den die Suite nur
deshalb gefunden hat, weil der Doppelgänger streng genug war.
+74 -19
View File
@@ -1,10 +1,10 @@
# #028 M15 – Quellenarten und Microsoft-Graph-Quelle
**Status:** offen
**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:** –
**Geschätzt:** 10 h **Tatsächlich:** 4 h 10 min
## Ziel
@@ -17,39 +17,94 @@ Dieser Meilenstein legt dazu die Weiche, die auch [#029](029-scim-endpunkt.md) b
## Zu entscheiden
- [ ] **Ein Enum `QuellenArt {LDAP GRAPH SCIM}` auf `Verzeichnisquelle`** — Empfehlung. Die Felder
- [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.
- [ ] **Wie `{{ou}}` in Entra ID aussieht.** Es gibt keinen DN und keine OU. Ehrlich ist, den
- [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
- [ ] `QuellenArt` als Enum, Migration mit `LDAP` für alle bestehenden Zeilen
- [ ] Graph-Zugang: `tenantId`, `clientId`, `clientSecret` (verschlüsselt wie das Bind-Passwort, nie
- [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
- [ ] Lesen über `GET /v1.0/users` mit `$select`, Seitenabruf über `@odata.nextLink`; der
- [x] 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`,
- [x] 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
- [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
- [ ] 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
- [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
- [ ] Test gegen einen **abspielenden HTTP-Doppelgänger** mit aufgezeichneten Graph-Antworten: erster
- [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`
- [ ] 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
- [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).
+2 -2
View File
@@ -32,7 +32,7 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
| #025 | [Healthcheck gibt die rohe Datenbankfehlermeldung heraus](025-gesundheit-fehlermeldung.md) | erledigt | v0.2.0 | 1 h | 25 min |
| #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) | offen | v0.2.0 | 10 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 | – |
| #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 |
@@ -40,4 +40,4 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
| #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** | **56 h 25 min** |
| | **Summe** | | | **229 h** | **60 h 35 min** |