diff --git a/CHANGELOG.md b/CHANGELOG.md
index f9b6643..0da2c0a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -67,6 +67,19 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Hinzugefügt
+- **Entra ID als Verzeichnisquelle, über Microsoft Graph.** Ein Haus ohne lokales Active Directory
+ hat kein LDAP, auf das sich zeigen ließe. Eine Verzeichnisquelle hat jetzt eine **Art** — `LDAP`
+ oder `GRAPH` —, und beide laufen durch **denselben** Abgleich: anlegen, aktualisieren,
+ deaktivieren, **nie löschen**. Nur das Holen der Einträge unterscheidet sich, sonst drifteten die
+ Regeln auseinander.
+ Gelesen wird mit dem Client-Credentials-Flow über `GET /v1.0/users`, seitenweise über
+ `@odata.nextLink`, der Vorgesetzte über `$expand=manager($select=id)`. Das Client-Secret wird
+ verschlüsselt gespeichert wie das LDAP-Bind-Passwort und erscheint in keiner Antwort und in keinem
+ Protokolleintrag.
+ **Zwei Platzhalter bleiben bei Graph bewusst leer:** `{{ou}}` (Entra ID hat keine OU — steht an der
+ Abteilung eine Ziel-OU, greift die) und `{{sam}}` ohne AD-Synchronisierung. Was in Entra ID
+ einzurichten ist, steht in `docs/betrieb/entra-id.md` — mit einer Prüfliste, denn gegen einen
+ echten Mandanten ist dieser Weg **nicht** gelaufen (#028)
- **Mitarbeiter-Selbstauskunft.** „Was ist eigentlich mit meinem Notebook passiert?“ musste bisher
ein Techniker nachsehen. Ein Mitarbeiter kann die Arbeiten an **seinen** Geräten jetzt selbst
ansehen, unter **Meine Geräte** — mit der neuen Rolle `MITARBEITER`, zugeordnet über den **UPN**
diff --git a/CLAUDE.md b/CLAUDE.md
index 0246b7d..a1aff5d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -47,6 +47,7 @@ Enums: `Rolle {ADMIN REDAKTEUR TECHNIKER LESER MITARBEITER}` · `Betriebsmodus {
`VariablenTyp {TEXT ZAHL AUSWAHL DATUM}` ·
`GeraeteTyp {PC NOTEBOOK SERVER FIREWALL SWITCH DRUCKER RAUM SONSTIGES}` ·
`VersionStatus {ENTWURF VEROEFFENTLICHT}` · `Herkunft {ABGLEICH MANUELL}` ·
+`QuellenArt {LDAP GRAPH SCIM}` ·
`Zustellweg {MAIL WEBHOOK}` · `Zustellzustand {OFFEN GESENDET FEHLGESCHLAGEN AUFGEGEBEN}` ·
`Meldeanlass {LAUF_ZUGEWIESEN LAUF_UEBERFAELLIG LAUF_ABGESCHLOSSEN ABGLEICH_FEHLGESCHLAGEN}`.
diff --git a/docs/anleitung/06-verzeichnis.md b/docs/anleitung/06-verzeichnis.md
index ff22f4d..9acf49c 100644
--- a/docs/anleitung/06-verzeichnis.md
+++ b/docs/anleitung/06-verzeichnis.md
@@ -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

diff --git a/docs/betrieb/README.md b/docs/betrieb/README.md
index d04371c..442662f 100644
--- a/docs/betrieb/README.md
+++ b/docs/betrieb/README.md
@@ -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
diff --git a/docs/betrieb/entra-id.md b/docs/betrieb/entra-id.md
new file mode 100644
index 0000000..5e65055
--- /dev/null
+++ b/docs/betrieb/entra-id.md
@@ -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
diff --git a/docs/devlog.md b/docs/devlog.md
index 2eb5362..8cc574d 100644
--- a/docs/devlog.md
+++ b/docs/devlog.md
@@ -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.
diff --git a/docs/issues/028-graph-quelle.md b/docs/issues/028-graph-quelle.md
index 9ea5a4c..24a173a 100644
--- a/docs/issues/028-graph-quelle.md
+++ b/docs/issues/028-graph-quelle.md
@@ -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).
diff --git a/docs/issues/README.md b/docs/issues/README.md
index 0db2997..160951f 100644
--- a/docs/issues/README.md
+++ b/docs/issues/README.md
@@ -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** |
diff --git a/prisma/migrations/20261006120000_quellenart/migration.sql b/prisma/migrations/20261006120000_quellenart/migration.sql
new file mode 100644
index 0000000..8cc1b6a
--- /dev/null
+++ b/prisma/migrations/20261006120000_quellenart/migration.sql
@@ -0,0 +1,97 @@
+-- ---------------------------------------------------------------------------
+-- HANDGESCHRIEBEN. Prisma erzeugt hier zusaetzlich Loeschbefehle fuer die
+-- zusammengesetzten Fremdschluessel (Mandantengrenze), die Trigramm-Indizes
+-- (Archivsuche), die partiellen eindeutigen Indizes und alle Trigger: es kennt
+-- sie nicht, weil sie sich im Schema nicht ausdruecken lassen. Diese
+-- Loeschbefehle sind bewusst gestrichen.
+--
+-- tests/datenbankregeln.test.ts prueft, dass alle diese Objekte vorhanden sind –
+-- ein versehentlich stehengelassener Loeschbefehl faellt dort sofort auf.
+-- ---------------------------------------------------------------------------
+
+-- Quellenarten und Microsoft-Graph-Quelle (#028).
+
+CREATE TYPE "QuellenArt" AS ENUM ('LDAP', 'GRAPH', 'SCIM');
+
+/*
+ * **Die Reihenfolge der drei Schritte ist nicht beliebig.**
+ *
+ * Erst die Spalte mit Vorgabe `LDAP` anlegen, dann die bisherigen
+ * Pflichtfelder lockern, und erst danach den CHECK setzen. Umgekehrt waere der
+ * CHECK fuer die Dauer einer Anweisung gegen Zeilen gerichtet, die ihn noch
+ * nicht erfuellen koennen.
+ *
+ * Die Vorgabe `LDAP` ist zugleich die Nachfuehrung fuer alle bestehenden Zeilen:
+ * vor #028 war jede Quelle implizit LDAP, und `ADD COLUMN ... DEFAULT` schreibt
+ * den Wert in jede vorhandene Zeile. Ein eigenes `UPDATE` braucht es deshalb
+ * nicht — aber die Pruefung unten steht trotzdem da.
+ */
+ALTER TABLE "Verzeichnisquelle"
+ ADD COLUMN "art" "QuellenArt" NOT NULL DEFAULT 'LDAP';
+
+-- Die LDAP-Felder gelten nur noch fuer LDAP-Quellen.
+ALTER TABLE "Verzeichnisquelle"
+ ALTER COLUMN "url" DROP NOT NULL,
+ ALTER COLUMN "bindDn" DROP NOT NULL,
+ ALTER COLUMN "bindPasswortVerschluesselt" DROP NOT NULL,
+ ALTER COLUMN "basisDn" DROP NOT NULL,
+ ALTER COLUMN "filter" DROP NOT NULL;
+
+-- Und die Felder einer Graph-Quelle.
+ALTER TABLE "Verzeichnisquelle"
+ ADD COLUMN "tenantId" TEXT,
+ ADD COLUMN "clientId" TEXT,
+ ADD COLUMN "clientSecretVerschluesselt" TEXT;
+
+/*
+ * **Welche Felder eine Art braucht, haelt die Datenbank fest.**
+ *
+ * Ohne das waere eine Graph-Quelle mit `basisDn` moeglich — eine Zeile, die zwei
+ * Dinge gleichzeitig behauptet, und bei der niemand mehr sagen kann, was gilt.
+ * Der Dienst prueft dasselbe noch einmal, nur mit besserer Meldung.
+ *
+ * `SCIM` fehlt hier mit Absicht: die Art steht im Enum, weil #029 dieselbe
+ * Weiche braucht, hat aber noch keine Felder. Eine Bedingung auf Felder, die es
+ * nicht gibt, waere eine Zeile, die beim naechsten Meilenstein umgeschrieben
+ * werden muss — und der Dienst weist `SCIM` bis dahin ohnehin ab.
+ */
+ALTER TABLE "Verzeichnisquelle"
+ ADD CONSTRAINT "Verzeichnisquelle_felder_je_art"
+ CHECK (
+ ("art" = 'LDAP' AND "url" IS NOT NULL AND "bindDn" IS NOT NULL
+ AND "bindPasswortVerschluesselt" IS NOT NULL AND "basisDn" IS NOT NULL
+ AND "filter" IS NOT NULL
+ AND "tenantId" IS NULL AND "clientId" IS NULL AND "clientSecretVerschluesselt" IS NULL)
+ OR
+ ("art" = 'GRAPH' AND "tenantId" IS NOT NULL AND "clientId" IS NOT NULL
+ AND "clientSecretVerschluesselt" IS NOT NULL
+ AND "url" IS NULL AND "bindDn" IS NULL AND "bindPasswortVerschluesselt" IS NULL
+ AND "basisDn" IS NULL AND "filter" IS NULL)
+ OR
+ ("art" = 'SCIM')
+ );
+
+-- Das Client-Secret verschluesselt, dieselbe Pruefung wie beim Bind-Passwort.
+ALTER TABLE "Verzeichnisquelle"
+ ADD CONSTRAINT "Verzeichnisquelle_clientsecret_verschluesselt"
+ CHECK ("clientSecretVerschluesselt" ~ '^v[0-9]+\.');
+
+-- Der Zeitplan fragt nach faelligen **aktiven** Quellen; die Art kommt jetzt in
+-- dieselbe Abfrage, weil das Holen der Eintraege je Art ein anderes ist.
+CREATE INDEX "Verzeichnisquelle_art_idx" ON "Verzeichnisquelle"("art");
+
+/*
+ * Gegenprobe zur Nachfuehrung: nach dem `ADD COLUMN ... DEFAULT` darf keine
+ * Zeile ohne Art dastehen. `art` ist NOT NULL, das kann also nicht schiefgehen —
+ * aber eine Migration, die ihre eigene Nachfuehrung behauptet, ohne sie zu
+ * pruefen, ist genau die Art Zusage, die dieses Projekt teuer bezahlt hat.
+ */
+DO $$
+DECLARE
+ ohne_art INTEGER;
+BEGIN
+ SELECT count(*) INTO ohne_art FROM "Verzeichnisquelle" WHERE "art" IS NULL;
+ IF ohne_art > 0 THEN
+ RAISE EXCEPTION 'Nachfuehrung fehlgeschlagen: % Quellen ohne Art.', ohne_art;
+ END IF;
+END $$;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 5647755..bb589c4 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -314,18 +314,42 @@ model BenutzerKunde {
/// genau eine (das Firmen-AD); mehrere sind erlaubt, etwa eine zweite Domaene.
/// Nur fuer die eigene Organisation – im Modus DIENSTLEISTUNG gibt es keinen
/// Abgleich zu Kunden-ADs.
+/// Woher eine Verzeichnisquelle liest (#028).
+///
+/// Bis #028 war jede Quelle implizit LDAP. `SCIM` steht hier bereits, weil #029
+/// dieselbe Weiche braucht — behandelt wird es noch nicht, und der Dienst weist
+/// es mit einer Meldung ab, statt stillschweigend nichts zu tun.
+enum QuellenArt {
+ LDAP
+ GRAPH
+ SCIM
+}
+
model Verzeichnisquelle {
- id String @id @default(cuid())
+ id String @id @default(cuid())
kundeId String
name String
- aktiv Boolean @default(false)
- url String
- bindDn String
+ aktiv Boolean @default(false)
+ /// Bestehende Zeilen sind LDAP – das war vor #028 die einzige Art.
+ art QuellenArt @default(LDAP)
+ /// Ab hier: **nur bei `art = LDAP`** gesetzt. Welche Felder eine Art braucht,
+ /// haelt der CHECK `Verzeichnisquelle_felder_je_art` fest — eine Graph-Quelle
+ /// mit einem `basisDn` waere eine Zeile, die zwei Dinge gleichzeitig behauptet.
+ url String?
+ bindDn String?
/// AES-256-GCM aus APP_SECRET. Darf keine API-Antwort erreichen; dafuer sorgt
/// der Serializer in der Service-Schicht, abgedeckt durch einen eigenen Test.
- bindPasswortVerschluesselt String
- basisDn String
- filter String
+ bindPasswortVerschluesselt String?
+ basisDn String?
+ filter String?
+
+ /// Ab hier: **nur bei `art = GRAPH`**. Der Mandant in Entra ID und die
+ /// App-Registrierung, mit der gelesen wird (Client-Credentials-Flow).
+ tenantId String?
+ clientId String?
+ /// Verschluesselt wie das Bind-Passwort und mit demselben Vorsatz: es darf
+ /// keine Antwort und keinen Protokolleintrag erreichen.
+ clientSecretVerschluesselt String?
/// Zuordnung der LDAP-Attribute auf unsere Felder, mit AD-Vorgaben.
attributZuordnung Json
/// Eigenes CA-Zertifikat, falls ldaps gegen eine interne CA laeuft.
diff --git a/src/app/(app)/admin/verzeichnis/page.tsx b/src/app/(app)/admin/verzeichnis/page.tsx
index f5af6d1..5534802 100644
--- a/src/app/(app)/admin/verzeichnis/page.tsx
+++ b/src/app/(app)/admin/verzeichnis/page.tsx
@@ -164,8 +164,21 @@ export default async function VerzeichnisSeite() {
)}
+ {/*
+ * Die Art steht **an jeder Quelle** (#028). Ohne sie waere bei
+ * zwei Zeilen nicht zu sehen, welche aus dem AD liest und welche
+ * aus Entra ID — und die Felder darunter heissen je Art anders.
+ */}
diff --git a/src/components/verzeichnis/quelle-formular.tsx b/src/components/verzeichnis/quelle-formular.tsx
index 76e0b01..b4405ca 100644
--- a/src/components/verzeichnis/quelle-formular.tsx
+++ b/src/components/verzeichnis/quelle-formular.tsx
@@ -5,6 +5,7 @@ import { KeyRound, Save, Server, ShieldAlert, ShieldCheck } from "lucide-react";
import { FELD, KAESTCHEN } from "@/lib/formular";
import {
AD_ZUORDNUNG,
+ GRAPH_ZUORDNUNG,
INTERVALL_HOECHSTENS,
INTERVALL_MINDESTENS,
ZUORDNUNGS_BESCHRIFTUNG,
@@ -36,6 +37,14 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
const [zuordnungOffen, setzeZuordnungOffen] = useState(false);
const [adresse, setzeAdresse] = useState(quelle?.url ?? "");
const klartext = adresse.trim().startsWith("ldap://");
+ /*
+ * Die Art ist beim Anlegen waehlbar und beim Bearbeiten **fest** (#028): an
+ * ihr haengt, was die dauerhafte Kennung bedeutet, und nach einem Wechsel
+ * wuerde kein Mitarbeiter wiedererkannt. Der Dienst weist einen Wechsel
+ * ohnehin ab; hier gibt es ihn gar nicht erst zur Auswahl.
+ */
+ const [art, setzeArt] = useState<"LDAP" | "GRAPH">(quelle?.art === "GRAPH" ? "GRAPH" : "LDAP");
+ const istGraph = art === "GRAPH";
return (