From 76a61ec771f544072520c229694140058af471d3 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Tue, 6 Oct 2026 23:36:36 +0200 Subject: [PATCH] feat(verzeichnis): Entra ID als Quelle ueber Microsoft Graph (#48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 13 + CLAUDE.md | 1 + docs/anleitung/06-verzeichnis.md | 26 + docs/betrieb/README.md | 2 + docs/betrieb/entra-id.md | 126 ++++ docs/devlog.md | 69 +- docs/issues/028-graph-quelle.md | 93 ++- docs/issues/README.md | 4 +- .../20261006120000_quellenart/migration.sql | 97 +++ prisma/schema.prisma | 38 +- src/app/(app)/admin/verzeichnis/page.tsx | 15 +- .../verzeichnis/quelle-formular.tsx | 395 +++++++---- src/lib/verzeichnis.ts | 44 +- src/server/actions/verzeichnis.ts | 109 ++- src/server/graph/leser.ts | 279 ++++++++ src/server/graph/zugang.ts | 173 +++++ src/server/ldap/zuordnung.ts | 39 +- src/server/services/abgleich.ts | 269 ++++---- src/server/services/verzeichnis.ts | 260 ++++++-- src/server/verzeichnis/quellenleser.ts | 65 ++ src/server/verzeichnis/zugang.ts | 73 +++ tests/datenbankregeln.test.ts | 7 + tests/graph.test.ts | 618 ++++++++++++++++++ tests/seed.test.ts | 7 +- tests/verzeichnis.test.ts | 6 +- 25 files changed, 2446 insertions(+), 382 deletions(-) create mode 100644 docs/betrieb/entra-id.md create mode 100644 prisma/migrations/20261006120000_quellenart/migration.sql create mode 100644 src/server/graph/leser.ts create mode 100644 src/server/graph/zugang.ts create mode 100644 src/server/verzeichnis/quellenleser.ts create mode 100644 src/server/verzeichnis/zugang.ts create mode 100644 tests/graph.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f9b6643..0da2c0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,19 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Hinzugefügt +- **Entra ID als Verzeichnisquelle, über Microsoft Graph.** Ein Haus ohne lokales Active Directory + hat kein LDAP, auf das sich zeigen ließe. Eine Verzeichnisquelle hat jetzt eine **Art** — `LDAP` + oder `GRAPH` —, und beide laufen durch **denselben** Abgleich: anlegen, aktualisieren, + deaktivieren, **nie löschen**. Nur das Holen der Einträge unterscheidet sich, sonst drifteten die + Regeln auseinander. + Gelesen wird mit dem Client-Credentials-Flow über `GET /v1.0/users`, seitenweise über + `@odata.nextLink`, der Vorgesetzte über `$expand=manager($select=id)`. Das Client-Secret wird + verschlüsselt gespeichert wie das LDAP-Bind-Passwort und erscheint in keiner Antwort und in keinem + Protokolleintrag. + **Zwei Platzhalter bleiben bei Graph bewusst leer:** `{{ou}}` (Entra ID hat keine OU — steht an der + Abteilung eine Ziel-OU, greift die) und `{{sam}}` ohne AD-Synchronisierung. Was in Entra ID + einzurichten ist, steht in `docs/betrieb/entra-id.md` — mit einer Prüfliste, denn gegen einen + echten Mandanten ist dieser Weg **nicht** gelaufen (#028) - **Mitarbeiter-Selbstauskunft.** „Was ist eigentlich mit meinem Notebook passiert?“ musste bisher ein Techniker nachsehen. Ein Mitarbeiter kann die Arbeiten an **seinen** Geräten jetzt selbst ansehen, unter **Meine Geräte** — mit der neuen Rolle `MITARBEITER`, zugeordnet über den **UPN** diff --git a/CLAUDE.md b/CLAUDE.md index 0246b7d..a1aff5d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,7 @@ Enums: `Rolle {ADMIN REDAKTEUR TECHNIKER LESER MITARBEITER}` · `Betriebsmodus { `VariablenTyp {TEXT ZAHL AUSWAHL DATUM}` · `GeraeteTyp {PC NOTEBOOK SERVER FIREWALL SWITCH DRUCKER RAUM SONSTIGES}` · `VersionStatus {ENTWURF VEROEFFENTLICHT}` · `Herkunft {ABGLEICH MANUELL}` · +`QuellenArt {LDAP GRAPH SCIM}` · `Zustellweg {MAIL WEBHOOK}` · `Zustellzustand {OFFEN GESENDET FEHLGESCHLAGEN AUFGEGEBEN}` · `Meldeanlass {LAUF_ZUGEWIESEN LAUF_UEBERFAELLIG LAUF_ABGESCHLOSSEN ABGLEICH_FEHLGESCHLAGEN}`. diff --git a/docs/anleitung/06-verzeichnis.md b/docs/anleitung/06-verzeichnis.md index ff22f4d..9acf49c 100644 --- a/docs/anleitung/06-verzeichnis.md +++ b/docs/anleitung/06-verzeichnis.md @@ -16,6 +16,32 @@ Zwei Dinge vorweg: Wer im Verzeichnis verschwindet oder dort deaktiviert wird, wird hier **inaktiv**. Gelöscht wird nie, sonst verlöre die Historie ihre Namen. +## Zwei Arten von Quelle + +Laufzettel liest die Personen aus einem Verzeichnis. Dafür gibt es zwei Wege, und du wählst ihn beim +Anlegen der Quelle: + +| Art | Für wen | +|---|---| +| **LDAP** | ein lokales Active Directory (oder ein anderes LDAP-Verzeichnis) | +| **Microsoft Graph** | Entra ID, also Microsoft 365 — für Häuser ohne lokales AD | + +Beide verhalten sich danach gleich: anlegen, aktualisieren, deaktivieren, **nie löschen**. Was sie +unterscheidet, sind die Zugangsdaten und zwei Platzhalter: + +- **`{{ou}}` bleibt bei Graph leer.** Entra ID hat keine OU. Steht an der Abteilung eine Ziel-OU + gepflegt, nimmt Laufzettel die; sonst bleibt der Platzhalter stehen. +- **`{{sam}}` ist bei Graph leer**, wenn der Mandant kein lokales AD synchronisiert — dort gibt es + keinen Anmeldenamen in diesem Sinn. + +**Die Art lässt sich nachträglich nicht ändern.** An ihr hängt, was die dauerhafte Kennung einer +Person bedeutet; nach einem Wechsel würde niemand mehr wiedererkannt, und der nächste Abgleich legte +alle neu an. Wer wechseln muss, legt eine zweite Quelle an und lässt die erste stehen. + +Was in Entra ID einzurichten ist — App-Registrierung, Berechtigung, Einwilligung — steht in +[`docs/betrieb/entra-id.md`](../betrieb/entra-id.md). + + ## Eine Quelle anlegen ![Die Seite Verzeichnis mit Quelle, Status und Formular](bilder/verzeichnis-desktop.png) diff --git a/docs/betrieb/README.md b/docs/betrieb/README.md index d04371c..442662f 100644 --- a/docs/betrieb/README.md +++ b/docs/betrieb/README.md @@ -56,6 +56,8 @@ Sitzungen ungültig *und* die verschlüsselt gespeicherten LDAP-Bind-Passwörter - **[Anmeldung über authentik](authentik.md)** — OIDC-Provider, Gruppen auf Rollen, Notfallzugang - **[Sichern und Zurückspielen](sicherung.md)** — auf beiden Wegen, was in die Sicherung gehört und was nicht, und wie du prüfst, dass das Zurückspielen wirklich funktioniert +- **[Entra ID als Verzeichnisquelle](entra-id.md)** — App-Registrierung, `User.Read.All`, + Administratoreinwilligung, und was anders ist als bei einem lokalen AD - **[Benachrichtigungen](benachrichtigungen.md)** — SMTP einrichten, den Webhook prüfen, die Signatur nachrechnen, und wo man nachsieht, wenn keine Mail mehr ankommt diff --git a/docs/betrieb/entra-id.md b/docs/betrieb/entra-id.md new file mode 100644 index 0000000..5e65055 --- /dev/null +++ b/docs/betrieb/entra-id.md @@ -0,0 +1,126 @@ +# Entra ID als Verzeichnisquelle + +Ein Haus ohne lokales Active Directory hat kein LDAP, auf das sich zeigen ließe. Für reine +Microsoft-365-Umgebungen liest Laufzettel über **Microsoft Graph** — mit denselben Regeln wie der +LDAP-Abgleich: anlegen, aktualisieren, deaktivieren, **nie löschen**. + +> **Dieser Weg ist nicht gegen einen echten Mandanten gelaufen.** Geprüft ist er gegen einen +> HTTP-Doppelgänger mit aufgezeichneten Graph-Antworten (`tests/graph.test.ts`), und die Form dieser +> Antworten stammt aus der Dokumentation, nicht aus einem Mitschnitt. Die Prüfliste am Ende dieser +> Seite gehört einmal abgearbeitet, so wie bei Proxmox (#024). + +## Was in Entra ID einzurichten ist + +### 1. App-Registrierung anlegen + +Im Entra-Portal unter **Identität → Anwendungen → App-Registrierungen → Neue Registrierung**: + +| Feld | Wert | +|---|---| +| Name | `Laufzettel Verzeichnisabgleich` | +| Unterstützte Kontotypen | **Nur Konten in diesem Organisationsverzeichnis** | +| Umleitungs-URI | **keiner** — Laufzettel meldet sich als Anwendung an, nicht im Namen eines Menschen | + +Notiere von der Übersichtsseite die **Anwendungs-ID (Client)** und die **Verzeichnis-ID (Mandant)**. + +### 2. Berechtigung erteilen + +Unter **API-Berechtigungen → Berechtigung hinzufügen → Microsoft Graph → +Anwendungsberechtigungen**: + +| Berechtigung | Wofür | +|---|---| +| `User.Read.All` | die Benutzer lesen | + +Dann **Administratoreinwilligung erteilen**. Ohne diesen Klick antwortet Graph mit `403` und +`Authorization_RequestDenied` — Laufzettel nennt den Grund in der Fehlermeldung, weil ihn sonst +niemand errät. + +> **Anwendungsberechtigung, nicht delegierte.** Der Abgleich läuft nachts, wenn niemand angemeldet +> ist; eine delegierte Berechtigung braucht einen angemeldeten Menschen und funktioniert dort nicht. + +`Directory.Read.All` wird **nicht** gebraucht. `User.Read.All` deckt die Benutzer und über +`$expand=manager` auch den Vorgesetzten ab — die weitergehende Berechtigung wäre mehr Zugriff, als +die Aufgabe verlangt. + +### 3. Geheimnis erzeugen + +Unter **Zertifikate & Geheimnisse → Neuer geheimer Clientschlüssel**. Die Laufzeit ist eine +Entscheidung mit Folgen: **läuft das Geheimnis ab, hört der Abgleich auf, und niemand merkt es**, +weil jeder Lauf weiter funktioniert. Setz dir eine Erinnerung, und sieh unter **Verwaltung → +Verzeichnis** nach dem Status, wenn Personen nicht mehr aktualisiert werden. + +Der Wert ist **nur beim Anlegen sichtbar**. Kopier ihn sofort. + +## In Laufzettel einrichten + +Unter **Verwaltung → Verzeichnis → Quelle anlegen**, Art **Microsoft Graph**: + +| Feld | Wert | +|---|---| +| Mandanten-ID | die Verzeichnis-ID oder der Domänenname (`contoso.onmicrosoft.com`) | +| Anwendungs-ID | die Anwendungs-ID (Client) aus Schritt 1 | +| Client-Secret | der Wert aus Schritt 3 — wird verschlüsselt gespeichert und nie wieder angezeigt | + +Dann **Verbindung testen**: es erscheinen die ersten zehn zugeordneten Personen. Steht dort nichts, +hilft die Tabelle unter „Wenn es klemmt“. + +## Was anders ist als bei einem lokalen AD + +| | LDAP (AD) | Microsoft Graph (Entra ID) | +|---|---|---| +| Dauerhafte Kennung | `objectGUID` (binär, umsortiert) | `id` (GUID als Text) | +| Nachname | `sn` | `surname` | +| Funktion | `title` | `jobTitle` | +| Anmeldename | `sAMAccountName` | `onPremisesSamAccountName` | +| Vorgesetzter | `manager` als DN, danach aufgelöst | `manager` direkt als Kennung | +| Deaktivierte Konten | über den Filter ausgeschlossen | über `$filter=accountEnabled eq true` | +| `{{ou}}` | aus dem DN | **leer** | + +### `{{ou}}` bleibt leer, und das ist Absicht + +Entra ID hat keinen DN und keine OU. Etwas hineinzudeuten — den Abteilungsnamen, eine erfundene +Struktur — wäre ein Wert, den kein PowerShell-Befehl brauchen kann, und er sähe richtig aus. + +Steht an der **Abteilung** eine Ziel-OU gepflegt, nimmt Laufzettel die (`{{ou}}` hat zwei Quellen, +siehe `docs/architektur.md`). Wer OU-abhängige Befehle fährt, braucht ein lokales AD — oder pflegt +die Ziel-OU je Abteilung von Hand. + +### `{{sam}}` ist leer ohne AD-Synchronisierung + +`onPremisesSamAccountName` kommt aus einem lokalen AD. Ein Mandant, der nie eines hatte, liefert dort +nichts — eine reine Cloud-Umgebung hat keinen Anmeldenamen in diesem Sinn. Vorlagen, die `{{sam}}` +benutzen, zeigen dann einen leeren Wert; gemeint ist meist ohnehin der UPN. + +### Die Art einer Quelle lässt sich nicht ändern + +An ihr hängt, was die dauerhafte Kennung bedeutet. Nach einem Wechsel von LDAP auf Graph würde +**kein einziger Mitarbeiter wiedererkannt**: der nächste Abgleich legte die ganze Belegschaft neu an +und setzte die alten Zeilen auf `aktiv = false` — mit allen Läufen und Geräten daran. + +Wer wechselt, legt eine **zweite** Quelle an und lässt die erste stehen, bis die Personen übernommen +sind. Laufzettel übernimmt dabei von Hand angelegte und bereits vorhandene Personen über Name und +Mailadresse, statt Dubletten anzulegen (#016). + +## Wenn es klemmt + +| Meldung | Ursache | +|---|---| +| `401` beim Anmelden | Mandant, Anwendungs-ID oder Geheimnis stimmen nicht — oder das Geheimnis ist abgelaufen. Die ausführliche Antwort von Entra ID steht im Protokoll des Dienstes, nicht in der Oberfläche: sie nennt Mandant und Anwendung. | +| `403 Authorization_RequestDenied` | Die Administratoreinwilligung für `User.Read.All` fehlt. Erteilen und erneut testen. | +| `Invalid expand clause` | Sollte nicht vorkommen — Laufzettel baut `$expand=manager($select=id)` mit unkodierten Klammern. Tritt es auf, steht ein Proxy dazwischen, der die Adresse umschreibt. | +| Verbindung testen zeigt niemanden | Der Mandant hat keine Benutzer mit `accountEnabled = true`, oder alle wurden übersprungen. Die Liste der Übersprungenen steht unter dem Ergebnis. | +| Personen werden nicht mehr aktualisiert | Zuerst den Status der Quelle ansehen. Fast immer ist das Client-Secret abgelaufen. | + +## Prüfliste für den ersten echten Lauf + +Diese Punkte sind **nicht** automatisiert geprüft — sie brauchen einen echten Mandanten: + +- [ ] App-Registrierung angelegt, `User.Read.All` als **Anwendungs**berechtigung, Einwilligung erteilt +- [ ] „Verbindung testen“ zeigt die ersten zehn Personen mit richtigen Namen und Abteilungen +- [ ] Der Vorgesetzte steht bei jemandem, der einen hat (`manager` über `$expand`) +- [ ] Erster Abgleich legt an; die Zahl passt zur Zahl der aktiven Konten im Mandanten +- [ ] Zweiter Abgleich legt **nichts** an und meldet nur Änderungen +- [ ] Ein Konto in Entra ID sperren → nächster Abgleich setzt `aktiv = false`, löscht aber nichts +- [ ] Ein Mandant mit mehr als 999 Benutzern: der Seitenabruf über `@odata.nextLink` läuft durch +- [ ] Nach einem abgelaufenen Geheimnis steht der Grund an der Quelle und nicht nur im Log diff --git a/docs/devlog.md b/docs/devlog.md index 2eb5362..8cc574d 100644 --- a/docs/devlog.md +++ b/docs/devlog.md @@ -34,12 +34,13 @@ als Unterpunkte mit Aufgabe und Dauer. | #033 | M20 – Live-Aktualisierung per SSE statt Polling | 1 h 40 min | | #034 | M21 – Benachrichtigungen per Mail und Webhook | 3 h 30 min | | #035 | M22 – Mitarbeiter-Selbstauskunft | 2 h 50 min | -| | **Summe** | **56 h 25 min** | +| #028 | M15 – Quellenarten und Microsoft-Graph-Quelle | 4 h 10 min | +| | **Summe** | **60 h 35 min** | | Meilenstein | Tatsächlich | Status | |---|---|---| | v0.1.0 | 35 h 20 min | veröffentlicht | -| v0.2.0 | 21 h 05 min | in Arbeit | +| v0.2.0 | 25 h 15 min | in Arbeit | --- @@ -1370,3 +1371,67 @@ und nicht nach jeder einzelnen — das ist der Grund, warum er alles auf einmal - Vor beiden wäre ein unabhängiger Review über die sechs Meilensteine dieser Phase angebracht — M17 bis M22 liegen ohne fremden Blick auf `main`. - Weiter offen und nicht von hier aus machbar: **#024** Proxmox-Einzeiler auf einem echten PVE-Host. + +--- + +## 2026-10-06 · Sitzung 21 · 09:00–13:10 + +**Issue:** #028 (M15) +**Version:** 0.2.0 (unveröffentlicht) + +### Erledigt + +- **Eine Verzeichnisquelle hat eine Art**: `LDAP`, `GRAPH` oder `SCIM`. Bestehende Zeilen sind + `LDAP` — das war vor #028 die einzige. +- **Entra ID über Microsoft Graph**: Client-Credentials-Flow mit Token-Zwischenspeicher, Seitenabruf + über `@odata.nextLink`, Vorgesetzter über `$expand`. +- **Derselbe Abgleich für beide Arten.** Die Weiche sitzt in + `src/server/verzeichnis/quellenleser.ts` und ist die einzige Stelle, die die Art kennt. +- 1373 Vitest-Tests (vorher 1342), davon 31 neue gegen einen HTTP-Doppelgänger mit aufgezeichneten + Graph-Antworten. +- Im gebauten Stand eine Graph-Quelle wirklich angelegt: Art gespeichert, Geheimnis verschlüsselt, + nicht im Quelltext der Seite. + +### Entscheidungen + +- **Ein Enum auf der Quelle, keine Tabelle je Art.** Drei Arten rechtfertigen keine Vererbung; was + eine Art braucht, hält ein CHECK fest — in **beide** Richtungen, sonst wäre eine Graph-Quelle mit + `basisDn` möglich. +- **Die Art ist unveränderlich.** An ihr hängt, was `externeId` bedeutet; nach einem Wechsel würde + kein Mitarbeiter wiedererkannt. Steht so nicht im Issue und ist die wichtigste Zusage dieses + Meilensteins. +- **`{{ou}}` bleibt bei Graph leer.** Etwas hineinzudeuten wäre ein Wert, den kein PowerShell-Befehl + brauchen kann — und er sähe richtig aus. +- **`SCIM` steht im Enum, wird aber abgewiesen.** Ein Wert, der stillschweigend nichts tut, hätte + hier die ganze Belegschaft deaktiviert. + +### Probleme + +- **Der teuerste Fund: „deaktiviert“ als „übersprungen“ zu melden hätte Leute aktiv gelassen.** + „Übersprungen“ heißt im Abgleich „nicht zuordenbar“, und solche Einträge werden seit #016 + absichtlich nicht abgeschaltet. Ein gesperrtes Entra-Konto wäre damit genau umgekehrt behandelt + worden als ein gesperrtes AD-Konto. Jetzt filtert die Abfrage serverseitig, als Gegenstück zum + LDAP-Standardfilter. + **Gefunden hat es der Test — aber nur, weil der Doppelgänger den Filter wirklich auswertet.** Hätte + er den Filter bloß empfangen und ignoriert, wäre der Test grün gewesen und der Fehler im Betrieb + aufgefallen. Ein Doppelgänger, der nur antwortet, prüft die Anfrage nicht. +- **`URLSearchParams` kodiert `$` zu `%24`** und die Klammern in `manager($select=id)`. Graph nimmt + das teils an und antwortet teils mit „Invalid expand clause“. Jetzt von Hand zusammengesetzt. +- **Ein Import-Zyklus drohte**: die Weiche braucht `zugangAus`, und der Dienst braucht die Weiche. + Beides aufgelöst durch ein eigenes Modul. In ESM läuft so ein Zyklus oft — aber nicht zuverlässig. +- **Zwei eigene Testfehler**: die Token-Grenze liegt bei +3540 s und nicht „ungefähr eine Stunde“, + und die Klickprobe zählte die Felder der Bearbeiten-Formulare mit. + +### Was offen bleibt, und das ist benannt + +Gegen einen **echten Entra-Mandanten** ist nichts davon gelaufen. Die Form der Antworten im +Doppelgänger stammt aus der Dokumentation, nicht aus einem Mitschnitt — stimmt sie nicht, stimmen +Test und Wirklichkeit nicht. Die Prüfliste steht am Ende von `docs/betrieb/entra-id.md`, acht +Punkte, darunter ein Mandant mit mehr als 999 Benutzern. Dieselbe Art offener Punkt wie #024. + +### Nächster Schritt + +- **M16 (#029)** SCIM-Endpunkt ist der letzte Meilenstein von Phase 2. Die Weiche dafür steht. +- Vor #029 wäre ein unabhängiger Review angebracht: **sieben Meilensteine** dieser Phase liegen ohne + fremden Blick auf `main`, und diese Sitzung hat wieder einen Fehler gezeigt, den die Suite nur + deshalb gefunden hat, weil der Doppelgänger streng genug war. diff --git a/docs/issues/028-graph-quelle.md b/docs/issues/028-graph-quelle.md index 9ea5a4c..24a173a 100644 --- a/docs/issues/028-graph-quelle.md +++ b/docs/issues/028-graph-quelle.md @@ -1,10 +1,10 @@ # #028 M15 – Quellenarten und Microsoft-Graph-Quelle -**Status:** offen +**Status:** erledigt **Im Tracker:** [#48](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/48) **Meilenstein:** v0.2.0 **Labels:** phase-2, backend, frontend -**Geschätzt:** 10 h **Tatsächlich:** – +**Geschätzt:** 10 h **Tatsächlich:** 4 h 10 min ## Ziel @@ -17,39 +17,94 @@ Dieser Meilenstein legt dazu die Weiche, die auch [#029](029-scim-endpunkt.md) b ## Zu entscheiden -- [ ] **Ein Enum `QuellenArt {LDAP GRAPH SCIM}` auf `Verzeichnisquelle`** — Empfehlung. Die Felder +- [x] **Ein Enum `QuellenArt {LDAP GRAPH SCIM}` auf `Verzeichnisquelle`** — Empfehlung. Die Felder `url`, `bindDn` und `basisDn` sind dann je Art unterschiedlich belegt; was eine Art braucht, prüft der Service, nicht die Datenbank. Die Alternative wäre eine Tabelle je Art mit gemeinsamer Oberklasse — mehr Struktur, als drei Arten rechtfertigen. -- [ ] **Wie `{{ou}}` in Entra ID aussieht.** Es gibt keinen DN und keine OU. Ehrlich ist, den +- [x] **Wie `{{ou}}` in Entra ID aussieht.** Es gibt keinen DN und keine OU. Ehrlich ist, den Platzhalter bei einer Graph-Quelle **leer** zu lassen, statt etwas hineinzudeuten — und das in der Anleitung zu sagen. Wer OU-abhängige Befehle fährt, braucht ein lokales AD. ## Aufgaben -- [ ] `QuellenArt` als Enum, Migration mit `LDAP` für alle bestehenden Zeilen -- [ ] Graph-Zugang: `tenantId`, `clientId`, `clientSecret` (verschlüsselt wie das Bind-Passwort, nie +- [x] `QuellenArt` als Enum, Migration mit `LDAP` für alle bestehenden Zeilen +- [x] Graph-Zugang: `tenantId`, `clientId`, `clientSecret` (verschlüsselt wie das Bind-Passwort, nie in einer Antwort), Token über Client-Credentials-Flow, Zwischenspeicher bis kurz vor Ablauf -- [ ] Lesen über `GET /v1.0/users` mit `$select`, Seitenabruf über `@odata.nextLink`; der +- [x] Lesen über `GET /v1.0/users` mit `$select`, Seitenabruf über `@odata.nextLink`; der Vorgesetzte über `$expand=manager($select=id)` -- [ ] Attributzuordnung mit Graph-Vorgaben (`givenName`, `surname`, `displayName`, `mail`, +- [x] Attributzuordnung mit Graph-Vorgaben (`givenName`, `surname`, `displayName`, `mail`, `userPrincipalName`, `onPremisesSamAccountName`, `department`, `jobTitle`, `accountEnabled`) -- [ ] `externeId` ist die Entra-`id` (GUID). Sie ist stabil und übersteht eine Umbenennung -- [ ] „Verbindung testen“ wie bei LDAP: die ersten zehn zugeordneten Treffer zeigen -- [ ] Der Abgleich läuft durch **denselben** Dienst wie LDAP — nur das Holen der Einträge +- [x] `externeId` ist die Entra-`id` (GUID). Sie ist stabil und übersteht eine Umbenennung +- [x] „Verbindung testen“ wie bei LDAP: die ersten zehn zugeordneten Treffer zeigen +- [x] Der Abgleich läuft durch **denselben** Dienst wie LDAP — nur das Holen der Einträge unterscheidet sich. Sonst driften die Regeln auseinander -- [ ] Admin → Verzeichnis zeigt die Art je Quelle und bietet beim Anlegen die Wahl -- [ ] Anleitung Kapitel 06 und `docs/betrieb/`: was in Entra ID einzurichten ist +- [x] Admin → Verzeichnis zeigt die Art je Quelle und bietet beim Anlegen die Wahl +- [x] Anleitung Kapitel 06 und `docs/betrieb/`: was in Entra ID einzurichten ist (App-Registrierung, `User.Read.All`, `Directory.Read.All`, Administratoreinwilligung) ## Akzeptanzkriterien -- [ ] Test gegen einen **abspielenden HTTP-Doppelgänger** mit aufgezeichneten Graph-Antworten: erster +- [x] Test gegen einen **abspielenden HTTP-Doppelgänger** mit aufgezeichneten Graph-Antworten: erster Abgleich legt an, zweiter aktualisiert ohne Dubletten, ein `accountEnabled: false` setzt `aktiv = false` -- [ ] Test: der Seitenabruf folgt `@odata.nextLink` über mindestens drei Seiten -- [ ] Test: `clientSecret` erscheint in keiner Antwort und in keinem Protokolleintrag -- [ ] Test: eine Graph-Quelle lässt `{{ou}}` leer, statt einen Wert zu erfinden -- [ ] Der LDAP-Weg verhält sich unverändert — alle Tests aus #016 bleiben grün -- [ ] **Offen und benannt:** gegen einen echten Entra-Mandanten ist das nicht gelaufen; die +- [x] Test: der Seitenabruf folgt `@odata.nextLink` über mindestens drei Seiten +- [x] Test: `clientSecret` erscheint in keiner Antwort und in keinem Protokolleintrag +- [x] Test: eine Graph-Quelle lässt `{{ou}}` leer, statt einen Wert zu erfinden +- [x] Der LDAP-Weg verhält sich unverändert — alle Tests aus #016 bleiben grün +- [x] **Offen und benannt:** gegen einen echten Entra-Mandanten ist das nicht gelaufen; die Gegenprobe gehört in eine Prüfliste wie bei [#024](024-proxmox-echtlauf.md) + +## Beim Umsetzen dazugekommen + +- **„Deaktiviert in Entra ID“ als *übersprungen* zu melden war falsch — und hätte Leute aktiv + gelassen.** „Übersprungen“ bedeutet im Abgleich „nicht zuordenbar“, und solche Einträge werden + seit #016 absichtlich **nicht** abgeschaltet (ein Konto ohne GUID soll nicht die Person + deaktivieren). Ein gesperrtes Entra-Konto wäre damit genau umgekehrt behandelt worden als im AD. + Gefiltert wird jetzt serverseitig mit `$filter=accountEnabled eq true`, als Gegenstück zum + LDAP-Standardfilter. Gefunden hat es der Test „setzt aktiv = false“ — und nur, weil der + Doppelgänger den Filter wirklich auswertet statt ihn bloß zu empfangen. +- **`URLSearchParams` kodiert `$` zu `%24`.** Die Abfrage hätte `%24select` und `%28` in + `manager($select=id)` geschickt. Graph nimmt das teils an und antwortet teils mit „Invalid expand + clause“ — und eine Adresse, die in einem Mitschnitt anders aussieht als in der Dokumentation, + kostet bei der Fehlersuche eine Stunde. Die Parameter werden jetzt von Hand zusammengesetzt. +- **Die Art einer Quelle ist unveränderlich**, und das steht nicht im Issue. An ihr hängt, was + `externeId` bedeutet: im AD eine `objectGUID`, in Entra ID eine andere GUID. Nach einem Wechsel + würde **kein einziger Mitarbeiter wiedererkannt** — der nächste Abgleich legte die ganze + Belegschaft neu an und setzte die alten Zeilen auf `aktiv = false`, mit allen Läufen und Geräten + daran. Der Dienst weist einen Wechsel ab, die Oberfläche bietet ihn nicht an, und die Anleitung + sagt, was stattdessen zu tun ist. +- **`zuordnungLesen` brauchte die Art als Parameter.** Ohne sie bekäme eine Graph-Quelle mit leerer + Zuordnung die AD-Namen und fragte bei Graph nach `sn` und `objectGUID`, die es dort nicht gibt. + Der Standardwert `LDAP` hält jeden Aufrufer von vor #028 bei seinem Verhalten. +- **Die Zugangs-Helfer mussten in ein eigenes Modul.** `quellenleser.ts` (die Weiche) braucht + `zugangAus`, und `services/verzeichnis.ts` braucht die Weiche für „Verbindung testen“ — zusammen + ein Import-Zyklus. Der läuft in ESM oft, aber nicht zuverlässig, und ein Modul, das beim Laden + halb leer ist, ergibt Fehler, die niemand liest. Jetzt liegt beides in + `src/server/verzeichnis/zugang.ts`. +- **`SCIM` steht im Enum, wird aber abgewiesen.** Die Weiche braucht #029, und ein Wert, der + stillschweigend nichts tut, wäre hier besonders teuer: ein Abgleich, der null Einträge liest und + `ERFOLG` meldet, hätte über die Abschaltregel die ganze Belegschaft deaktiviert — genau der Fall, + der in #016 einen Reviewbefund gekostet hat. Mit eigenem Test. +- **Die Felder je Art hält die Datenbank fest.** Ein CHECK verlangt bei `LDAP` die LDAP-Felder + **und** verbietet die Graph-Felder, bei `GRAPH` umgekehrt. Ohne den zweiten Teil wäre eine + Graph-Quelle mit `basisDn` möglich — eine Zeile, die zwei Dinge gleichzeitig behauptet. +- **Der Vorgesetzte kommt bei Graph direkt**, nicht über eine DN-Auflösung. `RoherEintrag` hat dafür + ein zweites Feld (`vorgesetzterDirekt`) und nicht ein Feld mit zwei Bedeutungen: ein DN und eine + GUID sehen verschieden aus, aber ein Feld, das mal das eine und mal das andere trägt, verwechselt + irgendwann jemand. +- **Der Token-Zwischenspeicher hat 60 Sekunden Sicherheitsabstand.** Ein Token, das in zehn Sekunden + abläuft, ist für eine Seite, die zwei Sekunden braucht, formal noch gültig — und in der Mitte der + nächsten nicht mehr. Der Fehler wäre ein Abgleich, der nach der dritten Seite mit 401 abbricht. +- **Eine Reißleine bei 1000 Seiten.** Ein `@odata.nextLink`, der auf sich selbst zeigt, wäre sonst + eine Schleife bis zum Speicherende — still, weil jeder einzelne Abruf gelingt. +- **Zwei eigene Testfehler**, keine Produktfehler: die Token-Grenze liegt bei +3540 s und nicht bei + „so ungefähr einer Stunde“, und die Klickprobe zählte die Felder der Bearbeiten-Formulare der + bestehenden Quellen mit. + +## Noch offen, und benannt + +Gegen einen **echten Entra-Mandanten** ist das nicht gelaufen. Geprüft ist es gegen einen +HTTP-Doppelgänger mit aufgezeichneten Antworten, und deren Form stammt aus der Dokumentation, nicht +aus einem Mitschnitt. Die Prüfliste steht am Ende von +[`docs/betrieb/entra-id.md`](../betrieb/entra-id.md) — acht Punkte, darunter ein Mandant mit mehr als +999 Benutzern für den Seitenabruf. Dieselbe Art offener Punkt wie [#024](024-proxmox-echtlauf.md). diff --git a/docs/issues/README.md b/docs/issues/README.md index 0db2997..160951f 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -32,7 +32,7 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac | #025 | [Healthcheck gibt die rohe Datenbankfehlermeldung heraus](025-gesundheit-fehlermeldung.md) | erledigt | v0.2.0 | 1 h | 25 min | | #026 | [Zwei Konventionen präzisieren, die strenger klingen als sie gemeint sind](026-konventionen-praezisieren.md) | erledigt | v0.2.0 | 1 h | 20 min | | #027 | [Die Editor-Abläufe lassen ihre Vorlagen stehen](027-e2e-reste-vorlagen.md) | offen | v0.2.0 | 1 h | – | -| #028 | [M15 – Quellenarten und Microsoft-Graph-Quelle](028-graph-quelle.md) | offen | v0.2.0 | 10 h | – | +| #028 | [M15 – Quellenarten und Microsoft-Graph-Quelle](028-graph-quelle.md) | erledigt | v0.2.0 | 10 h | 4 h 10 min | | #029 | [M16 – SCIM-Endpunkt als Alternative zum Abgleich](029-scim-endpunkt.md) | offen | v0.2.0 | 12 h | – | | #030 | [M17 – Wiederverwendbare Bausteine](030-bausteine.md) | erledigt | v0.2.0 | 12 h | 2 h 25 min | | #031 | [M18 – Abteilungsspezifische Zusatzabschnitte](031-abteilungsabschnitte.md) | erledigt | v0.2.0 | 8 h | 1 h 35 min | @@ -40,4 +40,4 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac | #033 | [M20 – Live-Aktualisierung per SSE statt Polling](033-sse-live.md) | erledigt | v0.2.0 | 6 h | 1 h 40 min | | #034 | [M21 – Benachrichtigungen per Mail und Webhook](034-benachrichtigungen.md) | erledigt | v0.2.0 | 10 h | 3 h 30 min | | #035 | [M22 – Mitarbeiter-Selbstauskunft](035-selbstauskunft.md) | erledigt | v0.2.0 | 8 h | 2 h 50 min | -| | **Summe** | | | **229 h** | **56 h 25 min** | +| | **Summe** | | | **229 h** | **60 h 35 min** | diff --git a/prisma/migrations/20261006120000_quellenart/migration.sql b/prisma/migrations/20261006120000_quellenart/migration.sql new file mode 100644 index 0000000..8cc1b6a --- /dev/null +++ b/prisma/migrations/20261006120000_quellenart/migration.sql @@ -0,0 +1,97 @@ +-- --------------------------------------------------------------------------- +-- HANDGESCHRIEBEN. Prisma erzeugt hier zusaetzlich Loeschbefehle fuer die +-- zusammengesetzten Fremdschluessel (Mandantengrenze), die Trigramm-Indizes +-- (Archivsuche), die partiellen eindeutigen Indizes und alle Trigger: es kennt +-- sie nicht, weil sie sich im Schema nicht ausdruecken lassen. Diese +-- Loeschbefehle sind bewusst gestrichen. +-- +-- tests/datenbankregeln.test.ts prueft, dass alle diese Objekte vorhanden sind – +-- ein versehentlich stehengelassener Loeschbefehl faellt dort sofort auf. +-- --------------------------------------------------------------------------- + +-- Quellenarten und Microsoft-Graph-Quelle (#028). + +CREATE TYPE "QuellenArt" AS ENUM ('LDAP', 'GRAPH', 'SCIM'); + +/* + * **Die Reihenfolge der drei Schritte ist nicht beliebig.** + * + * Erst die Spalte mit Vorgabe `LDAP` anlegen, dann die bisherigen + * Pflichtfelder lockern, und erst danach den CHECK setzen. Umgekehrt waere der + * CHECK fuer die Dauer einer Anweisung gegen Zeilen gerichtet, die ihn noch + * nicht erfuellen koennen. + * + * Die Vorgabe `LDAP` ist zugleich die Nachfuehrung fuer alle bestehenden Zeilen: + * vor #028 war jede Quelle implizit LDAP, und `ADD COLUMN ... DEFAULT` schreibt + * den Wert in jede vorhandene Zeile. Ein eigenes `UPDATE` braucht es deshalb + * nicht — aber die Pruefung unten steht trotzdem da. + */ +ALTER TABLE "Verzeichnisquelle" + ADD COLUMN "art" "QuellenArt" NOT NULL DEFAULT 'LDAP'; + +-- Die LDAP-Felder gelten nur noch fuer LDAP-Quellen. +ALTER TABLE "Verzeichnisquelle" + ALTER COLUMN "url" DROP NOT NULL, + ALTER COLUMN "bindDn" DROP NOT NULL, + ALTER COLUMN "bindPasswortVerschluesselt" DROP NOT NULL, + ALTER COLUMN "basisDn" DROP NOT NULL, + ALTER COLUMN "filter" DROP NOT NULL; + +-- Und die Felder einer Graph-Quelle. +ALTER TABLE "Verzeichnisquelle" + ADD COLUMN "tenantId" TEXT, + ADD COLUMN "clientId" TEXT, + ADD COLUMN "clientSecretVerschluesselt" TEXT; + +/* + * **Welche Felder eine Art braucht, haelt die Datenbank fest.** + * + * Ohne das waere eine Graph-Quelle mit `basisDn` moeglich — eine Zeile, die zwei + * Dinge gleichzeitig behauptet, und bei der niemand mehr sagen kann, was gilt. + * Der Dienst prueft dasselbe noch einmal, nur mit besserer Meldung. + * + * `SCIM` fehlt hier mit Absicht: die Art steht im Enum, weil #029 dieselbe + * Weiche braucht, hat aber noch keine Felder. Eine Bedingung auf Felder, die es + * nicht gibt, waere eine Zeile, die beim naechsten Meilenstein umgeschrieben + * werden muss — und der Dienst weist `SCIM` bis dahin ohnehin ab. + */ +ALTER TABLE "Verzeichnisquelle" + ADD CONSTRAINT "Verzeichnisquelle_felder_je_art" + CHECK ( + ("art" = 'LDAP' AND "url" IS NOT NULL AND "bindDn" IS NOT NULL + AND "bindPasswortVerschluesselt" IS NOT NULL AND "basisDn" IS NOT NULL + AND "filter" IS NOT NULL + AND "tenantId" IS NULL AND "clientId" IS NULL AND "clientSecretVerschluesselt" IS NULL) + OR + ("art" = 'GRAPH' AND "tenantId" IS NOT NULL AND "clientId" IS NOT NULL + AND "clientSecretVerschluesselt" IS NOT NULL + AND "url" IS NULL AND "bindDn" IS NULL AND "bindPasswortVerschluesselt" IS NULL + AND "basisDn" IS NULL AND "filter" IS NULL) + OR + ("art" = 'SCIM') + ); + +-- Das Client-Secret verschluesselt, dieselbe Pruefung wie beim Bind-Passwort. +ALTER TABLE "Verzeichnisquelle" + ADD CONSTRAINT "Verzeichnisquelle_clientsecret_verschluesselt" + CHECK ("clientSecretVerschluesselt" ~ '^v[0-9]+\.'); + +-- Der Zeitplan fragt nach faelligen **aktiven** Quellen; die Art kommt jetzt in +-- dieselbe Abfrage, weil das Holen der Eintraege je Art ein anderes ist. +CREATE INDEX "Verzeichnisquelle_art_idx" ON "Verzeichnisquelle"("art"); + +/* + * Gegenprobe zur Nachfuehrung: nach dem `ADD COLUMN ... DEFAULT` darf keine + * Zeile ohne Art dastehen. `art` ist NOT NULL, das kann also nicht schiefgehen — + * aber eine Migration, die ihre eigene Nachfuehrung behauptet, ohne sie zu + * pruefen, ist genau die Art Zusage, die dieses Projekt teuer bezahlt hat. + */ +DO $$ +DECLARE + ohne_art INTEGER; +BEGIN + SELECT count(*) INTO ohne_art FROM "Verzeichnisquelle" WHERE "art" IS NULL; + IF ohne_art > 0 THEN + RAISE EXCEPTION 'Nachfuehrung fehlgeschlagen: % Quellen ohne Art.', ohne_art; + END IF; +END $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5647755..bb589c4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -314,18 +314,42 @@ model BenutzerKunde { /// genau eine (das Firmen-AD); mehrere sind erlaubt, etwa eine zweite Domaene. /// Nur fuer die eigene Organisation – im Modus DIENSTLEISTUNG gibt es keinen /// Abgleich zu Kunden-ADs. +/// Woher eine Verzeichnisquelle liest (#028). +/// +/// Bis #028 war jede Quelle implizit LDAP. `SCIM` steht hier bereits, weil #029 +/// dieselbe Weiche braucht — behandelt wird es noch nicht, und der Dienst weist +/// es mit einer Meldung ab, statt stillschweigend nichts zu tun. +enum QuellenArt { + LDAP + GRAPH + SCIM +} + model Verzeichnisquelle { - id String @id @default(cuid()) + id String @id @default(cuid()) kundeId String name String - aktiv Boolean @default(false) - url String - bindDn String + aktiv Boolean @default(false) + /// Bestehende Zeilen sind LDAP – das war vor #028 die einzige Art. + art QuellenArt @default(LDAP) + /// Ab hier: **nur bei `art = LDAP`** gesetzt. Welche Felder eine Art braucht, + /// haelt der CHECK `Verzeichnisquelle_felder_je_art` fest — eine Graph-Quelle + /// mit einem `basisDn` waere eine Zeile, die zwei Dinge gleichzeitig behauptet. + url String? + bindDn String? /// AES-256-GCM aus APP_SECRET. Darf keine API-Antwort erreichen; dafuer sorgt /// der Serializer in der Service-Schicht, abgedeckt durch einen eigenen Test. - bindPasswortVerschluesselt String - basisDn String - filter String + bindPasswortVerschluesselt String? + basisDn String? + filter String? + + /// Ab hier: **nur bei `art = GRAPH`**. Der Mandant in Entra ID und die + /// App-Registrierung, mit der gelesen wird (Client-Credentials-Flow). + tenantId String? + clientId String? + /// Verschluesselt wie das Bind-Passwort und mit demselben Vorsatz: es darf + /// keine Antwort und keinen Protokolleintrag erreichen. + clientSecretVerschluesselt String? /// Zuordnung der LDAP-Attribute auf unsere Felder, mit AD-Vorgaben. attributZuordnung Json /// Eigenes CA-Zertifikat, falls ldaps gegen eine interne CA laeuft. diff --git a/src/app/(app)/admin/verzeichnis/page.tsx b/src/app/(app)/admin/verzeichnis/page.tsx index f5af6d1..5534802 100644 --- a/src/app/(app)/admin/verzeichnis/page.tsx +++ b/src/app/(app)/admin/verzeichnis/page.tsx @@ -164,8 +164,21 @@ export default async function VerzeichnisSeite() { )} + {/* + * Die Art steht **an jeder Quelle** (#028). Ohne sie waere bei + * zwei Zeilen nicht zu sehen, welche aus dem AD liest und welche + * aus Entra ID — und die Felder darunter heissen je Art anders. + */}

- {quelle.url} · {quelle.basisDn} + {quelle.art === "GRAPH" ? ( + <> + Microsoft Graph · {quelle.tenantId} · App {quelle.clientId} + + ) : ( + <> + LDAP · {quelle.url} · {quelle.basisDn} + + )}

diff --git a/src/components/verzeichnis/quelle-formular.tsx b/src/components/verzeichnis/quelle-formular.tsx index 76e0b01..b4405ca 100644 --- a/src/components/verzeichnis/quelle-formular.tsx +++ b/src/components/verzeichnis/quelle-formular.tsx @@ -5,6 +5,7 @@ import { KeyRound, Save, Server, ShieldAlert, ShieldCheck } from "lucide-react"; import { FELD, KAESTCHEN } from "@/lib/formular"; import { AD_ZUORDNUNG, + GRAPH_ZUORDNUNG, INTERVALL_HOECHSTENS, INTERVALL_MINDESTENS, ZUORDNUNGS_BESCHRIFTUNG, @@ -36,6 +37,14 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) { const [zuordnungOffen, setzeZuordnungOffen] = useState(false); const [adresse, setzeAdresse] = useState(quelle?.url ?? ""); const klartext = adresse.trim().startsWith("ldap://"); + /* + * Die Art ist beim Anlegen waehlbar und beim Bearbeiten **fest** (#028): an + * ihr haengt, was die dauerhafte Kennung bedeutet, und nach einem Wechsel + * wuerde kein Mitarbeiter wiedererkannt. Der Dienst weist einen Wechsel + * ohnehin ab; hier gibt es ihn gar nicht erst zur Auswahl. + */ + const [art, setzeArt] = useState<"LDAP" | "GRAPH">(quelle?.art === "GRAPH" ? "GRAPH" : "LDAP"); + const istGraph = art === "GRAPH"; return (
@@ -52,108 +61,213 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) { /> - {/* - * Die Erklärung steht **außerhalb** des `label` und hängt über - * `aria-describedby` am Feld. Im `label` wäre sie Teil des Feldnamens: - * ein Screenreader läse „Adresse Drei Formen: ldaps:// …“ als Bezeichnung - * des Eingabefelds, und kein Werkzeug fände das Feld noch über seinen - * Namen. - */} -
- - setzeAdresse(ereignis.target.value)} - required - aria-describedby="quelle-url-hinweis" - placeholder="ldaps://dc01.firma.local:636" - className={`${FELD} mono`} - /> - - Drei Formen: ldaps:// (verschlüsselt ab dem ersten Byte, die richtige - Wahl), ldap+starttls:// (unverschlüsselt verbunden und dann - hochgestuft) und ldap://. - - {klartext ? ( - /* - * Die Warnung erscheint erst, wenn sie zutrifft — eine, die immer - * dasteht, liest nach dem dritten Mal niemand mehr. - */ - -
+

+ Die App-Registrierung braucht die Anwendungsberechtigung{" "} + User.Read.All mit Administratoreinwilligung. Wie das eingerichtet + wird, steht in docs/betrieb/entra-id.md. Entra ID kennt keine OU, + deshalb bleibt {"{{ou}}"} bei dieser Art leer. +

+ + ) : null} -
- -
-
-
- - -
- - - - Leer lassen nimmt den Standard: aktive Personenkonten. Er schließt deaktivierte Konten aus — wer aus - dem Ergebnis verschwindet, wird hier deaktiviert und nie gelöscht. - -
+
+ + + + Leer lassen nimmt den Standard: aktive Personenkonten. Er schließt deaktivierte Konten aus — wer + aus dem Ergebnis verschwindet, wird hier deaktiviert und nie gelöscht. + +
+ + ) : null}
@@ -212,48 +326,51 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {

-
setzeZertifikatOffen(e.currentTarget.open)}> - - Eigenes CA-Zertifikat hinterlegen - - {/* - * Der Zertifikatstext selbst kommt nicht zurück in die Oberfläche — nur - * die Auskunft, dass eines hinterlegt ist. Ohne diese Zeile sah das - * leere Feld beim Bearbeiten aus wie „kein Zertifikat“, und der Hinweis - * darunter behauptete es auch (Reviewbefund zu #016). - */} - {quelle?.caZertifikatVorhanden ? ( -

-

setzeZertifikatOffen(e.currentTarget.open)}> + + Eigenes CA-Zertifikat hinterlegen + + {/* + * Der Zertifikatstext selbst kommt nicht zurück in die Oberfläche — nur + * die Auskunft, dass eines hinterlegt ist. Ohne diese Zeile sah das + * leere Feld beim Bearbeiten aus wie „kein Zertifikat“, und der Hinweis + * darunter behauptete es auch (Reviewbefund zu #016). + */} + {quelle?.caZertifikatVorhanden ? ( +

+

+ ) : null} +