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
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- **Entra ID als Verzeichnisquelle, über Microsoft Graph.** Ein Haus ohne lokales Active Directory
|
||||||
|
hat kein LDAP, auf das sich zeigen ließe. Eine Verzeichnisquelle hat jetzt eine **Art** — `LDAP`
|
||||||
|
oder `GRAPH` —, und beide laufen durch **denselben** Abgleich: anlegen, aktualisieren,
|
||||||
|
deaktivieren, **nie löschen**. Nur das Holen der Einträge unterscheidet sich, sonst drifteten die
|
||||||
|
Regeln auseinander.
|
||||||
|
Gelesen wird mit dem Client-Credentials-Flow über `GET /v1.0/users`, seitenweise über
|
||||||
|
`@odata.nextLink`, der Vorgesetzte über `$expand=manager($select=id)`. Das Client-Secret wird
|
||||||
|
verschlüsselt gespeichert wie das LDAP-Bind-Passwort und erscheint in keiner Antwort und in keinem
|
||||||
|
Protokolleintrag.
|
||||||
|
**Zwei Platzhalter bleiben bei Graph bewusst leer:** `{{ou}}` (Entra ID hat keine OU — steht an der
|
||||||
|
Abteilung eine Ziel-OU, greift die) und `{{sam}}` ohne AD-Synchronisierung. Was in Entra ID
|
||||||
|
einzurichten ist, steht in `docs/betrieb/entra-id.md` — mit einer Prüfliste, denn gegen einen
|
||||||
|
echten Mandanten ist dieser Weg **nicht** gelaufen (#028)
|
||||||
- **Mitarbeiter-Selbstauskunft.** „Was ist eigentlich mit meinem Notebook passiert?“ musste bisher
|
- **Mitarbeiter-Selbstauskunft.** „Was ist eigentlich mit meinem Notebook passiert?“ musste bisher
|
||||||
ein Techniker nachsehen. Ein Mitarbeiter kann die Arbeiten an **seinen** Geräten jetzt selbst
|
ein Techniker nachsehen. Ein Mitarbeiter kann die Arbeiten an **seinen** Geräten jetzt selbst
|
||||||
ansehen, unter **Meine Geräte** — mit der neuen Rolle `MITARBEITER`, zugeordnet über den **UPN**
|
ansehen, unter **Meine Geräte** — mit der neuen Rolle `MITARBEITER`, zugeordnet über den **UPN**
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ Enums: `Rolle {ADMIN REDAKTEUR TECHNIKER LESER MITARBEITER}` · `Betriebsmodus {
|
|||||||
`VariablenTyp {TEXT ZAHL AUSWAHL DATUM}` ·
|
`VariablenTyp {TEXT ZAHL AUSWAHL DATUM}` ·
|
||||||
`GeraeteTyp {PC NOTEBOOK SERVER FIREWALL SWITCH DRUCKER RAUM SONSTIGES}` ·
|
`GeraeteTyp {PC NOTEBOOK SERVER FIREWALL SWITCH DRUCKER RAUM SONSTIGES}` ·
|
||||||
`VersionStatus {ENTWURF VEROEFFENTLICHT}` · `Herkunft {ABGLEICH MANUELL}` ·
|
`VersionStatus {ENTWURF VEROEFFENTLICHT}` · `Herkunft {ABGLEICH MANUELL}` ·
|
||||||
|
`QuellenArt {LDAP GRAPH SCIM}` ·
|
||||||
`Zustellweg {MAIL WEBHOOK}` · `Zustellzustand {OFFEN GESENDET FEHLGESCHLAGEN AUFGEGEBEN}` ·
|
`Zustellweg {MAIL WEBHOOK}` · `Zustellzustand {OFFEN GESENDET FEHLGESCHLAGEN AUFGEGEBEN}` ·
|
||||||
`Meldeanlass {LAUF_ZUGEWIESEN LAUF_UEBERFAELLIG LAUF_ABGESCHLOSSEN ABGLEICH_FEHLGESCHLAGEN}`.
|
`Meldeanlass {LAUF_ZUGEWIESEN LAUF_UEBERFAELLIG LAUF_ABGESCHLOSSEN ABGLEICH_FEHLGESCHLAGEN}`.
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,32 @@ Zwei Dinge vorweg:
|
|||||||
Wer im Verzeichnis verschwindet oder dort deaktiviert wird, wird hier **inaktiv**. Gelöscht wird
|
Wer im Verzeichnis verschwindet oder dort deaktiviert wird, wird hier **inaktiv**. Gelöscht wird
|
||||||
nie, sonst verlöre die Historie ihre Namen.
|
nie, sonst verlöre die Historie ihre Namen.
|
||||||
|
|
||||||
|
## Zwei Arten von Quelle
|
||||||
|
|
||||||
|
Laufzettel liest die Personen aus einem Verzeichnis. Dafür gibt es zwei Wege, und du wählst ihn beim
|
||||||
|
Anlegen der Quelle:
|
||||||
|
|
||||||
|
| Art | Für wen |
|
||||||
|
|---|---|
|
||||||
|
| **LDAP** | ein lokales Active Directory (oder ein anderes LDAP-Verzeichnis) |
|
||||||
|
| **Microsoft Graph** | Entra ID, also Microsoft 365 — für Häuser ohne lokales AD |
|
||||||
|
|
||||||
|
Beide verhalten sich danach gleich: anlegen, aktualisieren, deaktivieren, **nie löschen**. Was sie
|
||||||
|
unterscheidet, sind die Zugangsdaten und zwei Platzhalter:
|
||||||
|
|
||||||
|
- **`{{ou}}` bleibt bei Graph leer.** Entra ID hat keine OU. Steht an der Abteilung eine Ziel-OU
|
||||||
|
gepflegt, nimmt Laufzettel die; sonst bleibt der Platzhalter stehen.
|
||||||
|
- **`{{sam}}` ist bei Graph leer**, wenn der Mandant kein lokales AD synchronisiert — dort gibt es
|
||||||
|
keinen Anmeldenamen in diesem Sinn.
|
||||||
|
|
||||||
|
**Die Art lässt sich nachträglich nicht ändern.** An ihr hängt, was die dauerhafte Kennung einer
|
||||||
|
Person bedeutet; nach einem Wechsel würde niemand mehr wiedererkannt, und der nächste Abgleich legte
|
||||||
|
alle neu an. Wer wechseln muss, legt eine zweite Quelle an und lässt die erste stehen.
|
||||||
|
|
||||||
|
Was in Entra ID einzurichten ist — App-Registrierung, Berechtigung, Einwilligung — steht in
|
||||||
|
[`docs/betrieb/entra-id.md`](../betrieb/entra-id.md).
|
||||||
|
|
||||||
|
|
||||||
## Eine Quelle anlegen
|
## Eine Quelle anlegen
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
@@ -56,6 +56,8 @@ Sitzungen ungültig *und* die verschlüsselt gespeicherten LDAP-Bind-Passwörter
|
|||||||
- **[Anmeldung über authentik](authentik.md)** — OIDC-Provider, Gruppen auf Rollen, Notfallzugang
|
- **[Anmeldung über authentik](authentik.md)** — OIDC-Provider, Gruppen auf Rollen, Notfallzugang
|
||||||
- **[Sichern und Zurückspielen](sicherung.md)** — auf beiden Wegen, was in die Sicherung gehört und
|
- **[Sichern und Zurückspielen](sicherung.md)** — auf beiden Wegen, was in die Sicherung gehört und
|
||||||
was nicht, und wie du prüfst, dass das Zurückspielen wirklich funktioniert
|
was nicht, und wie du prüfst, dass das Zurückspielen wirklich funktioniert
|
||||||
|
- **[Entra ID als Verzeichnisquelle](entra-id.md)** — App-Registrierung, `User.Read.All`,
|
||||||
|
Administratoreinwilligung, und was anders ist als bei einem lokalen AD
|
||||||
- **[Benachrichtigungen](benachrichtigungen.md)** — SMTP einrichten, den Webhook prüfen, die
|
- **[Benachrichtigungen](benachrichtigungen.md)** — SMTP einrichten, den Webhook prüfen, die
|
||||||
Signatur nachrechnen, und wo man nachsieht, wenn keine Mail mehr ankommt
|
Signatur nachrechnen, und wo man nachsieht, wenn keine Mail mehr ankommt
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
| #033 | M20 – Live-Aktualisierung per SSE statt Polling | 1 h 40 min |
|
||||||
| #034 | M21 – Benachrichtigungen per Mail und Webhook | 3 h 30 min |
|
| #034 | M21 – Benachrichtigungen per Mail und Webhook | 3 h 30 min |
|
||||||
| #035 | M22 – Mitarbeiter-Selbstauskunft | 2 h 50 min |
|
| #035 | M22 – Mitarbeiter-Selbstauskunft | 2 h 50 min |
|
||||||
| | **Summe** | **56 h 25 min** |
|
| #028 | M15 – Quellenarten und Microsoft-Graph-Quelle | 4 h 10 min |
|
||||||
|
| | **Summe** | **60 h 35 min** |
|
||||||
|
|
||||||
| Meilenstein | Tatsächlich | Status |
|
| Meilenstein | Tatsächlich | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| v0.1.0 | 35 h 20 min | veröffentlicht |
|
| v0.1.0 | 35 h 20 min | veröffentlicht |
|
||||||
| v0.2.0 | 21 h 05 min | in Arbeit |
|
| v0.2.0 | 25 h 15 min | in Arbeit |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1370,3 +1371,67 @@ und nicht nach jeder einzelnen — das ist der Grund, warum er alles auf einmal
|
|||||||
- Vor beiden wäre ein unabhängiger Review über die sechs Meilensteine dieser Phase angebracht —
|
- Vor beiden wäre ein unabhängiger Review über die sechs Meilensteine dieser Phase angebracht —
|
||||||
M17 bis M22 liegen ohne fremden Blick auf `main`.
|
M17 bis M22 liegen ohne fremden Blick auf `main`.
|
||||||
- Weiter offen und nicht von hier aus machbar: **#024** Proxmox-Einzeiler auf einem echten PVE-Host.
|
- Weiter offen und nicht von hier aus machbar: **#024** Proxmox-Einzeiler auf einem echten PVE-Host.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-10-06 · Sitzung 21 · 09:00–13:10
|
||||||
|
|
||||||
|
**Issue:** #028 (M15)
|
||||||
|
**Version:** 0.2.0 (unveröffentlicht)
|
||||||
|
|
||||||
|
### Erledigt
|
||||||
|
|
||||||
|
- **Eine Verzeichnisquelle hat eine Art**: `LDAP`, `GRAPH` oder `SCIM`. Bestehende Zeilen sind
|
||||||
|
`LDAP` — das war vor #028 die einzige.
|
||||||
|
- **Entra ID über Microsoft Graph**: Client-Credentials-Flow mit Token-Zwischenspeicher, Seitenabruf
|
||||||
|
über `@odata.nextLink`, Vorgesetzter über `$expand`.
|
||||||
|
- **Derselbe Abgleich für beide Arten.** Die Weiche sitzt in
|
||||||
|
`src/server/verzeichnis/quellenleser.ts` und ist die einzige Stelle, die die Art kennt.
|
||||||
|
- 1373 Vitest-Tests (vorher 1342), davon 31 neue gegen einen HTTP-Doppelgänger mit aufgezeichneten
|
||||||
|
Graph-Antworten.
|
||||||
|
- Im gebauten Stand eine Graph-Quelle wirklich angelegt: Art gespeichert, Geheimnis verschlüsselt,
|
||||||
|
nicht im Quelltext der Seite.
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
|
||||||
|
- **Ein Enum auf der Quelle, keine Tabelle je Art.** Drei Arten rechtfertigen keine Vererbung; was
|
||||||
|
eine Art braucht, hält ein CHECK fest — in **beide** Richtungen, sonst wäre eine Graph-Quelle mit
|
||||||
|
`basisDn` möglich.
|
||||||
|
- **Die Art ist unveränderlich.** An ihr hängt, was `externeId` bedeutet; nach einem Wechsel würde
|
||||||
|
kein Mitarbeiter wiedererkannt. Steht so nicht im Issue und ist die wichtigste Zusage dieses
|
||||||
|
Meilensteins.
|
||||||
|
- **`{{ou}}` bleibt bei Graph leer.** Etwas hineinzudeuten wäre ein Wert, den kein PowerShell-Befehl
|
||||||
|
brauchen kann — und er sähe richtig aus.
|
||||||
|
- **`SCIM` steht im Enum, wird aber abgewiesen.** Ein Wert, der stillschweigend nichts tut, hätte
|
||||||
|
hier die ganze Belegschaft deaktiviert.
|
||||||
|
|
||||||
|
### Probleme
|
||||||
|
|
||||||
|
- **Der teuerste Fund: „deaktiviert“ als „übersprungen“ zu melden hätte Leute aktiv gelassen.**
|
||||||
|
„Übersprungen“ heißt im Abgleich „nicht zuordenbar“, und solche Einträge werden seit #016
|
||||||
|
absichtlich nicht abgeschaltet. Ein gesperrtes Entra-Konto wäre damit genau umgekehrt behandelt
|
||||||
|
worden als ein gesperrtes AD-Konto. Jetzt filtert die Abfrage serverseitig, als Gegenstück zum
|
||||||
|
LDAP-Standardfilter.
|
||||||
|
**Gefunden hat es der Test — aber nur, weil der Doppelgänger den Filter wirklich auswertet.** Hätte
|
||||||
|
er den Filter bloß empfangen und ignoriert, wäre der Test grün gewesen und der Fehler im Betrieb
|
||||||
|
aufgefallen. Ein Doppelgänger, der nur antwortet, prüft die Anfrage nicht.
|
||||||
|
- **`URLSearchParams` kodiert `$` zu `%24`** und die Klammern in `manager($select=id)`. Graph nimmt
|
||||||
|
das teils an und antwortet teils mit „Invalid expand clause“. Jetzt von Hand zusammengesetzt.
|
||||||
|
- **Ein Import-Zyklus drohte**: die Weiche braucht `zugangAus`, und der Dienst braucht die Weiche.
|
||||||
|
Beides aufgelöst durch ein eigenes Modul. In ESM läuft so ein Zyklus oft — aber nicht zuverlässig.
|
||||||
|
- **Zwei eigene Testfehler**: die Token-Grenze liegt bei +3540 s und nicht „ungefähr eine Stunde“,
|
||||||
|
und die Klickprobe zählte die Felder der Bearbeiten-Formulare mit.
|
||||||
|
|
||||||
|
### Was offen bleibt, und das ist benannt
|
||||||
|
|
||||||
|
Gegen einen **echten Entra-Mandanten** ist nichts davon gelaufen. Die Form der Antworten im
|
||||||
|
Doppelgänger stammt aus der Dokumentation, nicht aus einem Mitschnitt — stimmt sie nicht, stimmen
|
||||||
|
Test und Wirklichkeit nicht. Die Prüfliste steht am Ende von `docs/betrieb/entra-id.md`, acht
|
||||||
|
Punkte, darunter ein Mandant mit mehr als 999 Benutzern. Dieselbe Art offener Punkt wie #024.
|
||||||
|
|
||||||
|
### Nächster Schritt
|
||||||
|
|
||||||
|
- **M16 (#029)** SCIM-Endpunkt ist der letzte Meilenstein von Phase 2. Die Weiche dafür steht.
|
||||||
|
- Vor #029 wäre ein unabhängiger Review angebracht: **sieben Meilensteine** dieser Phase liegen ohne
|
||||||
|
fremden Blick auf `main`, und diese Sitzung hat wieder einen Fehler gezeigt, den die Suite nur
|
||||||
|
deshalb gefunden hat, weil der Doppelgänger streng genug war.
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# #028 M15 – Quellenarten und Microsoft-Graph-Quelle
|
# #028 M15 – Quellenarten und Microsoft-Graph-Quelle
|
||||||
|
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Im Tracker:** [#48](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/48)
|
**Im Tracker:** [#48](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/48)
|
||||||
**Meilenstein:** v0.2.0
|
**Meilenstein:** v0.2.0
|
||||||
**Labels:** phase-2, backend, frontend
|
**Labels:** phase-2, backend, frontend
|
||||||
**Geschätzt:** 10 h **Tatsächlich:** –
|
**Geschätzt:** 10 h **Tatsächlich:** 4 h 10 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
|
|
||||||
@@ -17,39 +17,94 @@ Dieser Meilenstein legt dazu die Weiche, die auch [#029](029-scim-endpunkt.md) b
|
|||||||
|
|
||||||
## Zu entscheiden
|
## Zu entscheiden
|
||||||
|
|
||||||
- [ ] **Ein Enum `QuellenArt {LDAP GRAPH SCIM}` auf `Verzeichnisquelle`** — Empfehlung. Die Felder
|
- [x] **Ein Enum `QuellenArt {LDAP GRAPH SCIM}` auf `Verzeichnisquelle`** — Empfehlung. Die Felder
|
||||||
`url`, `bindDn` und `basisDn` sind dann je Art unterschiedlich belegt; was eine Art braucht,
|
`url`, `bindDn` und `basisDn` sind dann je Art unterschiedlich belegt; was eine Art braucht,
|
||||||
prüft der Service, nicht die Datenbank. Die Alternative wäre eine Tabelle je Art mit
|
prüft der Service, nicht die Datenbank. Die Alternative wäre eine Tabelle je Art mit
|
||||||
gemeinsamer Oberklasse — mehr Struktur, als drei Arten rechtfertigen.
|
gemeinsamer Oberklasse — mehr Struktur, als drei Arten rechtfertigen.
|
||||||
- [ ] **Wie `{{ou}}` in Entra ID aussieht.** Es gibt keinen DN und keine OU. Ehrlich ist, den
|
- [x] **Wie `{{ou}}` in Entra ID aussieht.** Es gibt keinen DN und keine OU. Ehrlich ist, den
|
||||||
Platzhalter bei einer Graph-Quelle **leer** zu lassen, statt etwas hineinzudeuten — und das
|
Platzhalter bei einer Graph-Quelle **leer** zu lassen, statt etwas hineinzudeuten — und das
|
||||||
in der Anleitung zu sagen. Wer OU-abhängige Befehle fährt, braucht ein lokales AD.
|
in der Anleitung zu sagen. Wer OU-abhängige Befehle fährt, braucht ein lokales AD.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
|
|
||||||
- [ ] `QuellenArt` als Enum, Migration mit `LDAP` für alle bestehenden Zeilen
|
- [x] `QuellenArt` als Enum, Migration mit `LDAP` für alle bestehenden Zeilen
|
||||||
- [ ] Graph-Zugang: `tenantId`, `clientId`, `clientSecret` (verschlüsselt wie das Bind-Passwort, nie
|
- [x] Graph-Zugang: `tenantId`, `clientId`, `clientSecret` (verschlüsselt wie das Bind-Passwort, nie
|
||||||
in einer Antwort), Token über Client-Credentials-Flow, Zwischenspeicher bis kurz vor Ablauf
|
in einer Antwort), Token über Client-Credentials-Flow, Zwischenspeicher bis kurz vor Ablauf
|
||||||
- [ ] Lesen über `GET /v1.0/users` mit `$select`, Seitenabruf über `@odata.nextLink`; der
|
- [x] Lesen über `GET /v1.0/users` mit `$select`, Seitenabruf über `@odata.nextLink`; der
|
||||||
Vorgesetzte über `$expand=manager($select=id)`
|
Vorgesetzte über `$expand=manager($select=id)`
|
||||||
- [ ] Attributzuordnung mit Graph-Vorgaben (`givenName`, `surname`, `displayName`, `mail`,
|
- [x] Attributzuordnung mit Graph-Vorgaben (`givenName`, `surname`, `displayName`, `mail`,
|
||||||
`userPrincipalName`, `onPremisesSamAccountName`, `department`, `jobTitle`, `accountEnabled`)
|
`userPrincipalName`, `onPremisesSamAccountName`, `department`, `jobTitle`, `accountEnabled`)
|
||||||
- [ ] `externeId` ist die Entra-`id` (GUID). Sie ist stabil und übersteht eine Umbenennung
|
- [x] `externeId` ist die Entra-`id` (GUID). Sie ist stabil und übersteht eine Umbenennung
|
||||||
- [ ] „Verbindung testen“ wie bei LDAP: die ersten zehn zugeordneten Treffer zeigen
|
- [x] „Verbindung testen“ wie bei LDAP: die ersten zehn zugeordneten Treffer zeigen
|
||||||
- [ ] Der Abgleich läuft durch **denselben** Dienst wie LDAP — nur das Holen der Einträge
|
- [x] Der Abgleich läuft durch **denselben** Dienst wie LDAP — nur das Holen der Einträge
|
||||||
unterscheidet sich. Sonst driften die Regeln auseinander
|
unterscheidet sich. Sonst driften die Regeln auseinander
|
||||||
- [ ] Admin → Verzeichnis zeigt die Art je Quelle und bietet beim Anlegen die Wahl
|
- [x] Admin → Verzeichnis zeigt die Art je Quelle und bietet beim Anlegen die Wahl
|
||||||
- [ ] Anleitung Kapitel 06 und `docs/betrieb/`: was in Entra ID einzurichten ist
|
- [x] Anleitung Kapitel 06 und `docs/betrieb/`: was in Entra ID einzurichten ist
|
||||||
(App-Registrierung, `User.Read.All`, `Directory.Read.All`, Administratoreinwilligung)
|
(App-Registrierung, `User.Read.All`, `Directory.Read.All`, Administratoreinwilligung)
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
- [ ] Test gegen einen **abspielenden HTTP-Doppelgänger** mit aufgezeichneten Graph-Antworten: erster
|
- [x] Test gegen einen **abspielenden HTTP-Doppelgänger** mit aufgezeichneten Graph-Antworten: erster
|
||||||
Abgleich legt an, zweiter aktualisiert ohne Dubletten, ein `accountEnabled: false` setzt
|
Abgleich legt an, zweiter aktualisiert ohne Dubletten, ein `accountEnabled: false` setzt
|
||||||
`aktiv = false`
|
`aktiv = false`
|
||||||
- [ ] Test: der Seitenabruf folgt `@odata.nextLink` über mindestens drei Seiten
|
- [x] Test: der Seitenabruf folgt `@odata.nextLink` über mindestens drei Seiten
|
||||||
- [ ] Test: `clientSecret` erscheint in keiner Antwort und in keinem Protokolleintrag
|
- [x] Test: `clientSecret` erscheint in keiner Antwort und in keinem Protokolleintrag
|
||||||
- [ ] Test: eine Graph-Quelle lässt `{{ou}}` leer, statt einen Wert zu erfinden
|
- [x] Test: eine Graph-Quelle lässt `{{ou}}` leer, statt einen Wert zu erfinden
|
||||||
- [ ] Der LDAP-Weg verhält sich unverändert — alle Tests aus #016 bleiben grün
|
- [x] Der LDAP-Weg verhält sich unverändert — alle Tests aus #016 bleiben grün
|
||||||
- [ ] **Offen und benannt:** gegen einen echten Entra-Mandanten ist das nicht gelaufen; die
|
- [x] **Offen und benannt:** gegen einen echten Entra-Mandanten ist das nicht gelaufen; die
|
||||||
Gegenprobe gehört in eine Prüfliste wie bei [#024](024-proxmox-echtlauf.md)
|
Gegenprobe gehört in eine Prüfliste wie bei [#024](024-proxmox-echtlauf.md)
|
||||||
|
|
||||||
|
## Beim Umsetzen dazugekommen
|
||||||
|
|
||||||
|
- **„Deaktiviert in Entra ID“ als *übersprungen* zu melden war falsch — und hätte Leute aktiv
|
||||||
|
gelassen.** „Übersprungen“ bedeutet im Abgleich „nicht zuordenbar“, und solche Einträge werden
|
||||||
|
seit #016 absichtlich **nicht** abgeschaltet (ein Konto ohne GUID soll nicht die Person
|
||||||
|
deaktivieren). Ein gesperrtes Entra-Konto wäre damit genau umgekehrt behandelt worden als im AD.
|
||||||
|
Gefiltert wird jetzt serverseitig mit `$filter=accountEnabled eq true`, als Gegenstück zum
|
||||||
|
LDAP-Standardfilter. Gefunden hat es der Test „setzt aktiv = false“ — und nur, weil der
|
||||||
|
Doppelgänger den Filter wirklich auswertet statt ihn bloß zu empfangen.
|
||||||
|
- **`URLSearchParams` kodiert `$` zu `%24`.** Die Abfrage hätte `%24select` und `%28` in
|
||||||
|
`manager($select=id)` geschickt. Graph nimmt das teils an und antwortet teils mit „Invalid expand
|
||||||
|
clause“ — und eine Adresse, die in einem Mitschnitt anders aussieht als in der Dokumentation,
|
||||||
|
kostet bei der Fehlersuche eine Stunde. Die Parameter werden jetzt von Hand zusammengesetzt.
|
||||||
|
- **Die Art einer Quelle ist unveränderlich**, und das steht nicht im Issue. An ihr hängt, was
|
||||||
|
`externeId` bedeutet: im AD eine `objectGUID`, in Entra ID eine andere GUID. Nach einem Wechsel
|
||||||
|
würde **kein einziger Mitarbeiter wiedererkannt** — der nächste Abgleich legte die ganze
|
||||||
|
Belegschaft neu an und setzte die alten Zeilen auf `aktiv = false`, mit allen Läufen und Geräten
|
||||||
|
daran. Der Dienst weist einen Wechsel ab, die Oberfläche bietet ihn nicht an, und die Anleitung
|
||||||
|
sagt, was stattdessen zu tun ist.
|
||||||
|
- **`zuordnungLesen` brauchte die Art als Parameter.** Ohne sie bekäme eine Graph-Quelle mit leerer
|
||||||
|
Zuordnung die AD-Namen und fragte bei Graph nach `sn` und `objectGUID`, die es dort nicht gibt.
|
||||||
|
Der Standardwert `LDAP` hält jeden Aufrufer von vor #028 bei seinem Verhalten.
|
||||||
|
- **Die Zugangs-Helfer mussten in ein eigenes Modul.** `quellenleser.ts` (die Weiche) braucht
|
||||||
|
`zugangAus`, und `services/verzeichnis.ts` braucht die Weiche für „Verbindung testen“ — zusammen
|
||||||
|
ein Import-Zyklus. Der läuft in ESM oft, aber nicht zuverlässig, und ein Modul, das beim Laden
|
||||||
|
halb leer ist, ergibt Fehler, die niemand liest. Jetzt liegt beides in
|
||||||
|
`src/server/verzeichnis/zugang.ts`.
|
||||||
|
- **`SCIM` steht im Enum, wird aber abgewiesen.** Die Weiche braucht #029, und ein Wert, der
|
||||||
|
stillschweigend nichts tut, wäre hier besonders teuer: ein Abgleich, der null Einträge liest und
|
||||||
|
`ERFOLG` meldet, hätte über die Abschaltregel die ganze Belegschaft deaktiviert — genau der Fall,
|
||||||
|
der in #016 einen Reviewbefund gekostet hat. Mit eigenem Test.
|
||||||
|
- **Die Felder je Art hält die Datenbank fest.** Ein CHECK verlangt bei `LDAP` die LDAP-Felder
|
||||||
|
**und** verbietet die Graph-Felder, bei `GRAPH` umgekehrt. Ohne den zweiten Teil wäre eine
|
||||||
|
Graph-Quelle mit `basisDn` möglich — eine Zeile, die zwei Dinge gleichzeitig behauptet.
|
||||||
|
- **Der Vorgesetzte kommt bei Graph direkt**, nicht über eine DN-Auflösung. `RoherEintrag` hat dafür
|
||||||
|
ein zweites Feld (`vorgesetzterDirekt`) und nicht ein Feld mit zwei Bedeutungen: ein DN und eine
|
||||||
|
GUID sehen verschieden aus, aber ein Feld, das mal das eine und mal das andere trägt, verwechselt
|
||||||
|
irgendwann jemand.
|
||||||
|
- **Der Token-Zwischenspeicher hat 60 Sekunden Sicherheitsabstand.** Ein Token, das in zehn Sekunden
|
||||||
|
abläuft, ist für eine Seite, die zwei Sekunden braucht, formal noch gültig — und in der Mitte der
|
||||||
|
nächsten nicht mehr. Der Fehler wäre ein Abgleich, der nach der dritten Seite mit 401 abbricht.
|
||||||
|
- **Eine Reißleine bei 1000 Seiten.** Ein `@odata.nextLink`, der auf sich selbst zeigt, wäre sonst
|
||||||
|
eine Schleife bis zum Speicherende — still, weil jeder einzelne Abruf gelingt.
|
||||||
|
- **Zwei eigene Testfehler**, keine Produktfehler: die Token-Grenze liegt bei +3540 s und nicht bei
|
||||||
|
„so ungefähr einer Stunde“, und die Klickprobe zählte die Felder der Bearbeiten-Formulare der
|
||||||
|
bestehenden Quellen mit.
|
||||||
|
|
||||||
|
## Noch offen, und benannt
|
||||||
|
|
||||||
|
Gegen einen **echten Entra-Mandanten** ist das nicht gelaufen. Geprüft ist es gegen einen
|
||||||
|
HTTP-Doppelgänger mit aufgezeichneten Antworten, und deren Form stammt aus der Dokumentation, nicht
|
||||||
|
aus einem Mitschnitt. Die Prüfliste steht am Ende von
|
||||||
|
[`docs/betrieb/entra-id.md`](../betrieb/entra-id.md) — acht Punkte, darunter ein Mandant mit mehr als
|
||||||
|
999 Benutzern für den Seitenabruf. Dieselbe Art offener Punkt wie [#024](024-proxmox-echtlauf.md).
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
|
|||||||
| #025 | [Healthcheck gibt die rohe Datenbankfehlermeldung heraus](025-gesundheit-fehlermeldung.md) | erledigt | v0.2.0 | 1 h | 25 min |
|
| #025 | [Healthcheck gibt die rohe Datenbankfehlermeldung heraus](025-gesundheit-fehlermeldung.md) | erledigt | v0.2.0 | 1 h | 25 min |
|
||||||
| #026 | [Zwei Konventionen präzisieren, die strenger klingen als sie gemeint sind](026-konventionen-praezisieren.md) | erledigt | v0.2.0 | 1 h | 20 min |
|
| #026 | [Zwei Konventionen präzisieren, die strenger klingen als sie gemeint sind](026-konventionen-praezisieren.md) | erledigt | v0.2.0 | 1 h | 20 min |
|
||||||
| #027 | [Die Editor-Abläufe lassen ihre Vorlagen stehen](027-e2e-reste-vorlagen.md) | offen | v0.2.0 | 1 h | – |
|
| #027 | [Die Editor-Abläufe lassen ihre Vorlagen stehen](027-e2e-reste-vorlagen.md) | offen | v0.2.0 | 1 h | – |
|
||||||
| #028 | [M15 – Quellenarten und Microsoft-Graph-Quelle](028-graph-quelle.md) | offen | v0.2.0 | 10 h | – |
|
| #028 | [M15 – Quellenarten und Microsoft-Graph-Quelle](028-graph-quelle.md) | erledigt | v0.2.0 | 10 h | 4 h 10 min |
|
||||||
| #029 | [M16 – SCIM-Endpunkt als Alternative zum Abgleich](029-scim-endpunkt.md) | offen | v0.2.0 | 12 h | – |
|
| #029 | [M16 – SCIM-Endpunkt als Alternative zum Abgleich](029-scim-endpunkt.md) | offen | v0.2.0 | 12 h | – |
|
||||||
| #030 | [M17 – Wiederverwendbare Bausteine](030-bausteine.md) | erledigt | v0.2.0 | 12 h | 2 h 25 min |
|
| #030 | [M17 – Wiederverwendbare Bausteine](030-bausteine.md) | erledigt | v0.2.0 | 12 h | 2 h 25 min |
|
||||||
| #031 | [M18 – Abteilungsspezifische Zusatzabschnitte](031-abteilungsabschnitte.md) | erledigt | v0.2.0 | 8 h | 1 h 35 min |
|
| #031 | [M18 – Abteilungsspezifische Zusatzabschnitte](031-abteilungsabschnitte.md) | erledigt | v0.2.0 | 8 h | 1 h 35 min |
|
||||||
@@ -40,4 +40,4 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
|
|||||||
| #033 | [M20 – Live-Aktualisierung per SSE statt Polling](033-sse-live.md) | erledigt | v0.2.0 | 6 h | 1 h 40 min |
|
| #033 | [M20 – Live-Aktualisierung per SSE statt Polling](033-sse-live.md) | erledigt | v0.2.0 | 6 h | 1 h 40 min |
|
||||||
| #034 | [M21 – Benachrichtigungen per Mail und Webhook](034-benachrichtigungen.md) | erledigt | v0.2.0 | 10 h | 3 h 30 min |
|
| #034 | [M21 – Benachrichtigungen per Mail und Webhook](034-benachrichtigungen.md) | erledigt | v0.2.0 | 10 h | 3 h 30 min |
|
||||||
| #035 | [M22 – Mitarbeiter-Selbstauskunft](035-selbstauskunft.md) | erledigt | v0.2.0 | 8 h | 2 h 50 min |
|
| #035 | [M22 – Mitarbeiter-Selbstauskunft](035-selbstauskunft.md) | erledigt | v0.2.0 | 8 h | 2 h 50 min |
|
||||||
| | **Summe** | | | **229 h** | **56 h 25 min** |
|
| | **Summe** | | | **229 h** | **60 h 35 min** |
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- HANDGESCHRIEBEN. Prisma erzeugt hier zusaetzlich Loeschbefehle fuer die
|
||||||
|
-- zusammengesetzten Fremdschluessel (Mandantengrenze), die Trigramm-Indizes
|
||||||
|
-- (Archivsuche), die partiellen eindeutigen Indizes und alle Trigger: es kennt
|
||||||
|
-- sie nicht, weil sie sich im Schema nicht ausdruecken lassen. Diese
|
||||||
|
-- Loeschbefehle sind bewusst gestrichen.
|
||||||
|
--
|
||||||
|
-- tests/datenbankregeln.test.ts prueft, dass alle diese Objekte vorhanden sind –
|
||||||
|
-- ein versehentlich stehengelassener Loeschbefehl faellt dort sofort auf.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Quellenarten und Microsoft-Graph-Quelle (#028).
|
||||||
|
|
||||||
|
CREATE TYPE "QuellenArt" AS ENUM ('LDAP', 'GRAPH', 'SCIM');
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **Die Reihenfolge der drei Schritte ist nicht beliebig.**
|
||||||
|
*
|
||||||
|
* Erst die Spalte mit Vorgabe `LDAP` anlegen, dann die bisherigen
|
||||||
|
* Pflichtfelder lockern, und erst danach den CHECK setzen. Umgekehrt waere der
|
||||||
|
* CHECK fuer die Dauer einer Anweisung gegen Zeilen gerichtet, die ihn noch
|
||||||
|
* nicht erfuellen koennen.
|
||||||
|
*
|
||||||
|
* Die Vorgabe `LDAP` ist zugleich die Nachfuehrung fuer alle bestehenden Zeilen:
|
||||||
|
* vor #028 war jede Quelle implizit LDAP, und `ADD COLUMN ... DEFAULT` schreibt
|
||||||
|
* den Wert in jede vorhandene Zeile. Ein eigenes `UPDATE` braucht es deshalb
|
||||||
|
* nicht — aber die Pruefung unten steht trotzdem da.
|
||||||
|
*/
|
||||||
|
ALTER TABLE "Verzeichnisquelle"
|
||||||
|
ADD COLUMN "art" "QuellenArt" NOT NULL DEFAULT 'LDAP';
|
||||||
|
|
||||||
|
-- Die LDAP-Felder gelten nur noch fuer LDAP-Quellen.
|
||||||
|
ALTER TABLE "Verzeichnisquelle"
|
||||||
|
ALTER COLUMN "url" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "bindDn" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "bindPasswortVerschluesselt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "basisDn" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "filter" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- Und die Felder einer Graph-Quelle.
|
||||||
|
ALTER TABLE "Verzeichnisquelle"
|
||||||
|
ADD COLUMN "tenantId" TEXT,
|
||||||
|
ADD COLUMN "clientId" TEXT,
|
||||||
|
ADD COLUMN "clientSecretVerschluesselt" TEXT;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **Welche Felder eine Art braucht, haelt die Datenbank fest.**
|
||||||
|
*
|
||||||
|
* Ohne das waere eine Graph-Quelle mit `basisDn` moeglich — eine Zeile, die zwei
|
||||||
|
* Dinge gleichzeitig behauptet, und bei der niemand mehr sagen kann, was gilt.
|
||||||
|
* Der Dienst prueft dasselbe noch einmal, nur mit besserer Meldung.
|
||||||
|
*
|
||||||
|
* `SCIM` fehlt hier mit Absicht: die Art steht im Enum, weil #029 dieselbe
|
||||||
|
* Weiche braucht, hat aber noch keine Felder. Eine Bedingung auf Felder, die es
|
||||||
|
* nicht gibt, waere eine Zeile, die beim naechsten Meilenstein umgeschrieben
|
||||||
|
* werden muss — und der Dienst weist `SCIM` bis dahin ohnehin ab.
|
||||||
|
*/
|
||||||
|
ALTER TABLE "Verzeichnisquelle"
|
||||||
|
ADD CONSTRAINT "Verzeichnisquelle_felder_je_art"
|
||||||
|
CHECK (
|
||||||
|
("art" = 'LDAP' AND "url" IS NOT NULL AND "bindDn" IS NOT NULL
|
||||||
|
AND "bindPasswortVerschluesselt" IS NOT NULL AND "basisDn" IS NOT NULL
|
||||||
|
AND "filter" IS NOT NULL
|
||||||
|
AND "tenantId" IS NULL AND "clientId" IS NULL AND "clientSecretVerschluesselt" IS NULL)
|
||||||
|
OR
|
||||||
|
("art" = 'GRAPH' AND "tenantId" IS NOT NULL AND "clientId" IS NOT NULL
|
||||||
|
AND "clientSecretVerschluesselt" IS NOT NULL
|
||||||
|
AND "url" IS NULL AND "bindDn" IS NULL AND "bindPasswortVerschluesselt" IS NULL
|
||||||
|
AND "basisDn" IS NULL AND "filter" IS NULL)
|
||||||
|
OR
|
||||||
|
("art" = 'SCIM')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Das Client-Secret verschluesselt, dieselbe Pruefung wie beim Bind-Passwort.
|
||||||
|
ALTER TABLE "Verzeichnisquelle"
|
||||||
|
ADD CONSTRAINT "Verzeichnisquelle_clientsecret_verschluesselt"
|
||||||
|
CHECK ("clientSecretVerschluesselt" ~ '^v[0-9]+\.');
|
||||||
|
|
||||||
|
-- Der Zeitplan fragt nach faelligen **aktiven** Quellen; die Art kommt jetzt in
|
||||||
|
-- dieselbe Abfrage, weil das Holen der Eintraege je Art ein anderes ist.
|
||||||
|
CREATE INDEX "Verzeichnisquelle_art_idx" ON "Verzeichnisquelle"("art");
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Gegenprobe zur Nachfuehrung: nach dem `ADD COLUMN ... DEFAULT` darf keine
|
||||||
|
* Zeile ohne Art dastehen. `art` ist NOT NULL, das kann also nicht schiefgehen —
|
||||||
|
* aber eine Migration, die ihre eigene Nachfuehrung behauptet, ohne sie zu
|
||||||
|
* pruefen, ist genau die Art Zusage, die dieses Projekt teuer bezahlt hat.
|
||||||
|
*/
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
ohne_art INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*) INTO ohne_art FROM "Verzeichnisquelle" WHERE "art" IS NULL;
|
||||||
|
IF ohne_art > 0 THEN
|
||||||
|
RAISE EXCEPTION 'Nachfuehrung fehlgeschlagen: % Quellen ohne Art.', ohne_art;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
+31
-7
@@ -314,18 +314,42 @@ model BenutzerKunde {
|
|||||||
/// genau eine (das Firmen-AD); mehrere sind erlaubt, etwa eine zweite Domaene.
|
/// genau eine (das Firmen-AD); mehrere sind erlaubt, etwa eine zweite Domaene.
|
||||||
/// Nur fuer die eigene Organisation – im Modus DIENSTLEISTUNG gibt es keinen
|
/// Nur fuer die eigene Organisation – im Modus DIENSTLEISTUNG gibt es keinen
|
||||||
/// Abgleich zu Kunden-ADs.
|
/// Abgleich zu Kunden-ADs.
|
||||||
|
/// Woher eine Verzeichnisquelle liest (#028).
|
||||||
|
///
|
||||||
|
/// Bis #028 war jede Quelle implizit LDAP. `SCIM` steht hier bereits, weil #029
|
||||||
|
/// dieselbe Weiche braucht — behandelt wird es noch nicht, und der Dienst weist
|
||||||
|
/// es mit einer Meldung ab, statt stillschweigend nichts zu tun.
|
||||||
|
enum QuellenArt {
|
||||||
|
LDAP
|
||||||
|
GRAPH
|
||||||
|
SCIM
|
||||||
|
}
|
||||||
|
|
||||||
model Verzeichnisquelle {
|
model Verzeichnisquelle {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
kundeId String
|
kundeId String
|
||||||
name String
|
name String
|
||||||
aktiv Boolean @default(false)
|
aktiv Boolean @default(false)
|
||||||
url String
|
/// Bestehende Zeilen sind LDAP – das war vor #028 die einzige Art.
|
||||||
bindDn String
|
art QuellenArt @default(LDAP)
|
||||||
|
/// Ab hier: **nur bei `art = LDAP`** gesetzt. Welche Felder eine Art braucht,
|
||||||
|
/// haelt der CHECK `Verzeichnisquelle_felder_je_art` fest — eine Graph-Quelle
|
||||||
|
/// mit einem `basisDn` waere eine Zeile, die zwei Dinge gleichzeitig behauptet.
|
||||||
|
url String?
|
||||||
|
bindDn String?
|
||||||
/// AES-256-GCM aus APP_SECRET. Darf keine API-Antwort erreichen; dafuer sorgt
|
/// AES-256-GCM aus APP_SECRET. Darf keine API-Antwort erreichen; dafuer sorgt
|
||||||
/// der Serializer in der Service-Schicht, abgedeckt durch einen eigenen Test.
|
/// der Serializer in der Service-Schicht, abgedeckt durch einen eigenen Test.
|
||||||
bindPasswortVerschluesselt String
|
bindPasswortVerschluesselt String?
|
||||||
basisDn String
|
basisDn String?
|
||||||
filter String
|
filter String?
|
||||||
|
|
||||||
|
/// Ab hier: **nur bei `art = GRAPH`**. Der Mandant in Entra ID und die
|
||||||
|
/// App-Registrierung, mit der gelesen wird (Client-Credentials-Flow).
|
||||||
|
tenantId String?
|
||||||
|
clientId String?
|
||||||
|
/// Verschluesselt wie das Bind-Passwort und mit demselben Vorsatz: es darf
|
||||||
|
/// keine Antwort und keinen Protokolleintrag erreichen.
|
||||||
|
clientSecretVerschluesselt String?
|
||||||
/// Zuordnung der LDAP-Attribute auf unsere Felder, mit AD-Vorgaben.
|
/// Zuordnung der LDAP-Attribute auf unsere Felder, mit AD-Vorgaben.
|
||||||
attributZuordnung Json
|
attributZuordnung Json
|
||||||
/// Eigenes CA-Zertifikat, falls ldaps gegen eine interne CA laeuft.
|
/// Eigenes CA-Zertifikat, falls ldaps gegen eine interne CA laeuft.
|
||||||
|
|||||||
@@ -164,8 +164,21 @@ export default async function VerzeichnisSeite() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
* Die Art steht **an jeder Quelle** (#028). Ohne sie waere bei
|
||||||
|
* zwei Zeilen nicht zu sehen, welche aus dem AD liest und welche
|
||||||
|
* aus Entra ID — und die Felder darunter heissen je Art anders.
|
||||||
|
*/}
|
||||||
<p className="mono mt-2 text-xs break-all text-nebentext">
|
<p className="mono mt-2 text-xs break-all text-nebentext">
|
||||||
{quelle.url} · {quelle.basisDn}
|
{quelle.art === "GRAPH" ? (
|
||||||
|
<>
|
||||||
|
Microsoft Graph · {quelle.tenantId} · App {quelle.clientId}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
LDAP · {quelle.url} · {quelle.basisDn}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Status quelle={quelle} />
|
<Status quelle={quelle} />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { KeyRound, Save, Server, ShieldAlert, ShieldCheck } from "lucide-react";
|
|||||||
import { FELD, KAESTCHEN } from "@/lib/formular";
|
import { FELD, KAESTCHEN } from "@/lib/formular";
|
||||||
import {
|
import {
|
||||||
AD_ZUORDNUNG,
|
AD_ZUORDNUNG,
|
||||||
|
GRAPH_ZUORDNUNG,
|
||||||
INTERVALL_HOECHSTENS,
|
INTERVALL_HOECHSTENS,
|
||||||
INTERVALL_MINDESTENS,
|
INTERVALL_MINDESTENS,
|
||||||
ZUORDNUNGS_BESCHRIFTUNG,
|
ZUORDNUNGS_BESCHRIFTUNG,
|
||||||
@@ -36,6 +37,14 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
|
|||||||
const [zuordnungOffen, setzeZuordnungOffen] = useState(false);
|
const [zuordnungOffen, setzeZuordnungOffen] = useState(false);
|
||||||
const [adresse, setzeAdresse] = useState(quelle?.url ?? "");
|
const [adresse, setzeAdresse] = useState(quelle?.url ?? "");
|
||||||
const klartext = adresse.trim().startsWith("ldap://");
|
const klartext = adresse.trim().startsWith("ldap://");
|
||||||
|
/*
|
||||||
|
* Die Art ist beim Anlegen waehlbar und beim Bearbeiten **fest** (#028): an
|
||||||
|
* ihr haengt, was die dauerhafte Kennung bedeutet, und nach einem Wechsel
|
||||||
|
* wuerde kein Mitarbeiter wiedererkannt. Der Dienst weist einen Wechsel
|
||||||
|
* ohnehin ab; hier gibt es ihn gar nicht erst zur Auswahl.
|
||||||
|
*/
|
||||||
|
const [art, setzeArt] = useState<"LDAP" | "GRAPH">(quelle?.art === "GRAPH" ? "GRAPH" : "LDAP");
|
||||||
|
const istGraph = art === "GRAPH";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form action={aktion} className="flex flex-col gap-4">
|
<form action={aktion} className="flex flex-col gap-4">
|
||||||
@@ -52,108 +61,213 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{/*
|
{bearbeiten ? (
|
||||||
* Die Erklärung steht **außerhalb** des `label` und hängt über
|
<>
|
||||||
* `aria-describedby` am Feld. Im `label` wäre sie Teil des Feldnamens:
|
<input type="hidden" name="art" value={art} />
|
||||||
* ein Screenreader läse „Adresse Drei Formen: ldaps:// …“ als Bezeichnung
|
<p className="text-sm text-nebentext">
|
||||||
* des Eingabefelds, und kein Werkzeug fände das Feld noch über seinen
|
Art: <strong className="text-text">{istGraph ? "Microsoft Graph" : "LDAP"}</strong> — sie lässt
|
||||||
* Namen.
|
sich nicht ändern. Daran hängt, was die dauerhafte Kennung bedeutet; nach einem Wechsel würde kein
|
||||||
*/}
|
Mitarbeiter wiedererkannt.
|
||||||
<div className="flex flex-col gap-1.5 text-sm">
|
</p>
|
||||||
<label htmlFor="quelle-url" className="text-nebentext">
|
</>
|
||||||
Adresse
|
) : (
|
||||||
</label>
|
<fieldset className="flex flex-col gap-2 text-sm">
|
||||||
<input
|
<legend className="mb-1 text-nebentext">Art</legend>
|
||||||
id="quelle-url"
|
{(
|
||||||
name="url"
|
[
|
||||||
value={adresse}
|
{
|
||||||
onChange={(ereignis) => setzeAdresse(ereignis.target.value)}
|
wert: "LDAP" as const,
|
||||||
required
|
titel: "LDAP",
|
||||||
aria-describedby="quelle-url-hinweis"
|
text: "Ein lokales Active Directory oder ein anderes LDAP-Verzeichnis.",
|
||||||
placeholder="ldaps://dc01.firma.local:636"
|
},
|
||||||
className={`${FELD} mono`}
|
{
|
||||||
/>
|
wert: "GRAPH" as const,
|
||||||
<span id="quelle-url-hinweis" className="text-xs text-nebentext">
|
titel: "Microsoft Graph",
|
||||||
Drei Formen: <span className="mono">ldaps://</span> (verschlüsselt ab dem ersten Byte, die richtige
|
text: "Entra ID (Microsoft 365). Für Häuser ohne lokales AD.",
|
||||||
Wahl), <span className="mono">ldap+starttls://</span> (unverschlüsselt verbunden und dann
|
},
|
||||||
hochgestuft) und <span className="mono">ldap://</span>.
|
] satisfies readonly { wert: "LDAP" | "GRAPH"; titel: string; text: string }[]
|
||||||
</span>
|
).map((wahl) => (
|
||||||
{klartext ? (
|
<label
|
||||||
/*
|
key={wahl.wert}
|
||||||
* Die Warnung erscheint erst, wenn sie zutrifft — eine, die immer
|
className="flex min-h-11 cursor-pointer items-start gap-3 rounded-karte border border-linie p-3 md:min-h-10"
|
||||||
* dasteht, liest nach dem dritten Mal niemand mehr.
|
>
|
||||||
*/
|
<input
|
||||||
<span role="status" className="flex items-start gap-2 text-xs text-faellig">
|
type="radio"
|
||||||
<ShieldAlert className="mt-0.5 size-3.5 shrink-0" strokeWidth={2} aria-hidden="true" />
|
name="art"
|
||||||
<span>
|
value={wahl.wert}
|
||||||
Mit <span className="mono">ldap://</span> geht auch das Bind-Passwort im Klartext über das Netz.
|
checked={art === wahl.wert}
|
||||||
Für ein Dienstkonto im Active Directory ist das keine gute Idee.
|
onChange={() => setzeArt(wahl.wert)}
|
||||||
|
className="mt-0.5 size-4 accent-akzent"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="font-medium">{wahl.titel}</span>
|
||||||
|
<span className="block text-xs text-nebentext">{wahl.text}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{istGraph ? (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<label className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">Mandanten-ID</span>
|
||||||
|
<input
|
||||||
|
name="tenantId"
|
||||||
|
defaultValue={quelle?.tenantId ?? ""}
|
||||||
|
required
|
||||||
|
placeholder="contoso.onmicrosoft.com oder die GUID"
|
||||||
|
className={`${FELD} mono`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">Anwendungs-ID</span>
|
||||||
|
<input
|
||||||
|
name="clientId"
|
||||||
|
defaultValue={quelle?.clientId ?? ""}
|
||||||
|
required
|
||||||
|
placeholder="GUID der App-Registrierung"
|
||||||
|
className={`${FELD} mono`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<label htmlFor="quelle-secret" className="flex items-center gap-2 text-nebentext">
|
||||||
|
<KeyRound className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
Client-Secret
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="quelle-secret"
|
||||||
|
name="clientSecret"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
aria-describedby="quelle-secret-hinweis"
|
||||||
|
placeholder={quelle?.clientSecretVorhanden ? "unverändert lassen" : ""}
|
||||||
|
required={!bearbeiten}
|
||||||
|
className={FELD}
|
||||||
|
/>
|
||||||
|
<span id="quelle-secret-hinweis" className="text-xs text-nebentext">
|
||||||
|
{quelle?.clientSecretVorhanden
|
||||||
|
? "Leer lassen behält das gespeicherte Geheimnis."
|
||||||
|
: "Wird verschlüsselt gespeichert und nie wieder angezeigt."}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</div>
|
||||||
) : null}
|
<p className="max-w-prose text-xs text-nebentext">
|
||||||
</div>
|
Die App-Registrierung braucht die <strong>Anwendungsberechtigung</strong>{" "}
|
||||||
|
<span className="mono">User.Read.All</span> mit Administratoreinwilligung. Wie das eingerichtet
|
||||||
|
wird, steht in <span className="mono">docs/betrieb/entra-id.md</span>. Entra ID kennt keine OU,
|
||||||
|
deshalb bleibt <span className="mono">{"{{ou}}"}</span> bei dieser Art leer.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
{!istGraph ? (
|
||||||
<label className="flex flex-col gap-1.5 text-sm">
|
<>
|
||||||
<span className="text-nebentext">Anmelde-DN</span>
|
{/*
|
||||||
<input
|
* Die Erklärung steht **außerhalb** des `label` und hängt über
|
||||||
name="bindDn"
|
* `aria-describedby` am Feld. Im `label` wäre sie Teil des Feldnamens:
|
||||||
defaultValue={quelle?.bindDn ?? ""}
|
* ein Screenreader läse „Adresse Drei Formen: ldaps:// …“ als Bezeichnung
|
||||||
required
|
* des Eingabefelds, und kein Werkzeug fände das Feld noch über seinen
|
||||||
placeholder="CN=laufzettel,OU=Dienstkonten,DC=firma,DC=local"
|
* Namen.
|
||||||
className={`${FELD} mono`}
|
*/}
|
||||||
/>
|
<div className="flex flex-col gap-1.5 text-sm">
|
||||||
</label>
|
<label htmlFor="quelle-url" className="text-nebentext">
|
||||||
<div className="flex flex-col gap-1.5 text-sm">
|
Adresse
|
||||||
<label htmlFor="quelle-passwort" className="flex items-center gap-2 text-nebentext">
|
</label>
|
||||||
<KeyRound className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
<input
|
||||||
Passwort
|
id="quelle-url"
|
||||||
|
name="url"
|
||||||
|
value={adresse}
|
||||||
|
onChange={(ereignis) => setzeAdresse(ereignis.target.value)}
|
||||||
|
required
|
||||||
|
aria-describedby="quelle-url-hinweis"
|
||||||
|
placeholder="ldaps://dc01.firma.local:636"
|
||||||
|
className={`${FELD} mono`}
|
||||||
|
/>
|
||||||
|
<span id="quelle-url-hinweis" className="text-xs text-nebentext">
|
||||||
|
Drei Formen: <span className="mono">ldaps://</span> (verschlüsselt ab dem ersten Byte, die
|
||||||
|
richtige Wahl), <span className="mono">ldap+starttls://</span> (unverschlüsselt verbunden und
|
||||||
|
dann hochgestuft) und <span className="mono">ldap://</span>.
|
||||||
|
</span>
|
||||||
|
{klartext ? (
|
||||||
|
/*
|
||||||
|
* Die Warnung erscheint erst, wenn sie zutrifft — eine, die immer
|
||||||
|
* dasteht, liest nach dem dritten Mal niemand mehr.
|
||||||
|
*/
|
||||||
|
<span role="status" className="flex items-start gap-2 text-xs text-faellig">
|
||||||
|
<ShieldAlert className="mt-0.5 size-3.5 shrink-0" strokeWidth={2} aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
Mit <span className="mono">ldap://</span> geht auch das Bind-Passwort im Klartext über das
|
||||||
|
Netz. Für ein Dienstkonto im Active Directory ist das keine gute Idee.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<label className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">Anmelde-DN</span>
|
||||||
|
<input
|
||||||
|
name="bindDn"
|
||||||
|
defaultValue={quelle?.bindDn ?? ""}
|
||||||
|
required
|
||||||
|
placeholder="CN=laufzettel,OU=Dienstkonten,DC=firma,DC=local"
|
||||||
|
className={`${FELD} mono`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<label htmlFor="quelle-passwort" className="flex items-center gap-2 text-nebentext">
|
||||||
|
<KeyRound className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
Passwort
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="quelle-passwort"
|
||||||
|
name="bindPasswort"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
aria-describedby="quelle-passwort-hinweis"
|
||||||
|
placeholder={bearbeiten ? "unverändert lassen" : ""}
|
||||||
|
required={!bearbeiten}
|
||||||
|
className={FELD}
|
||||||
|
/>
|
||||||
|
<span id="quelle-passwort-hinweis" className="text-xs text-nebentext">
|
||||||
|
{bearbeiten
|
||||||
|
? "Leer lassen behält das gespeicherte Passwort."
|
||||||
|
: "Wird verschlüsselt gespeichert und nie wieder angezeigt."}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">Basis-DN</span>
|
||||||
|
<input
|
||||||
|
name="basisDn"
|
||||||
|
defaultValue={quelle?.basisDn ?? ""}
|
||||||
|
required
|
||||||
|
placeholder="OU=Benutzer,DC=firma,DC=local"
|
||||||
|
className={`${FELD} mono`}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<input
|
|
||||||
id="quelle-passwort"
|
|
||||||
name="bindPasswort"
|
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
aria-describedby="quelle-passwort-hinweis"
|
|
||||||
placeholder={bearbeiten ? "unverändert lassen" : ""}
|
|
||||||
required={!bearbeiten}
|
|
||||||
className={FELD}
|
|
||||||
/>
|
|
||||||
<span id="quelle-passwort-hinweis" className="text-xs text-nebentext">
|
|
||||||
{bearbeiten
|
|
||||||
? "Leer lassen behält das gespeicherte Passwort."
|
|
||||||
: "Wird verschlüsselt gespeichert und nie wieder angezeigt."}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="flex flex-col gap-1.5 text-sm">
|
<div className="flex flex-col gap-1.5 text-sm">
|
||||||
<span className="text-nebentext">Basis-DN</span>
|
<label htmlFor="quelle-filter" className="text-nebentext">
|
||||||
<input
|
Filter (optional)
|
||||||
name="basisDn"
|
</label>
|
||||||
defaultValue={quelle?.basisDn ?? ""}
|
<input
|
||||||
required
|
id="quelle-filter"
|
||||||
placeholder="OU=Benutzer,DC=firma,DC=local"
|
name="filter"
|
||||||
className={`${FELD} mono`}
|
defaultValue={quelle?.filter ?? ""}
|
||||||
/>
|
aria-describedby="quelle-filter-hinweis"
|
||||||
</label>
|
className={`${FELD} mono`}
|
||||||
|
/>
|
||||||
<div className="flex flex-col gap-1.5 text-sm">
|
<span id="quelle-filter-hinweis" className="text-xs text-nebentext">
|
||||||
<label htmlFor="quelle-filter" className="text-nebentext">
|
Leer lassen nimmt den Standard: aktive Personenkonten. Er schließt deaktivierte Konten aus — wer
|
||||||
Filter (optional)
|
aus dem Ergebnis verschwindet, wird hier deaktiviert und nie gelöscht.
|
||||||
</label>
|
</span>
|
||||||
<input
|
</div>
|
||||||
id="quelle-filter"
|
</>
|
||||||
name="filter"
|
) : null}
|
||||||
defaultValue={quelle?.filter ?? ""}
|
|
||||||
aria-describedby="quelle-filter-hinweis"
|
|
||||||
className={`${FELD} mono`}
|
|
||||||
/>
|
|
||||||
<span id="quelle-filter-hinweis" className="text-xs text-nebentext">
|
|
||||||
Leer lassen nimmt den Standard: aktive Personenkonten. Er schließt deaktivierte Konten aus — wer aus
|
|
||||||
dem Ergebnis verschwindet, wird hier deaktiviert und nie gelöscht.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<label className="flex flex-col gap-1.5 text-sm">
|
<label className="flex flex-col gap-1.5 text-sm">
|
||||||
@@ -200,7 +314,7 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
|
|||||||
<input
|
<input
|
||||||
name={`attr.${feld}`}
|
name={`attr.${feld}`}
|
||||||
defaultValue={quelle?.attributZuordnung[feld] ?? AD_ZUORDNUNG[feld]}
|
defaultValue={quelle?.attributZuordnung[feld] ?? AD_ZUORDNUNG[feld]}
|
||||||
placeholder={AD_ZUORDNUNG[feld]}
|
placeholder={(istGraph ? GRAPH_ZUORDNUNG : AD_ZUORDNUNG)[feld]}
|
||||||
className={`${FELD} mono`}
|
className={`${FELD} mono`}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -212,48 +326,51 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
|
|||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details open={zertifikatOffen} onToggle={(e) => setzeZertifikatOffen(e.currentTarget.open)}>
|
{/* Ein eigenes CA-Zertifikat gilt nur fuer LDAP – Graph laeuft ueber oeffentliches TLS. */}
|
||||||
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
|
{!istGraph ? (
|
||||||
Eigenes CA-Zertifikat hinterlegen
|
<details open={zertifikatOffen} onToggle={(e) => setzeZertifikatOffen(e.currentTarget.open)}>
|
||||||
</summary>
|
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
|
||||||
{/*
|
Eigenes CA-Zertifikat hinterlegen
|
||||||
* Der Zertifikatstext selbst kommt nicht zurück in die Oberfläche — nur
|
</summary>
|
||||||
* die Auskunft, dass eines hinterlegt ist. Ohne diese Zeile sah das
|
{/*
|
||||||
* leere Feld beim Bearbeiten aus wie „kein Zertifikat“, und der Hinweis
|
* Der Zertifikatstext selbst kommt nicht zurück in die Oberfläche — nur
|
||||||
* darunter behauptete es auch (Reviewbefund zu #016).
|
* die Auskunft, dass eines hinterlegt ist. Ohne diese Zeile sah das
|
||||||
*/}
|
* leere Feld beim Bearbeiten aus wie „kein Zertifikat“, und der Hinweis
|
||||||
{quelle?.caZertifikatVorhanden ? (
|
* darunter behauptete es auch (Reviewbefund zu #016).
|
||||||
<p className="mt-2 flex items-start gap-2 text-xs text-erledigt">
|
*/}
|
||||||
<ShieldCheck className="mt-0.5 size-3.5 shrink-0" strokeWidth={2} aria-hidden="true" />
|
{quelle?.caZertifikatVorhanden ? (
|
||||||
<span>
|
<p className="mt-2 flex items-start gap-2 text-xs text-erledigt">
|
||||||
Ein CA-Zertifikat ist hinterlegt. Das Feld unten bleibt leer — Text eintragen ersetzt es, leer
|
<ShieldCheck className="mt-0.5 size-3.5 shrink-0" strokeWidth={2} aria-hidden="true" />
|
||||||
lassen behält es.
|
<span>
|
||||||
|
Ein CA-Zertifikat ist hinterlegt. Das Feld unten bleibt leer — Text eintragen ersetzt es, leer
|
||||||
|
lassen behält es.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<label className="mt-2 flex flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">PEM</span>
|
||||||
|
<textarea
|
||||||
|
name="caZertifikatPem"
|
||||||
|
rows={4}
|
||||||
|
defaultValue=""
|
||||||
|
placeholder="-----BEGIN CERTIFICATE-----"
|
||||||
|
className={`${FELD} mono py-2`}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-nebentext">
|
||||||
|
Nur nötig, wenn der Domänencontroller ein Zertifikat aus einer eigenen CA trägt.
|
||||||
|
{quelle?.caZertifikatVorhanden
|
||||||
|
? " Ohne hinterlegtes Zertifikat gelten die Wurzelzertifikate des Systems."
|
||||||
|
: " Ohne Eintrag gelten die Wurzelzertifikate des Systems."}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
<label className="mt-2 flex flex-col gap-1.5 text-sm">
|
|
||||||
<span className="text-nebentext">PEM</span>
|
|
||||||
<textarea
|
|
||||||
name="caZertifikatPem"
|
|
||||||
rows={4}
|
|
||||||
defaultValue=""
|
|
||||||
placeholder="-----BEGIN CERTIFICATE-----"
|
|
||||||
className={`${FELD} mono py-2`}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-nebentext">
|
|
||||||
Nur nötig, wenn der Domänencontroller ein Zertifikat aus einer eigenen CA trägt.
|
|
||||||
{quelle?.caZertifikatVorhanden
|
|
||||||
? " Ohne hinterlegtes Zertifikat gelten die Wurzelzertifikate des Systems."
|
|
||||||
: " Ohne Eintrag gelten die Wurzelzertifikate des Systems."}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
{quelle?.caZertifikatVorhanden ? (
|
|
||||||
<label className="mt-2 flex min-h-11 items-center gap-2 text-sm text-nebentext md:min-h-10">
|
|
||||||
<input type="checkbox" name="caZertifikatEntfernen" value="1" className={KAESTCHEN} />
|
|
||||||
Hinterlegtes Zertifikat entfernen
|
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
{quelle?.caZertifikatVorhanden ? (
|
||||||
</details>
|
<label className="mt-2 flex min-h-11 items-center gap-2 text-sm text-nebentext md:min-h-10">
|
||||||
|
<input type="checkbox" name="caZertifikatEntfernen" value="1" className={KAESTCHEN} />
|
||||||
|
Hinterlegtes Zertifikat entfernen
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{ergebnis.fehler ? (
|
{ergebnis.fehler ? (
|
||||||
<p role="alert" className="max-w-prose text-sm text-fehler">
|
<p role="alert" className="max-w-prose text-sm text-fehler">
|
||||||
|
|||||||
+42
-2
@@ -60,6 +60,40 @@ export const AD_ZUORDNUNG: AttributZuordnung = {
|
|||||||
externeId: "objectGUID",
|
externeId: "objectGUID",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dieselben Felder mit den Namen, die **Microsoft Graph** benutzt (#028).
|
||||||
|
*
|
||||||
|
* Vier Unterschiede zum AD, und jeder hat einen Grund:
|
||||||
|
*
|
||||||
|
* - `surname` statt `sn` — Graph schreibt die Namen aus.
|
||||||
|
* - `jobTitle` statt `title` — `title` gibt es bei Graph auch, es ist aber die
|
||||||
|
* Anrede („Dr.“), nicht die Funktion.
|
||||||
|
* - `onPremisesSamAccountName` statt `sAMAccountName` — der Wert kommt aus dem
|
||||||
|
* lokalen AD und ist **leer**, wenn der Mandant kein AD synchronisiert. Eine
|
||||||
|
* reine Cloud-Umgebung hat keinen Anmeldenamen in diesem Sinn; `{{sam}}`
|
||||||
|
* bleibt dort leer, statt dass etwas hineingedeutet wird.
|
||||||
|
* - `id` statt `objectGUID` — dieselbe GUID, nur schon als Text. Das Umsortieren
|
||||||
|
* der Bytes (`src/lib/ldap-guid.ts`) braucht es hier nicht.
|
||||||
|
*/
|
||||||
|
export const GRAPH_ZUORDNUNG: AttributZuordnung = {
|
||||||
|
vorname: "givenName",
|
||||||
|
nachname: "surname",
|
||||||
|
anzeigename: "displayName",
|
||||||
|
mail: "mail",
|
||||||
|
upn: "userPrincipalName",
|
||||||
|
sam: "onPremisesSamAccountName",
|
||||||
|
abteilung: "department",
|
||||||
|
titel: "jobTitle",
|
||||||
|
vorgesetzter: "manager",
|
||||||
|
externeId: "id",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Die Vorgaben je Quellenart. `SCIM` hat noch keine (#029). */
|
||||||
|
export const ZUORDNUNGS_VORGABE: Record<"LDAP" | "GRAPH", AttributZuordnung> = {
|
||||||
|
LDAP: AD_ZUORDNUNG,
|
||||||
|
GRAPH: GRAPH_ZUORDNUNG,
|
||||||
|
};
|
||||||
|
|
||||||
/** Die Felder in der Reihenfolge, in der das Formular sie zeigt. */
|
/** Die Felder in der Reihenfolge, in der das Formular sie zeigt. */
|
||||||
export const ZUORDNUNGS_FELDER = Object.keys(AD_ZUORDNUNG) as (keyof AttributZuordnung)[];
|
export const ZUORDNUNGS_FELDER = Object.keys(AD_ZUORDNUNG) as (keyof AttributZuordnung)[];
|
||||||
|
|
||||||
@@ -92,9 +126,15 @@ export const ATTRIBUT_MUSTER = /^[A-Za-z][A-Za-z0-9-]*$/u;
|
|||||||
* mitschreiben muessen, und eine von Hand geleerte Zeile darf kein Attribut mit
|
* mitschreiben muessen, und eine von Hand geleerte Zeile darf kein Attribut mit
|
||||||
* leerem Namen anfragen.
|
* leerem Namen anfragen.
|
||||||
*/
|
*/
|
||||||
export function zuordnungLesen(roh: unknown): AttributZuordnung {
|
export function zuordnungLesen(roh: unknown, art: "LDAP" | "GRAPH" = "LDAP"): AttributZuordnung {
|
||||||
const felder = (typeof roh === "object" && roh !== null ? roh : {}) as Record<string, unknown>;
|
const felder = (typeof roh === "object" && roh !== null ? roh : {}) as Record<string, unknown>;
|
||||||
const gelesen = { ...AD_ZUORDNUNG };
|
/*
|
||||||
|
* Die Vorgabe haengt an der **Art** (#028). Ohne diesen Parameter bekaeme eine
|
||||||
|
* Graph-Quelle mit leerer Zuordnung die AD-Namen — und fragte dann bei Graph
|
||||||
|
* nach `sn` und `objectGUID`, die es dort nicht gibt. Der Standardwert `LDAP`
|
||||||
|
* haelt jeden Aufrufer von vor #028 bei seinem bisherigen Verhalten.
|
||||||
|
*/
|
||||||
|
const gelesen = { ...ZUORDNUNGS_VORGABE[art] };
|
||||||
for (const schluessel of ZUORDNUNGS_FELDER) {
|
for (const schluessel of ZUORDNUNGS_FELDER) {
|
||||||
const wert = felder[schluessel];
|
const wert = felder[schluessel];
|
||||||
if (typeof wert === "string" && wert.trim()) gelesen[schluessel] = wert.trim();
|
if (typeof wert === "string" && wert.trim()) gelesen[schluessel] = wert.trim();
|
||||||
|
|||||||
@@ -85,8 +85,30 @@ function zuordnungAusFormular(formular: FormData) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const QuelleSchema = z.object({
|
/**
|
||||||
|
* Was fuer **jede** Art gilt.
|
||||||
|
*
|
||||||
|
* Seit #028 haengen die uebrigen Felder an der Art (`QuelleSchema` je Art
|
||||||
|
* darunter). Zwei Schemas und nicht eines mit lauter `optional`: ein Schema, das
|
||||||
|
* alles zulaesst, pruefte nichts — und die Meldung „Bitte die Adresse angeben“
|
||||||
|
* fuer eine Graph-Quelle waere schlicht falsch.
|
||||||
|
*/
|
||||||
|
const GemeinsamSchema = z.object({
|
||||||
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
|
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
|
||||||
|
art: z.enum(["LDAP", "GRAPH"], {
|
||||||
|
message: "Bitte die Art der Quelle wählen: LDAP oder Microsoft Graph.",
|
||||||
|
}),
|
||||||
|
caZertifikatPem: z.string().trim().optional(),
|
||||||
|
// Dieselben Grenzen wie im Dienst, aus derselben Quelle.
|
||||||
|
abgleichIntervallMinuten: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(INTERVALL_MINDESTENS, intervallMeldung())
|
||||||
|
.max(INTERVALL_HOECHSTENS, intervallMeldung()),
|
||||||
|
aktiv: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const LdapSchema = z.object({
|
||||||
url: z
|
url: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
@@ -99,16 +121,39 @@ const QuelleSchema = z.object({
|
|||||||
bindDn: z.string().trim().min(1, "Bitte den Benutzer für die Anmeldung angeben."),
|
bindDn: z.string().trim().min(1, "Bitte den Benutzer für die Anmeldung angeben."),
|
||||||
basisDn: z.string().trim().min(1, "Bitte angeben, ab welchem Knoten gesucht wird."),
|
basisDn: z.string().trim().min(1, "Bitte angeben, ab welchem Knoten gesucht wird."),
|
||||||
filter: z.string().trim().optional(),
|
filter: z.string().trim().optional(),
|
||||||
caZertifikatPem: z.string().trim().optional(),
|
|
||||||
// Dieselben Grenzen wie im Dienst, aus derselben Quelle.
|
|
||||||
abgleichIntervallMinuten: z.coerce
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.min(INTERVALL_MINDESTENS, intervallMeldung())
|
|
||||||
.max(INTERVALL_HOECHSTENS, intervallMeldung()),
|
|
||||||
aktiv: z.boolean(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const GraphSchema = z.object({
|
||||||
|
tenantId: z.string().trim().min(1, "Bitte die Mandanten-ID oder den Domänennamen angeben."),
|
||||||
|
clientId: z.string().trim().min(1, "Bitte die Anwendungs-ID der App-Registrierung angeben."),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Die artabhaengigen Felder aus dem Formular, geprueft. */
|
||||||
|
function artFelder(
|
||||||
|
art: "LDAP" | "GRAPH",
|
||||||
|
formular: FormData,
|
||||||
|
): { readonly werte: Record<string, unknown> } | { readonly fehler: string } {
|
||||||
|
if (art === "LDAP") {
|
||||||
|
const gelesen = LdapSchema.safeParse({
|
||||||
|
url: text(formular, "url"),
|
||||||
|
bindDn: text(formular, "bindDn"),
|
||||||
|
basisDn: text(formular, "basisDn"),
|
||||||
|
filter: text(formular, "filter") || undefined,
|
||||||
|
});
|
||||||
|
return gelesen.success
|
||||||
|
? { werte: gelesen.data }
|
||||||
|
: { fehler: gelesen.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const gelesen = GraphSchema.safeParse({
|
||||||
|
tenantId: text(formular, "tenantId"),
|
||||||
|
clientId: text(formular, "clientId"),
|
||||||
|
});
|
||||||
|
return gelesen.success
|
||||||
|
? { werte: gelesen.data }
|
||||||
|
: { fehler: gelesen.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
|
||||||
|
}
|
||||||
|
|
||||||
export async function quelleAnlegenAktion(
|
export async function quelleAnlegenAktion(
|
||||||
_vorher: AktionsErgebnis,
|
_vorher: AktionsErgebnis,
|
||||||
formular: FormData,
|
formular: FormData,
|
||||||
@@ -116,12 +161,9 @@ export async function quelleAnlegenAktion(
|
|||||||
const zugang = await angemeldetOderMeldung();
|
const zugang = await angemeldetOderMeldung();
|
||||||
if ("fehler" in zugang) return zugang;
|
if ("fehler" in zugang) return zugang;
|
||||||
|
|
||||||
const eingabe = QuelleSchema.safeParse({
|
const eingabe = GemeinsamSchema.safeParse({
|
||||||
name: text(formular, "name"),
|
name: text(formular, "name"),
|
||||||
url: text(formular, "url"),
|
art: text(formular, "art") || "LDAP",
|
||||||
bindDn: text(formular, "bindDn"),
|
|
||||||
basisDn: text(formular, "basisDn"),
|
|
||||||
filter: text(formular, "filter") || undefined,
|
|
||||||
caZertifikatPem: text(formular, "caZertifikatPem") || undefined,
|
caZertifikatPem: text(formular, "caZertifikatPem") || undefined,
|
||||||
abgleichIntervallMinuten: text(formular, "abgleichIntervallMinuten") || "60",
|
abgleichIntervallMinuten: text(formular, "abgleichIntervallMinuten") || "60",
|
||||||
aktiv: formular.get("aktiv") === "1",
|
aktiv: formular.get("aktiv") === "1",
|
||||||
@@ -130,15 +172,34 @@ export async function quelleAnlegenAktion(
|
|||||||
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
|
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const bindPasswort = text(formular, "bindPasswort");
|
const felder = artFelder(eingabe.data.art, formular);
|
||||||
if (!bindPasswort) return { fehler: "Ohne Passwort kommt keine Anmeldung am Verzeichnis zustande." };
|
if ("fehler" in felder) return felder;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Das Geheimnis heisst je Art anders, und die Meldung soll das auch sagen.
|
||||||
|
* „Ohne Passwort kommt keine Anmeldung zustande“ ist fuer eine
|
||||||
|
* App-Registrierung die falsche Auskunft.
|
||||||
|
*/
|
||||||
|
const geheimnis =
|
||||||
|
eingabe.data.art === "LDAP"
|
||||||
|
? { bindPasswort: text(formular, "bindPasswort") }
|
||||||
|
: { clientSecret: text(formular, "clientSecret") };
|
||||||
|
const geheimnisWert = Object.values(geheimnis)[0];
|
||||||
|
if (!geheimnisWert) {
|
||||||
|
return {
|
||||||
|
fehler:
|
||||||
|
eingabe.data.art === "LDAP"
|
||||||
|
? "Ohne Passwort kommt keine Anmeldung am Verzeichnis zustande."
|
||||||
|
: "Ohne Client-Secret kommt keine Anmeldung am Mandanten zustande.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const zuordnung = zuordnungAusFormular(formular);
|
const zuordnung = zuordnungAusFormular(formular);
|
||||||
if ("fehler" in zuordnung) return zuordnung;
|
if ("fehler" in zuordnung) return zuordnung;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const quelle = await quelleAnlegen(
|
const quelle = await quelleAnlegen(
|
||||||
{ ...eingabe.data, bindPasswort, attributZuordnung: zuordnung.zuordnung },
|
{ ...eingabe.data, ...felder.werte, ...geheimnis, attributZuordnung: zuordnung.zuordnung },
|
||||||
zugang.benutzer,
|
zugang.benutzer,
|
||||||
);
|
);
|
||||||
revalidatePath("/admin/verzeichnis");
|
revalidatePath("/admin/verzeichnis");
|
||||||
@@ -158,12 +219,9 @@ export async function quelleBearbeitenAktion(
|
|||||||
const id = text(formular, "id");
|
const id = text(formular, "id");
|
||||||
if (!id) return { fehler: "Es fehlt die Quelle." };
|
if (!id) return { fehler: "Es fehlt die Quelle." };
|
||||||
|
|
||||||
const eingabe = QuelleSchema.safeParse({
|
const eingabe = GemeinsamSchema.safeParse({
|
||||||
name: text(formular, "name"),
|
name: text(formular, "name"),
|
||||||
url: text(formular, "url"),
|
art: text(formular, "art") || "LDAP",
|
||||||
bindDn: text(formular, "bindDn"),
|
|
||||||
basisDn: text(formular, "basisDn"),
|
|
||||||
filter: text(formular, "filter") || undefined,
|
|
||||||
// Das Zertifikat entscheidet sich weiter unten: es kennt drei Faelle, das
|
// Das Zertifikat entscheidet sich weiter unten: es kennt drei Faelle, das
|
||||||
// Schema nur zwei.
|
// Schema nur zwei.
|
||||||
caZertifikatPem: undefined,
|
caZertifikatPem: undefined,
|
||||||
@@ -174,6 +232,9 @@ export async function quelleBearbeitenAktion(
|
|||||||
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
|
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const felder = artFelder(eingabe.data.art, formular);
|
||||||
|
if ("fehler" in felder) return felder;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Drei Faelle, und das Formular kann sie nicht auf ein Feld abbilden: ein neues
|
* Drei Faelle, und das Formular kann sie nicht auf ein Feld abbilden: ein neues
|
||||||
* Zertifikat eintragen, das hinterlegte behalten (Feld leer) und das hinterlegte
|
* Zertifikat eintragen, das hinterlegte behalten (Feld leer) und das hinterlegte
|
||||||
@@ -200,11 +261,15 @@ export async function quelleBearbeitenAktion(
|
|||||||
if ("fehler" in zuordnung) return zuordnung;
|
if ("fehler" in zuordnung) return zuordnung;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const clientSecret = text(formular, "clientSecret");
|
||||||
const quelle = await quelleBearbeiten(
|
const quelle = await quelleBearbeiten(
|
||||||
id,
|
id,
|
||||||
{
|
{
|
||||||
...eingabe.data,
|
...eingabe.data,
|
||||||
|
...felder.werte,
|
||||||
|
// Leer heisst unveraendert — bei beiden Geheimnissen.
|
||||||
...(bindPasswort ? { bindPasswort } : {}),
|
...(bindPasswort ? { bindPasswort } : {}),
|
||||||
|
...(clientSecret ? { clientSecret } : {}),
|
||||||
...(caZertifikatPem !== undefined ? { caZertifikatPem } : {}),
|
...(caZertifikatPem !== undefined ? { caZertifikatPem } : {}),
|
||||||
attributZuordnung: zuordnung.zuordnung,
|
attributZuordnung: zuordnung.zuordnung,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
mail: string | null;
|
||||||
upn: string | null;
|
upn: string | null;
|
||||||
sam: string | null;
|
sam: string | null;
|
||||||
dn: string;
|
/**
|
||||||
|
* Der DN im Verzeichnis — **`null` bei einer Graph-Quelle** (#028).
|
||||||
|
*
|
||||||
|
* Entra ID hat keinen DN und keine OU. Ihn dort zu erfinden hiesse, `{{ou}}`
|
||||||
|
* mit etwas zu fuellen, das kein PowerShell-Befehl brauchen kann. `null` ist
|
||||||
|
* die ehrliche Antwort: `{{ou}}` bleibt dann leer und faellt auf die Ziel-OU
|
||||||
|
* der Abteilung zurueck, wenn eine gepflegt ist.
|
||||||
|
*/
|
||||||
|
dn: string | null;
|
||||||
abteilung: string | null;
|
abteilung: string | null;
|
||||||
titel: string | null;
|
titel: string | null;
|
||||||
vorgesetzterExterneId: string | null;
|
vorgesetzterExterneId: string | null;
|
||||||
@@ -62,6 +70,14 @@ export type GemappterEintrag = {
|
|||||||
*/
|
*/
|
||||||
export type RoherEintrag = Omit<GemappterEintrag, "vorgesetzterExterneId"> & {
|
export type RoherEintrag = Omit<GemappterEintrag, "vorgesetzterExterneId"> & {
|
||||||
vorgesetzterDn: string | null;
|
vorgesetzterDn: string | null;
|
||||||
|
/**
|
||||||
|
* Die Kennung des Vorgesetzten, falls sie schon beim Lesen feststeht (#028).
|
||||||
|
*
|
||||||
|
* Graph liefert sie ueber `$expand=manager($select=id)` direkt mit — dort gibt
|
||||||
|
* es keinen DN, der erst aufgeloest werden muesste. Ist dieses Feld gesetzt,
|
||||||
|
* uebergeht `vorgesetzteAufloesen` die DN-Tabelle.
|
||||||
|
*/
|
||||||
|
vorgesetzterDirekt?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -149,6 +165,9 @@ export function eintragZuordnen(
|
|||||||
zuordnung: AttributZuordnung,
|
zuordnung: AttributZuordnung,
|
||||||
): { eintrag: RoherEintrag } | { uebersprungen: Uebersprungen } {
|
): { eintrag: RoherEintrag } | { uebersprungen: Uebersprungen } {
|
||||||
const dn = eintrag.dn.trim();
|
const dn = eintrag.dn.trim();
|
||||||
|
// Bei LDAP steht immer ein DN; die Nullability von `dn` kommt von der
|
||||||
|
// Graph-Quelle (#028) und betrifft diese Funktion nicht.
|
||||||
|
|
||||||
let externeId: string | undefined;
|
let externeId: string | undefined;
|
||||||
try {
|
try {
|
||||||
externeId = externeIdLesen(eintrag, zuordnung.externeId);
|
externeId = externeIdLesen(eintrag, zuordnung.externeId);
|
||||||
@@ -190,11 +209,11 @@ export function eintragZuordnen(
|
|||||||
* wird er aus Nachname und Vorname gebaut; bleibt auch das leer, tritt der erste
|
* wird er aus Nachname und Vorname gebaut; bleibt auch das leer, tritt der erste
|
||||||
* Teil des DN ein.
|
* Teil des DN ein.
|
||||||
*/
|
*/
|
||||||
function ersatzName(vorname: string | null, nachname: string | null, dn: string): string {
|
export function ersatzName(vorname: string | null, nachname: string | null, kennzeichen: string): string {
|
||||||
const gebaut = [nachname, vorname].filter((teil) => teil).join(", ");
|
const gebaut = [nachname, vorname].filter((teil) => teil).join(", ");
|
||||||
if (gebaut) return gebaut;
|
if (gebaut) return gebaut;
|
||||||
const ersterTeil = dn.split(",")[0]?.split("=")[1]?.trim();
|
const ersterTeil = kennzeichen.split(",")[0]?.split("=")[1]?.trim();
|
||||||
return ersterTeil ?? dn;
|
return ersterTeil ?? kennzeichen;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -225,10 +244,16 @@ export function vorgesetzteAufloesen(
|
|||||||
): GemappterEintrag[] {
|
): GemappterEintrag[] {
|
||||||
const nachDn = new Map(zusaetzlicheKennungen ?? []);
|
const nachDn = new Map(zusaetzlicheKennungen ?? []);
|
||||||
for (const eintrag of eintraege) {
|
for (const eintrag of eintraege) {
|
||||||
nachDn.set(dnSchluessel(eintrag.dn), eintrag.externeId);
|
if (eintrag.dn) nachDn.set(dnSchluessel(eintrag.dn), eintrag.externeId);
|
||||||
}
|
}
|
||||||
return eintraege.map(({ vorgesetzterDn, ...rest }) => ({
|
return eintraege.map(({ vorgesetzterDn, vorgesetzterDirekt, ...rest }) => ({
|
||||||
...rest,
|
...rest,
|
||||||
vorgesetzterExterneId: vorgesetzterDn ? (nachDn.get(dnSchluessel(vorgesetzterDn)) ?? null) : null,
|
/*
|
||||||
|
* **Die direkte Kennung gewinnt** (#028). Eine Graph-Quelle kennt den
|
||||||
|
* Vorgesetzten schon beim Lesen; eine Aufloesung ueber DNs waere dort nicht
|
||||||
|
* nur unnoetig, sondern unmoeglich — es gibt keine.
|
||||||
|
*/
|
||||||
|
vorgesetzterExterneId:
|
||||||
|
vorgesetzterDirekt ?? (vorgesetzterDn ? (nachDn.get(dnSchluessel(vorgesetzterDn)) ?? null) : null),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
+144
-125
@@ -4,9 +4,9 @@ import { prisma } from "@/lib/db";
|
|||||||
import { satzEnde } from "@/lib/satzzeichen";
|
import { satzEnde } from "@/lib/satzzeichen";
|
||||||
import { darf } from "@/server/auth/rollen";
|
import { darf } from "@/server/auth/rollen";
|
||||||
import { betriebsmodus, eigeneOrganisation, type HandelnderBenutzer } from "@/server/mandant";
|
import { betriebsmodus, eigeneOrganisation, type HandelnderBenutzer } from "@/server/mandant";
|
||||||
import { eintraegeStroemen } from "@/server/ldap/leser";
|
import { eintraegeDerQuelleStroemen } from "@/server/verzeichnis/quellenleser";
|
||||||
import { fehlerMeldung } from "@/server/ldap/verbindung";
|
import { fehlerMeldung } from "@/server/ldap/verbindung";
|
||||||
import { dnSchluessel, type RoherEintrag, zuordnungLesen } from "@/server/ldap/zuordnung";
|
import { dnSchluessel, type RoherEintrag } from "@/server/ldap/zuordnung";
|
||||||
import { VerzeichnisFehler, quelleZeileHolen, zugangAus } from "@/server/services/verzeichnis";
|
import { VerzeichnisFehler, quelleZeileHolen, zugangAus } from "@/server/services/verzeichnis";
|
||||||
import { mitSperre } from "@/server/services/sperre";
|
import { mitSperre } from "@/server/services/sperre";
|
||||||
import { abgleichMeldungEinreihen } from "@/server/services/benachrichtigungen";
|
import { abgleichMeldungEinreihen } from "@/server/services/benachrichtigungen";
|
||||||
@@ -147,7 +147,16 @@ async function vonHandAngelegteFinden(kundeId: string, eintrag: RoherEintrag): P
|
|||||||
/** Was wir zwischen den beiden Durchgaengen ueber eine Person behalten muessen. */
|
/** Was wir zwischen den beiden Durchgaengen ueber eine Person behalten muessen. */
|
||||||
type OffenerVorgesetzter = {
|
type OffenerVorgesetzter = {
|
||||||
readonly externeId: string;
|
readonly externeId: string;
|
||||||
readonly vorgesetzterDn: string;
|
/** Bei LDAP: der DN, der im zweiten Durchgang aufgeloest wird. */
|
||||||
|
readonly vorgesetzterDn: string | null;
|
||||||
|
/**
|
||||||
|
* Bei Graph: die Kennung steht schon fest (#028).
|
||||||
|
*
|
||||||
|
* Beides als zwei Felder und nicht als eines mit zwei Bedeutungen: ein DN und
|
||||||
|
* eine GUID sehen verschieden aus, aber ein Feld, das mal das eine und mal
|
||||||
|
* das andere traegt, verwechselt irgendwann jemand.
|
||||||
|
*/
|
||||||
|
readonly vorgesetzterDirekt: string | null;
|
||||||
readonly vorherigeKennung: string | null;
|
readonly vorherigeKennung: string | null;
|
||||||
readonly neuAngelegt: boolean;
|
readonly neuAngelegt: boolean;
|
||||||
};
|
};
|
||||||
@@ -207,144 +216,147 @@ async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Pr
|
|||||||
let bindPasswort: string | undefined;
|
let bindPasswort: string | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const zugang = zugangAus(quelle);
|
/*
|
||||||
bindPasswort = zugang.bindPasswort;
|
* Das Bind-Passwort nur bei einer LDAP-Quelle — es dient unten dem
|
||||||
|
* Filtern der Fehlermeldung. Eine Graph-Quelle hat keines; ihr Geheimnis
|
||||||
|
* erscheint nirgends in einer Meldung, weil `tokenHolen` die ausfuehrliche
|
||||||
|
* Antwort von Entra ID selbst ins Prozessprotokoll schreibt (#028).
|
||||||
|
*/
|
||||||
|
bindPasswort = quelle.art === "LDAP" ? zugangAus(quelle).bindPasswort : undefined;
|
||||||
const kennungNachDn = new Map<string, string>();
|
const kennungNachDn = new Map<string, string>();
|
||||||
const offene: OffenerVorgesetzter[] = [];
|
const offene: OffenerVorgesetzter[] = [];
|
||||||
|
|
||||||
const bilanz = await eintraegeStroemen(
|
const bilanz = await eintraegeDerQuelleStroemen(quelle, async (eintrag) => {
|
||||||
zugang,
|
verarbeitet += 1;
|
||||||
{
|
// Nur LDAP liefert einen DN; bei Graph bleibt die Tabelle leer, und der
|
||||||
basisDn: quelle.basisDn,
|
// Vorgesetzte kommt ueber `vorgesetzterDirekt`.
|
||||||
filter: quelle.filter,
|
if (eintrag.dn) kennungNachDn.set(dnSchluessel(eintrag.dn), eintrag.externeId);
|
||||||
zuordnung: zuordnungLesen(quelle.attributZuordnung),
|
let vorhanden = await prisma.mitarbeiter.findFirst({
|
||||||
},
|
where: { quelleId, externeId: eintrag.externeId },
|
||||||
async (eintrag) => {
|
});
|
||||||
verarbeitet += 1;
|
/*
|
||||||
kennungNachDn.set(dnSchluessel(eintrag.dn), eintrag.externeId);
|
* Kennt die Quelle die Person noch nicht, kann sie trotzdem schon in der
|
||||||
let vorhanden = await prisma.mitarbeiter.findFirst({
|
* Datenbank stehen: beim Onboarding legt jemand sie von Hand an, das
|
||||||
where: { quelleId, externeId: eintrag.externeId },
|
* AD-Konto entsteht danach, und der naechste Abgleich findet sie.
|
||||||
});
|
*
|
||||||
/*
|
* Ohne diese Uebernahme entstuenden zwei Maria Huber — eine mit Laeufen
|
||||||
* Kennt die Quelle die Person noch nicht, kann sie trotzdem schon in der
|
* und Geraeten, eine mit der Kennung. Die von Hand angelegte waere die mit
|
||||||
* Datenbank stehen: beim Onboarding legt jemand sie von Hand an, das
|
* den Daten und die falsche in jeder Auswahlliste (Reviewbefund zu #016).
|
||||||
* AD-Konto entsteht danach, und der naechste Abgleich findet sie.
|
*/
|
||||||
*
|
let uebernommenJetzt = false;
|
||||||
* Ohne diese Uebernahme entstuenden zwei Maria Huber — eine mit Laeufen
|
if (!vorhanden) {
|
||||||
* und Geraeten, eine mit der Kennung. Die von Hand angelegte waere die mit
|
const vonHand = await vonHandAngelegteFinden(quelle.kundeId, eintrag);
|
||||||
* den Daten und die falsche in jeder Auswahlliste (Reviewbefund zu #016).
|
if (vonHand) {
|
||||||
*/
|
vorhanden = vonHand;
|
||||||
let uebernommenJetzt = false;
|
uebernommenJetzt = true;
|
||||||
if (!vorhanden) {
|
uebernommen += 1;
|
||||||
const vonHand = await vonHandAngelegteFinden(quelle.kundeId, eintrag);
|
|
||||||
if (vonHand) {
|
|
||||||
vorhanden = vonHand;
|
|
||||||
uebernommenJetzt = true;
|
|
||||||
uebernommen += 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const felder = {
|
const felder = {
|
||||||
vorname: eintrag.vorname,
|
vorname: eintrag.vorname,
|
||||||
nachname: eintrag.nachname,
|
nachname: eintrag.nachname,
|
||||||
anzeigename: eintrag.anzeigename,
|
anzeigename: eintrag.anzeigename,
|
||||||
mail: eintrag.mail,
|
mail: eintrag.mail,
|
||||||
upn: eintrag.upn,
|
upn: eintrag.upn,
|
||||||
sam: eintrag.sam,
|
sam: eintrag.sam,
|
||||||
dn: eintrag.dn,
|
dn: eintrag.dn,
|
||||||
abteilung: eintrag.abteilung,
|
abteilung: eintrag.abteilung,
|
||||||
titel: eintrag.titel,
|
titel: eintrag.titel,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!vorhanden) {
|
if (!vorhanden) {
|
||||||
await prisma.mitarbeiter.create({
|
await prisma.mitarbeiter.create({
|
||||||
data: {
|
|
||||||
kundeId: quelle.kundeId,
|
|
||||||
quelleId,
|
|
||||||
externeId: eintrag.externeId,
|
|
||||||
...felder,
|
|
||||||
// Der Vorgesetzte kommt im zweiten Durchgang: sein DN laesst sich
|
|
||||||
// erst aufloesen, wenn alle Kennungen bekannt sind.
|
|
||||||
vorgesetzterExterneId: null,
|
|
||||||
vorgesetzterId: null,
|
|
||||||
aktiv: true,
|
|
||||||
herkunft: Herkunft.ABGLEICH,
|
|
||||||
zuletztGesehenAm: beginn,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
angelegt += 1;
|
|
||||||
if (eintrag.vorgesetzterDn) {
|
|
||||||
offene.push({
|
|
||||||
externeId: eintrag.externeId,
|
|
||||||
vorgesetzterDn: eintrag.vorgesetzterDn,
|
|
||||||
vorherigeKennung: null,
|
|
||||||
neuAngelegt: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Steht im Verzeichnis kein Vorgesetzter mehr, wird ein alter Wert
|
|
||||||
// geleert. Steht einer, entscheidet der zweite Durchgang.
|
|
||||||
const vorgesetztenLeeren = eintrag.vorgesetzterDn === null;
|
|
||||||
if (
|
|
||||||
// Eine Uebernahme zaehlt in ihrer eigenen Spalte und nicht zusaetzlich
|
|
||||||
// als Aktualisierung; sonst gingen die Zahlen nicht mehr auf.
|
|
||||||
!uebernommenJetzt &&
|
|
||||||
(vorhanden.vorname !== felder.vorname ||
|
|
||||||
vorhanden.nachname !== felder.nachname ||
|
|
||||||
vorhanden.anzeigename !== felder.anzeigename ||
|
|
||||||
vorhanden.mail !== felder.mail ||
|
|
||||||
vorhanden.upn !== felder.upn ||
|
|
||||||
vorhanden.sam !== felder.sam ||
|
|
||||||
vorhanden.dn !== felder.dn ||
|
|
||||||
vorhanden.abteilung !== felder.abteilung ||
|
|
||||||
vorhanden.titel !== felder.titel ||
|
|
||||||
!vorhanden.aktiv ||
|
|
||||||
vorhanden.herkunft !== Herkunft.ABGLEICH ||
|
|
||||||
(vorgesetztenLeeren && vorhanden.vorgesetzterExterneId !== null))
|
|
||||||
) {
|
|
||||||
geaendert.add(eintrag.externeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.mitarbeiter.update({
|
|
||||||
where: { id: vorhanden.id },
|
|
||||||
data: {
|
data: {
|
||||||
|
kundeId: quelle.kundeId,
|
||||||
|
quelleId,
|
||||||
|
externeId: eintrag.externeId,
|
||||||
...felder,
|
...felder,
|
||||||
// Bei einer Uebernahme kommt die Herkunft dazu: ab jetzt fuehrt das
|
// Der Vorgesetzte kommt im zweiten Durchgang: sein DN laesst sich
|
||||||
// Verzeichnis diese Person, und der naechste Lauf findet sie ueber die
|
// erst aufloesen, wenn alle Kennungen bekannt sind.
|
||||||
// Kennung.
|
vorgesetzterExterneId: null,
|
||||||
...(uebernommenJetzt ? { quelleId, externeId: eintrag.externeId } : {}),
|
vorgesetzterId: null,
|
||||||
/*
|
|
||||||
* Steht im Verzeichnis kein Vorgesetzter, wird der **rohe** Wert
|
|
||||||
* geleert. Die gepflegte Zuordnung nur dann, wenn sie vom
|
|
||||||
* Verzeichnis kam — erkennbar daran, dass dort eine Kennung stand.
|
|
||||||
* Eine von Hand gesetzte Zuordnung bleibt stehen: das Verzeichnis
|
|
||||||
* fuehrt sie nicht, also hat es sie auch nicht zu loeschen (#020).
|
|
||||||
*/
|
|
||||||
...(vorgesetztenLeeren
|
|
||||||
? {
|
|
||||||
vorgesetzterExterneId: null,
|
|
||||||
...(vorhanden.vorgesetzterExterneId !== null ? { vorgesetzterId: null } : {}),
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
// Wer wieder im Ergebnis steht, ist wieder aktiv — eine
|
|
||||||
// wiedereingestellte Person behaelt so ihre Laeufe.
|
|
||||||
aktiv: true,
|
aktiv: true,
|
||||||
herkunft: Herkunft.ABGLEICH,
|
herkunft: Herkunft.ABGLEICH,
|
||||||
zuletztGesehenAm: beginn,
|
zuletztGesehenAm: beginn,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!vorgesetztenLeeren) {
|
angelegt += 1;
|
||||||
|
if (eintrag.vorgesetzterDn || eintrag.vorgesetzterDirekt) {
|
||||||
offene.push({
|
offene.push({
|
||||||
externeId: eintrag.externeId,
|
externeId: eintrag.externeId,
|
||||||
vorgesetzterDn: eintrag.vorgesetzterDn as string,
|
vorgesetzterDn: eintrag.vorgesetzterDn,
|
||||||
vorherigeKennung: vorhanden.vorgesetzterExterneId,
|
vorgesetzterDirekt: eintrag.vorgesetzterDirekt ?? null,
|
||||||
neuAngelegt: false,
|
vorherigeKennung: null,
|
||||||
|
neuAngelegt: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
return;
|
||||||
);
|
}
|
||||||
|
|
||||||
|
// Steht im Verzeichnis kein Vorgesetzter mehr, wird ein alter Wert
|
||||||
|
// geleert. Steht einer, entscheidet der zweite Durchgang.
|
||||||
|
// Kein Vorgesetzter heisst: keiner auf **beiden** Wegen.
|
||||||
|
const vorgesetztenLeeren =
|
||||||
|
eintrag.vorgesetzterDn === null && (eintrag.vorgesetzterDirekt ?? null) === null;
|
||||||
|
if (
|
||||||
|
// Eine Uebernahme zaehlt in ihrer eigenen Spalte und nicht zusaetzlich
|
||||||
|
// als Aktualisierung; sonst gingen die Zahlen nicht mehr auf.
|
||||||
|
!uebernommenJetzt &&
|
||||||
|
(vorhanden.vorname !== felder.vorname ||
|
||||||
|
vorhanden.nachname !== felder.nachname ||
|
||||||
|
vorhanden.anzeigename !== felder.anzeigename ||
|
||||||
|
vorhanden.mail !== felder.mail ||
|
||||||
|
vorhanden.upn !== felder.upn ||
|
||||||
|
vorhanden.sam !== felder.sam ||
|
||||||
|
vorhanden.dn !== felder.dn ||
|
||||||
|
vorhanden.abteilung !== felder.abteilung ||
|
||||||
|
vorhanden.titel !== felder.titel ||
|
||||||
|
!vorhanden.aktiv ||
|
||||||
|
vorhanden.herkunft !== Herkunft.ABGLEICH ||
|
||||||
|
(vorgesetztenLeeren && vorhanden.vorgesetzterExterneId !== null))
|
||||||
|
) {
|
||||||
|
geaendert.add(eintrag.externeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.mitarbeiter.update({
|
||||||
|
where: { id: vorhanden.id },
|
||||||
|
data: {
|
||||||
|
...felder,
|
||||||
|
// Bei einer Uebernahme kommt die Herkunft dazu: ab jetzt fuehrt das
|
||||||
|
// Verzeichnis diese Person, und der naechste Lauf findet sie ueber die
|
||||||
|
// Kennung.
|
||||||
|
...(uebernommenJetzt ? { quelleId, externeId: eintrag.externeId } : {}),
|
||||||
|
/*
|
||||||
|
* Steht im Verzeichnis kein Vorgesetzter, wird der **rohe** Wert
|
||||||
|
* geleert. Die gepflegte Zuordnung nur dann, wenn sie vom
|
||||||
|
* Verzeichnis kam — erkennbar daran, dass dort eine Kennung stand.
|
||||||
|
* Eine von Hand gesetzte Zuordnung bleibt stehen: das Verzeichnis
|
||||||
|
* fuehrt sie nicht, also hat es sie auch nicht zu loeschen (#020).
|
||||||
|
*/
|
||||||
|
...(vorgesetztenLeeren
|
||||||
|
? {
|
||||||
|
vorgesetzterExterneId: null,
|
||||||
|
...(vorhanden.vorgesetzterExterneId !== null ? { vorgesetzterId: null } : {}),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
// Wer wieder im Ergebnis steht, ist wieder aktiv — eine
|
||||||
|
// wiedereingestellte Person behaelt so ihre Laeufe.
|
||||||
|
aktiv: true,
|
||||||
|
herkunft: Herkunft.ABGLEICH,
|
||||||
|
zuletztGesehenAm: beginn,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!vorgesetztenLeeren) {
|
||||||
|
offene.push({
|
||||||
|
externeId: eintrag.externeId,
|
||||||
|
vorgesetzterDn: eintrag.vorgesetzterDn,
|
||||||
|
vorgesetzterDirekt: eintrag.vorgesetzterDirekt ?? null,
|
||||||
|
vorherigeKennung: vorhanden.vorgesetzterExterneId,
|
||||||
|
neuAngelegt: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
gelesen = bilanz.gesamtGelesen;
|
gelesen = bilanz.gesamtGelesen;
|
||||||
|
|
||||||
// --- Zweiter Durchgang: Vorgesetzte --------------------------------------
|
// --- Zweiter Durchgang: Vorgesetzte --------------------------------------
|
||||||
@@ -354,7 +366,14 @@ async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Pr
|
|||||||
// jeder Leseweg schoepft (#020). Vorher gab es nur das erste, und eine von
|
// jeder Leseweg schoepft (#020). Vorher gab es nur das erste, und eine von
|
||||||
// Hand angelegte Person konnte keinen Vorgesetzten haben.
|
// Hand angelegte Person konnte keinen Vorgesetzten haben.
|
||||||
for (const offen of offene) {
|
for (const offen of offene) {
|
||||||
const kennung = kennungNachDn.get(dnSchluessel(offen.vorgesetzterDn)) ?? null;
|
/*
|
||||||
|
* **Die direkte Kennung gewinnt** (#028). Bei Graph steht sie schon fest;
|
||||||
|
* die DN-Tabelle ist dort leer, und ein Nachschlagen darin ergaebe
|
||||||
|
* `null` — also keinen Vorgesetzten, obwohl einer bekannt ist.
|
||||||
|
*/
|
||||||
|
const kennung =
|
||||||
|
offen.vorgesetzterDirekt ??
|
||||||
|
(offen.vorgesetzterDn ? (kennungNachDn.get(dnSchluessel(offen.vorgesetzterDn)) ?? null) : null);
|
||||||
if (kennung === offen.vorherigeKennung) continue;
|
if (kennung === offen.vorherigeKennung) continue;
|
||||||
const vorgesetzter =
|
const vorgesetzter =
|
||||||
kennung === null
|
kennung === null
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
import type { Prisma, Verzeichnisquelle } from "@/generated/prisma/client";
|
import type { Prisma, Verzeichnisquelle } from "@/generated/prisma/client";
|
||||||
import { Betriebsmodus } from "@/generated/prisma/enums";
|
import { QuellenArt, Betriebsmodus } from "@/generated/prisma/enums";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
|
import { verschluesseln } from "@/lib/geheimnisse";
|
||||||
import { darf } from "@/server/auth/rollen";
|
import { darf } from "@/server/auth/rollen";
|
||||||
import { betriebsmodus, eigeneOrganisation, kundeErmitteln, type HandelnderBenutzer } from "@/server/mandant";
|
import { betriebsmodus, eigeneOrganisation, kundeErmitteln, type HandelnderBenutzer } from "@/server/mandant";
|
||||||
import { VerzeichnisFehler } from "@/server/ldap/fehler";
|
import { VerzeichnisFehler } from "@/server/ldap/fehler";
|
||||||
import { INTERVALL_HOECHSTENS, INTERVALL_MINDESTENS, intervallMeldung } from "@/lib/verzeichnis";
|
// Die Zugangs-Helfer liegen seit #028 in einem eigenen Modul – sonst waere die
|
||||||
import { eintraegeLesen } from "@/server/ldap/leser";
|
// Weiche in `quellenleser.ts` ein Import-Zyklus. Hier weitergereicht, damit
|
||||||
import { type LdapZugang, urlPruefen } from "@/server/ldap/verbindung";
|
// bestehende Aufrufer ihre Anlaufstelle behalten.
|
||||||
|
import { ldapVorgabeAus, zugangAus, zuordnungsArt } from "@/server/verzeichnis/zugang";
|
||||||
|
import {
|
||||||
|
ZUORDNUNGS_VORGABE,
|
||||||
|
INTERVALL_HOECHSTENS,
|
||||||
|
INTERVALL_MINDESTENS,
|
||||||
|
intervallMeldung,
|
||||||
|
} from "@/lib/verzeichnis";
|
||||||
|
import { eintraegeDerQuelleStroemen } from "@/server/verzeichnis/quellenleser";
|
||||||
|
import { urlPruefen } from "@/server/ldap/verbindung";
|
||||||
import {
|
import {
|
||||||
AD_ZUORDNUNG,
|
AD_ZUORDNUNG,
|
||||||
type AttributZuordnung,
|
type AttributZuordnung,
|
||||||
type GemappterEintrag,
|
type GemappterEintrag,
|
||||||
|
type RoherEintrag,
|
||||||
STANDARD_FILTER,
|
STANDARD_FILTER,
|
||||||
type Uebersprungen,
|
type Uebersprungen,
|
||||||
vorgesetzteAufloesen,
|
vorgesetzteAufloesen,
|
||||||
@@ -48,16 +58,24 @@ export { AD_ZUORDNUNG, STANDARD_FILTER };
|
|||||||
* Aufrufer sie von hier kennt.
|
* Aufrufer sie von hier kennt.
|
||||||
*/
|
*/
|
||||||
export { VerzeichnisFehler };
|
export { VerzeichnisFehler };
|
||||||
|
export { ldapVorgabeAus, zugangAus, zuordnungsArt };
|
||||||
|
|
||||||
/** Ohne Passwort – diese Form geht an die Oberflaeche. */
|
/** Ohne Passwort – diese Form geht an die Oberflaeche. */
|
||||||
export type QuellenSicht = {
|
export type QuellenSicht = {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly aktiv: boolean;
|
readonly aktiv: boolean;
|
||||||
readonly url: string;
|
/** Woher gelesen wird (#028). Bis dahin war jede Quelle LDAP. */
|
||||||
readonly bindDn: string;
|
readonly art: QuellenArt;
|
||||||
readonly basisDn: string;
|
/** Nur bei `art = LDAP` belegt. */
|
||||||
readonly filter: string;
|
readonly url: string | null;
|
||||||
|
readonly bindDn: string | null;
|
||||||
|
readonly basisDn: string | null;
|
||||||
|
readonly filter: string | null;
|
||||||
|
/** Nur bei `art = GRAPH` belegt – das Geheimnis **nie**, nur ob eines da ist. */
|
||||||
|
readonly tenantId: string | null;
|
||||||
|
readonly clientId: string | null;
|
||||||
|
readonly clientSecretVorhanden: boolean;
|
||||||
readonly attributZuordnung: AttributZuordnung;
|
readonly attributZuordnung: AttributZuordnung;
|
||||||
readonly caZertifikatVorhanden: boolean;
|
readonly caZertifikatVorhanden: boolean;
|
||||||
readonly abgleichIntervallMinuten: number;
|
readonly abgleichIntervallMinuten: number;
|
||||||
@@ -68,11 +86,18 @@ export type QuellenSicht = {
|
|||||||
|
|
||||||
export type NeueQuelle = {
|
export type NeueQuelle = {
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
/** Ohne Angabe `LDAP` – so verhalten sich Aufrufer von vor #028 unveraendert. */
|
||||||
bindDn: string;
|
art?: QuellenArt;
|
||||||
bindPasswort: string;
|
/** Pflicht bei `art = LDAP`, bei Graph nicht erlaubt. */
|
||||||
basisDn: string;
|
url?: string;
|
||||||
|
bindDn?: string;
|
||||||
|
bindPasswort?: string;
|
||||||
|
basisDn?: string;
|
||||||
filter?: string;
|
filter?: string;
|
||||||
|
/** Pflicht bei `art = GRAPH`. */
|
||||||
|
tenantId?: string;
|
||||||
|
clientId?: string;
|
||||||
|
clientSecret?: string;
|
||||||
attributZuordnung?: Partial<AttributZuordnung>;
|
attributZuordnung?: Partial<AttributZuordnung>;
|
||||||
caZertifikatPem?: string | null;
|
caZertifikatPem?: string | null;
|
||||||
abgleichIntervallMinuten?: number;
|
abgleichIntervallMinuten?: number;
|
||||||
@@ -150,11 +175,19 @@ function alsSicht(zeile: Verzeichnisquelle): QuellenSicht {
|
|||||||
id: zeile.id,
|
id: zeile.id,
|
||||||
name: zeile.name,
|
name: zeile.name,
|
||||||
aktiv: zeile.aktiv,
|
aktiv: zeile.aktiv,
|
||||||
|
art: zeile.art,
|
||||||
url: zeile.url,
|
url: zeile.url,
|
||||||
bindDn: zeile.bindDn,
|
bindDn: zeile.bindDn,
|
||||||
basisDn: zeile.basisDn,
|
basisDn: zeile.basisDn,
|
||||||
filter: zeile.filter,
|
filter: zeile.filter,
|
||||||
attributZuordnung: zuordnungLesen(zeile.attributZuordnung),
|
tenantId: zeile.tenantId,
|
||||||
|
clientId: zeile.clientId,
|
||||||
|
/*
|
||||||
|
* Wie beim Bind-Passwort: nur **ob** eines hinterlegt ist. Ein Feld, das es
|
||||||
|
* nicht in eine Antwort schafft, kann auch nicht versehentlich hinausgehen.
|
||||||
|
*/
|
||||||
|
clientSecretVorhanden: Boolean(zeile.clientSecretVerschluesselt?.trim()),
|
||||||
|
attributZuordnung: zuordnungLesen(zeile.attributZuordnung, zuordnungsArt(zeile.art)),
|
||||||
// Der Inhalt bleibt hier: die Oberflaeche muss nur wissen, ob eines
|
// Der Inhalt bleibt hier: die Oberflaeche muss nur wissen, ob eines
|
||||||
// hinterlegt ist, und braucht dafuer keinen Zertifikatstext.
|
// hinterlegt ist, und braucht dafuer keinen Zertifikatstext.
|
||||||
caZertifikatVorhanden: Boolean(zeile.caZertifikatPem?.trim()),
|
caZertifikatVorhanden: Boolean(zeile.caZertifikatPem?.trim()),
|
||||||
@@ -168,6 +201,64 @@ function alsSicht(zeile: Verzeichnisquelle): QuellenSicht {
|
|||||||
/** Auch fuer die Lesemodelle: die eine Stelle, die aus einer Zeile eine Sicht macht. */
|
/** Auch fuer die Lesemodelle: die eine Stelle, die aus einer Zeile eine Sicht macht. */
|
||||||
export { alsSicht as quellenSichtBauen };
|
export { alsSicht as quellenSichtBauen };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die artabhaengigen Felder einer neuen Quelle, geprueft.
|
||||||
|
*
|
||||||
|
* Eine eigene Funktion, weil beide Richtungen gelten muessen: was eine Art
|
||||||
|
* **braucht**, und was sie **nicht haben darf**. Ohne den zweiten Teil liesse
|
||||||
|
* sich eine Graph-Quelle mit `basisDn` anlegen — eine Zeile, die zwei Dinge
|
||||||
|
* gleichzeitig behauptet, und bei der niemand mehr sagen kann, was gilt. Die
|
||||||
|
* Datenbank weist das ohnehin ab; hier entsteht die Meldung, die weiterhilft.
|
||||||
|
*/
|
||||||
|
function feldernJeArt(art: QuellenArt, eingabe: NeueQuelle) {
|
||||||
|
if (art === "LDAP") {
|
||||||
|
const url = pflichtText(eingabe.url, "Die Adresse des Verzeichnisses");
|
||||||
|
urlPruefen(url);
|
||||||
|
const bindPasswort = eingabe.bindPasswort?.trim();
|
||||||
|
if (!bindPasswort) {
|
||||||
|
throw new VerzeichnisFehler("Das Bind-Passwort fehlt. Ein anonymer Bind wird nicht unterstützt.");
|
||||||
|
}
|
||||||
|
if (eingabe.tenantId?.trim() || eingabe.clientId?.trim() || eingabe.clientSecret?.trim()) {
|
||||||
|
throw new VerzeichnisFehler(
|
||||||
|
"Eine LDAP-Quelle hat keine Graph-Zugangsdaten. Für Entra ID gibt es die Art „Graph“.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
// Gespeichert wird die getippte Adresse samt Schema. `ldap+starttls://`
|
||||||
|
// ist Teil der Angabe und wird erst beim Verbinden abgetrennt — sonst
|
||||||
|
// waere nach dem Speichern nicht mehr zu erkennen, dass StartTLS
|
||||||
|
// gewuenscht war.
|
||||||
|
url,
|
||||||
|
bindDn: pflichtText(eingabe.bindDn, "Der Bind-DN"),
|
||||||
|
bindPasswortVerschluesselt: verschluesseln(bindPasswort),
|
||||||
|
basisDn: pflichtText(eingabe.basisDn, "Der Basis-DN"),
|
||||||
|
filter: eingabe.filter?.trim() || STANDARD_FILTER,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (art === "GRAPH") {
|
||||||
|
const clientSecret = eingabe.clientSecret?.trim();
|
||||||
|
if (!clientSecret) {
|
||||||
|
throw new VerzeichnisFehler("Das Client-Secret der App-Registrierung fehlt.");
|
||||||
|
}
|
||||||
|
if (eingabe.url?.trim() || eingabe.bindDn?.trim() || eingabe.basisDn?.trim()) {
|
||||||
|
throw new VerzeichnisFehler(
|
||||||
|
"Eine Graph-Quelle hat keine Adresse, keinen Bind-DN und keinen Basis-DN — Entra ID kennt " +
|
||||||
|
"beides nicht. Für ein lokales AD gibt es die Art „LDAP“.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tenantId: pflichtText(eingabe.tenantId, "Die Mandanten-ID"),
|
||||||
|
clientId: pflichtText(eingabe.clientId, "Die Anwendungs-ID"),
|
||||||
|
clientSecretVerschluesselt: verschluesseln(clientSecret),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new VerzeichnisFehler(
|
||||||
|
`Quellen der Art ${art} lassen sich noch nicht anlegen. Der SCIM-Endpunkt kommt mit #029.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function quelleAnlegen(
|
export async function quelleAnlegen(
|
||||||
eingabe: NeueQuelle,
|
eingabe: NeueQuelle,
|
||||||
benutzer: HandelnderBenutzer,
|
benutzer: HandelnderBenutzer,
|
||||||
@@ -177,31 +268,28 @@ export async function quelleAnlegen(
|
|||||||
const kundeId = await kundeErmitteln(null, benutzer);
|
const kundeId = await kundeErmitteln(null, benutzer);
|
||||||
|
|
||||||
const name = pflichtText(eingabe.name, "Der Name der Quelle");
|
const name = pflichtText(eingabe.name, "Der Name der Quelle");
|
||||||
const url = pflichtText(eingabe.url, "Die Adresse des Verzeichnisses");
|
const art = eingabe.art ?? "LDAP";
|
||||||
urlPruefen(url);
|
|
||||||
const bindDn = pflichtText(eingabe.bindDn, "Der Bind-DN");
|
|
||||||
const bindPasswort = eingabe.bindPasswort?.trim();
|
|
||||||
if (!bindPasswort) {
|
|
||||||
throw new VerzeichnisFehler("Das Bind-Passwort fehlt. Ein anonymer Bind wird nicht unterstützt.");
|
|
||||||
}
|
|
||||||
const basisDn = pflichtText(eingabe.basisDn, "Der Basis-DN");
|
|
||||||
const intervall = intervallPruefen(eingabe.abgleichIntervallMinuten);
|
const intervall = intervallPruefen(eingabe.abgleichIntervallMinuten);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **Welche Felder gelten, haengt an der Art** (#028). Geprueft wird hier mit
|
||||||
|
* einer brauchbaren Meldung; dieselbe Regel haelt die Datenbank fest (CHECK
|
||||||
|
* `Verzeichnisquelle_felder_je_art`), damit sie auch gilt, wenn jemand an
|
||||||
|
* dieser Schicht vorbei schreibt.
|
||||||
|
*/
|
||||||
|
const felder = feldernJeArt(art, eingabe);
|
||||||
|
|
||||||
const zeile = await prisma.$transaction(async (tx) => {
|
const zeile = await prisma.$transaction(async (tx) => {
|
||||||
const angelegt = await tx.verzeichnisquelle.create({
|
const angelegt = await tx.verzeichnisquelle.create({
|
||||||
data: {
|
data: {
|
||||||
kundeId,
|
kundeId,
|
||||||
name,
|
name,
|
||||||
// Gespeichert wird die getippte Adresse samt Schema. `ldap+starttls://`
|
art,
|
||||||
// ist Teil der Angabe und wird erst beim Verbinden abgetrennt — sonst
|
...felder,
|
||||||
// waere nach dem Speichern nicht mehr zu erkennen, dass StartTLS
|
attributZuordnung: zuordnungLesen(
|
||||||
// gewuenscht war.
|
{ ...ZUORDNUNGS_VORGABE[zuordnungsArt(art)], ...eingabe.attributZuordnung },
|
||||||
url,
|
zuordnungsArt(art),
|
||||||
bindDn,
|
),
|
||||||
bindPasswortVerschluesselt: verschluesseln(bindPasswort),
|
|
||||||
basisDn,
|
|
||||||
filter: eingabe.filter?.trim() || STANDARD_FILTER,
|
|
||||||
attributZuordnung: zuordnungLesen({ ...AD_ZUORDNUNG, ...eingabe.attributZuordnung }),
|
|
||||||
caZertifikatPem: zertifikatPruefen(eingabe.caZertifikatPem),
|
caZertifikatPem: zertifikatPruefen(eingabe.caZertifikatPem),
|
||||||
// Ohne Angabe bleibt der Standardwert des Schemas (60 Minuten) stehen.
|
// Ohne Angabe bleibt der Standardwert des Schemas (60 Minuten) stehen.
|
||||||
...(intervall === undefined ? {} : { abgleichIntervallMinuten: intervall }),
|
...(intervall === undefined ? {} : { abgleichIntervallMinuten: intervall }),
|
||||||
@@ -215,8 +303,20 @@ export async function quelleAnlegen(
|
|||||||
entitaetId: angelegt.id,
|
entitaetId: angelegt.id,
|
||||||
aktion: "Verzeichnisquelle angelegt",
|
aktion: "Verzeichnisquelle angelegt",
|
||||||
benutzerId: benutzer.id,
|
benutzerId: benutzer.id,
|
||||||
// Kein Passwort, auch nicht verschluesselt: das Protokoll wird gelesen.
|
/*
|
||||||
nachher: { name: angelegt.name, url: angelegt.url, basisDn: angelegt.basisDn },
|
* Kein Passwort und kein Client-Secret, auch nicht verschluesselt: das
|
||||||
|
* Protokoll wird gelesen (#023). Der Mandant und die Anwendungs-ID sind
|
||||||
|
* dagegen keine Geheimnisse — ohne sie liesse sich hinterher nicht
|
||||||
|
* sagen, welche Registrierung gemeint war.
|
||||||
|
*/
|
||||||
|
nachher: {
|
||||||
|
name: angelegt.name,
|
||||||
|
art: angelegt.art,
|
||||||
|
...(angelegt.url ? { url: angelegt.url } : {}),
|
||||||
|
...(angelegt.basisDn ? { basisDn: angelegt.basisDn } : {}),
|
||||||
|
...(angelegt.tenantId ? { tenantId: angelegt.tenantId } : {}),
|
||||||
|
...(angelegt.clientId ? { clientId: angelegt.clientId } : {}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return angelegt;
|
return angelegt;
|
||||||
@@ -236,6 +336,35 @@ export async function quelleBearbeiten(
|
|||||||
const daten: Prisma.VerzeichnisquelleUpdateInput = {};
|
const daten: Prisma.VerzeichnisquelleUpdateInput = {};
|
||||||
const geaendert: string[] = [];
|
const geaendert: string[] = [];
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **Die Art laesst sich nicht aendern** (#028).
|
||||||
|
*
|
||||||
|
* Nicht aus Bequemlichkeit: an der Art haengt, was `externeId` bedeutet. Im
|
||||||
|
* AD ist es eine `objectGUID`, in Entra ID eine andere GUID — nach einem
|
||||||
|
* Wechsel wuerde kein einziger Mitarbeiter wiedererkannt. Der naechste
|
||||||
|
* Abgleich legte die ganze Belegschaft neu an und setzte die alten Zeilen auf
|
||||||
|
* `aktiv = false`: Laeufe und Geraete haengen dann an den inaktiven.
|
||||||
|
*
|
||||||
|
* Wer wechselt, legt eine neue Quelle an und laesst die alte stehen, bis die
|
||||||
|
* Leute uebernommen sind — dafuer gibt es die Uebernahme von Hand angelegter
|
||||||
|
* Personen aus #016.
|
||||||
|
*/
|
||||||
|
if (aenderung.art !== undefined && aenderung.art !== vorher.art) {
|
||||||
|
throw new VerzeichnisFehler(
|
||||||
|
`Die Art einer Quelle lässt sich nicht ändern (${vorher.art} → ${aenderung.art}). ` +
|
||||||
|
"Daran hängt, was die dauerhafte Kennung bedeutet — nach einem Wechsel würde kein " +
|
||||||
|
"Mitarbeiter wiedererkannt. Leg stattdessen eine neue Quelle an.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nurBei = (art: QuellenArt, feld: string): void => {
|
||||||
|
if (vorher.art !== art) {
|
||||||
|
throw new VerzeichnisFehler(
|
||||||
|
`„${feld}“ gilt nur für eine Quelle der Art ${art}; „${vorher.name}“ ist ${vorher.art}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (aenderung.name !== undefined) {
|
if (aenderung.name !== undefined) {
|
||||||
const name = pflichtText(aenderung.name, "Der Name der Quelle");
|
const name = pflichtText(aenderung.name, "Der Name der Quelle");
|
||||||
if (name !== vorher.name) {
|
if (name !== vorher.name) {
|
||||||
@@ -243,7 +372,40 @@ export async function quelleBearbeiten(
|
|||||||
geaendert.push("Name");
|
geaendert.push("Name");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Graph-Felder ---------------------------------------------------------
|
||||||
|
if (aenderung.tenantId !== undefined) {
|
||||||
|
nurBei("GRAPH", "Mandanten-ID");
|
||||||
|
const wert = pflichtText(aenderung.tenantId, "Die Mandanten-ID");
|
||||||
|
if (wert !== vorher.tenantId) {
|
||||||
|
daten.tenantId = wert;
|
||||||
|
geaendert.push("Mandant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (aenderung.clientId !== undefined) {
|
||||||
|
nurBei("GRAPH", "Anwendungs-ID");
|
||||||
|
const wert = pflichtText(aenderung.clientId, "Die Anwendungs-ID");
|
||||||
|
if (wert !== vorher.clientId) {
|
||||||
|
daten.clientId = wert;
|
||||||
|
geaendert.push("Anwendung");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (aenderung.clientSecret !== undefined) {
|
||||||
|
nurBei("GRAPH", "Client-Secret");
|
||||||
|
/*
|
||||||
|
* Leer heisst **unveraendert**, wie beim Bind-Passwort: das Formular zeigt
|
||||||
|
* ein gesetztes Geheimnis nie an, also kommt es leer zurueck. Wuerde leer
|
||||||
|
* als „loeschen“ gelesen, machte jedes Umbenennen die Quelle unbrauchbar.
|
||||||
|
*/
|
||||||
|
const wert = aenderung.clientSecret.trim();
|
||||||
|
if (wert) {
|
||||||
|
daten.clientSecretVerschluesselt = verschluesseln(wert);
|
||||||
|
geaendert.push("Client-Secret");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (aenderung.url !== undefined) {
|
if (aenderung.url !== undefined) {
|
||||||
|
nurBei("LDAP", "Adresse");
|
||||||
const url = pflichtText(aenderung.url, "Die Adresse des Verzeichnisses");
|
const url = pflichtText(aenderung.url, "Die Adresse des Verzeichnisses");
|
||||||
urlPruefen(url);
|
urlPruefen(url);
|
||||||
if (url !== vorher.url) {
|
if (url !== vorher.url) {
|
||||||
@@ -252,6 +414,7 @@ export async function quelleBearbeiten(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (aenderung.bindDn !== undefined) {
|
if (aenderung.bindDn !== undefined) {
|
||||||
|
nurBei("LDAP", "Bind-DN");
|
||||||
const bindDn = pflichtText(aenderung.bindDn, "Der Bind-DN");
|
const bindDn = pflichtText(aenderung.bindDn, "Der Bind-DN");
|
||||||
if (bindDn !== vorher.bindDn) {
|
if (bindDn !== vorher.bindDn) {
|
||||||
daten.bindDn = bindDn;
|
daten.bindDn = bindDn;
|
||||||
@@ -259,6 +422,7 @@ export async function quelleBearbeiten(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (aenderung.bindPasswort !== undefined) {
|
if (aenderung.bindPasswort !== undefined) {
|
||||||
|
nurBei("LDAP", "Bind-Passwort");
|
||||||
// Ein leeres Feld heisst „unveraendert“. Ein Formular, das das Passwort nicht
|
// Ein leeres Feld heisst „unveraendert“. Ein Formular, das das Passwort nicht
|
||||||
// anzeigen kann (und das darf es nicht), schickt sonst bei jedem Speichern
|
// anzeigen kann (und das darf es nicht), schickt sonst bei jedem Speichern
|
||||||
// eine leere Zeichenkette und loeschte das Geheimnis.
|
// eine leere Zeichenkette und loeschte das Geheimnis.
|
||||||
@@ -269,6 +433,7 @@ export async function quelleBearbeiten(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (aenderung.basisDn !== undefined) {
|
if (aenderung.basisDn !== undefined) {
|
||||||
|
nurBei("LDAP", "Basis-DN");
|
||||||
const basisDn = pflichtText(aenderung.basisDn, "Der Basis-DN");
|
const basisDn = pflichtText(aenderung.basisDn, "Der Basis-DN");
|
||||||
if (basisDn !== vorher.basisDn) {
|
if (basisDn !== vorher.basisDn) {
|
||||||
daten.basisDn = basisDn;
|
daten.basisDn = basisDn;
|
||||||
@@ -276,6 +441,7 @@ export async function quelleBearbeiten(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (aenderung.filter !== undefined) {
|
if (aenderung.filter !== undefined) {
|
||||||
|
nurBei("LDAP", "Filter");
|
||||||
const filter = aenderung.filter.trim() || STANDARD_FILTER;
|
const filter = aenderung.filter.trim() || STANDARD_FILTER;
|
||||||
if (filter !== vorher.filter) {
|
if (filter !== vorher.filter) {
|
||||||
daten.filter = filter;
|
daten.filter = filter;
|
||||||
@@ -400,13 +566,15 @@ export async function verbindungTesten(
|
|||||||
const quelle = await quelleZeileHolen(quelleId);
|
const quelle = await quelleZeileHolen(quelleId);
|
||||||
const wieViele = Math.min(Math.max(Math.trunc(grenze), 1), PROBE_GRENZE_HOECHSTENS);
|
const wieViele = Math.min(Math.max(Math.trunc(grenze), 1), PROBE_GRENZE_HOECHSTENS);
|
||||||
|
|
||||||
const { eintraege, gesamtGelesen, uebersprungen } = await eintraegeLesen(
|
/*
|
||||||
zugangAus(quelle),
|
* **Dieselbe Weiche wie der Abgleich** (#028). Eine eigene Verzweigung hier
|
||||||
{
|
* waere eine zweite Stelle, die sich an eine neue Quellenart erinnern muss —
|
||||||
basisDn: quelle.basisDn,
|
* und die vergisst man.
|
||||||
filter: quelle.filter,
|
*/
|
||||||
zuordnung: zuordnungLesen(quelle.attributZuordnung),
|
const eintraege: RoherEintrag[] = [];
|
||||||
},
|
const { gesamtGelesen, uebersprungen } = await eintraegeDerQuelleStroemen(
|
||||||
|
quelle,
|
||||||
|
(eintrag) => void eintraege.push(eintrag),
|
||||||
wieViele,
|
wieViele,
|
||||||
);
|
);
|
||||||
return { treffer: vorgesetzteAufloesen(eintraege), gesamtGelesen, uebersprungen };
|
return { treffer: vorgesetzteAufloesen(eintraege), gesamtGelesen, uebersprungen };
|
||||||
@@ -433,13 +601,3 @@ export async function quelleZeileHolen(id: string): Promise<Verzeichnisquelle> {
|
|||||||
}
|
}
|
||||||
return zeile;
|
return zeile;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Entschluesselt das Bind-Passwort. Das Ergebnis bleibt in der Aufrufkette. */
|
|
||||||
export function zugangAus(quelle: Verzeichnisquelle): LdapZugang {
|
|
||||||
return {
|
|
||||||
url: quelle.url,
|
|
||||||
bindDn: quelle.bindDn,
|
|
||||||
bindPasswort: entschluesseln(quelle.bindPasswortVerschluesselt),
|
|
||||||
caZertifikatPem: quelle.caZertifikatPem,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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",
|
"Schrittergebnis_begruendung_noetig",
|
||||||
"TagCodeVergabe_form",
|
"TagCodeVergabe_form",
|
||||||
"Verzeichnisquelle_bindpasswort_verschluesselt",
|
"Verzeichnisquelle_bindpasswort_verschluesselt",
|
||||||
|
/*
|
||||||
|
* Seit #028 hat eine Quelle eine **Art**, und welche Felder sie braucht,
|
||||||
|
* haengt daran. Ohne diese Bedingung waere eine Graph-Quelle mit
|
||||||
|
* `basisDn` moeglich — eine Zeile, die zwei Dinge gleichzeitig behauptet.
|
||||||
|
*/
|
||||||
|
"Verzeichnisquelle_clientsecret_verschluesselt",
|
||||||
|
"Verzeichnisquelle_felder_je_art",
|
||||||
"VorlagenVersion_veroeffentlicht_mit_zeitpunkt",
|
"VorlagenVersion_veroeffentlicht_mit_zeitpunkt",
|
||||||
"Zustellung_bezug_passt_zum_anlass",
|
"Zustellung_bezug_passt_zum_anlass",
|
||||||
"Zustellung_empfaenger_nicht_leer",
|
"Zustellung_empfaenger_nicht_leer",
|
||||||
|
|||||||
@@ -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();
|
const quelle = await testPrisma.verzeichnisquelle.findFirstOrThrow();
|
||||||
expect(quelle.aktiv).toBe(false);
|
expect(quelle.aktiv).toBe(false);
|
||||||
expect(quelle.url.startsWith("ldaps://")).toBe(true);
|
// Seit #028 sind die LDAP-Felder nullable (nur bei `art = LDAP` belegt) — die
|
||||||
|
// Beispielquelle ist eine LDAP-Quelle, also stehen sie.
|
||||||
|
expect(quelle.art).toBe("LDAP");
|
||||||
|
expect(quelle.url?.startsWith("ldaps://")).toBe(true);
|
||||||
// Das Versionspraefix prueft auch die Datenbank; hier zaehlt, dass sich das
|
// Das Versionspraefix prueft auch die Datenbank; hier zaehlt, dass sich das
|
||||||
// Feld ueberhaupt entschluesseln laesst und nichts Lesbares enthaelt.
|
// Feld ueberhaupt entschluesseln laesst und nichts Lesbares enthaelt.
|
||||||
expect(quelle.bindPasswortVerschluesselt).toMatch(/^v[0-9]+\./u);
|
expect(quelle.bindPasswortVerschluesselt).toMatch(/^v[0-9]+\./u);
|
||||||
expect(entschluesseln(quelle.bindPasswortVerschluesselt).length).toBeGreaterThan(16);
|
expect(entschluesseln(quelle.bindPasswortVerschluesselt ?? "").length).toBeGreaterThan(16);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("erzeugt im Modus Dienstleistung die beiden Demo-Kunden mit MUS-PC-001", async () => {
|
it("erzeugt im Modus Dienstleistung die beiden Demo-Kunden mit MUS-PC-001", async () => {
|
||||||
|
|||||||
@@ -536,12 +536,12 @@ describe.runIf(datenbankVerfuegbar)("Verzeichnisquellen", () => {
|
|||||||
// Umbenennen kaputt.
|
// Umbenennen kaputt.
|
||||||
await quelleBearbeiten(quelle.id, { bindPasswort: "", name: "Neu" }, admin);
|
await quelleBearbeiten(quelle.id, { bindPasswort: "", name: "Neu" }, admin);
|
||||||
const nachher = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
|
const nachher = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
|
||||||
expect(entschluesseln(nachher.bindPasswortVerschluesselt)).toBe(LDAP_BIND_PASSWORT);
|
expect(entschluesseln(nachher.bindPasswortVerschluesselt ?? "")).toBe(LDAP_BIND_PASSWORT);
|
||||||
expect(nachher.bindPasswortVerschluesselt).toBe(vorher.bindPasswortVerschluesselt);
|
expect(nachher.bindPasswortVerschluesselt).toBe(vorher.bindPasswortVerschluesselt);
|
||||||
|
|
||||||
await quelleBearbeiten(quelle.id, { bindPasswort: "ein-neues-geheimnis" }, admin);
|
await quelleBearbeiten(quelle.id, { bindPasswort: "ein-neues-geheimnis" }, admin);
|
||||||
const dritte = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
|
const dritte = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
|
||||||
expect(entschluesseln(dritte.bindPasswortVerschluesselt)).toBe("ein-neues-geheimnis");
|
expect(entschluesseln(dritte.bindPasswortVerschluesselt ?? "")).toBe("ein-neues-geheimnis");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gibt das Bind-Passwort in keiner Antwort und in keinem Protokoll heraus", async () => {
|
it("gibt das Bind-Passwort in keiner Antwort und in keinem Protokoll heraus", async () => {
|
||||||
@@ -562,7 +562,7 @@ describe.runIf(datenbankVerfuegbar)("Verzeichnisquellen", () => {
|
|||||||
const zeile = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
|
const zeile = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
|
||||||
expect(zeile.bindPasswortVerschluesselt).not.toContain(LDAP_BIND_PASSWORT);
|
expect(zeile.bindPasswortVerschluesselt).not.toContain(LDAP_BIND_PASSWORT);
|
||||||
expect(zeile.bindPasswortVerschluesselt).toMatch(/^v1\./u);
|
expect(zeile.bindPasswortVerschluesselt).toMatch(/^v1\./u);
|
||||||
expect(entschluesseln(zeile.bindPasswortVerschluesselt)).toBe(LDAP_BIND_PASSWORT);
|
expect(entschluesseln(zeile.bindPasswortVerschluesselt ?? "")).toBe(LDAP_BIND_PASSWORT);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("loescht eine Quelle ohne Mitarbeiter und verweigert es sonst", async () => {
|
it("loescht eine Quelle ohne Mitarbeiter und verweigert es sonst", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user