feat(verzeichnis): Entra ID als Quelle ueber Microsoft Graph (#48)
Ein Haus ohne lokales Active Directory hat kein LDAP, auf das sich zeigen liesse. Eine Verzeichnisquelle hat jetzt eine **Art** — LDAP oder GRAPH —, und beide laufen durch **denselben** Abgleich: anlegen, aktualisieren, deaktivieren, nie loeschen. Nur das Holen der Eintraege unterscheidet sich; zwei Abgleiche nebeneinander wuerden auseinanderdriften, und die Zusage „es wird nichts geloescht“ gaelte dann fuer eine Art und fuer die andere nicht mehr ganz. Gelesen wird mit dem Client-Credentials-Flow, seitenweise ueber @odata.nextLink, der Vorgesetzte ueber $expand=manager($select=id). Das Client-Secret wird verschluesselt gespeichert wie das Bind-Passwort und erreicht keine Antwort und keinen Protokolleintrag. Die Art ist **unveraenderlich**, und das steht nicht im Issue: an ihr haengt, was `externeId` bedeutet. Im AD eine objectGUID, in Entra ID eine andere GUID — nach einem Wechsel wuerde kein einziger Mitarbeiter wiedererkannt, der naechste Abgleich legte die ganze Belegschaft neu an und setzte die alten Zeilen auf aktiv = false, mit allen Laeufen und Geraeten daran. Der teuerste Fund beim Bauen: „in Entra ID deaktiviert“ als *uebersprungen* zu melden haette die Leute **aktiv** gelassen. Uebersprungen heisst im Abgleich „nicht zuordenbar“, und solche Eintraege werden seit #016 absichtlich nicht abgeschaltet. Gefiltert wird deshalb serverseitig mit `$filter=accountEnabled eq true`, als Gegenstueck zum LDAP-Standardfilter. Gefunden hat es der Test — aber nur, weil der Doppelgaenger den Filter wirklich auswertet statt ihn bloss zu empfangen. {{ou}} bleibt bei einer Graph-Quelle leer: Entra ID hat keine OU, und etwas hineinzudeuten waere ein Wert, den kein PowerShell-Befehl brauchen kann — er saehe nur richtig aus. Steht an der Abteilung eine Ziel-OU gepflegt, greift die. Gegen einen echten Entra-Mandanten ist das **nicht** gelaufen. Geprueft ist es gegen einen HTTP-Doppelgaenger mit aufgezeichneten Antworten; die Pruefliste fuer den ersten echten Lauf steht am Ende von docs/betrieb/entra-id.md. Closes #48 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0158d0292f
commit
76a61ec771
@@ -67,6 +67,19 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Entra ID als Verzeichnisquelle, über Microsoft Graph.** Ein Haus ohne lokales Active Directory
|
||||
hat kein LDAP, auf das sich zeigen ließe. Eine Verzeichnisquelle hat jetzt eine **Art** — `LDAP`
|
||||
oder `GRAPH` —, und beide laufen durch **denselben** Abgleich: anlegen, aktualisieren,
|
||||
deaktivieren, **nie löschen**. Nur das Holen der Einträge unterscheidet sich, sonst drifteten die
|
||||
Regeln auseinander.
|
||||
Gelesen wird mit dem Client-Credentials-Flow über `GET /v1.0/users`, seitenweise über
|
||||
`@odata.nextLink`, der Vorgesetzte über `$expand=manager($select=id)`. Das Client-Secret wird
|
||||
verschlüsselt gespeichert wie das LDAP-Bind-Passwort und erscheint in keiner Antwort und in keinem
|
||||
Protokolleintrag.
|
||||
**Zwei Platzhalter bleiben bei Graph bewusst leer:** `{{ou}}` (Entra ID hat keine OU — steht an der
|
||||
Abteilung eine Ziel-OU, greift die) und `{{sam}}` ohne AD-Synchronisierung. Was in Entra ID
|
||||
einzurichten ist, steht in `docs/betrieb/entra-id.md` — mit einer Prüfliste, denn gegen einen
|
||||
echten Mandanten ist dieser Weg **nicht** gelaufen (#028)
|
||||
- **Mitarbeiter-Selbstauskunft.** „Was ist eigentlich mit meinem Notebook passiert?“ musste bisher
|
||||
ein Techniker nachsehen. Ein Mitarbeiter kann die Arbeiten an **seinen** Geräten jetzt selbst
|
||||
ansehen, unter **Meine Geräte** — mit der neuen Rolle `MITARBEITER`, zugeordnet über den **UPN**
|
||||
|
||||
@@ -47,6 +47,7 @@ Enums: `Rolle {ADMIN REDAKTEUR TECHNIKER LESER MITARBEITER}` · `Betriebsmodus {
|
||||
`VariablenTyp {TEXT ZAHL AUSWAHL DATUM}` ·
|
||||
`GeraeteTyp {PC NOTEBOOK SERVER FIREWALL SWITCH DRUCKER RAUM SONSTIGES}` ·
|
||||
`VersionStatus {ENTWURF VEROEFFENTLICHT}` · `Herkunft {ABGLEICH MANUELL}` ·
|
||||
`QuellenArt {LDAP GRAPH SCIM}` ·
|
||||
`Zustellweg {MAIL WEBHOOK}` · `Zustellzustand {OFFEN GESENDET FEHLGESCHLAGEN AUFGEGEBEN}` ·
|
||||
`Meldeanlass {LAUF_ZUGEWIESEN LAUF_UEBERFAELLIG LAUF_ABGESCHLOSSEN ABGLEICH_FEHLGESCHLAGEN}`.
|
||||
|
||||
|
||||
@@ -16,6 +16,32 @@ Zwei Dinge vorweg:
|
||||
Wer im Verzeichnis verschwindet oder dort deaktiviert wird, wird hier **inaktiv**. Gelöscht wird
|
||||
nie, sonst verlöre die Historie ihre Namen.
|
||||
|
||||
## Zwei Arten von Quelle
|
||||
|
||||
Laufzettel liest die Personen aus einem Verzeichnis. Dafür gibt es zwei Wege, und du wählst ihn beim
|
||||
Anlegen der Quelle:
|
||||
|
||||
| Art | Für wen |
|
||||
|---|---|
|
||||
| **LDAP** | ein lokales Active Directory (oder ein anderes LDAP-Verzeichnis) |
|
||||
| **Microsoft Graph** | Entra ID, also Microsoft 365 — für Häuser ohne lokales AD |
|
||||
|
||||
Beide verhalten sich danach gleich: anlegen, aktualisieren, deaktivieren, **nie löschen**. Was sie
|
||||
unterscheidet, sind die Zugangsdaten und zwei Platzhalter:
|
||||
|
||||
- **`{{ou}}` bleibt bei Graph leer.** Entra ID hat keine OU. Steht an der Abteilung eine Ziel-OU
|
||||
gepflegt, nimmt Laufzettel die; sonst bleibt der Platzhalter stehen.
|
||||
- **`{{sam}}` ist bei Graph leer**, wenn der Mandant kein lokales AD synchronisiert — dort gibt es
|
||||
keinen Anmeldenamen in diesem Sinn.
|
||||
|
||||
**Die Art lässt sich nachträglich nicht ändern.** An ihr hängt, was die dauerhafte Kennung einer
|
||||
Person bedeutet; nach einem Wechsel würde niemand mehr wiedererkannt, und der nächste Abgleich legte
|
||||
alle neu an. Wer wechseln muss, legt eine zweite Quelle an und lässt die erste stehen.
|
||||
|
||||
Was in Entra ID einzurichten ist — App-Registrierung, Berechtigung, Einwilligung — steht in
|
||||
[`docs/betrieb/entra-id.md`](../betrieb/entra-id.md).
|
||||
|
||||
|
||||
## Eine Quelle anlegen
|
||||
|
||||

|
||||
|
||||
@@ -56,6 +56,8 @@ Sitzungen ungültig *und* die verschlüsselt gespeicherten LDAP-Bind-Passwörter
|
||||
- **[Anmeldung über authentik](authentik.md)** — OIDC-Provider, Gruppen auf Rollen, Notfallzugang
|
||||
- **[Sichern und Zurückspielen](sicherung.md)** — auf beiden Wegen, was in die Sicherung gehört und
|
||||
was nicht, und wie du prüfst, dass das Zurückspielen wirklich funktioniert
|
||||
- **[Entra ID als Verzeichnisquelle](entra-id.md)** — App-Registrierung, `User.Read.All`,
|
||||
Administratoreinwilligung, und was anders ist als bei einem lokalen AD
|
||||
- **[Benachrichtigungen](benachrichtigungen.md)** — SMTP einrichten, den Webhook prüfen, die
|
||||
Signatur nachrechnen, und wo man nachsieht, wenn keine Mail mehr ankommt
|
||||
|
||||
|
||||
@@ -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
@@ -34,12 +34,13 @@ als Unterpunkte mit Aufgabe und Dauer.
|
||||
| #033 | M20 – Live-Aktualisierung per SSE statt Polling | 1 h 40 min |
|
||||
| #034 | M21 – Benachrichtigungen per Mail und Webhook | 3 h 30 min |
|
||||
| #035 | M22 – Mitarbeiter-Selbstauskunft | 2 h 50 min |
|
||||
| | **Summe** | **56 h 25 min** |
|
||||
| #028 | M15 – Quellenarten und Microsoft-Graph-Quelle | 4 h 10 min |
|
||||
| | **Summe** | **60 h 35 min** |
|
||||
|
||||
| Meilenstein | Tatsächlich | Status |
|
||||
|---|---|---|
|
||||
| v0.1.0 | 35 h 20 min | veröffentlicht |
|
||||
| v0.2.0 | 21 h 05 min | in Arbeit |
|
||||
| v0.2.0 | 25 h 15 min | in Arbeit |
|
||||
|
||||
---
|
||||
|
||||
@@ -1370,3 +1371,67 @@ und nicht nach jeder einzelnen — das ist der Grund, warum er alles auf einmal
|
||||
- Vor beiden wäre ein unabhängiger Review über die sechs Meilensteine dieser Phase angebracht —
|
||||
M17 bis M22 liegen ohne fremden Blick auf `main`.
|
||||
- Weiter offen und nicht von hier aus machbar: **#024** Proxmox-Einzeiler auf einem echten PVE-Host.
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-06 · Sitzung 21 · 09:00–13:10
|
||||
|
||||
**Issue:** #028 (M15)
|
||||
**Version:** 0.2.0 (unveröffentlicht)
|
||||
|
||||
### Erledigt
|
||||
|
||||
- **Eine Verzeichnisquelle hat eine Art**: `LDAP`, `GRAPH` oder `SCIM`. Bestehende Zeilen sind
|
||||
`LDAP` — das war vor #028 die einzige.
|
||||
- **Entra ID über Microsoft Graph**: Client-Credentials-Flow mit Token-Zwischenspeicher, Seitenabruf
|
||||
über `@odata.nextLink`, Vorgesetzter über `$expand`.
|
||||
- **Derselbe Abgleich für beide Arten.** Die Weiche sitzt in
|
||||
`src/server/verzeichnis/quellenleser.ts` und ist die einzige Stelle, die die Art kennt.
|
||||
- 1373 Vitest-Tests (vorher 1342), davon 31 neue gegen einen HTTP-Doppelgänger mit aufgezeichneten
|
||||
Graph-Antworten.
|
||||
- Im gebauten Stand eine Graph-Quelle wirklich angelegt: Art gespeichert, Geheimnis verschlüsselt,
|
||||
nicht im Quelltext der Seite.
|
||||
|
||||
### Entscheidungen
|
||||
|
||||
- **Ein Enum auf der Quelle, keine Tabelle je Art.** Drei Arten rechtfertigen keine Vererbung; was
|
||||
eine Art braucht, hält ein CHECK fest — in **beide** Richtungen, sonst wäre eine Graph-Quelle mit
|
||||
`basisDn` möglich.
|
||||
- **Die Art ist unveränderlich.** An ihr hängt, was `externeId` bedeutet; nach einem Wechsel würde
|
||||
kein Mitarbeiter wiedererkannt. Steht so nicht im Issue und ist die wichtigste Zusage dieses
|
||||
Meilensteins.
|
||||
- **`{{ou}}` bleibt bei Graph leer.** Etwas hineinzudeuten wäre ein Wert, den kein PowerShell-Befehl
|
||||
brauchen kann — und er sähe richtig aus.
|
||||
- **`SCIM` steht im Enum, wird aber abgewiesen.** Ein Wert, der stillschweigend nichts tut, hätte
|
||||
hier die ganze Belegschaft deaktiviert.
|
||||
|
||||
### Probleme
|
||||
|
||||
- **Der teuerste Fund: „deaktiviert“ als „übersprungen“ zu melden hätte Leute aktiv gelassen.**
|
||||
„Übersprungen“ heißt im Abgleich „nicht zuordenbar“, und solche Einträge werden seit #016
|
||||
absichtlich nicht abgeschaltet. Ein gesperrtes Entra-Konto wäre damit genau umgekehrt behandelt
|
||||
worden als ein gesperrtes AD-Konto. Jetzt filtert die Abfrage serverseitig, als Gegenstück zum
|
||||
LDAP-Standardfilter.
|
||||
**Gefunden hat es der Test — aber nur, weil der Doppelgänger den Filter wirklich auswertet.** Hätte
|
||||
er den Filter bloß empfangen und ignoriert, wäre der Test grün gewesen und der Fehler im Betrieb
|
||||
aufgefallen. Ein Doppelgänger, der nur antwortet, prüft die Anfrage nicht.
|
||||
- **`URLSearchParams` kodiert `$` zu `%24`** und die Klammern in `manager($select=id)`. Graph nimmt
|
||||
das teils an und antwortet teils mit „Invalid expand clause“. Jetzt von Hand zusammengesetzt.
|
||||
- **Ein Import-Zyklus drohte**: die Weiche braucht `zugangAus`, und der Dienst braucht die Weiche.
|
||||
Beides aufgelöst durch ein eigenes Modul. In ESM läuft so ein Zyklus oft — aber nicht zuverlässig.
|
||||
- **Zwei eigene Testfehler**: die Token-Grenze liegt bei +3540 s und nicht „ungefähr eine Stunde“,
|
||||
und die Klickprobe zählte die Felder der Bearbeiten-Formulare mit.
|
||||
|
||||
### Was offen bleibt, und das ist benannt
|
||||
|
||||
Gegen einen **echten Entra-Mandanten** ist nichts davon gelaufen. Die Form der Antworten im
|
||||
Doppelgänger stammt aus der Dokumentation, nicht aus einem Mitschnitt — stimmt sie nicht, stimmen
|
||||
Test und Wirklichkeit nicht. Die Prüfliste steht am Ende von `docs/betrieb/entra-id.md`, acht
|
||||
Punkte, darunter ein Mandant mit mehr als 999 Benutzern. Dieselbe Art offener Punkt wie #024.
|
||||
|
||||
### Nächster Schritt
|
||||
|
||||
- **M16 (#029)** SCIM-Endpunkt ist der letzte Meilenstein von Phase 2. Die Weiche dafür steht.
|
||||
- Vor #029 wäre ein unabhängiger Review angebracht: **sieben Meilensteine** dieser Phase liegen ohne
|
||||
fremden Blick auf `main`, und diese Sitzung hat wieder einen Fehler gezeigt, den die Suite nur
|
||||
deshalb gefunden hat, weil der Doppelgänger streng genug war.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# #028 M15 – Quellenarten und Microsoft-Graph-Quelle
|
||||
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Im Tracker:** [#48](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/48)
|
||||
**Meilenstein:** v0.2.0
|
||||
**Labels:** phase-2, backend, frontend
|
||||
**Geschätzt:** 10 h **Tatsächlich:** –
|
||||
**Geschätzt:** 10 h **Tatsächlich:** 4 h 10 min
|
||||
|
||||
## Ziel
|
||||
|
||||
@@ -17,39 +17,94 @@ Dieser Meilenstein legt dazu die Weiche, die auch [#029](029-scim-endpunkt.md) b
|
||||
|
||||
## Zu entscheiden
|
||||
|
||||
- [ ] **Ein Enum `QuellenArt {LDAP GRAPH SCIM}` auf `Verzeichnisquelle`** — Empfehlung. Die Felder
|
||||
- [x] **Ein Enum `QuellenArt {LDAP GRAPH SCIM}` auf `Verzeichnisquelle`** — Empfehlung. Die Felder
|
||||
`url`, `bindDn` und `basisDn` sind dann je Art unterschiedlich belegt; was eine Art braucht,
|
||||
prüft der Service, nicht die Datenbank. Die Alternative wäre eine Tabelle je Art mit
|
||||
gemeinsamer Oberklasse — mehr Struktur, als drei Arten rechtfertigen.
|
||||
- [ ] **Wie `{{ou}}` in Entra ID aussieht.** Es gibt keinen DN und keine OU. Ehrlich ist, den
|
||||
- [x] **Wie `{{ou}}` in Entra ID aussieht.** Es gibt keinen DN und keine OU. Ehrlich ist, den
|
||||
Platzhalter bei einer Graph-Quelle **leer** zu lassen, statt etwas hineinzudeuten — und das
|
||||
in der Anleitung zu sagen. Wer OU-abhängige Befehle fährt, braucht ein lokales AD.
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [ ] `QuellenArt` als Enum, Migration mit `LDAP` für alle bestehenden Zeilen
|
||||
- [ ] Graph-Zugang: `tenantId`, `clientId`, `clientSecret` (verschlüsselt wie das Bind-Passwort, nie
|
||||
- [x] `QuellenArt` als Enum, Migration mit `LDAP` für alle bestehenden Zeilen
|
||||
- [x] Graph-Zugang: `tenantId`, `clientId`, `clientSecret` (verschlüsselt wie das Bind-Passwort, nie
|
||||
in einer Antwort), Token über Client-Credentials-Flow, Zwischenspeicher bis kurz vor Ablauf
|
||||
- [ ] Lesen über `GET /v1.0/users` mit `$select`, Seitenabruf über `@odata.nextLink`; der
|
||||
- [x] Lesen über `GET /v1.0/users` mit `$select`, Seitenabruf über `@odata.nextLink`; der
|
||||
Vorgesetzte über `$expand=manager($select=id)`
|
||||
- [ ] Attributzuordnung mit Graph-Vorgaben (`givenName`, `surname`, `displayName`, `mail`,
|
||||
- [x] Attributzuordnung mit Graph-Vorgaben (`givenName`, `surname`, `displayName`, `mail`,
|
||||
`userPrincipalName`, `onPremisesSamAccountName`, `department`, `jobTitle`, `accountEnabled`)
|
||||
- [ ] `externeId` ist die Entra-`id` (GUID). Sie ist stabil und übersteht eine Umbenennung
|
||||
- [ ] „Verbindung testen“ wie bei LDAP: die ersten zehn zugeordneten Treffer zeigen
|
||||
- [ ] Der Abgleich läuft durch **denselben** Dienst wie LDAP — nur das Holen der Einträge
|
||||
- [x] `externeId` ist die Entra-`id` (GUID). Sie ist stabil und übersteht eine Umbenennung
|
||||
- [x] „Verbindung testen“ wie bei LDAP: die ersten zehn zugeordneten Treffer zeigen
|
||||
- [x] Der Abgleich läuft durch **denselben** Dienst wie LDAP — nur das Holen der Einträge
|
||||
unterscheidet sich. Sonst driften die Regeln auseinander
|
||||
- [ ] Admin → Verzeichnis zeigt die Art je Quelle und bietet beim Anlegen die Wahl
|
||||
- [ ] Anleitung Kapitel 06 und `docs/betrieb/`: was in Entra ID einzurichten ist
|
||||
- [x] Admin → Verzeichnis zeigt die Art je Quelle und bietet beim Anlegen die Wahl
|
||||
- [x] Anleitung Kapitel 06 und `docs/betrieb/`: was in Entra ID einzurichten ist
|
||||
(App-Registrierung, `User.Read.All`, `Directory.Read.All`, Administratoreinwilligung)
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Test gegen einen **abspielenden HTTP-Doppelgänger** mit aufgezeichneten Graph-Antworten: erster
|
||||
- [x] Test gegen einen **abspielenden HTTP-Doppelgänger** mit aufgezeichneten Graph-Antworten: erster
|
||||
Abgleich legt an, zweiter aktualisiert ohne Dubletten, ein `accountEnabled: false` setzt
|
||||
`aktiv = false`
|
||||
- [ ] Test: der Seitenabruf folgt `@odata.nextLink` über mindestens drei Seiten
|
||||
- [ ] Test: `clientSecret` erscheint in keiner Antwort und in keinem Protokolleintrag
|
||||
- [ ] Test: eine Graph-Quelle lässt `{{ou}}` leer, statt einen Wert zu erfinden
|
||||
- [ ] Der LDAP-Weg verhält sich unverändert — alle Tests aus #016 bleiben grün
|
||||
- [ ] **Offen und benannt:** gegen einen echten Entra-Mandanten ist das nicht gelaufen; die
|
||||
- [x] Test: der Seitenabruf folgt `@odata.nextLink` über mindestens drei Seiten
|
||||
- [x] Test: `clientSecret` erscheint in keiner Antwort und in keinem Protokolleintrag
|
||||
- [x] Test: eine Graph-Quelle lässt `{{ou}}` leer, statt einen Wert zu erfinden
|
||||
- [x] Der LDAP-Weg verhält sich unverändert — alle Tests aus #016 bleiben grün
|
||||
- [x] **Offen und benannt:** gegen einen echten Entra-Mandanten ist das nicht gelaufen; die
|
||||
Gegenprobe gehört in eine Prüfliste wie bei [#024](024-proxmox-echtlauf.md)
|
||||
|
||||
## Beim Umsetzen dazugekommen
|
||||
|
||||
- **„Deaktiviert in Entra ID“ als *übersprungen* zu melden war falsch — und hätte Leute aktiv
|
||||
gelassen.** „Übersprungen“ bedeutet im Abgleich „nicht zuordenbar“, und solche Einträge werden
|
||||
seit #016 absichtlich **nicht** abgeschaltet (ein Konto ohne GUID soll nicht die Person
|
||||
deaktivieren). Ein gesperrtes Entra-Konto wäre damit genau umgekehrt behandelt worden als im AD.
|
||||
Gefiltert wird jetzt serverseitig mit `$filter=accountEnabled eq true`, als Gegenstück zum
|
||||
LDAP-Standardfilter. Gefunden hat es der Test „setzt aktiv = false“ — und nur, weil der
|
||||
Doppelgänger den Filter wirklich auswertet statt ihn bloß zu empfangen.
|
||||
- **`URLSearchParams` kodiert `$` zu `%24`.** Die Abfrage hätte `%24select` und `%28` in
|
||||
`manager($select=id)` geschickt. Graph nimmt das teils an und antwortet teils mit „Invalid expand
|
||||
clause“ — und eine Adresse, die in einem Mitschnitt anders aussieht als in der Dokumentation,
|
||||
kostet bei der Fehlersuche eine Stunde. Die Parameter werden jetzt von Hand zusammengesetzt.
|
||||
- **Die Art einer Quelle ist unveränderlich**, und das steht nicht im Issue. An ihr hängt, was
|
||||
`externeId` bedeutet: im AD eine `objectGUID`, in Entra ID eine andere GUID. Nach einem Wechsel
|
||||
würde **kein einziger Mitarbeiter wiedererkannt** — der nächste Abgleich legte die ganze
|
||||
Belegschaft neu an und setzte die alten Zeilen auf `aktiv = false`, mit allen Läufen und Geräten
|
||||
daran. Der Dienst weist einen Wechsel ab, die Oberfläche bietet ihn nicht an, und die Anleitung
|
||||
sagt, was stattdessen zu tun ist.
|
||||
- **`zuordnungLesen` brauchte die Art als Parameter.** Ohne sie bekäme eine Graph-Quelle mit leerer
|
||||
Zuordnung die AD-Namen und fragte bei Graph nach `sn` und `objectGUID`, die es dort nicht gibt.
|
||||
Der Standardwert `LDAP` hält jeden Aufrufer von vor #028 bei seinem Verhalten.
|
||||
- **Die Zugangs-Helfer mussten in ein eigenes Modul.** `quellenleser.ts` (die Weiche) braucht
|
||||
`zugangAus`, und `services/verzeichnis.ts` braucht die Weiche für „Verbindung testen“ — zusammen
|
||||
ein Import-Zyklus. Der läuft in ESM oft, aber nicht zuverlässig, und ein Modul, das beim Laden
|
||||
halb leer ist, ergibt Fehler, die niemand liest. Jetzt liegt beides in
|
||||
`src/server/verzeichnis/zugang.ts`.
|
||||
- **`SCIM` steht im Enum, wird aber abgewiesen.** Die Weiche braucht #029, und ein Wert, der
|
||||
stillschweigend nichts tut, wäre hier besonders teuer: ein Abgleich, der null Einträge liest und
|
||||
`ERFOLG` meldet, hätte über die Abschaltregel die ganze Belegschaft deaktiviert — genau der Fall,
|
||||
der in #016 einen Reviewbefund gekostet hat. Mit eigenem Test.
|
||||
- **Die Felder je Art hält die Datenbank fest.** Ein CHECK verlangt bei `LDAP` die LDAP-Felder
|
||||
**und** verbietet die Graph-Felder, bei `GRAPH` umgekehrt. Ohne den zweiten Teil wäre eine
|
||||
Graph-Quelle mit `basisDn` möglich — eine Zeile, die zwei Dinge gleichzeitig behauptet.
|
||||
- **Der Vorgesetzte kommt bei Graph direkt**, nicht über eine DN-Auflösung. `RoherEintrag` hat dafür
|
||||
ein zweites Feld (`vorgesetzterDirekt`) und nicht ein Feld mit zwei Bedeutungen: ein DN und eine
|
||||
GUID sehen verschieden aus, aber ein Feld, das mal das eine und mal das andere trägt, verwechselt
|
||||
irgendwann jemand.
|
||||
- **Der Token-Zwischenspeicher hat 60 Sekunden Sicherheitsabstand.** Ein Token, das in zehn Sekunden
|
||||
abläuft, ist für eine Seite, die zwei Sekunden braucht, formal noch gültig — und in der Mitte der
|
||||
nächsten nicht mehr. Der Fehler wäre ein Abgleich, der nach der dritten Seite mit 401 abbricht.
|
||||
- **Eine Reißleine bei 1000 Seiten.** Ein `@odata.nextLink`, der auf sich selbst zeigt, wäre sonst
|
||||
eine Schleife bis zum Speicherende — still, weil jeder einzelne Abruf gelingt.
|
||||
- **Zwei eigene Testfehler**, keine Produktfehler: die Token-Grenze liegt bei +3540 s und nicht bei
|
||||
„so ungefähr einer Stunde“, und die Klickprobe zählte die Felder der Bearbeiten-Formulare der
|
||||
bestehenden Quellen mit.
|
||||
|
||||
## Noch offen, und benannt
|
||||
|
||||
Gegen einen **echten Entra-Mandanten** ist das nicht gelaufen. Geprüft ist es gegen einen
|
||||
HTTP-Doppelgänger mit aufgezeichneten Antworten, und deren Form stammt aus der Dokumentation, nicht
|
||||
aus einem Mitschnitt. Die Prüfliste steht am Ende von
|
||||
[`docs/betrieb/entra-id.md`](../betrieb/entra-id.md) — acht Punkte, darunter ein Mandant mit mehr als
|
||||
999 Benutzern für den Seitenabruf. Dieselbe Art offener Punkt wie [#024](024-proxmox-echtlauf.md).
|
||||
|
||||
@@ -32,7 +32,7 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
|
||||
| #025 | [Healthcheck gibt die rohe Datenbankfehlermeldung heraus](025-gesundheit-fehlermeldung.md) | erledigt | v0.2.0 | 1 h | 25 min |
|
||||
| #026 | [Zwei Konventionen präzisieren, die strenger klingen als sie gemeint sind](026-konventionen-praezisieren.md) | erledigt | v0.2.0 | 1 h | 20 min |
|
||||
| #027 | [Die Editor-Abläufe lassen ihre Vorlagen stehen](027-e2e-reste-vorlagen.md) | offen | v0.2.0 | 1 h | – |
|
||||
| #028 | [M15 – Quellenarten und Microsoft-Graph-Quelle](028-graph-quelle.md) | offen | v0.2.0 | 10 h | – |
|
||||
| #028 | [M15 – Quellenarten und Microsoft-Graph-Quelle](028-graph-quelle.md) | erledigt | v0.2.0 | 10 h | 4 h 10 min |
|
||||
| #029 | [M16 – SCIM-Endpunkt als Alternative zum Abgleich](029-scim-endpunkt.md) | offen | v0.2.0 | 12 h | – |
|
||||
| #030 | [M17 – Wiederverwendbare Bausteine](030-bausteine.md) | erledigt | v0.2.0 | 12 h | 2 h 25 min |
|
||||
| #031 | [M18 – Abteilungsspezifische Zusatzabschnitte](031-abteilungsabschnitte.md) | erledigt | v0.2.0 | 8 h | 1 h 35 min |
|
||||
@@ -40,4 +40,4 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
|
||||
| #033 | [M20 – Live-Aktualisierung per SSE statt Polling](033-sse-live.md) | erledigt | v0.2.0 | 6 h | 1 h 40 min |
|
||||
| #034 | [M21 – Benachrichtigungen per Mail und Webhook](034-benachrichtigungen.md) | erledigt | v0.2.0 | 10 h | 3 h 30 min |
|
||||
| #035 | [M22 – Mitarbeiter-Selbstauskunft](035-selbstauskunft.md) | erledigt | v0.2.0 | 8 h | 2 h 50 min |
|
||||
| | **Summe** | | | **229 h** | **56 h 25 min** |
|
||||
| | **Summe** | | | **229 h** | **60 h 35 min** |
|
||||
|
||||
@@ -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 $$;
|
||||
+29
-5
@@ -314,18 +314,42 @@ model BenutzerKunde {
|
||||
/// genau eine (das Firmen-AD); mehrere sind erlaubt, etwa eine zweite Domaene.
|
||||
/// Nur fuer die eigene Organisation – im Modus DIENSTLEISTUNG gibt es keinen
|
||||
/// Abgleich zu Kunden-ADs.
|
||||
/// Woher eine Verzeichnisquelle liest (#028).
|
||||
///
|
||||
/// Bis #028 war jede Quelle implizit LDAP. `SCIM` steht hier bereits, weil #029
|
||||
/// dieselbe Weiche braucht — behandelt wird es noch nicht, und der Dienst weist
|
||||
/// es mit einer Meldung ab, statt stillschweigend nichts zu tun.
|
||||
enum QuellenArt {
|
||||
LDAP
|
||||
GRAPH
|
||||
SCIM
|
||||
}
|
||||
|
||||
model Verzeichnisquelle {
|
||||
id String @id @default(cuid())
|
||||
kundeId String
|
||||
name String
|
||||
aktiv Boolean @default(false)
|
||||
url String
|
||||
bindDn String
|
||||
/// Bestehende Zeilen sind LDAP – das war vor #028 die einzige Art.
|
||||
art QuellenArt @default(LDAP)
|
||||
/// Ab hier: **nur bei `art = LDAP`** gesetzt. Welche Felder eine Art braucht,
|
||||
/// haelt der CHECK `Verzeichnisquelle_felder_je_art` fest — eine Graph-Quelle
|
||||
/// mit einem `basisDn` waere eine Zeile, die zwei Dinge gleichzeitig behauptet.
|
||||
url String?
|
||||
bindDn String?
|
||||
/// AES-256-GCM aus APP_SECRET. Darf keine API-Antwort erreichen; dafuer sorgt
|
||||
/// der Serializer in der Service-Schicht, abgedeckt durch einen eigenen Test.
|
||||
bindPasswortVerschluesselt String
|
||||
basisDn String
|
||||
filter String
|
||||
bindPasswortVerschluesselt String?
|
||||
basisDn String?
|
||||
filter String?
|
||||
|
||||
/// Ab hier: **nur bei `art = GRAPH`**. Der Mandant in Entra ID und die
|
||||
/// App-Registrierung, mit der gelesen wird (Client-Credentials-Flow).
|
||||
tenantId String?
|
||||
clientId String?
|
||||
/// Verschluesselt wie das Bind-Passwort und mit demselben Vorsatz: es darf
|
||||
/// keine Antwort und keinen Protokolleintrag erreichen.
|
||||
clientSecretVerschluesselt String?
|
||||
/// Zuordnung der LDAP-Attribute auf unsere Felder, mit AD-Vorgaben.
|
||||
attributZuordnung Json
|
||||
/// Eigenes CA-Zertifikat, falls ldaps gegen eine interne CA laeuft.
|
||||
|
||||
@@ -164,8 +164,21 @@ export default async function VerzeichnisSeite() {
|
||||
)}
|
||||
</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">
|
||||
{quelle.url} · {quelle.basisDn}
|
||||
{quelle.art === "GRAPH" ? (
|
||||
<>
|
||||
Microsoft Graph · {quelle.tenantId} · App {quelle.clientId}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
LDAP · {quelle.url} · {quelle.basisDn}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<Status quelle={quelle} />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { KeyRound, Save, Server, ShieldAlert, ShieldCheck } from "lucide-react";
|
||||
import { FELD, KAESTCHEN } from "@/lib/formular";
|
||||
import {
|
||||
AD_ZUORDNUNG,
|
||||
GRAPH_ZUORDNUNG,
|
||||
INTERVALL_HOECHSTENS,
|
||||
INTERVALL_MINDESTENS,
|
||||
ZUORDNUNGS_BESCHRIFTUNG,
|
||||
@@ -36,6 +37,14 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
|
||||
const [zuordnungOffen, setzeZuordnungOffen] = useState(false);
|
||||
const [adresse, setzeAdresse] = useState(quelle?.url ?? "");
|
||||
const klartext = adresse.trim().startsWith("ldap://");
|
||||
/*
|
||||
* Die Art ist beim Anlegen waehlbar und beim Bearbeiten **fest** (#028): an
|
||||
* ihr haengt, was die dauerhafte Kennung bedeutet, und nach einem Wechsel
|
||||
* wuerde kein Mitarbeiter wiedererkannt. Der Dienst weist einen Wechsel
|
||||
* ohnehin ab; hier gibt es ihn gar nicht erst zur Auswahl.
|
||||
*/
|
||||
const [art, setzeArt] = useState<"LDAP" | "GRAPH">(quelle?.art === "GRAPH" ? "GRAPH" : "LDAP");
|
||||
const istGraph = art === "GRAPH";
|
||||
|
||||
return (
|
||||
<form action={aktion} className="flex flex-col gap-4">
|
||||
@@ -52,6 +61,109 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
|
||||
/>
|
||||
</label>
|
||||
|
||||
{bearbeiten ? (
|
||||
<>
|
||||
<input type="hidden" name="art" value={art} />
|
||||
<p className="text-sm text-nebentext">
|
||||
Art: <strong className="text-text">{istGraph ? "Microsoft Graph" : "LDAP"}</strong> — sie lässt
|
||||
sich nicht ändern. Daran hängt, was die dauerhafte Kennung bedeutet; nach einem Wechsel würde kein
|
||||
Mitarbeiter wiedererkannt.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<fieldset className="flex flex-col gap-2 text-sm">
|
||||
<legend className="mb-1 text-nebentext">Art</legend>
|
||||
{(
|
||||
[
|
||||
{
|
||||
wert: "LDAP" as const,
|
||||
titel: "LDAP",
|
||||
text: "Ein lokales Active Directory oder ein anderes LDAP-Verzeichnis.",
|
||||
},
|
||||
{
|
||||
wert: "GRAPH" as const,
|
||||
titel: "Microsoft Graph",
|
||||
text: "Entra ID (Microsoft 365). Für Häuser ohne lokales AD.",
|
||||
},
|
||||
] satisfies readonly { wert: "LDAP" | "GRAPH"; titel: string; text: string }[]
|
||||
).map((wahl) => (
|
||||
<label
|
||||
key={wahl.wert}
|
||||
className="flex min-h-11 cursor-pointer items-start gap-3 rounded-karte border border-linie p-3 md:min-h-10"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="art"
|
||||
value={wahl.wert}
|
||||
checked={art === wahl.wert}
|
||||
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>
|
||||
</div>
|
||||
<p className="max-w-prose text-xs text-nebentext">
|
||||
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}
|
||||
|
||||
{!istGraph ? (
|
||||
<>
|
||||
{/*
|
||||
* Die Erklärung steht **außerhalb** des `label` und hängt über
|
||||
* `aria-describedby` am Feld. Im `label` wäre sie Teil des Feldnamens:
|
||||
@@ -74,9 +186,9 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
|
||||
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>.
|
||||
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 ? (
|
||||
/*
|
||||
@@ -86,8 +198,8 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
|
||||
<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.
|
||||
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}
|
||||
@@ -150,10 +262,12 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
|
||||
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.
|
||||
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>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
@@ -200,7 +314,7 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
|
||||
<input
|
||||
name={`attr.${feld}`}
|
||||
defaultValue={quelle?.attributZuordnung[feld] ?? AD_ZUORDNUNG[feld]}
|
||||
placeholder={AD_ZUORDNUNG[feld]}
|
||||
placeholder={(istGraph ? GRAPH_ZUORDNUNG : AD_ZUORDNUNG)[feld]}
|
||||
className={`${FELD} mono`}
|
||||
/>
|
||||
</label>
|
||||
@@ -212,6 +326,8 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
|
||||
</p>
|
||||
</details>
|
||||
|
||||
{/* Ein eigenes CA-Zertifikat gilt nur fuer LDAP – Graph laeuft ueber oeffentliches TLS. */}
|
||||
{!istGraph ? (
|
||||
<details open={zertifikatOffen} onToggle={(e) => setzeZertifikatOffen(e.currentTarget.open)}>
|
||||
<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
|
||||
@@ -254,6 +370,7 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
|
||||
</label>
|
||||
) : null}
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
{ergebnis.fehler ? (
|
||||
<p role="alert" className="max-w-prose text-sm text-fehler">
|
||||
|
||||
+42
-2
@@ -60,6 +60,40 @@ export const AD_ZUORDNUNG: AttributZuordnung = {
|
||||
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. */
|
||||
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
|
||||
* 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 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) {
|
||||
const wert = felder[schluessel];
|
||||
if (typeof wert === "string" && wert.trim()) gelesen[schluessel] = wert.trim();
|
||||
|
||||
@@ -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."),
|
||||
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
|
||||
.string()
|
||||
.trim()
|
||||
@@ -99,16 +121,39 @@ const QuelleSchema = z.object({
|
||||
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."),
|
||||
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(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
@@ -116,12 +161,9 @@ export async function quelleAnlegenAktion(
|
||||
const zugang = await angemeldetOderMeldung();
|
||||
if ("fehler" in zugang) return zugang;
|
||||
|
||||
const eingabe = QuelleSchema.safeParse({
|
||||
const eingabe = GemeinsamSchema.safeParse({
|
||||
name: text(formular, "name"),
|
||||
url: text(formular, "url"),
|
||||
bindDn: text(formular, "bindDn"),
|
||||
basisDn: text(formular, "basisDn"),
|
||||
filter: text(formular, "filter") || undefined,
|
||||
art: text(formular, "art") || "LDAP",
|
||||
caZertifikatPem: text(formular, "caZertifikatPem") || undefined,
|
||||
abgleichIntervallMinuten: text(formular, "abgleichIntervallMinuten") || "60",
|
||||
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." };
|
||||
}
|
||||
|
||||
const bindPasswort = text(formular, "bindPasswort");
|
||||
if (!bindPasswort) return { fehler: "Ohne Passwort kommt keine Anmeldung am Verzeichnis zustande." };
|
||||
const felder = artFelder(eingabe.data.art, formular);
|
||||
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);
|
||||
if ("fehler" in zuordnung) return zuordnung;
|
||||
|
||||
try {
|
||||
const quelle = await quelleAnlegen(
|
||||
{ ...eingabe.data, bindPasswort, attributZuordnung: zuordnung.zuordnung },
|
||||
{ ...eingabe.data, ...felder.werte, ...geheimnis, attributZuordnung: zuordnung.zuordnung },
|
||||
zugang.benutzer,
|
||||
);
|
||||
revalidatePath("/admin/verzeichnis");
|
||||
@@ -158,12 +219,9 @@ export async function quelleBearbeitenAktion(
|
||||
const id = text(formular, "id");
|
||||
if (!id) return { fehler: "Es fehlt die Quelle." };
|
||||
|
||||
const eingabe = QuelleSchema.safeParse({
|
||||
const eingabe = GemeinsamSchema.safeParse({
|
||||
name: text(formular, "name"),
|
||||
url: text(formular, "url"),
|
||||
bindDn: text(formular, "bindDn"),
|
||||
basisDn: text(formular, "basisDn"),
|
||||
filter: text(formular, "filter") || undefined,
|
||||
art: text(formular, "art") || "LDAP",
|
||||
// Das Zertifikat entscheidet sich weiter unten: es kennt drei Faelle, das
|
||||
// Schema nur zwei.
|
||||
caZertifikatPem: undefined,
|
||||
@@ -174,6 +232,9 @@ export async function quelleBearbeitenAktion(
|
||||
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
|
||||
* Zertifikat eintragen, das hinterlegte behalten (Feld leer) und das hinterlegte
|
||||
@@ -200,11 +261,15 @@ export async function quelleBearbeitenAktion(
|
||||
if ("fehler" in zuordnung) return zuordnung;
|
||||
|
||||
try {
|
||||
const clientSecret = text(formular, "clientSecret");
|
||||
const quelle = await quelleBearbeiten(
|
||||
id,
|
||||
{
|
||||
...eingabe.data,
|
||||
...felder.werte,
|
||||
// Leer heisst unveraendert — bei beiden Geheimnissen.
|
||||
...(bindPasswort ? { bindPasswort } : {}),
|
||||
...(clientSecret ? { clientSecret } : {}),
|
||||
...(caZertifikatPem !== undefined ? { caZertifikatPem } : {}),
|
||||
attributZuordnung: zuordnung.zuordnung,
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -45,7 +45,15 @@ export type GemappterEintrag = {
|
||||
mail: string | null;
|
||||
upn: 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;
|
||||
titel: string | null;
|
||||
vorgesetzterExterneId: string | null;
|
||||
@@ -62,6 +70,14 @@ export type GemappterEintrag = {
|
||||
*/
|
||||
export type RoherEintrag = Omit<GemappterEintrag, "vorgesetzterExterneId"> & {
|
||||
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,
|
||||
): { eintrag: RoherEintrag } | { uebersprungen: Uebersprungen } {
|
||||
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;
|
||||
try {
|
||||
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
|
||||
* 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(", ");
|
||||
if (gebaut) return gebaut;
|
||||
const ersterTeil = dn.split(",")[0]?.split("=")[1]?.trim();
|
||||
return ersterTeil ?? dn;
|
||||
const ersterTeil = kennzeichen.split(",")[0]?.split("=")[1]?.trim();
|
||||
return ersterTeil ?? kennzeichen;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -225,10 +244,16 @@ export function vorgesetzteAufloesen(
|
||||
): GemappterEintrag[] {
|
||||
const nachDn = new Map(zusaetzlicheKennungen ?? []);
|
||||
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,
|
||||
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),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ import { prisma } from "@/lib/db";
|
||||
import { satzEnde } from "@/lib/satzzeichen";
|
||||
import { darf } from "@/server/auth/rollen";
|
||||
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 { 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 { mitSperre } from "@/server/services/sperre";
|
||||
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. */
|
||||
type OffenerVorgesetzter = {
|
||||
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 neuAngelegt: boolean;
|
||||
};
|
||||
@@ -207,21 +216,21 @@ async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Pr
|
||||
let bindPasswort: string | undefined;
|
||||
|
||||
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 offene: OffenerVorgesetzter[] = [];
|
||||
|
||||
const bilanz = await eintraegeStroemen(
|
||||
zugang,
|
||||
{
|
||||
basisDn: quelle.basisDn,
|
||||
filter: quelle.filter,
|
||||
zuordnung: zuordnungLesen(quelle.attributZuordnung),
|
||||
},
|
||||
async (eintrag) => {
|
||||
const bilanz = await eintraegeDerQuelleStroemen(quelle, async (eintrag) => {
|
||||
verarbeitet += 1;
|
||||
kennungNachDn.set(dnSchluessel(eintrag.dn), eintrag.externeId);
|
||||
// Nur LDAP liefert einen DN; bei Graph bleibt die Tabelle leer, und der
|
||||
// Vorgesetzte kommt ueber `vorgesetzterDirekt`.
|
||||
if (eintrag.dn) kennungNachDn.set(dnSchluessel(eintrag.dn), eintrag.externeId);
|
||||
let vorhanden = await prisma.mitarbeiter.findFirst({
|
||||
where: { quelleId, externeId: eintrag.externeId },
|
||||
});
|
||||
@@ -273,10 +282,11 @@ async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Pr
|
||||
},
|
||||
});
|
||||
angelegt += 1;
|
||||
if (eintrag.vorgesetzterDn) {
|
||||
if (eintrag.vorgesetzterDn || eintrag.vorgesetzterDirekt) {
|
||||
offene.push({
|
||||
externeId: eintrag.externeId,
|
||||
vorgesetzterDn: eintrag.vorgesetzterDn,
|
||||
vorgesetzterDirekt: eintrag.vorgesetzterDirekt ?? null,
|
||||
vorherigeKennung: null,
|
||||
neuAngelegt: true,
|
||||
});
|
||||
@@ -286,7 +296,9 @@ async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Pr
|
||||
|
||||
// Steht im Verzeichnis kein Vorgesetzter mehr, wird ein alter Wert
|
||||
// geleert. Steht einer, entscheidet der zweite Durchgang.
|
||||
const vorgesetztenLeeren = eintrag.vorgesetzterDn === null;
|
||||
// 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.
|
||||
@@ -338,13 +350,13 @@ async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Pr
|
||||
if (!vorgesetztenLeeren) {
|
||||
offene.push({
|
||||
externeId: eintrag.externeId,
|
||||
vorgesetzterDn: eintrag.vorgesetzterDn as string,
|
||||
vorgesetzterDn: eintrag.vorgesetzterDn,
|
||||
vorgesetzterDirekt: eintrag.vorgesetzterDirekt ?? null,
|
||||
vorherigeKennung: vorhanden.vorgesetzterExterneId,
|
||||
neuAngelegt: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
gelesen = bilanz.gesamtGelesen;
|
||||
|
||||
// --- 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
|
||||
// Hand angelegte Person konnte keinen Vorgesetzten haben.
|
||||
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;
|
||||
const vorgesetzter =
|
||||
kennung === null
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
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 { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
|
||||
import { verschluesseln } from "@/lib/geheimnisse";
|
||||
import { darf } from "@/server/auth/rollen";
|
||||
import { betriebsmodus, eigeneOrganisation, kundeErmitteln, type HandelnderBenutzer } from "@/server/mandant";
|
||||
import { VerzeichnisFehler } from "@/server/ldap/fehler";
|
||||
import { INTERVALL_HOECHSTENS, INTERVALL_MINDESTENS, intervallMeldung } from "@/lib/verzeichnis";
|
||||
import { eintraegeLesen } from "@/server/ldap/leser";
|
||||
import { type LdapZugang, urlPruefen } from "@/server/ldap/verbindung";
|
||||
// Die Zugangs-Helfer liegen seit #028 in einem eigenen Modul – sonst waere die
|
||||
// Weiche in `quellenleser.ts` ein Import-Zyklus. Hier weitergereicht, damit
|
||||
// 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 {
|
||||
AD_ZUORDNUNG,
|
||||
type AttributZuordnung,
|
||||
type GemappterEintrag,
|
||||
type RoherEintrag,
|
||||
STANDARD_FILTER,
|
||||
type Uebersprungen,
|
||||
vorgesetzteAufloesen,
|
||||
@@ -48,16 +58,24 @@ export { AD_ZUORDNUNG, STANDARD_FILTER };
|
||||
* Aufrufer sie von hier kennt.
|
||||
*/
|
||||
export { VerzeichnisFehler };
|
||||
export { ldapVorgabeAus, zugangAus, zuordnungsArt };
|
||||
|
||||
/** Ohne Passwort – diese Form geht an die Oberflaeche. */
|
||||
export type QuellenSicht = {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly aktiv: boolean;
|
||||
readonly url: string;
|
||||
readonly bindDn: string;
|
||||
readonly basisDn: string;
|
||||
readonly filter: string;
|
||||
/** Woher gelesen wird (#028). Bis dahin war jede Quelle LDAP. */
|
||||
readonly art: QuellenArt;
|
||||
/** Nur bei `art = LDAP` belegt. */
|
||||
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 caZertifikatVorhanden: boolean;
|
||||
readonly abgleichIntervallMinuten: number;
|
||||
@@ -68,11 +86,18 @@ export type QuellenSicht = {
|
||||
|
||||
export type NeueQuelle = {
|
||||
name: string;
|
||||
url: string;
|
||||
bindDn: string;
|
||||
bindPasswort: string;
|
||||
basisDn: string;
|
||||
/** Ohne Angabe `LDAP` – so verhalten sich Aufrufer von vor #028 unveraendert. */
|
||||
art?: QuellenArt;
|
||||
/** Pflicht bei `art = LDAP`, bei Graph nicht erlaubt. */
|
||||
url?: string;
|
||||
bindDn?: string;
|
||||
bindPasswort?: string;
|
||||
basisDn?: string;
|
||||
filter?: string;
|
||||
/** Pflicht bei `art = GRAPH`. */
|
||||
tenantId?: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
attributZuordnung?: Partial<AttributZuordnung>;
|
||||
caZertifikatPem?: string | null;
|
||||
abgleichIntervallMinuten?: number;
|
||||
@@ -150,11 +175,19 @@ function alsSicht(zeile: Verzeichnisquelle): QuellenSicht {
|
||||
id: zeile.id,
|
||||
name: zeile.name,
|
||||
aktiv: zeile.aktiv,
|
||||
art: zeile.art,
|
||||
url: zeile.url,
|
||||
bindDn: zeile.bindDn,
|
||||
basisDn: zeile.basisDn,
|
||||
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
|
||||
// hinterlegt ist, und braucht dafuer keinen Zertifikatstext.
|
||||
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. */
|
||||
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(
|
||||
eingabe: NeueQuelle,
|
||||
benutzer: HandelnderBenutzer,
|
||||
@@ -177,31 +268,28 @@ export async function quelleAnlegen(
|
||||
const kundeId = await kundeErmitteln(null, benutzer);
|
||||
|
||||
const name = pflichtText(eingabe.name, "Der Name der Quelle");
|
||||
const url = pflichtText(eingabe.url, "Die Adresse des Verzeichnisses");
|
||||
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 art = eingabe.art ?? "LDAP";
|
||||
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 angelegt = await tx.verzeichnisquelle.create({
|
||||
data: {
|
||||
kundeId,
|
||||
name,
|
||||
// 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,
|
||||
bindPasswortVerschluesselt: verschluesseln(bindPasswort),
|
||||
basisDn,
|
||||
filter: eingabe.filter?.trim() || STANDARD_FILTER,
|
||||
attributZuordnung: zuordnungLesen({ ...AD_ZUORDNUNG, ...eingabe.attributZuordnung }),
|
||||
art,
|
||||
...felder,
|
||||
attributZuordnung: zuordnungLesen(
|
||||
{ ...ZUORDNUNGS_VORGABE[zuordnungsArt(art)], ...eingabe.attributZuordnung },
|
||||
zuordnungsArt(art),
|
||||
),
|
||||
caZertifikatPem: zertifikatPruefen(eingabe.caZertifikatPem),
|
||||
// Ohne Angabe bleibt der Standardwert des Schemas (60 Minuten) stehen.
|
||||
...(intervall === undefined ? {} : { abgleichIntervallMinuten: intervall }),
|
||||
@@ -215,8 +303,20 @@ export async function quelleAnlegen(
|
||||
entitaetId: angelegt.id,
|
||||
aktion: "Verzeichnisquelle angelegt",
|
||||
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;
|
||||
@@ -236,6 +336,35 @@ export async function quelleBearbeiten(
|
||||
const daten: Prisma.VerzeichnisquelleUpdateInput = {};
|
||||
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) {
|
||||
const name = pflichtText(aenderung.name, "Der Name der Quelle");
|
||||
if (name !== vorher.name) {
|
||||
@@ -243,7 +372,40 @@ export async function quelleBearbeiten(
|
||||
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) {
|
||||
nurBei("LDAP", "Adresse");
|
||||
const url = pflichtText(aenderung.url, "Die Adresse des Verzeichnisses");
|
||||
urlPruefen(url);
|
||||
if (url !== vorher.url) {
|
||||
@@ -252,6 +414,7 @@ export async function quelleBearbeiten(
|
||||
}
|
||||
}
|
||||
if (aenderung.bindDn !== undefined) {
|
||||
nurBei("LDAP", "Bind-DN");
|
||||
const bindDn = pflichtText(aenderung.bindDn, "Der Bind-DN");
|
||||
if (bindDn !== vorher.bindDn) {
|
||||
daten.bindDn = bindDn;
|
||||
@@ -259,6 +422,7 @@ export async function quelleBearbeiten(
|
||||
}
|
||||
}
|
||||
if (aenderung.bindPasswort !== undefined) {
|
||||
nurBei("LDAP", "Bind-Passwort");
|
||||
// Ein leeres Feld heisst „unveraendert“. Ein Formular, das das Passwort nicht
|
||||
// anzeigen kann (und das darf es nicht), schickt sonst bei jedem Speichern
|
||||
// eine leere Zeichenkette und loeschte das Geheimnis.
|
||||
@@ -269,6 +433,7 @@ export async function quelleBearbeiten(
|
||||
}
|
||||
}
|
||||
if (aenderung.basisDn !== undefined) {
|
||||
nurBei("LDAP", "Basis-DN");
|
||||
const basisDn = pflichtText(aenderung.basisDn, "Der Basis-DN");
|
||||
if (basisDn !== vorher.basisDn) {
|
||||
daten.basisDn = basisDn;
|
||||
@@ -276,6 +441,7 @@ export async function quelleBearbeiten(
|
||||
}
|
||||
}
|
||||
if (aenderung.filter !== undefined) {
|
||||
nurBei("LDAP", "Filter");
|
||||
const filter = aenderung.filter.trim() || STANDARD_FILTER;
|
||||
if (filter !== vorher.filter) {
|
||||
daten.filter = filter;
|
||||
@@ -400,13 +566,15 @@ export async function verbindungTesten(
|
||||
const quelle = await quelleZeileHolen(quelleId);
|
||||
const wieViele = Math.min(Math.max(Math.trunc(grenze), 1), PROBE_GRENZE_HOECHSTENS);
|
||||
|
||||
const { eintraege, gesamtGelesen, uebersprungen } = await eintraegeLesen(
|
||||
zugangAus(quelle),
|
||||
{
|
||||
basisDn: quelle.basisDn,
|
||||
filter: quelle.filter,
|
||||
zuordnung: zuordnungLesen(quelle.attributZuordnung),
|
||||
},
|
||||
/*
|
||||
* **Dieselbe Weiche wie der Abgleich** (#028). Eine eigene Verzweigung hier
|
||||
* waere eine zweite Stelle, die sich an eine neue Quellenart erinnern muss —
|
||||
* und die vergisst man.
|
||||
*/
|
||||
const eintraege: RoherEintrag[] = [];
|
||||
const { gesamtGelesen, uebersprungen } = await eintraegeDerQuelleStroemen(
|
||||
quelle,
|
||||
(eintrag) => void eintraege.push(eintrag),
|
||||
wieViele,
|
||||
);
|
||||
return { treffer: vorgesetzteAufloesen(eintraege), gesamtGelesen, uebersprungen };
|
||||
@@ -433,13 +601,3 @@ export async function quelleZeileHolen(id: string): Promise<Verzeichnisquelle> {
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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).",
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -144,6 +144,13 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
|
||||
"Schrittergebnis_begruendung_noetig",
|
||||
"TagCodeVergabe_form",
|
||||
"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",
|
||||
"Zustellung_bezug_passt_zum_anlass",
|
||||
"Zustellung_empfaenger_nicht_leer",
|
||||
|
||||
@@ -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
@@ -526,11 +526,14 @@ describe.runIf(datenbankVerfuegbar)("Seed gegen die Datenbank", () => {
|
||||
|
||||
const quelle = await testPrisma.verzeichnisquelle.findFirstOrThrow();
|
||||
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
|
||||
// Feld ueberhaupt entschluesseln laesst und nichts Lesbares enthaelt.
|
||||
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 () => {
|
||||
|
||||
@@ -536,12 +536,12 @@ describe.runIf(datenbankVerfuegbar)("Verzeichnisquellen", () => {
|
||||
// Umbenennen kaputt.
|
||||
await quelleBearbeiten(quelle.id, { bindPasswort: "", name: "Neu" }, admin);
|
||||
const nachher = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
|
||||
expect(entschluesseln(nachher.bindPasswortVerschluesselt)).toBe(LDAP_BIND_PASSWORT);
|
||||
expect(entschluesseln(nachher.bindPasswortVerschluesselt ?? "")).toBe(LDAP_BIND_PASSWORT);
|
||||
expect(nachher.bindPasswortVerschluesselt).toBe(vorher.bindPasswortVerschluesselt);
|
||||
|
||||
await quelleBearbeiten(quelle.id, { bindPasswort: "ein-neues-geheimnis" }, admin);
|
||||
const dritte = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
|
||||
expect(entschluesseln(dritte.bindPasswortVerschluesselt)).toBe("ein-neues-geheimnis");
|
||||
expect(entschluesseln(dritte.bindPasswortVerschluesselt ?? "")).toBe("ein-neues-geheimnis");
|
||||
});
|
||||
|
||||
it("gibt das Bind-Passwort in keiner Antwort und in keinem Protokoll heraus", async () => {
|
||||
@@ -562,7 +562,7 @@ describe.runIf(datenbankVerfuegbar)("Verzeichnisquellen", () => {
|
||||
const zeile = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
|
||||
expect(zeile.bindPasswortVerschluesselt).not.toContain(LDAP_BIND_PASSWORT);
|
||||
expect(zeile.bindPasswortVerschluesselt).toMatch(/^v1\./u);
|
||||
expect(entschluesseln(zeile.bindPasswortVerschluesselt)).toBe(LDAP_BIND_PASSWORT);
|
||||
expect(entschluesseln(zeile.bindPasswortVerschluesselt ?? "")).toBe(LDAP_BIND_PASSWORT);
|
||||
});
|
||||
|
||||
it("loescht eine Quelle ohne Mitarbeiter und verweigert es sonst", async () => {
|
||||
|
||||
Reference in New Issue
Block a user