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
+13
View File
@@ -67,6 +67,19 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Hinzugefügt ### 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 - **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 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** ansehen, unter **Meine Geräte** — mit der neuen Rolle `MITARBEITER`, zugeordnet über den **UPN**
+1
View File
@@ -47,6 +47,7 @@ Enums: `Rolle {ADMIN REDAKTEUR TECHNIKER LESER MITARBEITER}` · `Betriebsmodus {
`VariablenTyp {TEXT ZAHL AUSWAHL DATUM}` · `VariablenTyp {TEXT ZAHL AUSWAHL DATUM}` ·
`GeraeteTyp {PC NOTEBOOK SERVER FIREWALL SWITCH DRUCKER RAUM SONSTIGES}` · `GeraeteTyp {PC NOTEBOOK SERVER FIREWALL SWITCH DRUCKER RAUM SONSTIGES}` ·
`VersionStatus {ENTWURF VEROEFFENTLICHT}` · `Herkunft {ABGLEICH MANUELL}` · `VersionStatus {ENTWURF VEROEFFENTLICHT}` · `Herkunft {ABGLEICH MANUELL}` ·
`QuellenArt {LDAP GRAPH SCIM}` ·
`Zustellweg {MAIL WEBHOOK}` · `Zustellzustand {OFFEN GESENDET FEHLGESCHLAGEN AUFGEGEBEN}` · `Zustellweg {MAIL WEBHOOK}` · `Zustellzustand {OFFEN GESENDET FEHLGESCHLAGEN AUFGEGEBEN}` ·
`Meldeanlass {LAUF_ZUGEWIESEN LAUF_UEBERFAELLIG LAUF_ABGESCHLOSSEN ABGLEICH_FEHLGESCHLAGEN}`. `Meldeanlass {LAUF_ZUGEWIESEN LAUF_UEBERFAELLIG LAUF_ABGESCHLOSSEN ABGLEICH_FEHLGESCHLAGEN}`.
+26
View File
@@ -16,6 +16,32 @@ Zwei Dinge vorweg:
Wer im Verzeichnis verschwindet oder dort deaktiviert wird, wird hier **inaktiv**. Gelöscht wird Wer im Verzeichnis verschwindet oder dort deaktiviert wird, wird hier **inaktiv**. Gelöscht wird
nie, sonst verlöre die Historie ihre Namen. 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 ## Eine Quelle anlegen
![Die Seite Verzeichnis mit Quelle, Status und Formular](bilder/verzeichnis-desktop.png) ![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 - **[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 - **[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 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 - **[Benachrichtigungen](benachrichtigungen.md)** — SMTP einrichten, den Webhook prüfen, die
Signatur nachrechnen, und wo man nachsieht, wenn keine Mail mehr ankommt 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 | | #033 | M20 – Live-Aktualisierung per SSE statt Polling | 1 h 40 min |
| #034 | M21 – Benachrichtigungen per Mail und Webhook | 3 h 30 min | | #034 | M21 – Benachrichtigungen per Mail und Webhook | 3 h 30 min |
| #035 | M22 – Mitarbeiter-Selbstauskunft | 2 h 50 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 | | Meilenstein | Tatsächlich | Status |
|---|---|---| |---|---|---|
| v0.1.0 | 35 h 20 min | veröffentlicht | | 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 — - Vor beiden wäre ein unabhängiger Review über die sechs Meilensteine dieser Phase angebracht —
M17 bis M22 liegen ohne fremden Blick auf `main`. 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. - 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 # #028 M15 – Quellenarten und Microsoft-Graph-Quelle
**Status:** offen **Status:** erledigt
**Im Tracker:** [#48](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/48) **Im Tracker:** [#48](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/48)
**Meilenstein:** v0.2.0 **Meilenstein:** v0.2.0
**Labels:** phase-2, backend, frontend **Labels:** phase-2, backend, frontend
**Geschätzt:** 10 h **Tatsächlich:** – **Geschätzt:** 10 h **Tatsächlich:** 4 h 10 min
## Ziel ## Ziel
@@ -17,39 +17,94 @@ Dieser Meilenstein legt dazu die Weiche, die auch [#029](029-scim-endpunkt.md) b
## Zu entscheiden ## 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, `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 prüft der Service, nicht die Datenbank. Die Alternative wäre eine Tabelle je Art mit
gemeinsamer Oberklasse — mehr Struktur, als drei Arten rechtfertigen. 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 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. in der Anleitung zu sagen. Wer OU-abhängige Befehle fährt, braucht ein lokales AD.
## Aufgaben ## Aufgaben
- [ ] `QuellenArt` als Enum, Migration mit `LDAP` für alle bestehenden Zeilen - [x] `QuellenArt` als Enum, Migration mit `LDAP` für alle bestehenden Zeilen
- [ ] Graph-Zugang: `tenantId`, `clientId`, `clientSecret` (verschlüsselt wie das Bind-Passwort, nie - [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 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)` 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`) `userPrincipalName`, `onPremisesSamAccountName`, `department`, `jobTitle`, `accountEnabled`)
- [ ] `externeId` ist die Entra-`id` (GUID). Sie ist stabil und übersteht eine Umbenennung - [x] `externeId` ist die Entra-`id` (GUID). Sie ist stabil und übersteht eine Umbenennung
- [ ] „Verbindung testen“ wie bei LDAP: die ersten zehn zugeordneten Treffer zeigen - [x] „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] Der Abgleich läuft durch **denselben** Dienst wie LDAP — nur das Holen der Einträge
unterscheidet sich. Sonst driften die Regeln auseinander unterscheidet sich. Sonst driften die Regeln auseinander
- [ ] Admin → Verzeichnis zeigt die Art je Quelle und bietet beim Anlegen die Wahl - [x] 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] Anleitung Kapitel 06 und `docs/betrieb/`: was in Entra ID einzurichten ist
(App-Registrierung, `User.Read.All`, `Directory.Read.All`, Administratoreinwilligung) (App-Registrierung, `User.Read.All`, `Directory.Read.All`, Administratoreinwilligung)
## Akzeptanzkriterien ## 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 Abgleich legt an, zweiter aktualisiert ohne Dubletten, ein `accountEnabled: false` setzt
`aktiv = false` `aktiv = false`
- [ ] Test: der Seitenabruf folgt `@odata.nextLink` über mindestens drei Seiten - [x] Test: der Seitenabruf folgt `@odata.nextLink` über mindestens drei Seiten
- [ ] Test: `clientSecret` erscheint in keiner Antwort und in keinem Protokolleintrag - [x] Test: `clientSecret` erscheint in keiner Antwort und in keinem Protokolleintrag
- [ ] Test: eine Graph-Quelle lässt `{{ou}}` leer, statt einen Wert zu erfinden - [x] 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 - [x] 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] **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) 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 | | #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 | | #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 | – | | #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 | – | | #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 | | #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 | | #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 | | #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 | | #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 | | #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** |
@@ -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 $$;
+31 -7
View File
@@ -314,18 +314,42 @@ model BenutzerKunde {
/// genau eine (das Firmen-AD); mehrere sind erlaubt, etwa eine zweite Domaene. /// genau eine (das Firmen-AD); mehrere sind erlaubt, etwa eine zweite Domaene.
/// Nur fuer die eigene Organisation – im Modus DIENSTLEISTUNG gibt es keinen /// Nur fuer die eigene Organisation – im Modus DIENSTLEISTUNG gibt es keinen
/// Abgleich zu Kunden-ADs. /// 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 { model Verzeichnisquelle {
id String @id @default(cuid()) id String @id @default(cuid())
kundeId String kundeId String
name String name String
aktiv Boolean @default(false) aktiv Boolean @default(false)
url String /// Bestehende Zeilen sind LDAP – das war vor #028 die einzige Art.
bindDn String 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 /// AES-256-GCM aus APP_SECRET. Darf keine API-Antwort erreichen; dafuer sorgt
/// der Serializer in der Service-Schicht, abgedeckt durch einen eigenen Test. /// der Serializer in der Service-Schicht, abgedeckt durch einen eigenen Test.
bindPasswortVerschluesselt String bindPasswortVerschluesselt String?
basisDn String basisDn String?
filter 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. /// Zuordnung der LDAP-Attribute auf unsere Felder, mit AD-Vorgaben.
attributZuordnung Json attributZuordnung Json
/// Eigenes CA-Zertifikat, falls ldaps gegen eine interne CA laeuft. /// Eigenes CA-Zertifikat, falls ldaps gegen eine interne CA laeuft.
+14 -1
View File
@@ -164,8 +164,21 @@ export default async function VerzeichnisSeite() {
)} )}
</div> </div>
{/*
* 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.
*/}
<p className="mono mt-2 text-xs break-all text-nebentext"> <p className="mono mt-2 text-xs break-all text-nebentext">
{quelle.url} · {quelle.basisDn} {quelle.art === "GRAPH" ? (
<>
Microsoft Graph · {quelle.tenantId} · App {quelle.clientId}
</>
) : (
<>
LDAP · {quelle.url} · {quelle.basisDn}
</>
)}
</p> </p>
<Status quelle={quelle} /> <Status quelle={quelle} />
+256 -139
View File
@@ -5,6 +5,7 @@ import { KeyRound, Save, Server, ShieldAlert, ShieldCheck } from "lucide-react";
import { FELD, KAESTCHEN } from "@/lib/formular"; import { FELD, KAESTCHEN } from "@/lib/formular";
import { import {
AD_ZUORDNUNG, AD_ZUORDNUNG,
GRAPH_ZUORDNUNG,
INTERVALL_HOECHSTENS, INTERVALL_HOECHSTENS,
INTERVALL_MINDESTENS, INTERVALL_MINDESTENS,
ZUORDNUNGS_BESCHRIFTUNG, ZUORDNUNGS_BESCHRIFTUNG,
@@ -36,6 +37,14 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
const [zuordnungOffen, setzeZuordnungOffen] = useState(false); const [zuordnungOffen, setzeZuordnungOffen] = useState(false);
const [adresse, setzeAdresse] = useState(quelle?.url ?? ""); const [adresse, setzeAdresse] = useState(quelle?.url ?? "");
const klartext = adresse.trim().startsWith("ldap://"); 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 ( return (
<form action={aktion} className="flex flex-col gap-4"> <form action={aktion} className="flex flex-col gap-4">
@@ -52,108 +61,213 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
/> />
</label> </label>
{/* {bearbeiten ? (
* Die Erklärung steht **außerhalb** des `label` und hängt über <>
* `aria-describedby` am Feld. Im `label` wäre sie Teil des Feldnamens: <input type="hidden" name="art" value={art} />
* ein Screenreader läse „Adresse Drei Formen: ldaps:// …“ als Bezeichnung <p className="text-sm text-nebentext">
* des Eingabefelds, und kein Werkzeug fände das Feld noch über seinen Art: <strong className="text-text">{istGraph ? "Microsoft Graph" : "LDAP"}</strong> — sie lässt
* Namen. sich nicht ändern. Daran hängt, was die dauerhafte Kennung bedeutet; nach einem Wechsel würde kein
*/} Mitarbeiter wiedererkannt.
<div className="flex flex-col gap-1.5 text-sm"> </p>
<label htmlFor="quelle-url" className="text-nebentext"> </>
Adresse ) : (
</label> <fieldset className="flex flex-col gap-2 text-sm">
<input <legend className="mb-1 text-nebentext">Art</legend>
id="quelle-url" {(
name="url" [
value={adresse} {
onChange={(ereignis) => setzeAdresse(ereignis.target.value)} wert: "LDAP" as const,
required titel: "LDAP",
aria-describedby="quelle-url-hinweis" text: "Ein lokales Active Directory oder ein anderes LDAP-Verzeichnis.",
placeholder="ldaps://dc01.firma.local:636" },
className={`${FELD} mono`} {
/> wert: "GRAPH" as const,
<span id="quelle-url-hinweis" className="text-xs text-nebentext"> titel: "Microsoft Graph",
Drei Formen: <span className="mono">ldaps://</span> (verschlüsselt ab dem ersten Byte, die richtige text: "Entra ID (Microsoft 365). Für Häuser ohne lokales AD.",
Wahl), <span className="mono">ldap+starttls://</span> (unverschlüsselt verbunden und dann },
hochgestuft) und <span className="mono">ldap://</span>. ] satisfies readonly { wert: "LDAP" | "GRAPH"; titel: string; text: string }[]
</span> ).map((wahl) => (
{klartext ? ( <label
/* key={wahl.wert}
* Die Warnung erscheint erst, wenn sie zutrifft — eine, die immer className="flex min-h-11 cursor-pointer items-start gap-3 rounded-karte border border-linie p-3 md:min-h-10"
* dasteht, liest nach dem dritten Mal niemand mehr. >
*/ <input
<span role="status" className="flex items-start gap-2 text-xs text-faellig"> type="radio"
<ShieldAlert className="mt-0.5 size-3.5 shrink-0" strokeWidth={2} aria-hidden="true" /> name="art"
<span> value={wahl.wert}
Mit <span className="mono">ldap://</span> geht auch das Bind-Passwort im Klartext über das Netz. checked={art === wahl.wert}
Für ein Dienstkonto im Active Directory ist das keine gute Idee. onChange={() => setzeArt(wahl.wert)}
className="mt-0.5 size-4 accent-akzent"
/>
<span>
<span className="font-medium">{wahl.titel}</span>
<span className="block text-xs text-nebentext">{wahl.text}</span>
</span>
</label>
))}
</fieldset>
)}
{istGraph ? (
<>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Mandanten-ID</span>
<input
name="tenantId"
defaultValue={quelle?.tenantId ?? ""}
required
placeholder="contoso.onmicrosoft.com oder die GUID"
className={`${FELD} mono`}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Anwendungs-ID</span>
<input
name="clientId"
defaultValue={quelle?.clientId ?? ""}
required
placeholder="GUID der App-Registrierung"
className={`${FELD} mono`}
/>
</label>
</div>
<div className="flex flex-col gap-1.5 text-sm">
<label htmlFor="quelle-secret" className="flex items-center gap-2 text-nebentext">
<KeyRound className="size-4" strokeWidth={1.75} aria-hidden="true" />
Client-Secret
</label>
<input
id="quelle-secret"
name="clientSecret"
type="password"
autoComplete="new-password"
aria-describedby="quelle-secret-hinweis"
placeholder={quelle?.clientSecretVorhanden ? "unverändert lassen" : ""}
required={!bearbeiten}
className={FELD}
/>
<span id="quelle-secret-hinweis" className="text-xs text-nebentext">
{quelle?.clientSecretVorhanden
? "Leer lassen behält das gespeicherte Geheimnis."
: "Wird verschlüsselt gespeichert und nie wieder angezeigt."}
</span> </span>
</span> </div>
) : null} <p className="max-w-prose text-xs text-nebentext">
</div> Die App-Registrierung braucht die <strong>Anwendungsberechtigung</strong>{" "}
<span className="mono">User.Read.All</span> mit Administratoreinwilligung. Wie das eingerichtet
wird, steht in <span className="mono">docs/betrieb/entra-id.md</span>. Entra ID kennt keine OU,
deshalb bleibt <span className="mono">{"{{ou}}"}</span> bei dieser Art leer.
</p>
</>
) : null}
<div className="grid gap-4 sm:grid-cols-2"> {!istGraph ? (
<label className="flex flex-col gap-1.5 text-sm"> <>
<span className="text-nebentext">Anmelde-DN</span> {/*
<input * Die Erklärung steht **außerhalb** des `label` und hängt über
name="bindDn" * `aria-describedby` am Feld. Im `label` wäre sie Teil des Feldnamens:
defaultValue={quelle?.bindDn ?? ""} * ein Screenreader läse „Adresse Drei Formen: ldaps:// …“ als Bezeichnung
required * des Eingabefelds, und kein Werkzeug fände das Feld noch über seinen
placeholder="CN=laufzettel,OU=Dienstkonten,DC=firma,DC=local" * Namen.
className={`${FELD} mono`} */}
/> <div className="flex flex-col gap-1.5 text-sm">
</label> <label htmlFor="quelle-url" className="text-nebentext">
<div className="flex flex-col gap-1.5 text-sm"> Adresse
<label htmlFor="quelle-passwort" className="flex items-center gap-2 text-nebentext"> </label>
<KeyRound className="size-4" strokeWidth={1.75} aria-hidden="true" /> <input
Passwort id="quelle-url"
name="url"
value={adresse}
onChange={(ereignis) => setzeAdresse(ereignis.target.value)}
required
aria-describedby="quelle-url-hinweis"
placeholder="ldaps://dc01.firma.local:636"
className={`${FELD} mono`}
/>
<span id="quelle-url-hinweis" className="text-xs text-nebentext">
Drei Formen: <span className="mono">ldaps://</span> (verschlüsselt ab dem ersten Byte, die
richtige Wahl), <span className="mono">ldap+starttls://</span> (unverschlüsselt verbunden und
dann hochgestuft) und <span className="mono">ldap://</span>.
</span>
{klartext ? (
/*
* Die Warnung erscheint erst, wenn sie zutrifft — eine, die immer
* dasteht, liest nach dem dritten Mal niemand mehr.
*/
<span role="status" className="flex items-start gap-2 text-xs text-faellig">
<ShieldAlert className="mt-0.5 size-3.5 shrink-0" strokeWidth={2} aria-hidden="true" />
<span>
Mit <span className="mono">ldap://</span> geht auch das Bind-Passwort im Klartext über das
Netz. Für ein Dienstkonto im Active Directory ist das keine gute Idee.
</span>
</span>
) : null}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Anmelde-DN</span>
<input
name="bindDn"
defaultValue={quelle?.bindDn ?? ""}
required
placeholder="CN=laufzettel,OU=Dienstkonten,DC=firma,DC=local"
className={`${FELD} mono`}
/>
</label>
<div className="flex flex-col gap-1.5 text-sm">
<label htmlFor="quelle-passwort" className="flex items-center gap-2 text-nebentext">
<KeyRound className="size-4" strokeWidth={1.75} aria-hidden="true" />
Passwort
</label>
<input
id="quelle-passwort"
name="bindPasswort"
type="password"
autoComplete="new-password"
aria-describedby="quelle-passwort-hinweis"
placeholder={bearbeiten ? "unverändert lassen" : ""}
required={!bearbeiten}
className={FELD}
/>
<span id="quelle-passwort-hinweis" className="text-xs text-nebentext">
{bearbeiten
? "Leer lassen behält das gespeicherte Passwort."
: "Wird verschlüsselt gespeichert und nie wieder angezeigt."}
</span>
</div>
</div>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Basis-DN</span>
<input
name="basisDn"
defaultValue={quelle?.basisDn ?? ""}
required
placeholder="OU=Benutzer,DC=firma,DC=local"
className={`${FELD} mono`}
/>
</label> </label>
<input
id="quelle-passwort"
name="bindPasswort"
type="password"
autoComplete="new-password"
aria-describedby="quelle-passwort-hinweis"
placeholder={bearbeiten ? "unverändert lassen" : ""}
required={!bearbeiten}
className={FELD}
/>
<span id="quelle-passwort-hinweis" className="text-xs text-nebentext">
{bearbeiten
? "Leer lassen behält das gespeicherte Passwort."
: "Wird verschlüsselt gespeichert und nie wieder angezeigt."}
</span>
</div>
</div>
<label className="flex flex-col gap-1.5 text-sm"> <div className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Basis-DN</span> <label htmlFor="quelle-filter" className="text-nebentext">
<input Filter (optional)
name="basisDn" </label>
defaultValue={quelle?.basisDn ?? ""} <input
required id="quelle-filter"
placeholder="OU=Benutzer,DC=firma,DC=local" name="filter"
className={`${FELD} mono`} defaultValue={quelle?.filter ?? ""}
/> aria-describedby="quelle-filter-hinweis"
</label> className={`${FELD} mono`}
/>
<div className="flex flex-col gap-1.5 text-sm"> <span id="quelle-filter-hinweis" className="text-xs text-nebentext">
<label htmlFor="quelle-filter" className="text-nebentext"> Leer lassen nimmt den Standard: aktive Personenkonten. Er schließt deaktivierte Konten aus — wer
Filter (optional) aus dem Ergebnis verschwindet, wird hier deaktiviert und nie gelöscht.
</label> </span>
<input </div>
id="quelle-filter" </>
name="filter" ) : null}
defaultValue={quelle?.filter ?? ""}
aria-describedby="quelle-filter-hinweis"
className={`${FELD} mono`}
/>
<span id="quelle-filter-hinweis" className="text-xs text-nebentext">
Leer lassen nimmt den Standard: aktive Personenkonten. Er schließt deaktivierte Konten aus — wer aus
dem Ergebnis verschwindet, wird hier deaktiviert und nie gelöscht.
</span>
</div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm"> <label className="flex flex-col gap-1.5 text-sm">
@@ -200,7 +314,7 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
<input <input
name={`attr.${feld}`} name={`attr.${feld}`}
defaultValue={quelle?.attributZuordnung[feld] ?? AD_ZUORDNUNG[feld]} defaultValue={quelle?.attributZuordnung[feld] ?? AD_ZUORDNUNG[feld]}
placeholder={AD_ZUORDNUNG[feld]} placeholder={(istGraph ? GRAPH_ZUORDNUNG : AD_ZUORDNUNG)[feld]}
className={`${FELD} mono`} className={`${FELD} mono`}
/> />
</label> </label>
@@ -212,48 +326,51 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
</p> </p>
</details> </details>
<details open={zertifikatOffen} onToggle={(e) => setzeZertifikatOffen(e.currentTarget.open)}> {/* Ein eigenes CA-Zertifikat gilt nur fuer LDAP – Graph laeuft ueber oeffentliches TLS. */}
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10"> {!istGraph ? (
Eigenes CA-Zertifikat hinterlegen <details open={zertifikatOffen} onToggle={(e) => setzeZertifikatOffen(e.currentTarget.open)}>
</summary> <summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
{/* Eigenes CA-Zertifikat hinterlegen
* Der Zertifikatstext selbst kommt nicht zurück in die Oberfläche — nur </summary>
* die Auskunft, dass eines hinterlegt ist. Ohne diese Zeile sah das {/*
* leere Feld beim Bearbeiten aus wie „kein Zertifikat“, und der Hinweis * Der Zertifikatstext selbst kommt nicht zurück in die Oberfläche — nur
* darunter behauptete es auch (Reviewbefund zu #016). * die Auskunft, dass eines hinterlegt ist. Ohne diese Zeile sah das
*/} * leere Feld beim Bearbeiten aus wie „kein Zertifikat“, und der Hinweis
{quelle?.caZertifikatVorhanden ? ( * darunter behauptete es auch (Reviewbefund zu #016).
<p className="mt-2 flex items-start gap-2 text-xs text-erledigt"> */}
<ShieldCheck className="mt-0.5 size-3.5 shrink-0" strokeWidth={2} aria-hidden="true" /> {quelle?.caZertifikatVorhanden ? (
<span> <p className="mt-2 flex items-start gap-2 text-xs text-erledigt">
Ein CA-Zertifikat ist hinterlegt. Das Feld unten bleibt leer — Text eintragen ersetzt es, leer <ShieldCheck className="mt-0.5 size-3.5 shrink-0" strokeWidth={2} aria-hidden="true" />
lassen behält es. <span>
Ein CA-Zertifikat ist hinterlegt. Das Feld unten bleibt leer — Text eintragen ersetzt es, leer
lassen behält es.
</span>
</p>
) : null}
<label className="mt-2 flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">PEM</span>
<textarea
name="caZertifikatPem"
rows={4}
defaultValue=""
placeholder="-----BEGIN CERTIFICATE-----"
className={`${FELD} mono py-2`}
/>
<span className="text-xs text-nebentext">
Nur nötig, wenn der Domänencontroller ein Zertifikat aus einer eigenen CA trägt.
{quelle?.caZertifikatVorhanden
? " Ohne hinterlegtes Zertifikat gelten die Wurzelzertifikate des Systems."
: " Ohne Eintrag gelten die Wurzelzertifikate des Systems."}
</span> </span>
</p>
) : null}
<label className="mt-2 flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">PEM</span>
<textarea
name="caZertifikatPem"
rows={4}
defaultValue=""
placeholder="-----BEGIN CERTIFICATE-----"
className={`${FELD} mono py-2`}
/>
<span className="text-xs text-nebentext">
Nur nötig, wenn der Domänencontroller ein Zertifikat aus einer eigenen CA trägt.
{quelle?.caZertifikatVorhanden
? " Ohne hinterlegtes Zertifikat gelten die Wurzelzertifikate des Systems."
: " Ohne Eintrag gelten die Wurzelzertifikate des Systems."}
</span>
</label>
{quelle?.caZertifikatVorhanden ? (
<label className="mt-2 flex min-h-11 items-center gap-2 text-sm text-nebentext md:min-h-10">
<input type="checkbox" name="caZertifikatEntfernen" value="1" className={KAESTCHEN} />
Hinterlegtes Zertifikat entfernen
</label> </label>
) : null} {quelle?.caZertifikatVorhanden ? (
</details> <label className="mt-2 flex min-h-11 items-center gap-2 text-sm text-nebentext md:min-h-10">
<input type="checkbox" name="caZertifikatEntfernen" value="1" className={KAESTCHEN} />
Hinterlegtes Zertifikat entfernen
</label>
) : null}
</details>
) : null}
{ergebnis.fehler ? ( {ergebnis.fehler ? (
<p role="alert" className="max-w-prose text-sm text-fehler"> <p role="alert" className="max-w-prose text-sm text-fehler">
+42 -2
View File
@@ -60,6 +60,40 @@ export const AD_ZUORDNUNG: AttributZuordnung = {
externeId: "objectGUID", externeId: "objectGUID",
}; };
/**
* Dieselben Felder mit den Namen, die **Microsoft Graph** benutzt (#028).
*
* Vier Unterschiede zum AD, und jeder hat einen Grund:
*
* - `surname` statt `sn` — Graph schreibt die Namen aus.
* - `jobTitle` statt `title` — `title` gibt es bei Graph auch, es ist aber die
* Anrede („Dr.“), nicht die Funktion.
* - `onPremisesSamAccountName` statt `sAMAccountName` — der Wert kommt aus dem
* lokalen AD und ist **leer**, wenn der Mandant kein AD synchronisiert. Eine
* reine Cloud-Umgebung hat keinen Anmeldenamen in diesem Sinn; `{{sam}}`
* bleibt dort leer, statt dass etwas hineingedeutet wird.
* - `id` statt `objectGUID` — dieselbe GUID, nur schon als Text. Das Umsortieren
* der Bytes (`src/lib/ldap-guid.ts`) braucht es hier nicht.
*/
export const GRAPH_ZUORDNUNG: AttributZuordnung = {
vorname: "givenName",
nachname: "surname",
anzeigename: "displayName",
mail: "mail",
upn: "userPrincipalName",
sam: "onPremisesSamAccountName",
abteilung: "department",
titel: "jobTitle",
vorgesetzter: "manager",
externeId: "id",
};
/** Die Vorgaben je Quellenart. `SCIM` hat noch keine (#029). */
export const ZUORDNUNGS_VORGABE: Record<"LDAP" | "GRAPH", AttributZuordnung> = {
LDAP: AD_ZUORDNUNG,
GRAPH: GRAPH_ZUORDNUNG,
};
/** Die Felder in der Reihenfolge, in der das Formular sie zeigt. */ /** Die Felder in der Reihenfolge, in der das Formular sie zeigt. */
export const ZUORDNUNGS_FELDER = Object.keys(AD_ZUORDNUNG) as (keyof AttributZuordnung)[]; export const ZUORDNUNGS_FELDER = Object.keys(AD_ZUORDNUNG) as (keyof AttributZuordnung)[];
@@ -92,9 +126,15 @@ export const ATTRIBUT_MUSTER = /^[A-Za-z][A-Za-z0-9-]*$/u;
* mitschreiben muessen, und eine von Hand geleerte Zeile darf kein Attribut mit * mitschreiben muessen, und eine von Hand geleerte Zeile darf kein Attribut mit
* leerem Namen anfragen. * leerem Namen anfragen.
*/ */
export function zuordnungLesen(roh: unknown): AttributZuordnung { export function zuordnungLesen(roh: unknown, art: "LDAP" | "GRAPH" = "LDAP"): AttributZuordnung {
const felder = (typeof roh === "object" && roh !== null ? roh : {}) as Record<string, unknown>; const felder = (typeof roh === "object" && roh !== null ? roh : {}) as Record<string, unknown>;
const gelesen = { ...AD_ZUORDNUNG }; /*
* Die Vorgabe haengt an der **Art** (#028). Ohne diesen Parameter bekaeme eine
* Graph-Quelle mit leerer Zuordnung die AD-Namen — und fragte dann bei Graph
* nach `sn` und `objectGUID`, die es dort nicht gibt. Der Standardwert `LDAP`
* haelt jeden Aufrufer von vor #028 bei seinem bisherigen Verhalten.
*/
const gelesen = { ...ZUORDNUNGS_VORGABE[art] };
for (const schluessel of ZUORDNUNGS_FELDER) { for (const schluessel of ZUORDNUNGS_FELDER) {
const wert = felder[schluessel]; const wert = felder[schluessel];
if (typeof wert === "string" && wert.trim()) gelesen[schluessel] = wert.trim(); if (typeof wert === "string" && wert.trim()) gelesen[schluessel] = wert.trim();
+87 -22
View File
@@ -85,8 +85,30 @@ function zuordnungAusFormular(formular: FormData) {
); );
} }
const QuelleSchema = z.object({ /**
* Was fuer **jede** Art gilt.
*
* Seit #028 haengen die uebrigen Felder an der Art (`QuelleSchema` je Art
* darunter). Zwei Schemas und nicht eines mit lauter `optional`: ein Schema, das
* alles zulaesst, pruefte nichts — und die Meldung „Bitte die Adresse angeben“
* fuer eine Graph-Quelle waere schlicht falsch.
*/
const GemeinsamSchema = z.object({
name: z.string().trim().min(1, "Bitte einen Namen angeben."), name: z.string().trim().min(1, "Bitte einen Namen angeben."),
art: z.enum(["LDAP", "GRAPH"], {
message: "Bitte die Art der Quelle wählen: LDAP oder Microsoft Graph.",
}),
caZertifikatPem: z.string().trim().optional(),
// Dieselben Grenzen wie im Dienst, aus derselben Quelle.
abgleichIntervallMinuten: z.coerce
.number()
.int()
.min(INTERVALL_MINDESTENS, intervallMeldung())
.max(INTERVALL_HOECHSTENS, intervallMeldung()),
aktiv: z.boolean(),
});
const LdapSchema = z.object({
url: z url: z
.string() .string()
.trim() .trim()
@@ -99,16 +121,39 @@ const QuelleSchema = z.object({
bindDn: z.string().trim().min(1, "Bitte den Benutzer für die Anmeldung angeben."), bindDn: z.string().trim().min(1, "Bitte den Benutzer für die Anmeldung angeben."),
basisDn: z.string().trim().min(1, "Bitte angeben, ab welchem Knoten gesucht wird."), basisDn: z.string().trim().min(1, "Bitte angeben, ab welchem Knoten gesucht wird."),
filter: z.string().trim().optional(), filter: z.string().trim().optional(),
caZertifikatPem: z.string().trim().optional(),
// Dieselben Grenzen wie im Dienst, aus derselben Quelle.
abgleichIntervallMinuten: z.coerce
.number()
.int()
.min(INTERVALL_MINDESTENS, intervallMeldung())
.max(INTERVALL_HOECHSTENS, intervallMeldung()),
aktiv: z.boolean(),
}); });
const GraphSchema = z.object({
tenantId: z.string().trim().min(1, "Bitte die Mandanten-ID oder den Domänennamen angeben."),
clientId: z.string().trim().min(1, "Bitte die Anwendungs-ID der App-Registrierung angeben."),
});
/** Die artabhaengigen Felder aus dem Formular, geprueft. */
function artFelder(
art: "LDAP" | "GRAPH",
formular: FormData,
): { readonly werte: Record<string, unknown> } | { readonly fehler: string } {
if (art === "LDAP") {
const gelesen = LdapSchema.safeParse({
url: text(formular, "url"),
bindDn: text(formular, "bindDn"),
basisDn: text(formular, "basisDn"),
filter: text(formular, "filter") || undefined,
});
return gelesen.success
? { werte: gelesen.data }
: { fehler: gelesen.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
const gelesen = GraphSchema.safeParse({
tenantId: text(formular, "tenantId"),
clientId: text(formular, "clientId"),
});
return gelesen.success
? { werte: gelesen.data }
: { fehler: gelesen.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
export async function quelleAnlegenAktion( export async function quelleAnlegenAktion(
_vorher: AktionsErgebnis, _vorher: AktionsErgebnis,
formular: FormData, formular: FormData,
@@ -116,12 +161,9 @@ export async function quelleAnlegenAktion(
const zugang = await angemeldetOderMeldung(); const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang; if ("fehler" in zugang) return zugang;
const eingabe = QuelleSchema.safeParse({ const eingabe = GemeinsamSchema.safeParse({
name: text(formular, "name"), name: text(formular, "name"),
url: text(formular, "url"), art: text(formular, "art") || "LDAP",
bindDn: text(formular, "bindDn"),
basisDn: text(formular, "basisDn"),
filter: text(formular, "filter") || undefined,
caZertifikatPem: text(formular, "caZertifikatPem") || undefined, caZertifikatPem: text(formular, "caZertifikatPem") || undefined,
abgleichIntervallMinuten: text(formular, "abgleichIntervallMinuten") || "60", abgleichIntervallMinuten: text(formular, "abgleichIntervallMinuten") || "60",
aktiv: formular.get("aktiv") === "1", aktiv: formular.get("aktiv") === "1",
@@ -130,15 +172,34 @@ export async function quelleAnlegenAktion(
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." }; return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
} }
const bindPasswort = text(formular, "bindPasswort"); const felder = artFelder(eingabe.data.art, formular);
if (!bindPasswort) return { fehler: "Ohne Passwort kommt keine Anmeldung am Verzeichnis zustande." }; if ("fehler" in felder) return felder;
/*
* Das Geheimnis heisst je Art anders, und die Meldung soll das auch sagen.
* „Ohne Passwort kommt keine Anmeldung zustande“ ist fuer eine
* App-Registrierung die falsche Auskunft.
*/
const geheimnis =
eingabe.data.art === "LDAP"
? { bindPasswort: text(formular, "bindPasswort") }
: { clientSecret: text(formular, "clientSecret") };
const geheimnisWert = Object.values(geheimnis)[0];
if (!geheimnisWert) {
return {
fehler:
eingabe.data.art === "LDAP"
? "Ohne Passwort kommt keine Anmeldung am Verzeichnis zustande."
: "Ohne Client-Secret kommt keine Anmeldung am Mandanten zustande.",
};
}
const zuordnung = zuordnungAusFormular(formular); const zuordnung = zuordnungAusFormular(formular);
if ("fehler" in zuordnung) return zuordnung; if ("fehler" in zuordnung) return zuordnung;
try { try {
const quelle = await quelleAnlegen( const quelle = await quelleAnlegen(
{ ...eingabe.data, bindPasswort, attributZuordnung: zuordnung.zuordnung }, { ...eingabe.data, ...felder.werte, ...geheimnis, attributZuordnung: zuordnung.zuordnung },
zugang.benutzer, zugang.benutzer,
); );
revalidatePath("/admin/verzeichnis"); revalidatePath("/admin/verzeichnis");
@@ -158,12 +219,9 @@ export async function quelleBearbeitenAktion(
const id = text(formular, "id"); const id = text(formular, "id");
if (!id) return { fehler: "Es fehlt die Quelle." }; if (!id) return { fehler: "Es fehlt die Quelle." };
const eingabe = QuelleSchema.safeParse({ const eingabe = GemeinsamSchema.safeParse({
name: text(formular, "name"), name: text(formular, "name"),
url: text(formular, "url"), art: text(formular, "art") || "LDAP",
bindDn: text(formular, "bindDn"),
basisDn: text(formular, "basisDn"),
filter: text(formular, "filter") || undefined,
// Das Zertifikat entscheidet sich weiter unten: es kennt drei Faelle, das // Das Zertifikat entscheidet sich weiter unten: es kennt drei Faelle, das
// Schema nur zwei. // Schema nur zwei.
caZertifikatPem: undefined, caZertifikatPem: undefined,
@@ -174,6 +232,9 @@ export async function quelleBearbeitenAktion(
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." }; return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
} }
const felder = artFelder(eingabe.data.art, formular);
if ("fehler" in felder) return felder;
/* /*
* Drei Faelle, und das Formular kann sie nicht auf ein Feld abbilden: ein neues * Drei Faelle, und das Formular kann sie nicht auf ein Feld abbilden: ein neues
* Zertifikat eintragen, das hinterlegte behalten (Feld leer) und das hinterlegte * Zertifikat eintragen, das hinterlegte behalten (Feld leer) und das hinterlegte
@@ -200,11 +261,15 @@ export async function quelleBearbeitenAktion(
if ("fehler" in zuordnung) return zuordnung; if ("fehler" in zuordnung) return zuordnung;
try { try {
const clientSecret = text(formular, "clientSecret");
const quelle = await quelleBearbeiten( const quelle = await quelleBearbeiten(
id, id,
{ {
...eingabe.data, ...eingabe.data,
...felder.werte,
// Leer heisst unveraendert — bei beiden Geheimnissen.
...(bindPasswort ? { bindPasswort } : {}), ...(bindPasswort ? { bindPasswort } : {}),
...(clientSecret ? { clientSecret } : {}),
...(caZertifikatPem !== undefined ? { caZertifikatPem } : {}), ...(caZertifikatPem !== undefined ? { caZertifikatPem } : {}),
attributZuordnung: zuordnung.zuordnung, attributZuordnung: zuordnung.zuordnung,
}, },
+279
View File
@@ -0,0 +1,279 @@
import type { AttributZuordnung } from "@/lib/verzeichnis";
import { ersatzName, type RoherEintrag, type Uebersprungen } from "@/server/ldap/zuordnung";
import { GraphFehler, graphBasis, tokenHolen, type GraphZugang } from "@/server/graph/zugang";
/**
* Benutzer aus Microsoft Graph lesen (#028).
*
* Derselbe Vertrag wie `src/server/ldap/leser.ts`: Seite für Seite, jeder Eintrag
* einzeln weitergegeben, und der Abgleich schreibt mit. Bei einem Mandanten mit
* einigen Tausend Konten ist das der Unterschied zwischen einem Abgleich und
* einem Speicherproblem.
*
* **Was Graph anders macht als LDAP, und was daraus folgt:**
*
* - Es gibt **keinen DN und keine OU**. `dn` bleibt `null`, und `{{ou}}` damit
* leer — statt dass etwas hineingedeutet wird, das kein Befehl brauchen kann.
* - Der Vorgesetzte kommt über `$expand` **direkt als Kennung** mit. Die
* DN-Auflösung des LDAP-Wegs entfällt; `vorgesetzterDirekt` sagt das dem
* Abgleich.
* - **`$filter=accountEnabled eq true` ersetzt den LDAP-Standardfilter.** Ein
* deaktiviertes Konto wird gar nicht geliefert, und was nicht geliefert wird,
* setzt der Abgleich auf `aktiv = false` — dieselbe Mechanik, nur mit anderer
* Abfrage. Serverseitig gefiltert und nicht hier beim Zuordnen: „übersprungen“
* heisst im Abgleich „nicht zuordenbar“, und solche Eintraege werden
* absichtlich **nicht** abgeschaltet.
*/
/**
* Wie viele Konten je Seite.
*
* 999 und nicht 1000: Graph lässt bei `/users` höchstens 999 zu und antwortet
* auf 1000 mit einem Fehler. Eine Zahl, die man einmal nachliest und dann
* aufschreibt.
*/
export const SEITENGROESSE = 999;
/** Wie viele Seiten höchstens, als Reißleine gegen eine Schleife. */
const SEITEN_HOECHSTENS = 1000;
export type GraphLeseBilanz = {
readonly gesamtGelesen: number;
readonly uebersprungen: readonly Uebersprungen[];
/** Wie viele Seiten geholt wurden — der Seitenabruf ist sonst unsichtbar. */
readonly seiten: number;
};
type GraphBenutzer = Record<string, unknown> & {
manager?: { id?: unknown } | null;
};
type GraphSeite = {
value?: unknown;
"@odata.nextLink"?: unknown;
error?: { message?: unknown; code?: unknown };
};
/** Ein einzelner Wert als Text, oder `null`. */
function textWert(nutzer: GraphBenutzer, feld: string): string | null {
const wert = nutzer[feld];
if (typeof wert === "string") return wert.trim() || null;
if (typeof wert === "number" || typeof wert === "boolean") return String(wert);
return null;
}
/**
* Welche Felder angefragt werden – genau die zugeordneten, nichts sonst.
*
* Dieselbe Begründung wie bei LDAP: ohne `$select` liefert Graph ein breites
* Standardprofil. Gespeichert wird davon nichts, aber gelesen eben doch — es
* stünde im Speicher, in einer Fehlerausgabe, in einem Netzwerkmitschnitt.
*
* `manager` fliegt dabei heraus: der ist keine Eigenschaft, sondern eine
* Beziehung und kommt über `$expand`. Ihn in `$select` zu schreiben beantwortet
* Graph mit einem Fehler, und der ist schwer zu lesen.
*/
export function angefragteFelder(zuordnung: AttributZuordnung): string[] {
const felder = new Set<string>();
for (const [name, wert] of Object.entries(zuordnung)) {
if (name === "vorgesetzter") continue;
if (wert) felder.add(wert);
}
// Immer dabei: ohne `id` gibt es keine Wiedererkennung, ohne `accountEnabled`
// keine Unterscheidung zwischen gesperrt und gelöscht.
felder.add("id");
felder.add("accountEnabled");
return [...felder];
}
/**
* Der Filter, der deaktivierte Konten gar nicht erst liefert.
*
* **Das Gegenstück zum LDAP-Standardfilter** (`!(userAccountControl…=2)`), und
* aus demselben Grund: was nicht im Ergebnis steht, setzt der Abgleich auf
* `aktiv = false`. Ein gesperrtes Konto in Entra ID soll genauso behandelt
* werden wie ein gesperrtes im AD.
*
* **Warum serverseitig und nicht hier beim Zuordnen:** „übersprungen“ bedeutet
* im Abgleich „konnte nicht zugeordnet werden“, und solche Einträge werden
* absichtlich **nicht** deaktiviert — ein Konto ohne GUID soll nicht die Person
* abschalten. Ein deaktiviertes Konto als übersprungen zu melden hätte es
* deshalb aktiv gelassen. Gefunden vom Test „setzt aktiv = false“.
*/
export const GRAPH_FILTER = "accountEnabled eq true";
/**
* Die erste Adresse der Abfrage. Danach folgt der Abruf `@odata.nextLink`.
*
* Die Parameter werden **von Hand** zusammengesetzt und nicht mit
* `URLSearchParams`: das kodiert `$` zu `%24` und die Klammern in
* `manager($select=id)` zu `%28`/`%29`. 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 Werte selbst sind feste Feldnamen aus `ATTRIBUT_MUSTER`
* (Buchstaben, Ziffern, Bindestrich), also ist hier nichts zu maskieren.
*/
export function ersteAdresse(zuordnung: AttributZuordnung, seitengroesse = SEITENGROESSE): string {
const teile = [
`$select=${angefragteFelder(zuordnung).join(",")}`,
`$top=${seitengroesse}`,
`$filter=${encodeURIComponent(GRAPH_FILTER)}`,
"$expand=manager($select=id)",
];
return `${graphBasis()}/v1.0/users?${teile.join("&")}`;
}
/**
* Ein Graph-Benutzer wird ein `RoherEintrag`, oder er wird übersprungen.
*
* Übersprungen wird genau ein Fall, wie bei LDAP: ohne stabile Kennung gibt es
* nichts, woran ein zweiter Abgleich den Eintrag wiedererkennen könnte.
*
* Deaktivierte Konten kommen hier **nicht** an — dafür sorgt `GRAPH_FILTER` in
* der Abfrage. Kommt doch eines, ist das ein Fehler in der Abfrage, und die
* Funktion wirft; die Begründung steht unten an der Stelle.
*/
export function benutzerZuordnen(
nutzer: GraphBenutzer,
zuordnung: AttributZuordnung,
): { readonly eintrag: RoherEintrag } | { readonly uebersprungen: Uebersprungen } {
const externeId = textWert(nutzer, zuordnung.externeId);
/*
* Als Kennzeichen in der Übersprungen-Liste dient der UPN und nicht ein DN —
* den gibt es hier nicht. Die Liste soll sagen, **wer** übersprungen wurde;
* dafür ist der Anmeldename die brauchbarste Angabe.
*/
const kennzeichen = textWert(nutzer, zuordnung.upn) ?? externeId ?? "(ohne Kennung)";
if (!externeId) {
return {
uebersprungen: {
dn: kennzeichen,
grund: `Ohne ${zuordnung.externeId} gibt es keine dauerhafte Kennung.`,
},
};
}
/*
* **Kein Fall für die Übersprungen-Liste.** Deaktivierte Konten filtert die
* Abfrage serverseitig heraus (`GRAPH_FILTER`); kommt hier trotzdem eines an,
* ist der Filter nicht angekommen. Dann ist das ein Fehler in der Abfrage und
* keine Eigenschaft des Kontos — und ein Eintrag in der Übersprungen-Liste
* hätte zur Folge, dass die Person **aktiv** bleibt, weil der Abgleich
* übersprungene Einträge absichtlich nicht abschaltet.
*/
if (nutzer["accountEnabled"] === false) {
throw new GraphFehler(
`Graph hat ein deaktiviertes Konto geliefert (${kennzeichen}), obwohl der Filter ` +
`„${GRAPH_FILTER}“ gesetzt war. Das ist ein Fehler in der Abfrage, nicht am Konto.`,
);
}
const vorname = textWert(nutzer, zuordnung.vorname);
const nachname = textWert(nutzer, zuordnung.nachname);
const vorgesetzterId = typeof nutzer.manager?.id === "string" ? nutzer.manager.id.trim() : "";
return {
eintrag: {
externeId,
vorname: vorname ?? "",
nachname: nachname ?? "",
anzeigename: textWert(nutzer, zuordnung.anzeigename) ?? ersatzName(vorname, nachname, kennzeichen),
mail: textWert(nutzer, zuordnung.mail),
upn: textWert(nutzer, zuordnung.upn),
sam: textWert(nutzer, zuordnung.sam),
// Kein DN in Entra ID – siehe Kopfkommentar.
dn: null,
abteilung: textWert(nutzer, zuordnung.abteilung),
titel: textWert(nutzer, zuordnung.titel),
vorgesetzterDn: null,
vorgesetzterDirekt: vorgesetzterId || null,
},
};
}
/**
* Läuft über alle Seiten und gibt jeden Eintrag einzeln weiter.
*
* `grenze` zählt die **zugeordneten** Einträge, wie bei LDAP: „die ersten zehn
* Treffer zeigen“ meint zehn Zeilen in der Tabelle. Ist die Grenze erreicht,
* wird keine weitere Seite geholt.
*/
export async function graphEintraegeStroemen(
zugang: GraphZugang,
zuordnung: AttributZuordnung,
verarbeiten: (eintrag: RoherEintrag) => Promise<void> | void,
grenze?: number,
seitengroesse = SEITENGROESSE,
): Promise<GraphLeseBilanz> {
const token = await tokenHolen(zugang);
let adresse: string | undefined = ersteAdresse(zuordnung, seitengroesse);
let gesamtGelesen = 0;
let zugeordnet = 0;
let seiten = 0;
const uebersprungen: Uebersprungen[] = [];
while (adresse) {
seiten += 1;
if (seiten > SEITEN_HOECHSTENS) {
/*
* Reißleine. Ein `@odata.nextLink`, der auf sich selbst zeigt, wäre sonst
* eine Schleife, die bis zum Speicherende läuft — und zwar still, weil
* jeder einzelne Abruf gelingt.
*/
throw new GraphFehler(
`Der Seitenabruf hat ${SEITEN_HOECHSTENS} Seiten überschritten. Das ist kein Verzeichnis ` +
"dieser Größe, sondern ein Fehler bei der Weiterleitung.",
);
}
let antwort: Response;
try {
antwort = await fetch(adresse, {
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
signal: AbortSignal.timeout(60_000),
});
} catch (fehler) {
throw new GraphFehler(
`Graph war nicht erreichbar: ${fehler instanceof Error ? fehler.message : "unbekannter Fehler"}`,
);
}
const seite = (await antwort.json().catch(() => ({}))) as GraphSeite;
if (!antwort.ok) {
const meldung = typeof seite.error?.message === "string" ? seite.error.message : antwort.statusText;
const code = typeof seite.error?.code === "string" ? ` (${seite.error.code})` : "";
/*
* 403 ist hier der häufigste Fehler und hat genau eine Ursache: die
* Administratoreinwilligung für `User.Read.All` fehlt. Das steht in der
* Meldung, weil es sonst niemand errät.
*/
const hinweis =
antwort.status === 403
? " Fehlt die Administratoreinwilligung für User.Read.All in der App-Registrierung?"
: "";
throw new GraphFehler(`Graph antwortete mit ${antwort.status}${code}: ${meldung}.${hinweis}`);
}
const nutzer = Array.isArray(seite.value) ? (seite.value as GraphBenutzer[]) : [];
for (const einer of nutzer) {
gesamtGelesen += 1;
const ergebnis = benutzerZuordnen(einer, zuordnung);
if ("uebersprungen" in ergebnis) {
uebersprungen.push(ergebnis.uebersprungen);
continue;
}
await verarbeiten(ergebnis.eintrag);
zugeordnet += 1;
if (grenze !== undefined && zugeordnet >= grenze) {
return { gesamtGelesen, uebersprungen, seiten };
}
}
const weiter = seite["@odata.nextLink"];
adresse = typeof weiter === "string" && weiter ? weiter : undefined;
}
return { gesamtGelesen, uebersprungen, seiten };
}
+173
View File
@@ -0,0 +1,173 @@
/**
* Zugang zu Microsoft Graph (#028).
*
* Gelesen wird mit dem **Client-Credentials-Flow**: die Anwendung meldet sich als
* sie selbst an, nicht im Namen eines Menschen. Das ist für einen Abgleich der
* richtige Weg — er läuft nachts, wenn niemand angemeldet ist.
*
* **Die Endpunkte sind einstellbar**, aus zwei Gründen. Erstens gibt es Graph
* nicht nur unter `graph.microsoft.com`: eine Behörde liegt in einer nationalen
* Cloud mit eigener Adresse. Zweitens prüft `tests/graph.test.ts` den ganzen
* Weg gegen einen HTTP-Doppelgänger — gegen einen echten Mandanten ist dieser
* Code **nicht** gelaufen, und das steht so auch im Issue.
*/
import { entschluesseln } from "@/lib/geheimnisse";
export class GraphFehler extends Error {
constructor(meldung: string) {
super(meldung);
this.name = "GraphFehler";
}
}
export type GraphZugang = {
readonly tenantId: string;
readonly clientId: string;
readonly clientSecret: string;
};
/** `https://login.microsoftonline.com` – umstellbar für nationale Clouds und Tests. */
export function loginBasis(): string {
return (process.env["GRAPH_LOGIN_URL"]?.trim() || "https://login.microsoftonline.com").replace(/\/+$/, "");
}
/** `https://graph.microsoft.com` – dito. */
export function graphBasis(): string {
return (process.env["GRAPH_API_URL"]?.trim() || "https://graph.microsoft.com").replace(/\/+$/, "");
}
/**
* Der Zugang aus einer Quellenzeile.
*
* Wirft, wenn Felder fehlen: bei `art = GRAPH` hält die Datenbank sie fest
* (CHECK `Verzeichnisquelle_felder_je_art`), aber ein Aufruf mit einer
* LDAP-Zeile wäre ein Programmierfehler, und der soll nicht als
* `undefined`-Zugang weiterlaufen.
*/
export function graphZugangAus(quelle: {
readonly art: string;
readonly tenantId: string | null;
readonly clientId: string | null;
readonly clientSecretVerschluesselt: string | null;
}): GraphZugang {
if (!quelle.tenantId || !quelle.clientId || !quelle.clientSecretVerschluesselt) {
throw new GraphFehler(
"Dieser Quelle fehlen die Graph-Zugangsdaten (Mandant, Anwendungs-ID oder Geheimnis).",
);
}
return {
tenantId: quelle.tenantId,
clientId: quelle.clientId,
clientSecret: entschluesseln(quelle.clientSecretVerschluesselt),
};
}
type Zwischenspeicher = { readonly token: string; readonly gueltigBis: number };
/*
* Ein Token je Mandant und Anwendung, an `globalThis` wie der Prisma-Client.
*
* **Warum überhaupt zwischenspeichern:** ein Token gilt eine Stunde, und ein
* Abgleich über 5000 Konten holt rund fünf Seiten — ohne Zwischenspeicher wäre
* das fünfmal dieselbe Anmeldung. Microsoft begrenzt die Zahl der
* Token-Anfragen, und ein Abgleich, der dagegen läuft, scheitert mitten im
* Schreiben.
*
* **Warum an `globalThis`:** Hot Reload lädt das Modul neu, und ein Token je
* Speichervorgang wäre dasselbe Problem im Kleinen.
*/
const global = globalThis as typeof globalThis & {
laufzettelGraphToken?: Map<string, Zwischenspeicher>;
};
function speicher(): Map<string, Zwischenspeicher> {
global.laufzettelGraphToken ??= new Map();
return global.laufzettelGraphToken;
}
/**
* Sicherheitsabstand zum Ablauf.
*
* 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.
* Sechzig Sekunden Abstand kosten nichts und ersparen den Fehler, der am
* schwersten zu lesen ist: ein Abgleich, der nach der dritten Seite mit 401
* abbricht.
*/
const ABSTAND_MS = 60_000;
function schluessel(zugang: GraphZugang): string {
return `${zugang.tenantId}/${zugang.clientId}`;
}
/** Nur für Tests: den Zwischenspeicher leeren. */
export function tokenSpeicherLeeren(): void {
speicher().clear();
}
type TokenAntwort = { access_token?: unknown; expires_in?: unknown; error_description?: unknown };
/**
* Ein Zugriffstoken holen – aus dem Zwischenspeicher, sonst neu.
*
* Der Fehlerfall ist hier wichtiger als der Normalfall: Entra ID antwortet bei
* einem falschen Geheimnis mit einer **sehr** ausführlichen Meldung, die den
* Mandanten, die Anwendungs-ID und eine Ablaufverfolgungskennung enthält. Die
* gehört ins Protokoll des Prozesses, nicht in eine Antwort und nicht an die
* Quelle — `letzterAbgleichFehler` ist in der Oberfläche sichtbar.
*/
export async function tokenHolen(zugang: GraphZugang, jetzt = Date.now()): Promise<string> {
const vorhanden = speicher().get(schluessel(zugang));
if (vorhanden && vorhanden.gueltigBis - ABSTAND_MS > jetzt) return vorhanden.token;
const adresse = `${loginBasis()}/${encodeURIComponent(zugang.tenantId)}/oauth2/v2.0/token`;
const rumpf = new URLSearchParams({
grant_type: "client_credentials",
client_id: zugang.clientId,
client_secret: zugang.clientSecret,
scope: `${graphBasis()}/.default`,
});
let antwort: Response;
try {
antwort = await fetch(adresse, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: rumpf,
signal: AbortSignal.timeout(20_000),
});
} catch (fehler) {
throw new GraphFehler(
`Der Anmeldedienst war nicht erreichbar: ${fehler instanceof Error ? fehler.message : "unbekannter Fehler"}`,
);
}
const daten = (await antwort.json().catch(() => ({}))) as TokenAntwort;
if (!antwort.ok) {
/*
* Die ausführliche Meldung ins Protokoll des Prozesses, eine kurze nach
* draußen. Dieselbe Trennung wie beim LDAP-Bind-Fehler (#016) und beim
* Healthcheck (#039): eine Meldung, die Mandant und Anwendungs-ID nennt,
* gehört nicht in eine Oberfläche.
*/
console.error(
"Graph-Anmeldung fehlgeschlagen:",
typeof daten.error_description === "string" ? daten.error_description : antwort.statusText,
);
throw new GraphFehler(
`Die Anmeldung am Mandanten ist fehlgeschlagen (${antwort.status}). Prüfe Mandant, ` +
"Anwendungs-ID und Geheimnis; Einzelheiten stehen im Protokoll des Dienstes.",
);
}
const token = typeof daten.access_token === "string" ? daten.access_token : undefined;
if (!token) {
throw new GraphFehler("Der Anmeldedienst hat kein Zugriffstoken geliefert.");
}
const sekunden = typeof daten.expires_in === "number" ? daten.expires_in : 3600;
speicher().set(schluessel(zugang), { token, gueltigBis: jetzt + sekunden * 1000 });
return token;
}
+32 -7
View File
@@ -45,7 +45,15 @@ export type GemappterEintrag = {
mail: string | null; mail: string | null;
upn: string | null; upn: string | null;
sam: string | null; sam: string | null;
dn: string; /**
* Der DN im Verzeichnis — **`null` bei einer Graph-Quelle** (#028).
*
* Entra ID hat keinen DN und keine OU. Ihn dort zu erfinden hiesse, `{{ou}}`
* mit etwas zu fuellen, das kein PowerShell-Befehl brauchen kann. `null` ist
* die ehrliche Antwort: `{{ou}}` bleibt dann leer und faellt auf die Ziel-OU
* der Abteilung zurueck, wenn eine gepflegt ist.
*/
dn: string | null;
abteilung: string | null; abteilung: string | null;
titel: string | null; titel: string | null;
vorgesetzterExterneId: string | null; vorgesetzterExterneId: string | null;
@@ -62,6 +70,14 @@ export type GemappterEintrag = {
*/ */
export type RoherEintrag = Omit<GemappterEintrag, "vorgesetzterExterneId"> & { export type RoherEintrag = Omit<GemappterEintrag, "vorgesetzterExterneId"> & {
vorgesetzterDn: string | null; vorgesetzterDn: string | null;
/**
* Die Kennung des Vorgesetzten, falls sie schon beim Lesen feststeht (#028).
*
* Graph liefert sie ueber `$expand=manager($select=id)` direkt mit — dort gibt
* es keinen DN, der erst aufgeloest werden muesste. Ist dieses Feld gesetzt,
* uebergeht `vorgesetzteAufloesen` die DN-Tabelle.
*/
vorgesetzterDirekt?: string | null;
}; };
/** /**
@@ -149,6 +165,9 @@ export function eintragZuordnen(
zuordnung: AttributZuordnung, zuordnung: AttributZuordnung,
): { eintrag: RoherEintrag } | { uebersprungen: Uebersprungen } { ): { eintrag: RoherEintrag } | { uebersprungen: Uebersprungen } {
const dn = eintrag.dn.trim(); const dn = eintrag.dn.trim();
// Bei LDAP steht immer ein DN; die Nullability von `dn` kommt von der
// Graph-Quelle (#028) und betrifft diese Funktion nicht.
let externeId: string | undefined; let externeId: string | undefined;
try { try {
externeId = externeIdLesen(eintrag, zuordnung.externeId); externeId = externeIdLesen(eintrag, zuordnung.externeId);
@@ -190,11 +209,11 @@ export function eintragZuordnen(
* wird er aus Nachname und Vorname gebaut; bleibt auch das leer, tritt der erste * wird er aus Nachname und Vorname gebaut; bleibt auch das leer, tritt der erste
* Teil des DN ein. * Teil des DN ein.
*/ */
function ersatzName(vorname: string | null, nachname: string | null, dn: string): string { export function ersatzName(vorname: string | null, nachname: string | null, kennzeichen: string): string {
const gebaut = [nachname, vorname].filter((teil) => teil).join(", "); const gebaut = [nachname, vorname].filter((teil) => teil).join(", ");
if (gebaut) return gebaut; if (gebaut) return gebaut;
const ersterTeil = dn.split(",")[0]?.split("=")[1]?.trim(); const ersterTeil = kennzeichen.split(",")[0]?.split("=")[1]?.trim();
return ersterTeil ?? dn; return ersterTeil ?? kennzeichen;
} }
/** /**
@@ -225,10 +244,16 @@ export function vorgesetzteAufloesen(
): GemappterEintrag[] { ): GemappterEintrag[] {
const nachDn = new Map(zusaetzlicheKennungen ?? []); const nachDn = new Map(zusaetzlicheKennungen ?? []);
for (const eintrag of eintraege) { for (const eintrag of eintraege) {
nachDn.set(dnSchluessel(eintrag.dn), eintrag.externeId); if (eintrag.dn) nachDn.set(dnSchluessel(eintrag.dn), eintrag.externeId);
} }
return eintraege.map(({ vorgesetzterDn, ...rest }) => ({ return eintraege.map(({ vorgesetzterDn, vorgesetzterDirekt, ...rest }) => ({
...rest, ...rest,
vorgesetzterExterneId: vorgesetzterDn ? (nachDn.get(dnSchluessel(vorgesetzterDn)) ?? null) : null, /*
* **Die direkte Kennung gewinnt** (#028). Eine Graph-Quelle kennt den
* Vorgesetzten schon beim Lesen; eine Aufloesung ueber DNs waere dort nicht
* nur unnoetig, sondern unmoeglich — es gibt keine.
*/
vorgesetzterExterneId:
vorgesetzterDirekt ?? (vorgesetzterDn ? (nachDn.get(dnSchluessel(vorgesetzterDn)) ?? null) : null),
})); }));
} }
+144 -125
View File
@@ -4,9 +4,9 @@ import { prisma } from "@/lib/db";
import { satzEnde } from "@/lib/satzzeichen"; import { satzEnde } from "@/lib/satzzeichen";
import { darf } from "@/server/auth/rollen"; import { darf } from "@/server/auth/rollen";
import { betriebsmodus, eigeneOrganisation, type HandelnderBenutzer } from "@/server/mandant"; import { betriebsmodus, eigeneOrganisation, type HandelnderBenutzer } from "@/server/mandant";
import { eintraegeStroemen } from "@/server/ldap/leser"; import { eintraegeDerQuelleStroemen } from "@/server/verzeichnis/quellenleser";
import { fehlerMeldung } from "@/server/ldap/verbindung"; import { fehlerMeldung } from "@/server/ldap/verbindung";
import { dnSchluessel, type RoherEintrag, zuordnungLesen } from "@/server/ldap/zuordnung"; import { dnSchluessel, type RoherEintrag } from "@/server/ldap/zuordnung";
import { VerzeichnisFehler, quelleZeileHolen, zugangAus } from "@/server/services/verzeichnis"; import { VerzeichnisFehler, quelleZeileHolen, zugangAus } from "@/server/services/verzeichnis";
import { mitSperre } from "@/server/services/sperre"; import { mitSperre } from "@/server/services/sperre";
import { abgleichMeldungEinreihen } from "@/server/services/benachrichtigungen"; import { abgleichMeldungEinreihen } from "@/server/services/benachrichtigungen";
@@ -147,7 +147,16 @@ async function vonHandAngelegteFinden(kundeId: string, eintrag: RoherEintrag): P
/** Was wir zwischen den beiden Durchgaengen ueber eine Person behalten muessen. */ /** Was wir zwischen den beiden Durchgaengen ueber eine Person behalten muessen. */
type OffenerVorgesetzter = { type OffenerVorgesetzter = {
readonly externeId: string; readonly externeId: string;
readonly vorgesetzterDn: string; /** Bei LDAP: der DN, der im zweiten Durchgang aufgeloest wird. */
readonly vorgesetzterDn: string | null;
/**
* Bei Graph: die Kennung steht schon fest (#028).
*
* Beides als zwei Felder und nicht als eines mit zwei Bedeutungen: ein DN und
* eine GUID sehen verschieden aus, aber ein Feld, das mal das eine und mal
* das andere traegt, verwechselt irgendwann jemand.
*/
readonly vorgesetzterDirekt: string | null;
readonly vorherigeKennung: string | null; readonly vorherigeKennung: string | null;
readonly neuAngelegt: boolean; readonly neuAngelegt: boolean;
}; };
@@ -207,144 +216,147 @@ async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Pr
let bindPasswort: string | undefined; let bindPasswort: string | undefined;
try { try {
const zugang = zugangAus(quelle); /*
bindPasswort = zugang.bindPasswort; * Das Bind-Passwort nur bei einer LDAP-Quelle — es dient unten dem
* Filtern der Fehlermeldung. Eine Graph-Quelle hat keines; ihr Geheimnis
* erscheint nirgends in einer Meldung, weil `tokenHolen` die ausfuehrliche
* Antwort von Entra ID selbst ins Prozessprotokoll schreibt (#028).
*/
bindPasswort = quelle.art === "LDAP" ? zugangAus(quelle).bindPasswort : undefined;
const kennungNachDn = new Map<string, string>(); const kennungNachDn = new Map<string, string>();
const offene: OffenerVorgesetzter[] = []; const offene: OffenerVorgesetzter[] = [];
const bilanz = await eintraegeStroemen( const bilanz = await eintraegeDerQuelleStroemen(quelle, async (eintrag) => {
zugang, verarbeitet += 1;
{ // Nur LDAP liefert einen DN; bei Graph bleibt die Tabelle leer, und der
basisDn: quelle.basisDn, // Vorgesetzte kommt ueber `vorgesetzterDirekt`.
filter: quelle.filter, if (eintrag.dn) kennungNachDn.set(dnSchluessel(eintrag.dn), eintrag.externeId);
zuordnung: zuordnungLesen(quelle.attributZuordnung), let vorhanden = await prisma.mitarbeiter.findFirst({
}, where: { quelleId, externeId: eintrag.externeId },
async (eintrag) => { });
verarbeitet += 1; /*
kennungNachDn.set(dnSchluessel(eintrag.dn), eintrag.externeId); * Kennt die Quelle die Person noch nicht, kann sie trotzdem schon in der
let vorhanden = await prisma.mitarbeiter.findFirst({ * Datenbank stehen: beim Onboarding legt jemand sie von Hand an, das
where: { quelleId, externeId: eintrag.externeId }, * AD-Konto entsteht danach, und der naechste Abgleich findet sie.
}); *
/* * Ohne diese Uebernahme entstuenden zwei Maria Huber — eine mit Laeufen
* Kennt die Quelle die Person noch nicht, kann sie trotzdem schon in der * und Geraeten, eine mit der Kennung. Die von Hand angelegte waere die mit
* Datenbank stehen: beim Onboarding legt jemand sie von Hand an, das * den Daten und die falsche in jeder Auswahlliste (Reviewbefund zu #016).
* AD-Konto entsteht danach, und der naechste Abgleich findet sie. */
* let uebernommenJetzt = false;
* Ohne diese Uebernahme entstuenden zwei Maria Huber — eine mit Laeufen if (!vorhanden) {
* und Geraeten, eine mit der Kennung. Die von Hand angelegte waere die mit const vonHand = await vonHandAngelegteFinden(quelle.kundeId, eintrag);
* den Daten und die falsche in jeder Auswahlliste (Reviewbefund zu #016). if (vonHand) {
*/ vorhanden = vonHand;
let uebernommenJetzt = false; uebernommenJetzt = true;
if (!vorhanden) { uebernommen += 1;
const vonHand = await vonHandAngelegteFinden(quelle.kundeId, eintrag);
if (vonHand) {
vorhanden = vonHand;
uebernommenJetzt = true;
uebernommen += 1;
}
} }
}
const felder = { const felder = {
vorname: eintrag.vorname, vorname: eintrag.vorname,
nachname: eintrag.nachname, nachname: eintrag.nachname,
anzeigename: eintrag.anzeigename, anzeigename: eintrag.anzeigename,
mail: eintrag.mail, mail: eintrag.mail,
upn: eintrag.upn, upn: eintrag.upn,
sam: eintrag.sam, sam: eintrag.sam,
dn: eintrag.dn, dn: eintrag.dn,
abteilung: eintrag.abteilung, abteilung: eintrag.abteilung,
titel: eintrag.titel, titel: eintrag.titel,
}; };
if (!vorhanden) { if (!vorhanden) {
await prisma.mitarbeiter.create({ await prisma.mitarbeiter.create({
data: {
kundeId: quelle.kundeId,
quelleId,
externeId: eintrag.externeId,
...felder,
// Der Vorgesetzte kommt im zweiten Durchgang: sein DN laesst sich
// erst aufloesen, wenn alle Kennungen bekannt sind.
vorgesetzterExterneId: null,
vorgesetzterId: null,
aktiv: true,
herkunft: Herkunft.ABGLEICH,
zuletztGesehenAm: beginn,
},
});
angelegt += 1;
if (eintrag.vorgesetzterDn) {
offene.push({
externeId: eintrag.externeId,
vorgesetzterDn: eintrag.vorgesetzterDn,
vorherigeKennung: null,
neuAngelegt: true,
});
}
return;
}
// Steht im Verzeichnis kein Vorgesetzter mehr, wird ein alter Wert
// geleert. Steht einer, entscheidet der zweite Durchgang.
const vorgesetztenLeeren = eintrag.vorgesetzterDn === null;
if (
// Eine Uebernahme zaehlt in ihrer eigenen Spalte und nicht zusaetzlich
// als Aktualisierung; sonst gingen die Zahlen nicht mehr auf.
!uebernommenJetzt &&
(vorhanden.vorname !== felder.vorname ||
vorhanden.nachname !== felder.nachname ||
vorhanden.anzeigename !== felder.anzeigename ||
vorhanden.mail !== felder.mail ||
vorhanden.upn !== felder.upn ||
vorhanden.sam !== felder.sam ||
vorhanden.dn !== felder.dn ||
vorhanden.abteilung !== felder.abteilung ||
vorhanden.titel !== felder.titel ||
!vorhanden.aktiv ||
vorhanden.herkunft !== Herkunft.ABGLEICH ||
(vorgesetztenLeeren && vorhanden.vorgesetzterExterneId !== null))
) {
geaendert.add(eintrag.externeId);
}
await prisma.mitarbeiter.update({
where: { id: vorhanden.id },
data: { data: {
kundeId: quelle.kundeId,
quelleId,
externeId: eintrag.externeId,
...felder, ...felder,
// Bei einer Uebernahme kommt die Herkunft dazu: ab jetzt fuehrt das // Der Vorgesetzte kommt im zweiten Durchgang: sein DN laesst sich
// Verzeichnis diese Person, und der naechste Lauf findet sie ueber die // erst aufloesen, wenn alle Kennungen bekannt sind.
// Kennung. vorgesetzterExterneId: null,
...(uebernommenJetzt ? { quelleId, externeId: eintrag.externeId } : {}), vorgesetzterId: null,
/*
* Steht im Verzeichnis kein Vorgesetzter, wird der **rohe** Wert
* geleert. Die gepflegte Zuordnung nur dann, wenn sie vom
* Verzeichnis kam — erkennbar daran, dass dort eine Kennung stand.
* Eine von Hand gesetzte Zuordnung bleibt stehen: das Verzeichnis
* fuehrt sie nicht, also hat es sie auch nicht zu loeschen (#020).
*/
...(vorgesetztenLeeren
? {
vorgesetzterExterneId: null,
...(vorhanden.vorgesetzterExterneId !== null ? { vorgesetzterId: null } : {}),
}
: {}),
// Wer wieder im Ergebnis steht, ist wieder aktiv — eine
// wiedereingestellte Person behaelt so ihre Laeufe.
aktiv: true, aktiv: true,
herkunft: Herkunft.ABGLEICH, herkunft: Herkunft.ABGLEICH,
zuletztGesehenAm: beginn, zuletztGesehenAm: beginn,
}, },
}); });
if (!vorgesetztenLeeren) { angelegt += 1;
if (eintrag.vorgesetzterDn || eintrag.vorgesetzterDirekt) {
offene.push({ offene.push({
externeId: eintrag.externeId, externeId: eintrag.externeId,
vorgesetzterDn: eintrag.vorgesetzterDn as string, vorgesetzterDn: eintrag.vorgesetzterDn,
vorherigeKennung: vorhanden.vorgesetzterExterneId, vorgesetzterDirekt: eintrag.vorgesetzterDirekt ?? null,
neuAngelegt: false, vorherigeKennung: null,
neuAngelegt: true,
}); });
} }
}, return;
); }
// Steht im Verzeichnis kein Vorgesetzter mehr, wird ein alter Wert
// geleert. Steht einer, entscheidet der zweite Durchgang.
// Kein Vorgesetzter heisst: keiner auf **beiden** Wegen.
const vorgesetztenLeeren =
eintrag.vorgesetzterDn === null && (eintrag.vorgesetzterDirekt ?? null) === null;
if (
// Eine Uebernahme zaehlt in ihrer eigenen Spalte und nicht zusaetzlich
// als Aktualisierung; sonst gingen die Zahlen nicht mehr auf.
!uebernommenJetzt &&
(vorhanden.vorname !== felder.vorname ||
vorhanden.nachname !== felder.nachname ||
vorhanden.anzeigename !== felder.anzeigename ||
vorhanden.mail !== felder.mail ||
vorhanden.upn !== felder.upn ||
vorhanden.sam !== felder.sam ||
vorhanden.dn !== felder.dn ||
vorhanden.abteilung !== felder.abteilung ||
vorhanden.titel !== felder.titel ||
!vorhanden.aktiv ||
vorhanden.herkunft !== Herkunft.ABGLEICH ||
(vorgesetztenLeeren && vorhanden.vorgesetzterExterneId !== null))
) {
geaendert.add(eintrag.externeId);
}
await prisma.mitarbeiter.update({
where: { id: vorhanden.id },
data: {
...felder,
// Bei einer Uebernahme kommt die Herkunft dazu: ab jetzt fuehrt das
// Verzeichnis diese Person, und der naechste Lauf findet sie ueber die
// Kennung.
...(uebernommenJetzt ? { quelleId, externeId: eintrag.externeId } : {}),
/*
* Steht im Verzeichnis kein Vorgesetzter, wird der **rohe** Wert
* geleert. Die gepflegte Zuordnung nur dann, wenn sie vom
* Verzeichnis kam — erkennbar daran, dass dort eine Kennung stand.
* Eine von Hand gesetzte Zuordnung bleibt stehen: das Verzeichnis
* fuehrt sie nicht, also hat es sie auch nicht zu loeschen (#020).
*/
...(vorgesetztenLeeren
? {
vorgesetzterExterneId: null,
...(vorhanden.vorgesetzterExterneId !== null ? { vorgesetzterId: null } : {}),
}
: {}),
// Wer wieder im Ergebnis steht, ist wieder aktiv — eine
// wiedereingestellte Person behaelt so ihre Laeufe.
aktiv: true,
herkunft: Herkunft.ABGLEICH,
zuletztGesehenAm: beginn,
},
});
if (!vorgesetztenLeeren) {
offene.push({
externeId: eintrag.externeId,
vorgesetzterDn: eintrag.vorgesetzterDn,
vorgesetzterDirekt: eintrag.vorgesetzterDirekt ?? null,
vorherigeKennung: vorhanden.vorgesetzterExterneId,
neuAngelegt: false,
});
}
});
gelesen = bilanz.gesamtGelesen; gelesen = bilanz.gesamtGelesen;
// --- Zweiter Durchgang: Vorgesetzte -------------------------------------- // --- Zweiter Durchgang: Vorgesetzte --------------------------------------
@@ -354,7 +366,14 @@ async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Pr
// jeder Leseweg schoepft (#020). Vorher gab es nur das erste, und eine von // jeder Leseweg schoepft (#020). Vorher gab es nur das erste, und eine von
// Hand angelegte Person konnte keinen Vorgesetzten haben. // Hand angelegte Person konnte keinen Vorgesetzten haben.
for (const offen of offene) { for (const offen of offene) {
const kennung = kennungNachDn.get(dnSchluessel(offen.vorgesetzterDn)) ?? null; /*
* **Die direkte Kennung gewinnt** (#028). Bei Graph steht sie schon fest;
* die DN-Tabelle ist dort leer, und ein Nachschlagen darin ergaebe
* `null` — also keinen Vorgesetzten, obwohl einer bekannt ist.
*/
const kennung =
offen.vorgesetzterDirekt ??
(offen.vorgesetzterDn ? (kennungNachDn.get(dnSchluessel(offen.vorgesetzterDn)) ?? null) : null);
if (kennung === offen.vorherigeKennung) continue; if (kennung === offen.vorherigeKennung) continue;
const vorgesetzter = const vorgesetzter =
kennung === null kennung === null
+209 -51
View File
@@ -1,17 +1,27 @@
import type { Prisma, Verzeichnisquelle } from "@/generated/prisma/client"; import type { Prisma, Verzeichnisquelle } from "@/generated/prisma/client";
import { Betriebsmodus } from "@/generated/prisma/enums"; import { QuellenArt, Betriebsmodus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse"; import { verschluesseln } from "@/lib/geheimnisse";
import { darf } from "@/server/auth/rollen"; import { darf } from "@/server/auth/rollen";
import { betriebsmodus, eigeneOrganisation, kundeErmitteln, type HandelnderBenutzer } from "@/server/mandant"; import { betriebsmodus, eigeneOrganisation, kundeErmitteln, type HandelnderBenutzer } from "@/server/mandant";
import { VerzeichnisFehler } from "@/server/ldap/fehler"; import { VerzeichnisFehler } from "@/server/ldap/fehler";
import { INTERVALL_HOECHSTENS, INTERVALL_MINDESTENS, intervallMeldung } from "@/lib/verzeichnis"; // Die Zugangs-Helfer liegen seit #028 in einem eigenen Modul – sonst waere die
import { eintraegeLesen } from "@/server/ldap/leser"; // Weiche in `quellenleser.ts` ein Import-Zyklus. Hier weitergereicht, damit
import { type LdapZugang, urlPruefen } from "@/server/ldap/verbindung"; // bestehende Aufrufer ihre Anlaufstelle behalten.
import { ldapVorgabeAus, zugangAus, zuordnungsArt } from "@/server/verzeichnis/zugang";
import {
ZUORDNUNGS_VORGABE,
INTERVALL_HOECHSTENS,
INTERVALL_MINDESTENS,
intervallMeldung,
} from "@/lib/verzeichnis";
import { eintraegeDerQuelleStroemen } from "@/server/verzeichnis/quellenleser";
import { urlPruefen } from "@/server/ldap/verbindung";
import { import {
AD_ZUORDNUNG, AD_ZUORDNUNG,
type AttributZuordnung, type AttributZuordnung,
type GemappterEintrag, type GemappterEintrag,
type RoherEintrag,
STANDARD_FILTER, STANDARD_FILTER,
type Uebersprungen, type Uebersprungen,
vorgesetzteAufloesen, vorgesetzteAufloesen,
@@ -48,16 +58,24 @@ export { AD_ZUORDNUNG, STANDARD_FILTER };
* Aufrufer sie von hier kennt. * Aufrufer sie von hier kennt.
*/ */
export { VerzeichnisFehler }; export { VerzeichnisFehler };
export { ldapVorgabeAus, zugangAus, zuordnungsArt };
/** Ohne Passwort – diese Form geht an die Oberflaeche. */ /** Ohne Passwort – diese Form geht an die Oberflaeche. */
export type QuellenSicht = { export type QuellenSicht = {
readonly id: string; readonly id: string;
readonly name: string; readonly name: string;
readonly aktiv: boolean; readonly aktiv: boolean;
readonly url: string; /** Woher gelesen wird (#028). Bis dahin war jede Quelle LDAP. */
readonly bindDn: string; readonly art: QuellenArt;
readonly basisDn: string; /** Nur bei `art = LDAP` belegt. */
readonly filter: string; readonly url: string | null;
readonly bindDn: string | null;
readonly basisDn: string | null;
readonly filter: string | null;
/** Nur bei `art = GRAPH` belegt – das Geheimnis **nie**, nur ob eines da ist. */
readonly tenantId: string | null;
readonly clientId: string | null;
readonly clientSecretVorhanden: boolean;
readonly attributZuordnung: AttributZuordnung; readonly attributZuordnung: AttributZuordnung;
readonly caZertifikatVorhanden: boolean; readonly caZertifikatVorhanden: boolean;
readonly abgleichIntervallMinuten: number; readonly abgleichIntervallMinuten: number;
@@ -68,11 +86,18 @@ export type QuellenSicht = {
export type NeueQuelle = { export type NeueQuelle = {
name: string; name: string;
url: string; /** Ohne Angabe `LDAP` – so verhalten sich Aufrufer von vor #028 unveraendert. */
bindDn: string; art?: QuellenArt;
bindPasswort: string; /** Pflicht bei `art = LDAP`, bei Graph nicht erlaubt. */
basisDn: string; url?: string;
bindDn?: string;
bindPasswort?: string;
basisDn?: string;
filter?: string; filter?: string;
/** Pflicht bei `art = GRAPH`. */
tenantId?: string;
clientId?: string;
clientSecret?: string;
attributZuordnung?: Partial<AttributZuordnung>; attributZuordnung?: Partial<AttributZuordnung>;
caZertifikatPem?: string | null; caZertifikatPem?: string | null;
abgleichIntervallMinuten?: number; abgleichIntervallMinuten?: number;
@@ -150,11 +175,19 @@ function alsSicht(zeile: Verzeichnisquelle): QuellenSicht {
id: zeile.id, id: zeile.id,
name: zeile.name, name: zeile.name,
aktiv: zeile.aktiv, aktiv: zeile.aktiv,
art: zeile.art,
url: zeile.url, url: zeile.url,
bindDn: zeile.bindDn, bindDn: zeile.bindDn,
basisDn: zeile.basisDn, basisDn: zeile.basisDn,
filter: zeile.filter, filter: zeile.filter,
attributZuordnung: zuordnungLesen(zeile.attributZuordnung), tenantId: zeile.tenantId,
clientId: zeile.clientId,
/*
* Wie beim Bind-Passwort: nur **ob** eines hinterlegt ist. Ein Feld, das es
* nicht in eine Antwort schafft, kann auch nicht versehentlich hinausgehen.
*/
clientSecretVorhanden: Boolean(zeile.clientSecretVerschluesselt?.trim()),
attributZuordnung: zuordnungLesen(zeile.attributZuordnung, zuordnungsArt(zeile.art)),
// Der Inhalt bleibt hier: die Oberflaeche muss nur wissen, ob eines // Der Inhalt bleibt hier: die Oberflaeche muss nur wissen, ob eines
// hinterlegt ist, und braucht dafuer keinen Zertifikatstext. // hinterlegt ist, und braucht dafuer keinen Zertifikatstext.
caZertifikatVorhanden: Boolean(zeile.caZertifikatPem?.trim()), caZertifikatVorhanden: Boolean(zeile.caZertifikatPem?.trim()),
@@ -168,6 +201,64 @@ function alsSicht(zeile: Verzeichnisquelle): QuellenSicht {
/** Auch fuer die Lesemodelle: die eine Stelle, die aus einer Zeile eine Sicht macht. */ /** Auch fuer die Lesemodelle: die eine Stelle, die aus einer Zeile eine Sicht macht. */
export { alsSicht as quellenSichtBauen }; export { alsSicht as quellenSichtBauen };
/**
* Die artabhaengigen Felder einer neuen Quelle, geprueft.
*
* Eine eigene Funktion, weil beide Richtungen gelten muessen: was eine Art
* **braucht**, und was sie **nicht haben darf**. Ohne den zweiten Teil liesse
* sich eine Graph-Quelle mit `basisDn` anlegen — eine Zeile, die zwei Dinge
* gleichzeitig behauptet, und bei der niemand mehr sagen kann, was gilt. Die
* Datenbank weist das ohnehin ab; hier entsteht die Meldung, die weiterhilft.
*/
function feldernJeArt(art: QuellenArt, eingabe: NeueQuelle) {
if (art === "LDAP") {
const url = pflichtText(eingabe.url, "Die Adresse des Verzeichnisses");
urlPruefen(url);
const bindPasswort = eingabe.bindPasswort?.trim();
if (!bindPasswort) {
throw new VerzeichnisFehler("Das Bind-Passwort fehlt. Ein anonymer Bind wird nicht unterstützt.");
}
if (eingabe.tenantId?.trim() || eingabe.clientId?.trim() || eingabe.clientSecret?.trim()) {
throw new VerzeichnisFehler(
"Eine LDAP-Quelle hat keine Graph-Zugangsdaten. Für Entra ID gibt es die Art „Graph“.",
);
}
return {
// Gespeichert wird die getippte Adresse samt Schema. `ldap+starttls://`
// ist Teil der Angabe und wird erst beim Verbinden abgetrennt — sonst
// waere nach dem Speichern nicht mehr zu erkennen, dass StartTLS
// gewuenscht war.
url,
bindDn: pflichtText(eingabe.bindDn, "Der Bind-DN"),
bindPasswortVerschluesselt: verschluesseln(bindPasswort),
basisDn: pflichtText(eingabe.basisDn, "Der Basis-DN"),
filter: eingabe.filter?.trim() || STANDARD_FILTER,
};
}
if (art === "GRAPH") {
const clientSecret = eingabe.clientSecret?.trim();
if (!clientSecret) {
throw new VerzeichnisFehler("Das Client-Secret der App-Registrierung fehlt.");
}
if (eingabe.url?.trim() || eingabe.bindDn?.trim() || eingabe.basisDn?.trim()) {
throw new VerzeichnisFehler(
"Eine Graph-Quelle hat keine Adresse, keinen Bind-DN und keinen Basis-DN — Entra ID kennt " +
"beides nicht. Für ein lokales AD gibt es die Art „LDAP“.",
);
}
return {
tenantId: pflichtText(eingabe.tenantId, "Die Mandanten-ID"),
clientId: pflichtText(eingabe.clientId, "Die Anwendungs-ID"),
clientSecretVerschluesselt: verschluesseln(clientSecret),
};
}
throw new VerzeichnisFehler(
`Quellen der Art ${art} lassen sich noch nicht anlegen. Der SCIM-Endpunkt kommt mit #029.`,
);
}
export async function quelleAnlegen( export async function quelleAnlegen(
eingabe: NeueQuelle, eingabe: NeueQuelle,
benutzer: HandelnderBenutzer, benutzer: HandelnderBenutzer,
@@ -177,31 +268,28 @@ export async function quelleAnlegen(
const kundeId = await kundeErmitteln(null, benutzer); const kundeId = await kundeErmitteln(null, benutzer);
const name = pflichtText(eingabe.name, "Der Name der Quelle"); const name = pflichtText(eingabe.name, "Der Name der Quelle");
const url = pflichtText(eingabe.url, "Die Adresse des Verzeichnisses"); const art = eingabe.art ?? "LDAP";
urlPruefen(url);
const bindDn = pflichtText(eingabe.bindDn, "Der Bind-DN");
const bindPasswort = eingabe.bindPasswort?.trim();
if (!bindPasswort) {
throw new VerzeichnisFehler("Das Bind-Passwort fehlt. Ein anonymer Bind wird nicht unterstützt.");
}
const basisDn = pflichtText(eingabe.basisDn, "Der Basis-DN");
const intervall = intervallPruefen(eingabe.abgleichIntervallMinuten); const intervall = intervallPruefen(eingabe.abgleichIntervallMinuten);
/*
* **Welche Felder gelten, haengt an der Art** (#028). Geprueft wird hier mit
* einer brauchbaren Meldung; dieselbe Regel haelt die Datenbank fest (CHECK
* `Verzeichnisquelle_felder_je_art`), damit sie auch gilt, wenn jemand an
* dieser Schicht vorbei schreibt.
*/
const felder = feldernJeArt(art, eingabe);
const zeile = await prisma.$transaction(async (tx) => { const zeile = await prisma.$transaction(async (tx) => {
const angelegt = await tx.verzeichnisquelle.create({ const angelegt = await tx.verzeichnisquelle.create({
data: { data: {
kundeId, kundeId,
name, name,
// Gespeichert wird die getippte Adresse samt Schema. `ldap+starttls://` art,
// ist Teil der Angabe und wird erst beim Verbinden abgetrennt — sonst ...felder,
// waere nach dem Speichern nicht mehr zu erkennen, dass StartTLS attributZuordnung: zuordnungLesen(
// gewuenscht war. { ...ZUORDNUNGS_VORGABE[zuordnungsArt(art)], ...eingabe.attributZuordnung },
url, zuordnungsArt(art),
bindDn, ),
bindPasswortVerschluesselt: verschluesseln(bindPasswort),
basisDn,
filter: eingabe.filter?.trim() || STANDARD_FILTER,
attributZuordnung: zuordnungLesen({ ...AD_ZUORDNUNG, ...eingabe.attributZuordnung }),
caZertifikatPem: zertifikatPruefen(eingabe.caZertifikatPem), caZertifikatPem: zertifikatPruefen(eingabe.caZertifikatPem),
// Ohne Angabe bleibt der Standardwert des Schemas (60 Minuten) stehen. // Ohne Angabe bleibt der Standardwert des Schemas (60 Minuten) stehen.
...(intervall === undefined ? {} : { abgleichIntervallMinuten: intervall }), ...(intervall === undefined ? {} : { abgleichIntervallMinuten: intervall }),
@@ -215,8 +303,20 @@ export async function quelleAnlegen(
entitaetId: angelegt.id, entitaetId: angelegt.id,
aktion: "Verzeichnisquelle angelegt", aktion: "Verzeichnisquelle angelegt",
benutzerId: benutzer.id, benutzerId: benutzer.id,
// Kein Passwort, auch nicht verschluesselt: das Protokoll wird gelesen. /*
nachher: { name: angelegt.name, url: angelegt.url, basisDn: angelegt.basisDn }, * Kein Passwort und kein Client-Secret, auch nicht verschluesselt: das
* Protokoll wird gelesen (#023). Der Mandant und die Anwendungs-ID sind
* dagegen keine Geheimnisse — ohne sie liesse sich hinterher nicht
* sagen, welche Registrierung gemeint war.
*/
nachher: {
name: angelegt.name,
art: angelegt.art,
...(angelegt.url ? { url: angelegt.url } : {}),
...(angelegt.basisDn ? { basisDn: angelegt.basisDn } : {}),
...(angelegt.tenantId ? { tenantId: angelegt.tenantId } : {}),
...(angelegt.clientId ? { clientId: angelegt.clientId } : {}),
},
}, },
}); });
return angelegt; return angelegt;
@@ -236,6 +336,35 @@ export async function quelleBearbeiten(
const daten: Prisma.VerzeichnisquelleUpdateInput = {}; const daten: Prisma.VerzeichnisquelleUpdateInput = {};
const geaendert: string[] = []; const geaendert: string[] = [];
/*
* **Die Art laesst sich nicht aendern** (#028).
*
* Nicht aus Bequemlichkeit: an der Art haengt, was `externeId` bedeutet. Im
* AD ist es 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`: Laeufe und Geraete haengen dann an den inaktiven.
*
* Wer wechselt, legt eine neue Quelle an und laesst die alte stehen, bis die
* Leute uebernommen sind — dafuer gibt es die Uebernahme von Hand angelegter
* Personen aus #016.
*/
if (aenderung.art !== undefined && aenderung.art !== vorher.art) {
throw new VerzeichnisFehler(
`Die Art einer Quelle lässt sich nicht ändern (${vorher.art} → ${aenderung.art}). ` +
"Daran hängt, was die dauerhafte Kennung bedeutet — nach einem Wechsel würde kein " +
"Mitarbeiter wiedererkannt. Leg stattdessen eine neue Quelle an.",
);
}
const nurBei = (art: QuellenArt, feld: string): void => {
if (vorher.art !== art) {
throw new VerzeichnisFehler(
`„${feld}“ gilt nur für eine Quelle der Art ${art}; „${vorher.name}“ ist ${vorher.art}.`,
);
}
};
if (aenderung.name !== undefined) { if (aenderung.name !== undefined) {
const name = pflichtText(aenderung.name, "Der Name der Quelle"); const name = pflichtText(aenderung.name, "Der Name der Quelle");
if (name !== vorher.name) { if (name !== vorher.name) {
@@ -243,7 +372,40 @@ export async function quelleBearbeiten(
geaendert.push("Name"); geaendert.push("Name");
} }
} }
// --- Graph-Felder ---------------------------------------------------------
if (aenderung.tenantId !== undefined) {
nurBei("GRAPH", "Mandanten-ID");
const wert = pflichtText(aenderung.tenantId, "Die Mandanten-ID");
if (wert !== vorher.tenantId) {
daten.tenantId = wert;
geaendert.push("Mandant");
}
}
if (aenderung.clientId !== undefined) {
nurBei("GRAPH", "Anwendungs-ID");
const wert = pflichtText(aenderung.clientId, "Die Anwendungs-ID");
if (wert !== vorher.clientId) {
daten.clientId = wert;
geaendert.push("Anwendung");
}
}
if (aenderung.clientSecret !== undefined) {
nurBei("GRAPH", "Client-Secret");
/*
* Leer heisst **unveraendert**, wie beim Bind-Passwort: das Formular zeigt
* ein gesetztes Geheimnis nie an, also kommt es leer zurueck. Wuerde leer
* als „loeschen“ gelesen, machte jedes Umbenennen die Quelle unbrauchbar.
*/
const wert = aenderung.clientSecret.trim();
if (wert) {
daten.clientSecretVerschluesselt = verschluesseln(wert);
geaendert.push("Client-Secret");
}
}
if (aenderung.url !== undefined) { if (aenderung.url !== undefined) {
nurBei("LDAP", "Adresse");
const url = pflichtText(aenderung.url, "Die Adresse des Verzeichnisses"); const url = pflichtText(aenderung.url, "Die Adresse des Verzeichnisses");
urlPruefen(url); urlPruefen(url);
if (url !== vorher.url) { if (url !== vorher.url) {
@@ -252,6 +414,7 @@ export async function quelleBearbeiten(
} }
} }
if (aenderung.bindDn !== undefined) { if (aenderung.bindDn !== undefined) {
nurBei("LDAP", "Bind-DN");
const bindDn = pflichtText(aenderung.bindDn, "Der Bind-DN"); const bindDn = pflichtText(aenderung.bindDn, "Der Bind-DN");
if (bindDn !== vorher.bindDn) { if (bindDn !== vorher.bindDn) {
daten.bindDn = bindDn; daten.bindDn = bindDn;
@@ -259,6 +422,7 @@ export async function quelleBearbeiten(
} }
} }
if (aenderung.bindPasswort !== undefined) { if (aenderung.bindPasswort !== undefined) {
nurBei("LDAP", "Bind-Passwort");
// Ein leeres Feld heisst „unveraendert“. Ein Formular, das das Passwort nicht // Ein leeres Feld heisst „unveraendert“. Ein Formular, das das Passwort nicht
// anzeigen kann (und das darf es nicht), schickt sonst bei jedem Speichern // anzeigen kann (und das darf es nicht), schickt sonst bei jedem Speichern
// eine leere Zeichenkette und loeschte das Geheimnis. // eine leere Zeichenkette und loeschte das Geheimnis.
@@ -269,6 +433,7 @@ export async function quelleBearbeiten(
} }
} }
if (aenderung.basisDn !== undefined) { if (aenderung.basisDn !== undefined) {
nurBei("LDAP", "Basis-DN");
const basisDn = pflichtText(aenderung.basisDn, "Der Basis-DN"); const basisDn = pflichtText(aenderung.basisDn, "Der Basis-DN");
if (basisDn !== vorher.basisDn) { if (basisDn !== vorher.basisDn) {
daten.basisDn = basisDn; daten.basisDn = basisDn;
@@ -276,6 +441,7 @@ export async function quelleBearbeiten(
} }
} }
if (aenderung.filter !== undefined) { if (aenderung.filter !== undefined) {
nurBei("LDAP", "Filter");
const filter = aenderung.filter.trim() || STANDARD_FILTER; const filter = aenderung.filter.trim() || STANDARD_FILTER;
if (filter !== vorher.filter) { if (filter !== vorher.filter) {
daten.filter = filter; daten.filter = filter;
@@ -400,13 +566,15 @@ export async function verbindungTesten(
const quelle = await quelleZeileHolen(quelleId); const quelle = await quelleZeileHolen(quelleId);
const wieViele = Math.min(Math.max(Math.trunc(grenze), 1), PROBE_GRENZE_HOECHSTENS); const wieViele = Math.min(Math.max(Math.trunc(grenze), 1), PROBE_GRENZE_HOECHSTENS);
const { eintraege, gesamtGelesen, uebersprungen } = await eintraegeLesen( /*
zugangAus(quelle), * **Dieselbe Weiche wie der Abgleich** (#028). Eine eigene Verzweigung hier
{ * waere eine zweite Stelle, die sich an eine neue Quellenart erinnern muss —
basisDn: quelle.basisDn, * und die vergisst man.
filter: quelle.filter, */
zuordnung: zuordnungLesen(quelle.attributZuordnung), const eintraege: RoherEintrag[] = [];
}, const { gesamtGelesen, uebersprungen } = await eintraegeDerQuelleStroemen(
quelle,
(eintrag) => void eintraege.push(eintrag),
wieViele, wieViele,
); );
return { treffer: vorgesetzteAufloesen(eintraege), gesamtGelesen, uebersprungen }; return { treffer: vorgesetzteAufloesen(eintraege), gesamtGelesen, uebersprungen };
@@ -433,13 +601,3 @@ export async function quelleZeileHolen(id: string): Promise<Verzeichnisquelle> {
} }
return zeile; return zeile;
} }
/** Entschluesselt das Bind-Passwort. Das Ergebnis bleibt in der Aufrufkette. */
export function zugangAus(quelle: Verzeichnisquelle): LdapZugang {
return {
url: quelle.url,
bindDn: quelle.bindDn,
bindPasswort: entschluesseln(quelle.bindPasswortVerschluesselt),
caZertifikatPem: quelle.caZertifikatPem,
};
}
+65
View File
@@ -0,0 +1,65 @@
import type { Verzeichnisquelle } from "@/generated/prisma/client";
import { zuordnungLesen } from "@/lib/verzeichnis";
import { graphEintraegeStroemen } from "@/server/graph/leser";
import { graphZugangAus } from "@/server/graph/zugang";
import { eintraegeStroemen } from "@/server/ldap/leser";
import type { RoherEintrag, Uebersprungen } from "@/server/ldap/zuordnung";
import { VerzeichnisFehler } from "@/server/ldap/fehler";
import { ldapVorgabeAus, zugangAus } from "@/server/verzeichnis/zugang";
/**
* Einträge lesen, egal woher (#028).
*
* **Das ist die ganze Weiche.** Der Abgleich in `services/abgleich.ts` ruft nur
* noch diese Funktion; was er danach tut — anlegen, übernehmen, aktualisieren,
* deaktivieren, **nie löschen** — ist für LDAP und Graph dasselbe.
*
* Genau so steht es im Issue, und es ist die wichtigste Entscheidung dieses
* Meilensteins: zwei Abgleiche nebeneinander würden auseinanderdriften. Der eine
* bekäme einen Reviewbefund nachgetragen, der andere nicht — und die Zusage
* „es wird nichts gelöscht“ gälte dann für eine Art und für die andere nicht
* mehr ganz.
*/
export type QuellenBilanz = {
readonly gesamtGelesen: number;
readonly uebersprungen: readonly Uebersprungen[];
};
/**
* Läuft über die Einträge einer Quelle und gibt jeden einzeln weiter.
*
* `grenze` zählt die zugeordneten Einträge — „die ersten zehn Treffer zeigen“.
*/
export async function eintraegeDerQuelleStroemen(
quelle: Verzeichnisquelle,
verarbeiten: (eintrag: RoherEintrag) => Promise<void> | void,
grenze?: number,
): Promise<QuellenBilanz> {
if (quelle.art === "LDAP") {
return eintraegeStroemen(zugangAus(quelle), ldapVorgabeAus(quelle), verarbeiten, grenze);
}
if (quelle.art === "GRAPH") {
const bilanz = await graphEintraegeStroemen(
graphZugangAus(quelle),
zuordnungLesen(quelle.attributZuordnung, "GRAPH"),
verarbeiten,
grenze,
);
// `seiten` interessiert nur den Graph-Test; der Abgleich rechnet mit zwei Zahlen.
return { gesamtGelesen: bilanz.gesamtGelesen, uebersprungen: bilanz.uebersprungen };
}
/*
* `SCIM` steht im Enum, weil #029 dieselbe Weiche braucht — behandelt wird es
* dort. **Bis dahin eine Meldung und kein stilles Nichts:** ein Abgleich, der
* null Einträge liest und `ERFOLG` meldet, würde über die Abschaltregel in
* `abgleichAusfuehren` die ganze Belegschaft deaktivieren. Genau dieser Fall
* hat in #016 einen Reviewbefund gekostet.
*/
throw new VerzeichnisFehler(
`Quellen der Art ${quelle.art} können noch nicht abgeglichen werden. ` +
"Der SCIM-Endpunkt kommt mit einem eigenen Meilenstein (#029).",
);
}
+73
View File
@@ -0,0 +1,73 @@
import type { Verzeichnisquelle } from "@/generated/prisma/client";
import type { QuellenArt } from "@/generated/prisma/enums";
import { entschluesseln } from "@/lib/geheimnisse";
import { type AttributZuordnung, zuordnungLesen } from "@/lib/verzeichnis";
import { VerzeichnisFehler } from "@/server/ldap/fehler";
import type { LdapZugang } from "@/server/ldap/verbindung";
/**
* Aus einer Quellenzeile wird ein Zugang (#016, je Art seit #028).
*
* **Warum ein eigenes Modul und nicht weiter in `services/verzeichnis.ts`:** die
* Weiche in `quellenleser.ts` braucht diese Funktionen, und
* `services/verzeichnis.ts` braucht die Weiche fuer „Verbindung testen“. Beides
* in einer Datei waere ein Import-Zyklus — der laeuft in ESM oft, aber nicht
* zuverlaessig, und ein Modul, das beim Laden halb leer ist, ergibt Fehler, die
* niemand liest.
*/
/**
* Entschluesselt das Bind-Passwort. Das Ergebnis bleibt in der Aufrufkette.
*
* Wirft, wenn die LDAP-Felder fehlen. Seit #028 sind sie nur bei `art = LDAP`
* belegt — die Datenbank haelt das fest (CHECK
* `Verzeichnisquelle_felder_je_art`), aber ein Aufruf mit einer Graph-Zeile waere
* ein Programmierfehler, und der soll nicht als Verbindung zu `null` weiterlaufen.
*/
export function zugangAus(quelle: Verzeichnisquelle): LdapZugang {
if (!quelle.url || !quelle.bindDn || !quelle.bindPasswortVerschluesselt) {
throw new VerzeichnisFehler(
`„${quelle.name}“ ist keine LDAP-Quelle oder hat keine vollständigen Zugangsdaten.`,
);
}
return {
url: quelle.url,
bindDn: quelle.bindDn,
bindPasswort: entschluesseln(quelle.bindPasswortVerschluesselt),
caZertifikatPem: quelle.caZertifikatPem,
};
}
/**
* Die LDAP-Lesevorgabe einer Quelle.
*
* Dieselbe Pruefung und derselbe Grund wie bei `zugangAus`: `basisDn` und
* `filter` sind seit #028 nullable.
*/
export function ldapVorgabeAus(quelle: Verzeichnisquelle): {
readonly basisDn: string;
readonly filter: string;
readonly zuordnung: AttributZuordnung;
} {
if (!quelle.basisDn || !quelle.filter) {
throw new VerzeichnisFehler(
`„${quelle.name}“ fehlt der Basis-DN oder der Filter — beides braucht eine LDAP-Quelle.`,
);
}
return {
basisDn: quelle.basisDn,
filter: quelle.filter,
zuordnung: zuordnungLesen(quelle.attributZuordnung, "LDAP"),
};
}
/**
* Welche Zuordnungs-Vorgaben fuer diese Art gelten.
*
* `SCIM` hat noch keine (#029) und faellt auf die LDAP-Namen zurueck — die Art
* wird vom Dienst ohnehin abgewiesen, und ein Wurf an dieser Stelle machte jede
* Liste unbenutzbar, in der eine SCIM-Zeile steht.
*/
export function zuordnungsArt(art: QuellenArt): "LDAP" | "GRAPH" {
return art === "GRAPH" ? "GRAPH" : "LDAP";
}
+7
View File
@@ -144,6 +144,13 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
"Schrittergebnis_begruendung_noetig", "Schrittergebnis_begruendung_noetig",
"TagCodeVergabe_form", "TagCodeVergabe_form",
"Verzeichnisquelle_bindpasswort_verschluesselt", "Verzeichnisquelle_bindpasswort_verschluesselt",
/*
* Seit #028 hat eine Quelle eine **Art**, und welche Felder sie braucht,
* haengt daran. Ohne diese Bedingung waere eine Graph-Quelle mit
* `basisDn` moeglich — eine Zeile, die zwei Dinge gleichzeitig behauptet.
*/
"Verzeichnisquelle_clientsecret_verschluesselt",
"Verzeichnisquelle_felder_je_art",
"VorlagenVersion_veroeffentlicht_mit_zeitpunkt", "VorlagenVersion_veroeffentlicht_mit_zeitpunkt",
"Zustellung_bezug_passt_zum_anlass", "Zustellung_bezug_passt_zum_anlass",
"Zustellung_empfaenger_nicht_leer", "Zustellung_empfaenger_nicht_leer",
+618
View File
@@ -0,0 +1,618 @@
import { createServer, type Server } from "node:http";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { Betriebsmodus } from "@/generated/prisma/enums";
import { GRAPH_ZUORDNUNG, zuordnungLesen } from "@/lib/verzeichnis";
import { verschluesseln } from "@/lib/geheimnisse";
import {
angefragteFelder,
benutzerZuordnen,
ersteAdresse,
graphEintraegeStroemen,
} from "@/server/graph/leser";
import { GraphFehler, graphZugangAus, tokenHolen, tokenSpeicherLeeren } from "@/server/graph/zugang";
import { vorgesetzteAufloesen } from "@/server/ldap/zuordnung";
import { abgleichAusfuehren } from "@/server/services/abgleich";
import { eintraegeDerQuelleStroemen } from "@/server/verzeichnis/quellenleser";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Microsoft-Graph-Quelle (#028).
*
* **Geprueft wird gegen einen HTTP-Doppelgaenger mit aufgezeichneten Antworten,
* nicht gegen eine abgefangene Funktion.** Dass der Seitenabruf, der
* Token-Austausch und die Zuordnung zusammenpassen, zeigt erst eine Antwort, die
* durch `fetch`, JSON und `@odata.nextLink` gegangen ist.
*
* **Was dieser Test nicht leisten kann:** gegen einen echten Entra-Mandanten ist
* das nicht gelaufen. Die Gegenprobe steht als Pruefliste im Issue, wie bei #024
* fuer Proxmox. Was der Doppelgaenger liefert, ist der dokumentierten Form von
* Graph nachgebaut — stimmt die Form nicht, stimmt dieser Test und die
* Wirklichkeit nicht.
*/
const MANDANT = "11111111-2222-3333-4444-555555555555";
const ANWENDUNG = "66666666-7777-8888-9999-000000000000";
const GEHEIMNIS = "streng-geheimes-client-secret";
const TOKEN = "ein-zugriffstoken";
/** Ein Benutzer in der Form, in der Graph ihn liefert. */
function nutzer(nummer: number, zusatz: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: `guid-${nummer}`,
givenName: `Vorname${nummer}`,
surname: `Nachname${nummer}`,
displayName: `Vorname${nummer} Nachname${nummer}`,
mail: `person${nummer}@firma.example`,
userPrincipalName: `person${nummer}@firma.example`,
onPremisesSamAccountName: `person${nummer}`,
department: "Buchhaltung",
jobTitle: "Sachbearbeiter",
accountEnabled: true,
manager: null,
...zusatz,
};
}
/* ------------------------------------------------------------------ */
/* Der Doppelgaenger */
/* ------------------------------------------------------------------ */
type Aufzeichnung = {
/** Seiten in der Reihenfolge, in der sie geliefert werden. */
seiten: Record<string, unknown>[][];
/** Antwortcode fuer `/users`; 200, wenn nicht gesetzt. */
usersStatus?: number;
usersFehler?: { code: string; message: string };
tokenStatus?: number;
};
let server: Server | undefined;
let basis = "";
let aufzeichnung: Aufzeichnung = { seiten: [] };
let tokenAnfragen = 0;
let tokenRuempfe: string[] = [];
let usersAnfragen: string[] = [];
let usersKopfzeilen: (string | undefined)[] = [];
beforeAll(async () => {
server = createServer((anfrage, antwort) => {
const adresse = new URL(anfrage.url ?? "/", basis);
if (adresse.pathname.endsWith("/oauth2/v2.0/token")) {
tokenAnfragen += 1;
let rumpf = "";
anfrage.on("data", (stueck) => {
rumpf += stueck;
});
anfrage.on("end", () => {
tokenRuempfe.push(rumpf);
const status = aufzeichnung.tokenStatus ?? 200;
antwort.writeHead(status, { "Content-Type": "application/json" });
if (status !== 200) {
/*
* Entra ID antwortet sehr ausfuehrlich — mit Mandant, Anwendung und
* einer Ablaufverfolgungskennung. Genau das soll nicht nach draussen
* gelangen, und deshalb steht es hier im Doppelgaenger.
*/
antwort.end(
JSON.stringify({
error: "invalid_client",
error_description: `AADSTS7000215: Invalid client secret provided. Trace ID: abc Tenant: ${MANDANT} App: ${ANWENDUNG}`,
}),
);
return;
}
antwort.end(JSON.stringify({ access_token: TOKEN, expires_in: 3600 }));
});
return;
}
if (adresse.pathname === "/v1.0/users") {
usersAnfragen.push(anfrage.url ?? "");
usersKopfzeilen.push(anfrage.headers.authorization);
const status = aufzeichnung.usersStatus ?? 200;
if (status !== 200) {
antwort.writeHead(status, { "Content-Type": "application/json" });
antwort.end(JSON.stringify({ error: aufzeichnung.usersFehler ?? { code: "x", message: "y" } }));
return;
}
// Die Seitennummer steckt in `$skiptoken`; die erste Anfrage hat keinen.
const marke = Number.parseInt(adresse.searchParams.get("$skiptoken") ?? "0", 10);
/*
* **Der Doppelgaenger filtert, wie Graph es tut.** Sonst prueft der Test
* den Filter nicht, sondern nur, dass er in der Adresse steht — und der
* Unterschied ist genau der Fehler, den der Test „setzt aktiv = false“
* gefunden hat.
*/
const filter = adresse.searchParams.get("$filter") ?? "";
const nurAktive = filter.includes("accountEnabled eq true");
const seite = (aufzeichnung.seiten[marke] ?? []).filter(
(einer) => !nurAktive || einer["accountEnabled"] !== false,
);
const weiter =
marke + 1 < aufzeichnung.seiten.length ? `${basis}/v1.0/users?$skiptoken=${marke + 1}` : undefined;
antwort.writeHead(200, { "Content-Type": "application/json" });
antwort.end(JSON.stringify({ value: seite, ...(weiter ? { "@odata.nextLink": weiter } : {}) }));
return;
}
antwort.writeHead(404).end("unbekannt");
});
await new Promise<void>((fertig) => {
server?.listen(0, "127.0.0.1", () => fertig());
});
const adresse = server?.address();
const port = typeof adresse === "object" && adresse ? adresse.port : 0;
basis = `http://127.0.0.1:${port}`;
process.env["GRAPH_LOGIN_URL"] = basis;
process.env["GRAPH_API_URL"] = basis;
});
afterAll(async () => {
delete process.env["GRAPH_LOGIN_URL"];
delete process.env["GRAPH_API_URL"];
await new Promise<void>((fertig) => {
server?.close(() => fertig());
});
});
beforeEach(() => {
aufzeichnung = { seiten: [] };
tokenAnfragen = 0;
tokenRuempfe = [];
usersAnfragen = [];
usersKopfzeilen = [];
tokenSpeicherLeeren();
});
const ZUGANG = { tenantId: MANDANT, clientId: ANWENDUNG, clientSecret: GEHEIMNIS };
/* ------------------------------------------------------------------ */
/* Die reinen Teile */
/* ------------------------------------------------------------------ */
describe("Die Abfrage, die gestellt wird", () => {
it("fragt genau die zugeordneten Felder an, plus id und accountEnabled", () => {
const felder = angefragteFelder(GRAPH_ZUORDNUNG);
for (const erwartet of [
"givenName",
"surname",
"displayName",
"mail",
"userPrincipalName",
"onPremisesSamAccountName",
"department",
"jobTitle",
"id",
"accountEnabled",
]) {
expect(felder, `${erwartet} fehlt`).toContain(erwartet);
}
});
it("schreibt manager nicht in $select", () => {
/*
* `manager` ist eine **Beziehung**, keine Eigenschaft. In `$select` antwortet
* Graph mit einem Fehler, der nach einem Tippfehler aussieht und keiner ist.
*/
expect(angefragteFelder(GRAPH_ZUORDNUNG)).not.toContain("manager");
});
it("baut $expand mit unkodierten Klammern", () => {
const adresse = ersteAdresse(GRAPH_ZUORDNUNG);
// Prozentkodierte Klammern beantwortet Graph mit „Invalid expand clause“.
expect(adresse).toContain("$expand=manager($select=id)");
expect(adresse).not.toContain("%28");
expect(adresse).toContain("$top=999");
expect(adresse).toContain("$select=");
// Kein %24: die Adresse soll so aussehen wie in der Dokumentation.
expect(adresse).not.toContain("%24");
});
it("nimmt eine abweichende Zuordnung mit", () => {
const eigen = { ...GRAPH_ZUORDNUNG, abteilung: "companyName" };
expect(angefragteFelder(eigen)).toContain("companyName");
expect(angefragteFelder(eigen)).not.toContain("department");
});
});
describe("Die Zuordnung eines Graph-Benutzers", () => {
it("füllt die Felder und lässt den DN leer", () => {
const ergebnis = benutzerZuordnen(nutzer(1), GRAPH_ZUORDNUNG);
expect("eintrag" in ergebnis).toBe(true);
if (!("eintrag" in ergebnis)) return;
expect(ergebnis.eintrag).toMatchObject({
externeId: "guid-1",
vorname: "Vorname1",
nachname: "Nachname1",
upn: "person1@firma.example",
sam: "person1",
abteilung: "Buchhaltung",
titel: "Sachbearbeiter",
// Kein DN in Entra ID – und deshalb auch kein {{ou}}.
dn: null,
vorgesetzterDn: null,
});
});
it("nimmt den Vorgesetzten direkt aus dem expand", () => {
const ergebnis = benutzerZuordnen(nutzer(2, { manager: { id: "guid-1" } }), GRAPH_ZUORDNUNG);
if (!("eintrag" in ergebnis)) throw new Error("übersprungen");
expect(ergebnis.eintrag.vorgesetzterDirekt).toBe("guid-1");
expect(ergebnis.eintrag.vorgesetzterDn).toBeNull();
});
it("überspringt einen Benutzer ohne id", () => {
const ergebnis = benutzerZuordnen(nutzer(3, { id: null }), GRAPH_ZUORDNUNG);
expect("uebersprungen" in ergebnis).toBe(true);
if (!("uebersprungen" in ergebnis)) return;
// Als Kennzeichen der UPN: „wer“ wurde übersprungen, nicht „welcher DN“.
expect(ergebnis.uebersprungen.dn).toBe("person3@firma.example");
expect(ergebnis.uebersprungen.grund).toContain("id");
});
it("wirft bei einem deaktivierten Konto, statt es zu überspringen", () => {
/*
* **Der Unterschied kostet eine Person ihren Zugang.** „Übersprungen“ heißt
* im Abgleich „nicht zuordenbar“, und solche Einträge werden absichtlich
* nicht abgeschaltet (#016). Ein deaktiviertes Konto als übersprungen zu
* melden hätte es also **aktiv** gelassen — genau umgekehrt.
*
* Gefiltert wird deshalb serverseitig. Kommt hier doch eines an, ist der
* Filter nicht angekommen, und das ist ein Fehler in der Abfrage.
*/
expect(() => benutzerZuordnen(nutzer(4, { accountEnabled: false }), GRAPH_ZUORDNUNG)).toThrow(/Filter/);
});
it("nennt den Filter in der Abfrage", () => {
expect(ersteAdresse(GRAPH_ZUORDNUNG)).toContain("accountEnabled%20eq%20true");
});
it("baut einen Anzeigenamen, wenn keiner da ist", () => {
const ergebnis = benutzerZuordnen(
nutzer(5, { displayName: null, givenName: "Ada", surname: "Lovelace" }),
GRAPH_ZUORDNUNG,
);
if (!("eintrag" in ergebnis)) throw new Error("übersprungen");
expect(ergebnis.eintrag.anzeigename).toBe("Lovelace, Ada");
});
it("lässt den Anmeldenamen leer, wenn der Mandant kein AD synchronisiert", () => {
// Eine reine Cloud-Umgebung hat kein `onPremisesSamAccountName`. `{{sam}}`
// bleibt dann leer, statt dass etwas hineingedeutet wird.
const ergebnis = benutzerZuordnen(nutzer(6, { onPremisesSamAccountName: null }), GRAPH_ZUORDNUNG);
if (!("eintrag" in ergebnis)) throw new Error("übersprungen");
expect(ergebnis.eintrag.sam).toBeNull();
});
it("gibt dem Vorgesetzten den Vorrang vor der DN-Auflösung", () => {
const eintraege = [{ ...nutzer(1) }, { ...nutzer(2, { manager: { id: "guid-1" } }) }].map((n) => {
const e = benutzerZuordnen(n, GRAPH_ZUORDNUNG);
if (!("eintrag" in e)) throw new Error("übersprungen");
return e.eintrag;
});
const aufgeloest = vorgesetzteAufloesen(eintraege);
expect(aufgeloest[1]?.vorgesetzterExterneId).toBe("guid-1");
expect(aufgeloest[0]?.vorgesetzterExterneId).toBeNull();
});
});
describe("Die Zuordnungs-Vorgaben", () => {
it("nimmt für eine Graph-Quelle die Graph-Namen", () => {
expect(zuordnungLesen({}, "GRAPH")).toEqual(GRAPH_ZUORDNUNG);
// Und für LDAP weiter die AD-Namen – der Standardwert hält jeden Aufrufer
// von vor #028 bei seinem Verhalten.
expect(zuordnungLesen({}).nachname).toBe("sn");
expect(zuordnungLesen({}, "GRAPH").nachname).toBe("surname");
});
it("lässt eine gespeicherte Abweichung gewinnen", () => {
expect(zuordnungLesen({ titel: "eigenesFeld" }, "GRAPH").titel).toBe("eigenesFeld");
});
});
/* ------------------------------------------------------------------ */
/* Gegen den Doppelgaenger */
/* ------------------------------------------------------------------ */
describe("Der Token-Austausch", () => {
it("holt ein Token und benutzt es wieder", async () => {
const erst = await tokenHolen(ZUGANG);
expect(erst).toBe(TOKEN);
expect(tokenAnfragen).toBe(1);
// Zweiter Aufruf aus dem Zwischenspeicher: Microsoft begrenzt die Zahl der
// Token-Anfragen, und ein Abgleich mit fünf Seiten wäre sonst fünf Anmeldungen.
await tokenHolen(ZUGANG);
expect(tokenAnfragen).toBe(1);
});
it("holt kurz vor Ablauf ein neues", async () => {
const jetzt = Date.now();
await tokenHolen(ZUGANG, jetzt);
expect(tokenAnfragen).toBe(1);
/*
* 3600 s Gültigkeit, 60 s Sicherheitsabstand — die Grenze liegt also bei
* +3540 s. Die Zahlen hier standen beim ersten Lauf daneben, und der Test
* war rot: ein Beispiel dafür, warum die Grenze eine eigene Prüfung braucht
* und nicht „so ungefähr eine Stunde“ heißt.
*/
await tokenHolen(ZUGANG, jetzt + 3_530_000);
expect(tokenAnfragen).toBe(1);
await tokenHolen(ZUGANG, jetzt + 3_545_000);
expect(tokenAnfragen).toBe(2);
});
it("schickt den Client-Credentials-Flow mit dem Scope .default", async () => {
await tokenHolen(ZUGANG);
const rumpf = new URLSearchParams(tokenRuempfe[0] ?? "");
expect(rumpf.get("grant_type")).toBe("client_credentials");
expect(rumpf.get("client_id")).toBe(ANWENDUNG);
expect(rumpf.get("client_secret")).toBe(GEHEIMNIS);
/*
* `.default` und nicht einzelne Berechtigungen: beim
* Client-Credentials-Flow gelten die, die in der App-Registrierung
* eingewilligt wurden. Eine Liste hier waere eine zweite Wahrheit.
*/
expect(rumpf.get("scope")).toMatch(/\/\.default$/u);
});
it("schickt das Token als Bearer an die Abfrage", async () => {
aufzeichnung.seiten = [[nutzer(1)]];
await graphEintraegeStroemen(ZUGANG, GRAPH_ZUORDNUNG, () => undefined);
expect(usersKopfzeilen[0]).toBe(`Bearer ${TOKEN}`);
});
it("nennt das Geheimnis nicht in der Fehlermeldung", async () => {
aufzeichnung.tokenStatus = 401;
try {
await tokenHolen(ZUGANG);
throw new Error("hätte werfen müssen");
} catch (fehler) {
expect(fehler).toBeInstanceOf(GraphFehler);
const meldung = (fehler as Error).message;
expect(meldung).not.toContain(GEHEIMNIS);
// Und auch nicht die ausführliche Antwort von Entra ID – die geht ins
// Protokoll des Prozesses, nicht in eine Oberfläche.
expect(meldung).not.toContain("AADSTS7000215");
expect(meldung).not.toContain(MANDANT);
expect(meldung).toContain("401");
}
});
});
describe("Der Seitenabruf", () => {
it("folgt @odata.nextLink über drei Seiten", async () => {
aufzeichnung.seiten = [[nutzer(1), nutzer(2)], [nutzer(3), nutzer(4)], [nutzer(5)]];
const gesehen: string[] = [];
const bilanz = await graphEintraegeStroemen(
ZUGANG,
GRAPH_ZUORDNUNG,
(eintrag) => void gesehen.push(eintrag.externeId),
);
expect(bilanz.seiten).toBe(3);
expect(bilanz.gesamtGelesen).toBe(5);
expect(gesehen).toEqual(["guid-1", "guid-2", "guid-3", "guid-4", "guid-5"]);
// Drei Abrufe, und nur der erste trägt die Abfrageparameter.
expect(usersAnfragen).toHaveLength(3);
expect(usersAnfragen[0]).toContain("$select=");
expect(usersAnfragen[1]).toContain("$skiptoken=1");
});
it("hört bei der Grenze auf und holt keine weitere Seite", async () => {
aufzeichnung.seiten = [[nutzer(1), nutzer(2)], [nutzer(3)], [nutzer(4)]];
const bilanz = await graphEintraegeStroemen(ZUGANG, GRAPH_ZUORDNUNG, () => undefined, 2);
expect(bilanz.seiten).toBe(1);
expect(usersAnfragen).toHaveLength(1);
});
it("zählt übersprungene mit und liefert sie als Liste", async () => {
// Das deaktivierte Konto liefert der Server gar nicht; übersprungen wird
// nur das ohne Kennung.
aufzeichnung.seiten = [[nutzer(1), nutzer(2, { accountEnabled: false }), nutzer(3, { id: null })]];
const gesehen: string[] = [];
const bilanz = await graphEintraegeStroemen(
ZUGANG,
GRAPH_ZUORDNUNG,
(eintrag) => void gesehen.push(eintrag.externeId),
);
expect(bilanz.gesamtGelesen).toBe(2);
expect(gesehen).toEqual(["guid-1"]);
expect(bilanz.uebersprungen).toHaveLength(1);
});
it("nennt bei 403 die fehlende Einwilligung", async () => {
aufzeichnung.usersStatus = 403;
aufzeichnung.usersFehler = { code: "Authorization_RequestDenied", message: "Insufficient privileges" };
await expect(graphEintraegeStroemen(ZUGANG, GRAPH_ZUORDNUNG, () => undefined)).rejects.toThrow(
/User\.Read\.All/,
);
});
});
/* ------------------------------------------------------------------ */
/* Der Abgleich – derselbe Dienst wie bei LDAP */
/* ------------------------------------------------------------------ */
const ADMIN = { id: "", rolle: "ADMIN" } as const;
async function graphQuelleAnlegen() {
const eigene = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } });
const quelle = await testPrisma.verzeichnisquelle.create({
data: {
kundeId: eigene.id,
name: "Entra ID",
art: "GRAPH",
aktiv: true,
tenantId: MANDANT,
clientId: ANWENDUNG,
clientSecretVerschluesselt: verschluesseln(GEHEIMNIS),
attributZuordnung: {},
},
});
return { eigene, quelle };
}
describe.runIf(datenbankVerfuegbar)("Der Abgleich einer Graph-Quelle", () => {
beforeEach(async () => {
await datenbankLeeren();
});
afterEach(() => {
tokenSpeicherLeeren();
});
it("legt beim ersten Lauf an und aktualisiert beim zweiten ohne Dubletten", async () => {
const { quelle } = await graphQuelleAnlegen();
aufzeichnung.seiten = [[nutzer(1), nutzer(2, { manager: { id: "guid-1" } })]];
const erst = await abgleichAusfuehren(quelle.id, ADMIN);
expect(erst.status).toBe("ERFOLG");
expect(erst.angelegt).toBe(2);
expect(await testPrisma.mitarbeiter.count()).toBe(2);
// Der Vorgesetzte kommt über `$expand` und nicht über eine DN-Auflösung.
const zwei = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { externeId: "guid-2" } });
const eins = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { externeId: "guid-1" } });
expect(zwei.vorgesetzterExterneId).toBe("guid-1");
expect(zwei.vorgesetzterId).toBe(eins.id);
// Und kein DN, also auch kein erfundenes {{ou}}.
expect(eins.dn).toBeNull();
// Zweiter Lauf: eine Änderung, keine Dublette.
aufzeichnung.seiten = [[nutzer(1, { department: "Vertrieb" }), nutzer(2, { manager: { id: "guid-1" } })]];
const zweit = await abgleichAusfuehren(quelle.id, ADMIN);
expect(zweit.angelegt).toBe(0);
expect(zweit.aktualisiert).toBe(1);
expect(await testPrisma.mitarbeiter.count()).toBe(2);
expect(
(await testPrisma.mitarbeiter.findFirstOrThrow({ where: { externeId: "guid-1" } })).abteilung,
).toBe("Vertrieb");
});
it("setzt aktiv = false, wenn accountEnabled auf false steht", async () => {
const { quelle } = await graphQuelleAnlegen();
aufzeichnung.seiten = [[nutzer(1), nutzer(2)]];
await abgleichAusfuehren(quelle.id, ADMIN);
// Konto in Entra ID gesperrt: es wird übersprungen, also nicht mehr
// wiedergesehen — und was nicht wiedergesehen wird, ist inaktiv.
aufzeichnung.seiten = [[nutzer(1), nutzer(2, { accountEnabled: false })]];
const ergebnis = await abgleichAusfuehren(quelle.id, ADMIN);
expect(ergebnis.deaktiviert).toBe(1);
const zwei = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { externeId: "guid-2" } });
expect(zwei.aktiv).toBe(false);
// Gelöscht wird nichts – die Zeile bleibt mit allem daran.
expect(await testPrisma.mitarbeiter.count()).toBe(2);
});
it("gibt das Client-Secret in keiner Antwort und in keinem Protokoll heraus", async () => {
const { quelle } = await graphQuelleAnlegen();
aufzeichnung.seiten = [[nutzer(1)]];
await abgleichAusfuehren(quelle.id, ADMIN);
const protokoll = await testPrisma.protokoll.findMany({ where: { entitaetId: quelle.id } });
const zeile = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
const alles = JSON.stringify({ protokoll, letzterFehler: zeile.letzterAbgleichFehler });
expect(alles).not.toContain(GEHEIMNIS);
expect(alles).not.toContain("clientSecret");
// In der Datenbank steht es verschlüsselt.
expect(zeile.clientSecretVerschluesselt).toMatch(/^v[0-9]+\./u);
expect(zeile.clientSecretVerschluesselt).not.toContain(GEHEIMNIS);
});
it("schreibt den Fehler an die Quelle, ohne das Geheimnis zu nennen", async () => {
const { quelle } = await graphQuelleAnlegen();
aufzeichnung.tokenStatus = 401;
const ergebnis = await abgleichAusfuehren(quelle.id, ADMIN);
expect(ergebnis.status).not.toBe("ERFOLG");
const zeile = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
expect(zeile.letzterAbgleichFehler).toBeTruthy();
expect(zeile.letzterAbgleichFehler).not.toContain(GEHEIMNIS);
expect(zeile.letzterAbgleichFehler).not.toContain("AADSTS7000215");
});
it("weist eine SCIM-Quelle ab, statt stillschweigend nichts zu tun", async () => {
/*
* `SCIM` steht im Enum, weil #029 dieselbe Weiche braucht. 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.
*/
const { eigene } = await graphQuelleAnlegen();
const scim = await testPrisma.verzeichnisquelle.create({
data: { kundeId: eigene.id, name: "SCIM später", art: "SCIM", aktiv: true, attributZuordnung: {} },
});
const ergebnis = await abgleichAusfuehren(scim.id, ADMIN);
expect(ergebnis.status).not.toBe("ERFOLG");
expect(ergebnis.fehler).toContain("#029");
});
it("liest eine LDAP-Quelle weiter über den LDAP-Weg", async () => {
// Die Weiche darf den bestehenden Weg nicht umlenken: eine LDAP-Quelle ohne
// erreichbaren Server scheitert an der **Verbindung** und nicht daran, dass
// Graph gefragt würde.
const { eigene } = await graphQuelleAnlegen();
const ldap = await testPrisma.verzeichnisquelle.create({
data: {
kundeId: eigene.id,
name: "AD",
art: "LDAP",
aktiv: true,
url: "ldap://127.0.0.1:1",
bindDn: "cn=admin,dc=firma,dc=test",
bindPasswortVerschluesselt: verschluesseln("egal"),
basisDn: "dc=firma,dc=test",
filter: "(objectClass=user)",
attributZuordnung: {},
},
});
await expect(
eintraegeDerQuelleStroemen(
await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: ldap.id } }),
() => undefined,
),
).rejects.toThrow();
// Keine Graph-Anfrage dabei.
expect(usersAnfragen).toHaveLength(0);
expect(tokenAnfragen).toBe(0);
});
});
describe("Der Zugang aus einer Quellenzeile", () => {
it("entschlüsselt das Geheimnis", () => {
const zugang = graphZugangAus({
art: "GRAPH",
tenantId: MANDANT,
clientId: ANWENDUNG,
clientSecretVerschluesselt: verschluesseln(GEHEIMNIS),
});
expect(zugang.clientSecret).toBe(GEHEIMNIS);
});
it("wirft bei einer Zeile ohne Graph-Felder", () => {
expect(() =>
graphZugangAus({ art: "LDAP", tenantId: null, clientId: null, clientSecretVerschluesselt: null }),
).toThrow(GraphFehler);
});
});
+5 -2
View File
@@ -526,11 +526,14 @@ describe.runIf(datenbankVerfuegbar)("Seed gegen die Datenbank", () => {
const quelle = await testPrisma.verzeichnisquelle.findFirstOrThrow(); const quelle = await testPrisma.verzeichnisquelle.findFirstOrThrow();
expect(quelle.aktiv).toBe(false); expect(quelle.aktiv).toBe(false);
expect(quelle.url.startsWith("ldaps://")).toBe(true); // Seit #028 sind die LDAP-Felder nullable (nur bei `art = LDAP` belegt) — die
// Beispielquelle ist eine LDAP-Quelle, also stehen sie.
expect(quelle.art).toBe("LDAP");
expect(quelle.url?.startsWith("ldaps://")).toBe(true);
// Das Versionspraefix prueft auch die Datenbank; hier zaehlt, dass sich das // Das Versionspraefix prueft auch die Datenbank; hier zaehlt, dass sich das
// Feld ueberhaupt entschluesseln laesst und nichts Lesbares enthaelt. // Feld ueberhaupt entschluesseln laesst und nichts Lesbares enthaelt.
expect(quelle.bindPasswortVerschluesselt).toMatch(/^v[0-9]+\./u); expect(quelle.bindPasswortVerschluesselt).toMatch(/^v[0-9]+\./u);
expect(entschluesseln(quelle.bindPasswortVerschluesselt).length).toBeGreaterThan(16); expect(entschluesseln(quelle.bindPasswortVerschluesselt ?? "").length).toBeGreaterThan(16);
}); });
it("erzeugt im Modus Dienstleistung die beiden Demo-Kunden mit MUS-PC-001", async () => { it("erzeugt im Modus Dienstleistung die beiden Demo-Kunden mit MUS-PC-001", async () => {
+3 -3
View File
@@ -536,12 +536,12 @@ describe.runIf(datenbankVerfuegbar)("Verzeichnisquellen", () => {
// Umbenennen kaputt. // Umbenennen kaputt.
await quelleBearbeiten(quelle.id, { bindPasswort: "", name: "Neu" }, admin); await quelleBearbeiten(quelle.id, { bindPasswort: "", name: "Neu" }, admin);
const nachher = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } }); 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); expect(nachher.bindPasswortVerschluesselt).toBe(vorher.bindPasswortVerschluesselt);
await quelleBearbeiten(quelle.id, { bindPasswort: "ein-neues-geheimnis" }, admin); await quelleBearbeiten(quelle.id, { bindPasswort: "ein-neues-geheimnis" }, admin);
const dritte = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } }); 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 () => { 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 } }); const zeile = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
expect(zeile.bindPasswortVerschluesselt).not.toContain(LDAP_BIND_PASSWORT); expect(zeile.bindPasswortVerschluesselt).not.toContain(LDAP_BIND_PASSWORT);
expect(zeile.bindPasswortVerschluesselt).toMatch(/^v1\./u); 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 () => { it("loescht eine Quelle ohne Mitarbeiter und verweigert es sonst", async () => {