From fcd67018b849c2f9cbcbc1a2cdb76e4db1bd4046 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 10 Oct 2026 19:50:54 +0200 Subject: [PATCH] feat(verzeichnis): SCIM-Endpunkt als dritte Quellenart (#029) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisher holt Laufzettel Personen aus dem Verzeichnis. Das setzt Lesezugriff und einen Netzweg dorthin voraus, und beides ist nicht ueberall gegeben. Mit der Art SCIM ruft stattdessen der Anbieter an — sofort, nicht beim naechsten Intervall. Umgesetzt ist, was authentik, Entra ID und Okta wirklich schicken: Users mit dem Filter userName eq, POST/GET/PUT/PATCH/DELETE, dazu ServiceProviderConfig, Schemas und ResourceTypes. Jeder Handler prueft sein Token selbst. Ein Token je Quelle, argon2-gehasht, einmal angezeigt — es ist der einzige Mandantenbeweis, den eine Zustellung mitbringt. DELETE deaktiviert und loescht nichts. Eine SCIM-Quelle wird nicht abgeglichen, an zwei Stellen gehalten: ein Abgleich, der dort nichts liest und ERFOLG meldet, haette ueber die Abschaltregel die ganze Belegschaft deaktiviert. Zwei Befunde kamen aus Gegenproben, nicht aus Tests: - PATCH remove wirkte gar nicht. Der Schalter nurGesetzte las bei PATCH jedes null als "nicht mitgeschickt"; eine beim Anbieter entfernte Abteilung stand hier weiter. Noetig war er nie — patchAnwenden liefert immer den vollstaendigen Zielzustand. - Entra ID schickt active als "True"/"False". Beim Deaktivieren kommt mit einem reinen value === true zufaellig das Richtige heraus, beim Aktivieren nicht: ein wieder freigeschaltetes Konto waere fuer immer inaktiv geblieben. Dazu ein Fehler aus #028: in verbindungTestenAktion stand zweimal dieselbe Bedingung, der zweite Zweig war seit dem ersten Tag unerreichbar. 1432 Tests (vorher 1373). Klickprobe am gebauten Stand mit einer echten Zustellung. Gegen echte Mandanten ist nichts gelaufen — die Anfrageformen stammen aus der Dokumentation, und das steht so in der Testdatei. Closes #49 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 28 + README.md | 30 +- docs/anleitung/06-verzeichnis.md | 135 +- docs/architektur.md | 28 + docs/betrieb/README.md | 2 + docs/betrieb/scim.md | 159 +++ docs/devlog.md | 100 +- docs/issues/029-scim-endpunkt.md | 134 +- docs/issues/README.md | 4 +- .../20261010090000_scim/migration.sql | 95 ++ prisma/schema.prisma | 30 +- src/app/(app)/admin/verzeichnis/page.tsx | 109 +- src/app/api/scim/v2/ResourceTypes/route.ts | 37 + src/app/api/scim/v2/Schemas/route.ts | 89 ++ .../scim/v2/ServiceProviderConfig/route.ts | 45 + src/app/api/scim/v2/Users/[id]/route.ts | 130 ++ src/app/api/scim/v2/Users/route.ts | 61 + .../verzeichnis/quelle-formular.tsx | 171 ++- src/components/verzeichnis/scim-token.tsx | 181 +++ src/lib/scim.ts | 324 +++++ src/lib/verzeichnis.ts | 14 + src/proxy.ts | 8 + src/server/actions/verzeichnis.ts | 81 +- src/server/scim/handler.ts | 115 ++ src/server/services/abgleich.ts | 8 + src/server/services/scim.ts | 478 +++++++ src/server/services/verzeichnis.ts | 59 +- src/server/verzeichnis/quellenleser.ts | 18 +- src/server/verzeichnis/zugang.ts | 7 +- tests/datenbankregeln.test.ts | 8 + tests/graph.test.ts | 14 +- tests/scim.test.ts | 1111 +++++++++++++++++ 32 files changed, 3672 insertions(+), 141 deletions(-) create mode 100644 docs/betrieb/scim.md create mode 100644 prisma/migrations/20261010090000_scim/migration.sql create mode 100644 src/app/api/scim/v2/ResourceTypes/route.ts create mode 100644 src/app/api/scim/v2/Schemas/route.ts create mode 100644 src/app/api/scim/v2/ServiceProviderConfig/route.ts create mode 100644 src/app/api/scim/v2/Users/[id]/route.ts create mode 100644 src/app/api/scim/v2/Users/route.ts create mode 100644 src/components/verzeichnis/scim-token.tsx create mode 100644 src/lib/scim.ts create mode 100644 src/server/scim/handler.ts create mode 100644 src/server/services/scim.ts create mode 100644 tests/scim.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0da2c0a..238027b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,10 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Behoben +- Der Verbindungstest einer Verzeichnisquelle unterschied „der Filter findet niemanden“ nicht mehr + von „gelesen, aber nichts zuordenbar“ — zwei verschiedene Ursachen, die den Administrator an + verschiedene Stellen schicken. Die Unterscheidung war in #028 eingebaut worden, aber durch eine + doppelte Bedingung seit dem ersten Tag unerreichbar (#029) - **`staerksteRolle` konnte mehr Rechte liefern als übergeben.** `LESER` war Startwert und damit zugleich Untergrenze: eine Rolle unterhalb davon kam nie heraus. Solange alle Rollen mindestens `LESER` waren, fiel das nicht auf — mit der neuen Rolle `MITARBEITER` ergab @@ -67,6 +71,30 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Hinzugefügt +- **SCIM 2.0: Personen können jetzt auch geschickt werden, statt geholt zu werden.** Bisher + verbindet sich Laufzettel nach Zeitplan mit einem Verzeichnis und liest. Das setzt zwei Dinge + voraus, die nicht überall gegeben sind: Lesezugriff auf das Verzeichnis, und ein Netzweg dorthin. + Mit der neuen Quellenart `SCIM` ruft stattdessen der Identitätsanbieter Laufzettel an — authentik, + Entra ID, Okta — und zwar in dem Moment, in dem sich bei ihm etwas ändert, nicht erst beim + nächsten Intervall. + Umgesetzt ist, was die Anbieter wirklich schicken: `Users` mit dem Filter `userName eq "…"`, + `POST`, `GET`, `PUT`, `PATCH`, `DELETE`, dazu `ServiceProviderConfig`, `Schemas` und + `ResourceTypes` — ohne die drei weigern sich manche Anbieter, überhaupt anzufangen. Übernommen + werden Anmeldename, Name, Mailadresse, Titel, Abteilung, der Vorgesetzte und ob das Konto aktiv + ist; keine Gruppen, keine Rollen, keine Passwörter. + **`DELETE` deaktiviert und löscht nicht.** Der Anbieter bekommt `204` und hält die Person damit + für entfernt — aus seiner Sicht richtig. In Laufzettel bleibt sie inaktiv stehen, weil Läufe und + Geräte an ihr hängen. Das steht in der Anleitung, weil es sonst wie ein Fehler wirkt. + **Eine SCIM-Quelle wird nicht abgeglichen**, und das ist keine Kosmetik: ein Abgleich, der dort + nichts liest und „Erfolg“ meldet, hätte über die Abschaltregel die ganze Belegschaft deaktiviert. + Verbindungstest und „Jetzt abgleichen“ gibt es für sie deshalb nicht; an ihrer Stelle steht die + letzte Zustellung. + Das **Token** wird unter Admin → Verzeichnis erzeugt, **einmal** angezeigt und nur als + argon2-Hash gespeichert — es gibt keinen zweiten Weg, es zu lesen. Es ist der einzige + Mandantenbeweis, den eine Zustellung mitbringt: ein Token sieht nur die Personen seiner eigenen + Quelle. Einrichtung in authentik und Entra ID steht in + [Kapitel 06](docs/anleitung/06-verzeichnis.md#scim-der-anbieter-schickt), der Betrieb in + [`docs/betrieb/scim.md`](docs/betrieb/scim.md) (#029) - **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, diff --git a/README.md b/README.md index 86155ea..5b0f130 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Mitarbeiter. Einstieg über QR-Aufkleber: Code scannen, Geräteseite öffnen, lo | Modus | Für wen | Kunden-Ebene | Verzeichnis | |---|---|---|---| -| `INTERN` (Standard) | eine Firma, eigene Mitarbeiter | unsichtbar | eigenes AD per LDAP | +| `INTERN` (Standard) | eine Firma, eigene Mitarbeiter | unsichtbar | eigenes Verzeichnis per LDAP, Microsoft Graph oder SCIM | | `DIENSTLEISTUNG` | IT-Dienstleister mit mehreren Kunden | sichtbar | manuell oder per CSV je Kunde | Der Modus liegt in der Datenbank und ist vom Admin ohne Neustart umschaltbar. Jede kundenbezogene @@ -162,11 +162,31 @@ zugeordnet. Unbekannte Benutzer werden beim ersten Login angelegt. Die Anbindung an authentik ist in [`docs/betrieb/authentik.md`](docs/betrieb/authentik.md) Schritt für Schritt beschrieben. -## Verzeichnis-Abgleich (nur Modus `INTERN`) +## Verzeichnis (nur Modus `INTERN`) -Unter **Admin → Verzeichnis** wird eine LDAP-Quelle angelegt: Adresse (`ldaps://` empfohlen, -`ldap+starttls://` und `ldap://` gehen auch), ein Dienstkonto mit **Leserecht**, der Basis-DN und -optional ein eigener Filter. Ein Dienstkonto genügt — Laufzettel schreibt nie ins Verzeichnis. +Unter **Admin → Verzeichnis** wird eine Quelle angelegt. Es gibt drei Arten, und sie unterscheiden +sich in der **Richtung**: + +| Art | Für wen | Richtung | +|---|---|---| +| `LDAP` | lokales Active Directory oder anderes LDAP-Verzeichnis | Laufzettel holt nach Zeitplan | +| `GRAPH` | Entra ID über Microsoft Graph, für Häuser ohne lokales AD | Laufzettel holt nach Zeitplan | +| `SCIM` | authentik, Entra ID, Okta — wenn Laufzettel nicht ins Verzeichnis sehen soll oder es nicht erreicht | der Anbieter schickt, sofort | + +**Die Art ist unveränderlich.** An ihr hängt, was die dauerhafte Kennung einer Person bedeutet; +nach einem Wechsel würde niemand wiedererkannt. Alle drei verhalten sich danach gleich: anlegen, +übernehmen, aktualisieren, deaktivieren, **nie löschen**. + +Für **LDAP** gehören ins Formular: Adresse (`ldaps://` empfohlen, `ldap+starttls://` und `ldap://` +gehen auch), ein Dienstkonto mit **Leserecht**, der Basis-DN und optional ein eigener Filter. Ein +Dienstkonto genügt — Laufzettel schreibt nie ins Verzeichnis. Für **Graph** eine App-Registrierung +mit der Anwendungsberechtigung `User.Read.All`, siehe `docs/betrieb/entra-id.md`. + +Für **SCIM** gibt es keine Zugangsdaten: Quelle anlegen, Token erzeugen (es erscheint **einmal**, +gespeichert wird nur ein argon2-Hash), Token und die Adresse `…/api/scim/v2` beim Anbieter +eintragen. Verbindungstest, Zeitplan und Abgleich-Log gibt es dort nicht — an ihrer Stelle steht +die letzte Zustellung. Ein `DELETE` des Anbieters **deaktiviert** die Person und löscht nichts. +Einzelheiten in `docs/betrieb/scim.md`. - **„Verbindung testen“** liest die ersten Einträge und zeigt sie **mit den gemappten Feldern**, ohne etwas zu speichern. Erst daran sieht man, ob die Zuordnung stimmt. diff --git a/docs/anleitung/06-verzeichnis.md b/docs/anleitung/06-verzeichnis.md index 9acf49c..e9430fa 100644 --- a/docs/anleitung/06-verzeichnis.md +++ b/docs/anleitung/06-verzeichnis.md @@ -16,18 +16,24 @@ 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 +## Drei 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: +Laufzettel braucht die Personen aus einem Verzeichnis. Dafür gibt es drei 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 | +| Art | Für wen | Richtung | +|---|---|---| +| **LDAP** | ein lokales Active Directory (oder ein anderes LDAP-Verzeichnis) | Laufzettel holt | +| **Microsoft Graph** | Entra ID, also Microsoft 365 — für Häuser ohne lokales AD | Laufzettel holt | +| **SCIM** | authentik, Entra ID, Okta — wenn Laufzettel nicht ins Verzeichnis sehen soll oder es nicht erreicht | der Anbieter schickt | -Beide verhalten sich danach gleich: anlegen, aktualisieren, deaktivieren, **nie löschen**. Was sie -unterscheidet, sind die Zugangsdaten und zwei Platzhalter: +Die ersten zwei sind dieselbe Arbeitsweise mit anderen Zugangsdaten: Laufzettel verbindet sich nach +Zeitplan und liest. **SCIM ist die andere Richtung** — dort gibt es keine Zugangsdaten, keinen +Zeitplan und keinen Verbindungstest; der Anbieter schickt jede Änderung in dem Moment, in dem sie +dort entsteht. Für SCIM gibt es [einen eigenen Abschnitt](#scim-der-anbieter-schickt) weiter unten. + +Alle drei verhalten sich danach gleich: anlegen, aktualisieren, deaktivieren, **nie löschen**. Was +LDAP und Graph 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. @@ -42,6 +48,109 @@ Was in Entra ID einzurichten ist — App-Registrierung, Berechtigung, Einwilligu [`docs/betrieb/entra-id.md`](../betrieb/entra-id.md). +## SCIM: der Anbieter schickt + +Bei den ersten zwei Arten fragt Laufzettel das Verzeichnis. Bei SCIM ist es umgekehrt: der +Identitätsanbieter ruft Laufzettel an, sobald sich bei ihm etwas ändert. Das ist der Weg für zwei +Lagen: + +- Das Haus will Laufzettel **keinen Lesezugriff** auf sein Verzeichnis geben. +- Laufzettel läuft in einem Netz, das das Verzeichnis **nicht erreicht** — ein Push von außen nach + innen geht, ein Lesezugriff von innen nach außen nicht. + +### Was anders ist + +| | LDAP und Graph | SCIM | +|---|---|---| +| Zugangsdaten | Dienstkonto bzw. App-Registrierung | **keine** — ein Token für den Anbieter | +| Zeitplan | alle *n* Minuten | keiner, es kommt sofort | +| Verbindung testen | zeigt die ersten zehn Treffer | gibt es nicht — es gibt keine Verbindung | +| Abgleich-Log | je Lauf eine Zeile | gibt es nicht; stattdessen **letzte Zustellung** | +| Attributzuordnung | einstellbar | fest, der Anbieter bestimmt die Feldnamen (RFC 7643) | +| `{{ou}}` | aus dem DN bzw. der Abteilung | leer — SCIM kennt keine OU | + +Was Laufzettel übernimmt: Anmeldename (`userName`), Vor- und Nachname, Anzeigename, Mailadresse, +Titel, Abteilung, der Vorgesetzte und ob das Konto aktiv ist. Mehr nicht — keine Gruppen, keine +Rollen, keine Passwörter. + +### Einrichten: Laufzettel zuerst + +1. **Quelle anlegen.** Unten auf der Seite **Art → SCIM (wird geliefert)** wählen, einen Namen + geben (etwa „authentik Push“), **Zustellungen annehmen** anhaken und speichern. Mehr Felder gibt + es nicht. +2. **Token erzeugen.** An der neuen Quelle steht **Token erzeugen**. Es erscheint **genau einmal** — + kopier es sofort. Gespeichert wird es nur als Hash; ein zweites Ansehen gibt es nicht. Wer es + verliert, erzeugt ein neues, und das alte gilt dann nicht mehr. +3. **Adresse ablesen.** Unter dem Namen der Quelle steht die Adresse, die der Anbieter braucht, etwa + `https://laufzettel.firma.de/api/scim/v2`. + +### Einrichten in authentik + +Unter **Applications → Providers → Create → SCIM Provider**: + +| Feld | Wert | +|---|---| +| **Name** | etwa „Laufzettel“ | +| **URL** | die Adresse von oben, also `https://…/api/scim/v2` | +| **Token** | das erzeugte Token, unverändert eingefügt | + +Bei **User filtering** wählst du die Gruppe, deren Mitglieder in Laufzettel stehen sollen — nicht +alle Konten, sondern die Menschen, für die Läufe entstehen. Unter **Attribute mapping** genügen die +Vorgaben; authentik schickt `userName`, `name`, `emails` und die Erweiterung +`enterprise:2.0:User` von selbst. + +Danach **Save** und einmal **Sync** auslösen. In Laufzettel steht dann an der Quelle **Letzte +Zustellung am …** und die Personen stehen unter **Mitarbeiter**. + +### Einrichten in Entra ID + +Unter **Entra ID → Enterprise applications → New application → Create your own application → +Integrate any other application you don't find in the gallery**. Dann in der neuen Anwendung +**Provisioning → New configuration**: + +| Feld | Wert | +|---|---| +| **Tenant URL** | die Adresse von oben, also `https://…/api/scim/v2` | +| **Secret Token** | das erzeugte Token | + +**Test Connection** muss grün werden — Entra ID fragt dabei `ServiceProviderConfig`, `Schemas` und +`ResourceTypes` ab und legt probeweise niemanden an. Danach unter **Users and groups** die Gruppe +zuweisen und das Provisioning auf **On** stellen. + +Zwei Eigenheiten von Entra ID, die hier nicht stören, aber erklären, was in den Protokollen steht: + +- Entra ID schickt `active` als **Zeichenkette** `"True"`/`"False"`, nicht als `true`/`false`. + Laufzettel liest beides. +- Der erste vollständige Durchlauf beginnt bis zu **40 Minuten** nach dem Einschalten. Das ist + Entra ID und nichts, was sich hier beschleunigen ließe. + +### `Löschen` beim Anbieter heißt hier `inaktiv` + +Nimmt jemand eine Person beim Anbieter aus der zugewiesenen Gruppe oder löscht sie dort, schickt +der Anbieter ein `DELETE`. **Laufzettel setzt die Person dann auf inaktiv und löscht nichts.** Die +Zeile bleibt, weil Läufe und Geräte an ihr hängen: ein archivierter Lauf muss auch in zehn Jahren +noch sagen können, für wen er lief. + +Der Anbieter bekommt `204` und hält die Person damit für entfernt — aus seiner Sicht richtig, denn +sie wird ihm nicht mehr zugewiesen. **In Laufzettel steht sie weiter in der Liste**, inaktiv. Das +sieht aus wie ein Fehler und ist keiner. Wer sie aus der Ansicht haben will, filtert auf „nur +aktive“ — das ist die Vorgabe der Mitarbeiterliste. + +### Wenn nichts ankommt + +| Was der Anbieter meldet | Woran es liegt | +|---|---| +| `401` | Das Token stimmt nicht, oder **Zustellungen annehmen** ist nicht angehakt. Beides ergibt dieselbe Antwort — absichtlich. | +| `404` auf eine bekannte Person | Die Person gehört zu einer **anderen** Quelle. Jedes Token sieht nur die Personen seiner eigenen. | +| `409` beim Anlegen | Die Person gibt es in dieser Quelle schon. Der Anbieter fragt normalerweise vorher mit einem Filter; schickt er trotzdem, ist `409` die richtige Antwort und kein Datenverlust. | +| `400 invalidFilter` | Der Anbieter benutzt einen Filter, den Laufzettel nicht unterstützt — nur `userName eq "…"` geht. | +| nichts, Zeitüberschreitung | Der Reverse Proxy lässt `/api/scim/` nicht durch, oder TLS stimmt nicht. Siehe [`docs/betrieb/scim.md`](../betrieb/scim.md). | + +Eine Quelle **stilllegen**, ohne das Token beim Anbieter zu löschen: Häkchen **Zustellungen +annehmen** entfernen. Der Endpunkt antwortet dann mit `401`, und beim Anbieter stehen die +Zustellungen als Fehler — sie gehen nicht verloren, er versucht es erneut. + + ## Eine Quelle anlegen ![Die Seite Verzeichnis mit Quelle, Status und Formular](bilder/verzeichnis-desktop.png) @@ -116,6 +225,10 @@ Namensänderung der Quelle ihre Anmeldung. ![Das Ergebnis eines Verbindungstests: „Die Verbindung steht. 4 Einträge gelesen, 1 übersprungen“ und darunter die ersten drei Einträge mit UPN, Mail, DN, sam und Abteilung](bilder/verbindungstest-desktop.png) +Die folgenden vier Abschnitte gelten für **LDAP und Graph**. Bei einer SCIM-Quelle gibt es weder +Verbindungstest noch Zeitplan noch Abgleich-Log — dort steht an ihrer Stelle die letzte Zustellung, +siehe [oben](#scim-der-anbieter-schickt). + **Verbindung testen** liest die ersten Einträge und zeigt sie so, wie sie ankämen — **gespeichert wird nichts**. Mach das immer vor dem ersten Abgleich. Klappt es nicht, steht der Grund in Rot darunter; die häufigsten stehen in [Kapitel 08](08-fehlerbehebung.md). @@ -195,3 +308,7 @@ deshalb als Hinweis und nicht als Fehler da. Steigt die Zahl, prüf die Attribut Eine Quelle ist keine Voraussetzung. Personen lassen sich von Hand anlegen oder aus einer CSV-Datei einlesen — ein Verzeichnis nimmt nur die Arbeit ab. Siehe [Kapitel 05](05-mitarbeiter-geraete.md). + +Von Hand angelegte Personen gehen dabei nicht verloren, wenn später eine Quelle dazukommt: taucht +jemand mit demselben Anmeldenamen aus dem Verzeichnis oder per SCIM auf, **übernimmt** Laufzettel +die vorhandene Zeile statt eine zweite anzulegen. Ihre Läufe und Geräte bleiben an ihr hängen. diff --git a/docs/architektur.md b/docs/architektur.md index b819827..1bd2d31 100644 --- a/docs/architektur.md +++ b/docs/architektur.md @@ -378,6 +378,34 @@ was darauf steht, ist aus der Hand gegeben. ## 5g. Der Verzeichnis-Abgleich: vier Schichten und eine Notbremse +Eine Verzeichnisquelle hat seit #028 eine **Art** — `LDAP`, `GRAPH` oder `SCIM` —, +und die erste Frage ist nicht, wie gelesen wird, sondern **in welche Richtung**: + +| Art | Richtung | Wo der Code liegt | +|---|---|---| +| `LDAP` | Laufzettel holt | `src/server/ldap/*` | +| `GRAPH` | Laufzettel holt | `src/server/graph/*` | +| `SCIM` | der Anbieter schickt | `src/app/api/scim/*`, `src/server/scim/*`, `src/server/services/scim.ts` | + +Die ersten zwei laufen durch **denselben** Abgleich; die eine Stelle, die die Art +kennt, ist `eintraegeDerQuelleStroemen` in +`src/server/verzeichnis/quellenleser.ts`. Eine zweite Verzweigung waere eine +zweite Stelle, die sich an eine neue Quellenart erinnern muss, und die vergisst +man. + +**Bei `SCIM` gibt es keinen Abgleich**, und das ist an zwei Stellen gehalten: +`faelligeQuellen` nimmt solche Quellen nicht auf, und die Weiche wirft. Der Grund +ist nicht Ordnungsliebe — ein Abgleich, der dort nichts liest und `ERFOLG` meldet, +haette ueber die Abschaltregel die ganze Belegschaft deaktiviert. Dasselbe Muster +wie der Reviewbefund aus #016, nur mit einer anderen Ursache. + +Was beim Schreiben danach gilt, gilt fuer alle drei gleich: anlegen, uebernehmen, +aktualisieren, deaktivieren, **nie loeschen** — auch ein `DELETE` eines +SCIM-Anbieters setzt nur `aktiv = false`. Das **Token** einer SCIM-Quelle ist der +einzige Mandantenbeweis, den eine Zustellung mitbringt; es steht als argon2-Hash +in der Datenbank, wird einmal angezeigt und kommt in keiner Sicht und keinem +Protokolleintrag vor. + Das LDAP-Modul liegt unter `src/server/ldap/` und ist absichtlich in vier Dateien geteilt, von denen nur eine das Netz anfasst: diff --git a/docs/betrieb/README.md b/docs/betrieb/README.md index 442662f..f912ce2 100644 --- a/docs/betrieb/README.md +++ b/docs/betrieb/README.md @@ -60,6 +60,8 @@ Sitzungen ungültig *und* die verschlüsselt gespeicherten LDAP-Bind-Passwörter 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 +- **[SCIM-Endpunkt](scim.md)** — die einzige Stelle, an der ein fremder Dienst von außen schreibend + zugreift: das Token, was nicht unterstützt wird, und was der Reverse Proxy durchlassen muss ## Wo sonst noch etwas steht diff --git a/docs/betrieb/scim.md b/docs/betrieb/scim.md new file mode 100644 index 0000000..3d65ca8 --- /dev/null +++ b/docs/betrieb/scim.md @@ -0,0 +1,159 @@ +# SCIM-Endpunkt betreiben + +Laufzettel kann Personen aus einem Verzeichnis **holen** (LDAP, Microsoft Graph) oder sich +**beliefern lassen**. Der zweite Weg ist SCIM 2.0, und er ist die einzige Stelle, an der ein +fremder Dienst von außen schreibend auf Laufzettel zugreift. Diese Seite beschreibt, was der +Betreiber dafür wissen muss. Wie eine Quelle eingerichtet wird, steht in +[Kapitel 06 der Anleitung](../anleitung/06-verzeichnis.md#scim-der-anbieter-schickt). + +## Was der Endpunkt ist + +| | | +|---|---| +| Adresse | `https:///api/scim/v2` | +| Anmeldung | `Authorization: Bearer `, ein Token je Quelle | +| Medientyp | `application/scim+json` | +| Normen | RFC 7643 (Schema), RFC 7644 (Protokoll) | + +Vollständig: + +``` +GET /api/scim/v2/ServiceProviderConfig +GET /api/scim/v2/Schemas +GET /api/scim/v2/ResourceTypes +GET /api/scim/v2/Users?filter=userName eq "…" +POST /api/scim/v2/Users +GET /api/scim/v2/Users/{id} +PUT /api/scim/v2/Users/{id} +PATCH /api/scim/v2/Users/{id} +DELETE /api/scim/v2/Users/{id} +``` + +Die drei Beschreibungs-Endpunkte sind nicht Zierde: manche Anbieter, Entra ID darunter, fragen sie +beim „Test Connection“ ab und weigern sich sonst anzufangen. Sie verlangen **ebenfalls** ein Token. + +## Was nicht unterstützt wird — und warum das so gemeldet wird + +`ServiceProviderConfig` sagt ausdrücklich `supported: false` für `bulk`, `sort`, `filter` und +`etag`. Die **Verneinungen** sind der wichtigere Teil der Antwort: ein Anbieter, der `bulk` für +unterstützt hält, schickt Anfragen, die niemand beantwortet, und meldet das als Fehler der +Gegenseite. + +Nicht umgesetzt sind außerdem: + +- **`/Groups`.** Laufzettel führt keine Gruppen. Rollen entstehen beim Anmelden aus den + OIDC-Gruppen (siehe [`authentik.md`](authentik.md)), nicht aus dem Provisioning. Ein Anbieter, + der Gruppen zuweisen will, bekommt nichts zugewiesen — `ResourceTypes` nennt nur `User`. +- **Allgemeine Filter.** Unterstützt wird genau `userName eq "…"` — die Frage, die ein Anbieter vor + jedem Anlegen stellt. Alles andere ergibt `400` mit `scimType: invalidFilter`. Das ist Absicht: + einen unverstandenen Filter zu ignorieren und **alle** Personen zu liefern wäre die gefährlichere + Antwort, denn der Anbieter schlösse daraus, die gesuchte Person gäbe es schon, und legte sie nie + an. +- **Paginierung über 200 Einträge.** Ein `GET /Users` ohne Filter liefert höchstens 200 Zeilen. Die + Anbieter fragen gefiltert; ein voller Abzug ist keine Betriebsart dieses Endpunkts. + +## Das Token + +Ein Token je Quelle, erzeugt unter **Admin → Verzeichnis**, Form `lzscim_` + 43 Zeichen aus +`randomBytes(32)`. + +- Es steht in der Datenbank **nur als argon2-Hash**. Ein CHECK hält das fest + (`Verzeichnisquelle_scimtoken_gehasht`): ein roh gespeichertes Token weist die Datenbank ab, auch + wenn jemand am Dienst vorbei schreibt. +- Es wird **einmal** angezeigt, direkt nach dem Erzeugen. Danach gibt es keinen Weg, es zu lesen — + auch nicht für einen Administrator, auch nicht aus einem Backup der Anwendung. +- Es erscheint in **keinem Protokolleintrag**. Vermerkt wird „SCIM-Token erzeugt“ mit dem Namen der + Quelle, nicht das Token; das Protokoll ist in der Anwendung ansehbar. +- Ein neues ersetzt das alte. Es gibt genau eines je Quelle — zwei gültige wären zwei Dinge, die man + widerrufen muss, und eines davon vergisst man. + +**Das Token ist der einzige Mandantenbeweis, den eine Zustellung mitbringt.** Aus ihm folgt die +Quelle, aus der Quelle der Kunde, und daran hängt, welche Personen die Anfrage sehen und ändern +darf. Ein Token einer anderen Quelle bekommt auf deren Personen `404` und nicht `403`: ob es eine +Person gibt, ist für eine fremde Quelle keine Auskunft. + +Gleiche Antwort für **kein Token** und **falsches Token**: `401` mit +`WWW-Authenticate: Bearer realm="Laufzettel SCIM"`. Die Unterscheidung wäre eine Auskunft darüber, +ob ein geratenes Token die richtige Form hat. + +Widerrufen geht jederzeit über **Token widerrufen**. Eine Quelle **stilllegen**, ohne das Token beim +Anbieter zu entfernen, geht über das Häkchen **Zustellungen annehmen** — der Endpunkt antwortet dann +ebenfalls mit `401`. + +## Reverse Proxy + +Der Pfad `/api/scim/` braucht **keine Sonderbehandlung**, aber er muss durchgelassen werden. Wer vor +Laufzettel eine Regel hat, die `/api/` einschränkt oder nur bekannte Pfade weiterleitet, trägt ihn +ein. Drei Punkte: + +- **Kein Cache.** Der Endpunkt setzt `Cache-Control: no-store` auf jede Antwort; ein Proxy, der das + überschreibt, liefert veraltete Personenlisten. +- **Der `Authorization`-Kopf muss ankommen.** Manche Konfigurationen entfernen ihn, wenn davor eine + eigene Authentifizierung sitzt. Dann kommt jede Zustellung als `401` zurück. +- **Eine Zustellung ist klein**, einige Kilobyte. Die Grenzen für Anhänge + (`client_max_body_size`, siehe [`reverse-proxy.md`](reverse-proxy.md)) reichen weit aus. + +Eine IP-Einschränkung auf die Adressen des Anbieters ist möglich und sinnvoll, wenn er feste hat. +authentik im eigenen Haus hat welche; Entra ID nicht zuverlässig — Microsoft veröffentlicht die +Bereiche, aber sie ändern sich, und eine veraltete Liste sperrt das Provisioning aus. + +## Was im Betrieb auffällt + +**Es wird nicht abgeglichen.** Eine SCIM-Quelle taucht im Zeitplan nicht auf. Das ist wichtig und +nicht nur Kosmetik: ein Abgleich, der an einer SCIM-Quelle nichts liest und „Erfolg“ meldet, würde +über die Abschaltregel die ganze Belegschaft deaktivieren. Der Zeitplan lässt sie deshalb aus, und +„Jetzt abgleichen“ bricht mit einer Erklärung ab. + +**`DELETE` deaktiviert.** Der Anbieter bekommt `204`, die Zeile bleibt mit `aktiv = false`. An einer +Person hängen Läufe und Geräte; ein archivierter Lauf muss auch in zehn Jahren noch sagen können, +für wen er lief. Das steht auch in der Anleitung, weil es sonst wie ein Fehler wirkt. + +**Protokolleinträge haben keinen Benutzer.** Jede Zustellung schreibt einen `Protokoll`-Eintrag mit +der Quelle als Urheber („Person angelegt (SCIM: authentik)“) und ohne `benutzerId`. Das ist ein +Systemlauf wie der Zeitplan; ein erfundener Administrator wäre eine Lüge im Protokoll. + +**„Letzte Zustellung“ ist die Lebenszeichenanzeige.** Sie steht an der Quelle und wird bei jeder +Anfrage gesetzt, die etwas geschrieben hat. Steht dort nichts, ist die Einrichtung beim Anbieter +nicht fertig — nicht die Verbindung kaputt, denn es gibt keine. + +## Prüfen von Hand + +Mit dem Token aus der Oberfläche, von einem Rechner aus, der die öffentliche Adresse erreicht: + +```bash +TOKEN='lzscim_…' +BASIS='https://laufzettel.firma.de/api/scim/v2' + +# 1. Antwortet der Endpunkt überhaupt, und gilt das Token? +curl -s -H "Authorization: Bearer $TOKEN" "$BASIS/ServiceProviderConfig" | head -c 400 + +# 2. Ohne Token muss 401 kommen. +curl -s -o /dev/null -w '%{http_code}\n' "$BASIS/Users" + +# 3. Die Frage, die jeder Anbieter vor dem Anlegen stellt. +curl -s -H "Authorization: Bearer $TOKEN" \ + --get --data-urlencode 'filter=userName eq "mhuber@firma.example"' "$BASIS/Users" +``` + +Die zweite Zeile muss `401` ausgeben. Kommt dort `200`, läuft etwas vor Laufzettel, das den +`Authorization`-Kopf selbst setzt — dann ist der Endpunkt offen, und das gehört sofort behoben. + +Anlegen von Hand ist zum Prüfen **nicht** nötig und auch nicht empfehlenswert: die Person stünde +danach in der Quelle, und der Anbieter kennt sie nicht. Löschen lässt sie sich nicht, nur +deaktivieren. + +## Fehlerbilder + +| Antwort | Ursache | +|---|---| +| `401` | Token falsch, widerrufen, oder **Zustellungen annehmen** nicht angehakt. | +| `404` auf eine bekannte Person | Sie gehört zu einer anderen Quelle. Jedes Token sieht nur seine eigene. | +| `409 uniqueness` | Die Person gibt es in dieser Quelle schon — richtige Antwort auf ein wiederholtes `POST`, kein Datenverlust. | +| `400 invalidFilter` | Ein Filter außer `userName eq "…"`. | +| `400 invalidSyntax` | Kein gültiges JSON, oder ein `PATCH` ohne `Operations`. | +| `500` | Ein Fehler in Laufzettel. Die Einzelheiten stehen im Protokoll des Dienstes, **nicht** in der Antwort: eine Datenbankmeldung nennt Host, Port und Benutzernamen. | + +`docker compose logs -f web` beziehungsweise `journalctl -u laufzettel -f` zeigt die Zeilen, die +hinter einem `500` stehen, und auch die Pfade, die ein `PATCH` mitgeschickt und Laufzettel übergangen +hat (`SCIM-PATCH: übergangene Pfade: …`) — die sind kein Fehler, aber die Antwort auf die Frage +„warum steht das Feld nicht drin“. diff --git a/docs/devlog.md b/docs/devlog.md index 8cc574d..485e382 100644 --- a/docs/devlog.md +++ b/docs/devlog.md @@ -35,12 +35,13 @@ als Unterpunkte mit Aufgabe und Dauer. | #034 | M21 – Benachrichtigungen per Mail und Webhook | 3 h 30 min | | #035 | M22 – Mitarbeiter-Selbstauskunft | 2 h 50 min | | #028 | M15 – Quellenarten und Microsoft-Graph-Quelle | 4 h 10 min | -| | **Summe** | **60 h 35 min** | +| #029 | M16 – SCIM-Endpunkt als Alternative zum Abgleich | 4 h 05 min | +| | **Summe** | **64 h 40 min** | | Meilenstein | Tatsächlich | Status | |---|---|---| | v0.1.0 | 35 h 20 min | veröffentlicht | -| v0.2.0 | 25 h 15 min | in Arbeit | +| v0.2.0 | 29 h 20 min | in Arbeit | --- @@ -1435,3 +1436,98 @@ Punkte, darunter ein Mandant mit mehr als 999 Benutzern. Dieselbe Art offener Pu - 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. + + +--- + +## 2026-10-10 · Sitzung 22 · 17:40–21:45 + +**Issue:** #029 (M16) +**Version:** 0.2.0 (unveröffentlicht) + +### Erledigt + +- **SCIM 2.0 als dritte Quellenart.** Neun Endpunkte unter `/api/scim/v2`, jeder prüft sein Token + selbst — die Hülle unter `(app)` liegt dort nicht darüber, und der Proxy lässt `/api/scim` + absichtlich ohne Cookie durch. +- **Ein Token je Quelle**, argon2-gehasht, einmal angezeigt. Zwei CHECKs halten das in der + Datenbank: `…_scimtoken_gehasht` (`LIKE '$argon2%'`) und `…_scimtoken_mit_zeitpunkt`. +- **Oberfläche**: Art SCIM im Anlege-Formular, Token erzeugen und widerrufen, letzte Zustellung, + Endpunktadresse an der Quelle. Verbindungstest, Zeitplan, Abgleich-Log und Attributzuordnung + fallen dort weg — es gibt sie nicht, und ein Knopf, der nur eine Erklärung auswirft, ist kein + Knopf. +- 1432 Vitest-Tests (vorher 1373), davon 59 neue. Die Anfrageformen von authentik und Entra ID + stehen als benannte Konstanten oben in `tests/scim.test.ts`. +- **Klickprobe am gebauten Stand, zwanzig Prüfungen**, von „Art SCIM wählbar“ über eine echte + Zustellung mit genau dem Token aus der Oberfläche bis „390 px ohne horizontales Scrollen“. Die + Probe hat ihre Daten danach selbst entfernt — die Lehre aus #027. +- Anleitung Kapitel 06 um den SCIM-Abschnitt erweitert (Einrichtung in authentik und Entra ID, + Fehlerbilder), `docs/betrieb/scim.md` neu. + +### Entscheidungen + +- **Umgesetzt ist nicht der Standard, sondern was die Anbieter schicken.** Komplexe Filter, + Sortierung, `bulk`, `/Groups` — nichts davon. `ServiceProviderConfig` **meldet** das als + `supported: false`, und die Verneinungen sind der wichtigere Teil der Antwort: ein Anbieter, der + `bulk` für unterstützt hält, schickt Anfragen, die niemand beantwortet. +- **Ein unverstandener Filter ergibt `400`, nicht „alle Personen“.** Die wichtigste + Einzelentscheidung hier. Alles zu liefern wäre die gefährlichere Antwort: der Anbieter schlösse + daraus, die gesuchte Person gäbe es schon, und legte sie **nie** an. +- **`404` und nicht `403`** für eine Person einer fremden Quelle. Ob es sie gibt, ist für ein + fremdes Token keine Auskunft. Ebenso dieselbe `401` für „kein Token“ und „falsches Token“. +- **`DELETE` deaktiviert.** Der Anbieter bekommt `204` und hält die Person für entfernt — aus seiner + Sicht richtig. In der Anleitung steht das ausdrücklich, sonst wirkt die inaktive Zeile wie ein + Fehler. +- **SCIM-Quellen bleiben aus dem Zeitplan heraus**, an zwei Stellen gehalten. Ein Abgleich, der dort + nichts liest und `ERFOLG` meldet, hätte die ganze Belegschaft deaktiviert. + +### Probleme + +- **Zwei von acht Gegenproben blieben grün — und das waren die wertvollen.** Sechs schlugen sofort + an. Die beiden stillen: + - **`PATCH remove` wirkte gar nicht.** Kein Testloch, ein Fehler: `personAendern` hatte einen + Schalter `nurGesetzte`, der bei `PATCH` jedes `null` als „nicht mitgeschickt“ las. Nimmt jemand + beim Anbieter die Abteilung aus dem Profil, stand sie in Laufzettel weiter — sichtbar, gepflegt + aussehend, falsch. Der Schalter war nie nötig: `patchAnwenden` setzt auf dem vorhandenen Stand + auf und liefert immer einen vollständigen Zielzustand. Der Unterschied zwischen `PUT` und + `PATCH` liegt **vor** der Funktion, nicht in ihr. + - **Entra ID schickt `active` als `"True"`/`"False"`.** Richtig umgesetzt, aber nur halb geprüft: + beim Deaktivieren kommt mit einem reinen `value === true` **zufällig** das Richtige heraus. Nur + das Aktivieren wäre kaputt gewesen — ein wieder freigeschaltetes Konto, das in Laufzettel für + immer inaktiv bleibt. Ein Fehler, der nur in einer Richtung sichtbar ist, ist der teuerste. +- **`manager` wäre fast untergegangen.** Es stand im Issue, und umgesetzt war zunächst nur + `department`. Dazugekommen: drei Lesearten (`{value}`, `{$ref}`, nackte Zeichenkette), die + Auflösung, und `manager` im `Schemas`-Endpunkt — das Letzte ist das Entscheidende, denn ein + Anbieter schickt nur, was dort steht. Ohne den Eintrag bliebe der Vorgesetzte dauerhaft leer, + **ohne Fehlermeldung auf einer der beiden Seiten.** +- **`manager` war ein Weg über die Quellengrenze.** Eine Kennung aus einer fremden Quelle hätte über + die Vorgesetzten-Anzeige einen fremden Namen in die Oberfläche gebracht. Die Auflösung sucht nur + in der eigenen Quelle; die Gegenprobe ohne diese Einschränkung ist rot. +- **Ein Fehler aus #028 im Nachbarcode**: in `verbindungTestenAktion` stand zweimal dieselbe + Bedingung, der zweite Zweig war unerreichbar. Genau dieser Zweig war dort als Reviewbefund + eingebaut worden — „gelesen, aber nichts zuordenbar“ sollte anders klingen als „der Filter findet + niemanden“, weil die Ursachen den Administrator an verschiedene Stellen schicken. Wirkungslos + seit dem Tag, an dem er geschrieben wurde. Das ist dasselbe Muster wie die stillen Gegenproben: + **Code, der aussieht, als prüfte er etwas.** +- **Ein eigener Probenfehler**: die Klickprobe suchte „Klick Probe“ in der Mitarbeiterliste. Ohne + `displayName` baut Laufzettel „Nachname, Vorname“ — wie der Abgleich. Kein Produktfehler, aber + ein Beleg dafür, dass ein roter Befund erst gelesen und dann bewertet gehört. +- Ein Test aus #028 war **überholt**, nicht falsch: er verlangte, dass die Meldung der Weiche „#029“ + nennt. + +### Was offen bleibt, und das ist benannt + +Gegen **echte Mandanten** ist nichts davon gelaufen. Die Anfrageformen stammen aus der +Dokumentation von authentik und Entra ID, nicht aus einem Mitschnitt — dieselbe Grenze wie bei #028, +und sie steht im Kopfkommentar von `tests/scim.test.ts`. Die Prüfbefehle für den ersten echten Lauf +stehen in `docs/betrieb/scim.md`. + +### Nächster Schritt + +- **Phase 2 ist damit vollständig.** Acht Meilensteine (M15–M22) liegen danach auf `main`. +- Offen bleiben **#024** (Proxmox-Einzeiler auf echtem PVE-Host) und **#027** (die Editor-Abläufe + lassen ihre Vorlagen in der Dev-Datenbank stehen). +- **Ein unabhängiger Review ist jetzt überfällig.** Acht Meilensteine dieser Phase liegen ohne + fremden Blick auf `main`, und diese Sitzung hat wieder zwei Dinge gezeigt, die eine grüne Suite + nicht sieht: einen Schalter, der eine Änderung verschluckte, und einen Zweig, der nie erreicht + wurde. Beide Befunde kamen aus Gegenproben, nicht aus Tests. diff --git a/docs/issues/029-scim-endpunkt.md b/docs/issues/029-scim-endpunkt.md index a5147f3..f110f21 100644 --- a/docs/issues/029-scim-endpunkt.md +++ b/docs/issues/029-scim-endpunkt.md @@ -1,10 +1,10 @@ # #029 M16 – SCIM-Endpunkt als Alternative zum Abgleich -**Status:** offen +**Status:** erledigt **Im Tracker:** [#49](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/49) **Meilenstein:** v0.2.0 **Labels:** phase-2, backend -**Geschätzt:** 12 h **Tatsächlich:** – +**Geschätzt:** 12 h **Tatsächlich:** 4 h 05 min ## Ziel @@ -16,41 +16,137 @@ Setzt [#028](028-graph-quelle.md) voraus: die Quellenart `SCIM` braucht die Weic ## Zu entscheiden -- [ ] **Ein Token je Quelle, als Hash gespeichert** — Empfehlung, dasselbe Verfahren wie beim +- [x] **Ein Token je Quelle, als Hash gespeichert** — Empfehlung, dasselbe Verfahren wie beim lokalen Passwort (argon2). Es wird **einmal** angezeigt und ist danach nicht mehr lesbar. Das Token identifiziert die Quelle und damit den Kunden; es ist der einzige Mandantenbeweis, den ein Push mitbringt. -- [ ] **Was bei `DELETE /Users/{id}` geschieht:** `aktiv = false`, nicht löschen. Das ist die Regel +- [x] **Was bei `DELETE /Users/{id}` geschieht:** `aktiv = false`, nicht löschen. Das ist die Regel des ganzen Projekts, und ein Identitätsanbieter, der löschen will, bekommt `204` — aber die Zeile bleibt, weil Läufe an ihr hängen. Das muss in der Anleitung stehen, sonst wirkt es wie ein Fehler. ## Aufgaben -- [ ] Route Handler unter `src/app/api/scim/v2/`: `Users` (GET mit Filter `userName eq`, POST), +- [x] Route Handler unter `src/app/api/scim/v2/`: `Users` (GET mit Filter `userName eq`, POST), `Users/{id}` (GET, PUT, PATCH, DELETE), dazu `ServiceProviderConfig`, `Schemas`, `ResourceTypes` — ohne die drei weigern sich manche Anbieter, überhaupt anzufangen -- [ ] **Jeder dieser Handler prüft selbst**: das Token, und daraus die Quelle und den Kunden. Die +- [x] **Jeder dieser Handler prüft selbst**: das Token, und daraus die Quelle und den Kunden. Die Hülle unter `(app)` liegt hier nicht darüber (CLAUDE.md) -- [ ] Zuordnung: `externalId` → `externeId`, `userName` → `upn`, `name.givenName`/`familyName`, +- [x] Zuordnung: `externalId` → `externeId`, `userName` → `upn`, `name.givenName`/`familyName`, `emails[primary]` → `mail`, `active` → `aktiv`, `urn:ietf:params:scim:schemas:extension:enterprise:2.0:User` für `department` und `manager` -- [ ] Antwortform nach RFC 7644: `schemas`, `id`, `meta.resourceType`, `meta.location`, bei Listen +- [x] Antwortform nach RFC 7644: `schemas`, `id`, `meta.resourceType`, `meta.location`, bei Listen `totalResults`/`itemsPerPage`/`startIndex`; Fehler als `urn:ietf:params:scim:api:messages:2.0:Error` -- [ ] `PATCH` mit `op: add|replace|remove` auf den Pfaden, die die Anbieter wirklich schicken — +- [x] `PATCH` mit `op: add|replace|remove` auf den Pfaden, die die Anbieter wirklich schicken — nicht der ganze Standard -- [ ] Idempotenz: zweimal dasselbe `POST` ergibt `409`, nicht eine zweite Person -- [ ] Protokolleintrag je Vorgang, mit der Quelle als Urheber (Systemlauf, kein Benutzer) -- [ ] Admin → Verzeichnis: Token erzeugen, einmal anzeigen, widerrufen; letzte Zustellung sichtbar -- [ ] Anleitung Kapitel 06: Einrichtung in authentik und in Entra ID +- [x] Idempotenz: zweimal dasselbe `POST` ergibt `409`, nicht eine zweite Person +- [x] Protokolleintrag je Vorgang, mit der Quelle als Urheber (Systemlauf, kein Benutzer) +- [x] Admin → Verzeichnis: Token erzeugen, einmal anzeigen, widerrufen; letzte Zustellung sichtbar +- [x] Anleitung Kapitel 06: Einrichtung in authentik und in Entra ID ## Akzeptanzkriterien -- [ ] Test gegen **aufgezeichnete Anfragen** von authentik und Entra ID (Form, nicht erfunden): +- [x] Test gegen **aufgezeichnete Anfragen** von authentik und Entra ID (Form, nicht erfunden): anlegen, aktualisieren, deaktivieren, wieder aktivieren -- [ ] Test: ohne Token `401`, mit fremdem Token kein Zugriff auf die Personen einer anderen Quelle -- [ ] Test: `DELETE` setzt `aktiv = false` und löscht nichts -- [ ] Test: zweimal dasselbe `POST` ergibt `409` und keine zweite Zeile -- [ ] Test: das Token steht nur als Hash in der Datenbank und in keiner Antwort -- [ ] Test: eine Quelle der Art `SCIM` wird vom Zeitplan **nicht** abgeglichen — sie bekommt +- [x] Test: ohne Token `401`, mit fremdem Token kein Zugriff auf die Personen einer anderen Quelle +- [x] Test: `DELETE` setzt `aktiv = false` und löscht nichts +- [x] Test: zweimal dasselbe `POST` ergibt `409` und keine zweite Zeile +- [x] Test: das Token steht nur als Hash in der Datenbank und in keiner Antwort +- [x] Test: eine Quelle der Art `SCIM` wird vom Zeitplan **nicht** abgeglichen — sie bekommt geschickt + +## Beim Umsetzen dazugekommen + +- **Zwei Gegenproben blieben grün, und beide deckten etwas auf.** Von acht Gegenproben (Regel + brechen, Test muss rot werden) schlugen sechs sofort an. Die zwei stillen waren die wertvollen: + + - **`PATCH remove` wirkte gar nicht** — ein echter Fehler, kein Testloch. `personAendern` hatte + einen Schalter `nurGesetzte`, der bei `PATCH` jedes `null` als „nicht mitgeschickt“ las und den + alten Wert stehen ließ. Nimmt jemand beim Anbieter die Abteilung aus dem Profil, stand sie in + Laufzettel weiter — sichtbar, gepflegt aussehend, falsch. Nötig war der Schalter nie: + `patchAnwenden` setzt auf dem vorhandenen Stand auf und liefert deshalb immer einen + **vollständigen** Zielzustand. Der Unterschied zwischen `PUT` und `PATCH` liegt vor der + Funktion, nicht in ihr. Schalter entfernt, Test ergänzt, Gegenprobe schlägt jetzt an. + - **Entra ID schickt `active` als Zeichenkette `"True"`/`"False"`.** Das war richtig umgesetzt, + aber nur halb geprüft: der Deaktivierungs-Test lief gegen `"False"`, und mit einem reinen + `value === true` kommt dort **zufällig** das Richtige heraus. Nur das Aktivieren wäre kaputt + gewesen — ein Konto, das wieder freigeschaltet wird und in Laufzettel für immer inaktiv bleibt. + Der Fehler wäre also nur in einer Richtung sichtbar gewesen. Jetzt steht `ENTRA_PATCH_AN` mit + `"True"` als eigene aufgezeichnete Form daneben. + +- **`manager` war im Issue genannt und wäre fast untergegangen.** Die Zuordnung nennt die + Enterprise-Erweiterung für `department` **und** `manager`; umgesetzt war zunächst nur die + Abteilung. Dazugekommen sind: `vorgesetzterAus` liest alle drei Formen, die die Anbieter schicken + (`{value}`, `{$ref}` und die nackte Zeichenkette — letztere nicht standardkonform, aber authentik + schickt sie), die Auflösung über die Laufzettel-Kennung, und `manager` im `Schemas`-Endpunkt. Das + Letzte ist das Entscheidende: ein Anbieter liest dieses Schema und schickt nur, was darin steht. + Fehlte es, bliebe der Vorgesetzte dauerhaft leer — **ohne Fehlermeldung auf einer der beiden + Seiten**. + +- **`manager` wäre ein Weg über die Quellengrenze gewesen.** Eine Kennung aus einer fremden Quelle + zeigt auf jemanden, den diese hier nicht sehen darf, und über die Vorgesetzten-Anzeige stünde + dessen Name in der Oberfläche. Die Auflösung sucht deshalb nur in der eigenen Quelle; eine + Gegenprobe ohne diese Einschränkung ist rot. Dasselbe für den Fall „sein eigener Vorgesetzter“ — + die Datenbank weist das ohnehin ab, aber dann wäre die ganze Zuweisung daran gescheitert. + +- **Ein unbekannter Vorgesetzter lässt die Zuweisung nicht scheitern.** Der Anbieter schickt die + Leute in beliebiger Reihenfolge. Wer an einer noch unbekannten Kennung scheiterte, ließe die halbe + Belegschaft draußen; nachgezogen wird beim nächsten `PATCH`. + +- **Ein unverstandener Filter ergibt `400` und nicht „alle Personen“.** Das ist die wichtigste + Einzelentscheidung dieses Meilensteins. Ihn zu ignorieren hieße, alle zu liefern — und der + Anbieter schlösse daraus, die gesuchte Person gäbe es schon, und legte sie **nie** an. Unterstützt + wird genau `userName eq "…"`, die Frage, die jeder Anbieter vor dem Anlegen stellt. + +- **Ein Fehler im Nachbarcode aus #028**: in `verbindungTestenAktion` stand zweimal dieselbe + Bedingung (`ergebnis.treffer.length === 0`), der zweite Zweig war damit unerreichbar. Genau der + Zweig war in #028 als Reviewbefund eingebaut worden — „gelesen, aber nichts zuordenbar“ sollte + anders klingen als „der Filter findet niemanden“, weil die Ursachen verschiedene sind und den + Administrator an verschiedene Stellen schicken. Wirkungslos, seit er geschrieben wurde. Behoben. + +- **„Noch nie abgeglichen“ ist an einer SCIM-Quelle eine Falschaussage**, und „kein Zeitplan“ liest + sich wie ein Mangel. Beides ersetzt: die Pille sagt „nimmt Zustellungen an“, und an der Stelle, an + der bei LDAP der Verbindungstest sitzt, steht die letzte Zustellung. Verbindungstest und „Jetzt + abgleichen“ gibt es dort nicht — ein Knopf, der nur eine Erklärung auswirft, ist kein Knopf. Wer + sie trotzdem über den Dienst ruft, bekommt eine Erklärung statt „unbekannter Fehler“. + +- **Der Zeitplan lässt SCIM-Quellen aus, und das ist keine Kosmetik.** Ein Abgleich, der an einer + SCIM-Quelle nichts liest und `ERFOLG` meldet, hätte über die Abschaltregel die **ganze + Belegschaft deaktiviert** — derselbe Fall, der in #016 einen Reviewbefund gekostet hat. Zwei + Stellen halten das: `faelligeQuellen` nimmt sie nicht auf, und die Weiche im Quellenleser wirft. + +- **Das Token in keiner Sicht** — geprüft nicht an einem Feld, sondern an der **ganzen** + serialisierten Sicht aus Liste und Detail. Ein später ergänztes Feld fällt damit auch auf. Dazu: + nicht im Protokolleintrag, nicht im HTML der Seite nach dem Neuladen (in der Klickprobe am + gebauten Stand nachgesehen). + +- **Dieselbe `401` für „kein Token“ und „falsches Token“.** Die Unterscheidung wäre eine Auskunft + darüber, ob ein geratenes Token die richtige Form hat. Und vor der Hash-Prüfung steht eine + Vorab-Prüfung auf das Präfix: ohne sie würde ein Token mit falschem Präfix gegen jeden Hash + geprüft, eines mit richtigem nur bis zum Treffer — ein Zeitorakel. + +- **Die Beschreibungs-Endpunkte verlangen ebenfalls ein Token**, und ihre **Verneinungen** sind der + wichtigere Teil: `bulk`, `sort`, `filter` und `etag` stehen ausdrücklich auf `false`. Ein + Anbieter, der `bulk` für unterstützt hält, schickt Anfragen, die niemand beantwortet, und meldet + das als Fehler der Gegenseite. + +- **Die Klickprobe am gebauten Stand fand keinen Bedienfehler** — zwanzig Prüfungen von „Art SCIM + wählbar“ über „Token angezeigt“, eine echte Zustellung mit genau diesem Token, „Person steht in + der Liste“, `DELETE` deaktiviert, Widerruf sperrt, bis „390 px ohne horizontales Scrollen“. Die + Probe hat ihre Daten danach selbst wieder entfernt; das ist die Lehre aus #027. + +- **Ein Test aus #028 war überholt**, nicht falsch: er prüfte, dass die Meldung der Weiche „#029“ + enthält. Jetzt ist #029 da, und die Antwort bleibt dieselbe — nur ohne Issue-Nummer, denn die + Meldung steht auch in der Oberfläche. + +## Was offen bleibt + +- **Gegen echte Mandanten ist das nicht gelaufen.** Die Anfrageformen in `tests/scim.test.ts` + stammen aus der Dokumentation von authentik und Entra ID, nicht aus einem Mitschnitt — dieselbe + Grenze wie bei #028, und sie ist dort im Kopfkommentar der Testdatei benannt. Stimmt eine Form + nicht, stimmen Test und Wirklichkeit nicht. Die Prüflisten für den ersten echten Lauf stehen in + [`docs/betrieb/scim.md`](../betrieb/scim.md) unter „Prüfen von Hand“. +- **Keine Paginierung über 200 Einträge** bei `GET /Users` ohne Filter. Die Anbieter fragen + gefiltert; ein voller Abzug ist keine Betriebsart dieses Endpunkts. +- **Kein `/Groups`.** Rollen entstehen beim Anmelden aus den OIDC-Gruppen, nicht aus dem + Provisioning. `ResourceTypes` nennt deshalb nur `User`. diff --git a/docs/issues/README.md b/docs/issues/README.md index 160951f..fc95732 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -33,11 +33,11 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac | #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) | 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) | erledigt | v0.2.0 | 12 h | 4 h 05 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 | | #032 | [M19 – Anhänge und Fotos an Schritten](032-anhaenge.md) | erledigt | v0.2.0 | 10 h | 3 h 05 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 | | #035 | [M22 – Mitarbeiter-Selbstauskunft](035-selbstauskunft.md) | erledigt | v0.2.0 | 8 h | 2 h 50 min | -| | **Summe** | | | **229 h** | **60 h 35 min** | +| | **Summe** | | | **229 h** | **64 h 40 min** | diff --git a/prisma/migrations/20261010090000_scim/migration.sql b/prisma/migrations/20261010090000_scim/migration.sql new file mode 100644 index 0000000..1b90d71 --- /dev/null +++ b/prisma/migrations/20261010090000_scim/migration.sql @@ -0,0 +1,95 @@ +-- --------------------------------------------------------------------------- +-- 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. +-- --------------------------------------------------------------------------- + +-- SCIM-Endpunkt als Alternative zum Abgleich (#029). + +ALTER TABLE "Verzeichnisquelle" + ADD COLUMN "scimTokenHash" TEXT, + ADD COLUMN "scimTokenGesetztAm" TIMESTAMP(3), + ADD COLUMN "letzteScimZustellungAm" TIMESTAMP(3); + +/* + * **Der CHECK je Art wird ersetzt, nicht ergaenzt.** + * + * Bis #028 liess er `SCIM` ohne jede Bedingung durch — die Art hatte noch keine + * Felder. Jetzt hat sie welche, und dieselbe Regel gilt in beide Richtungen: was + * eine Art braucht, und was sie **nicht** haben darf. Ohne den zweiten Teil + * waere eine SCIM-Quelle mit `basisDn` moeglich, und bei der koennte niemand + * mehr sagen, was gilt. + * + * **Das Token steht nicht in der Bedingung.** Eine SCIM-Quelle entsteht ohne + * Token und bekommt es danach — der Administrator legt die Quelle an, erzeugt + * das Token und traegt es beim Anbieter ein. Eine Quelle ohne Token nimmt + * nichts an: der Handler findet keinen Hash zum Vergleichen und antwortet mit + * 401. Das ist der richtige Zustand, nicht ein verbotener. + */ +ALTER TABLE "Verzeichnisquelle" DROP CONSTRAINT "Verzeichnisquelle_felder_je_art"; + +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 + AND "scimTokenHash" 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 + AND "scimTokenHash" IS NULL) + OR + ("art" = 'SCIM' + AND "url" IS NULL AND "bindDn" IS NULL AND "bindPasswortVerschluesselt" IS NULL + AND "basisDn" IS NULL AND "filter" IS NULL + AND "tenantId" IS NULL AND "clientId" IS NULL AND "clientSecretVerschluesselt" IS NULL) + ); + +-- Das Token als argon2-Hash, nicht im Klartext. `$argon2` ist das Praefix der +-- Bibliothek; ein Token, das versehentlich roh gespeichert wird, faellt hier auf. +ALTER TABLE "Verzeichnisquelle" + ADD CONSTRAINT "Verzeichnisquelle_scimtoken_gehasht" + CHECK ("scimTokenHash" IS NULL OR "scimTokenHash" LIKE '$argon2%'); + +-- Ein Token gehoert zu einem Zeitpunkt, in beide Richtungen: ohne ihn liesse +-- sich nicht sagen, seit wann es gilt, und ein Zeitpunkt ohne Token behauptete +-- ein Token, das es nicht gibt. +ALTER TABLE "Verzeichnisquelle" + ADD CONSTRAINT "Verzeichnisquelle_scimtoken_mit_zeitpunkt" + CHECK (("scimTokenHash" IS NULL) = ("scimTokenGesetztAm" IS NULL)); + +/* + * Der Index, auf dem der SCIM-Handler arbeitet. + * + * Jede Anfrage bringt ein Token mit und muss daraus die Quelle finden. Ein Hash + * laesst sich nicht nachschlagen — argon2 ist je Aufruf anders —, also werden + * die SCIM-Quellen gelesen und einzeln geprueft. Bei zwei Quellen ist das + * gleichgueltig; der Index haelt es auch bei zwanzig schnell und sagt zugleich, + * dass hier nach `art` gesucht wird. + */ +CREATE INDEX "Verzeichnisquelle_art_aktiv_idx" ON "Verzeichnisquelle"("art", "aktiv"); + +/* + * Gegenprobe: der ersetzte CHECK muss die bestehenden Zeilen noch zulassen. + * `ALTER TABLE ... ADD CONSTRAINT` prueft das von sich aus — aber eine + * Migration, die ihre eigene Annahme nicht nachrechnet, ist genau die Art + * Zusage, die dieses Projekt teuer bezahlt hat. + */ +DO $$ +DECLARE + anzahl INTEGER; +BEGIN + SELECT count(*) INTO anzahl FROM "Verzeichnisquelle" WHERE "art" NOT IN ('LDAP', 'GRAPH', 'SCIM'); + IF anzahl > 0 THEN + RAISE EXCEPTION 'Unerwartete Quellenart in % Zeilen.', anzahl; + END IF; +END $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bb589c4..5f377ca 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -350,18 +350,32 @@ model Verzeichnisquelle { /// Verschluesselt wie das Bind-Passwort und mit demselben Vorsatz: es darf /// keine Antwort und keinen Protokolleintrag erreichen. clientSecretVerschluesselt String? + + /// Ab hier: **nur bei `art = SCIM`** (#029). + /// + /// Das Token als **argon2-Hash**, nicht verschluesselt — anders als die + /// Geheimnisse darueber. Der Unterschied ist der Zweck: ein Bind-Passwort + /// muss Laufzettel **benutzen**, also wieder lesbar machen. Ein SCIM-Token + /// muss es nur **wiedererkennen**, und was man nie lesen muss, speichert man + /// so, dass es niemand lesen kann. Gezeigt wird es genau einmal. + scimTokenHash String? + /// Wann das Token erzeugt wurde — fuer „seit wann gilt das“ in der Oberflaeche. + scimTokenGesetztAm DateTime? + /// Wann zuletzt etwas geschickt wurde. Das Gegenstueck zu `letzterAbgleichAm`: + /// bei SCIM holt Laufzettel nicht, es bekommt. + letzteScimZustellungAm DateTime? /// Zuordnung der LDAP-Attribute auf unsere Felder, mit AD-Vorgaben. - attributZuordnung Json + attributZuordnung Json /// Eigenes CA-Zertifikat, falls ldaps gegen eine interne CA laeuft. - caZertifikatPem String? - abgleichIntervallMinuten Int @default(60) - letzterAbgleichAm DateTime? - letzterAbgleichStatus String? + caZertifikatPem String? + abgleichIntervallMinuten Int @default(60) + letzterAbgleichAm DateTime? + letzterAbgleichStatus String? /// Nimmt die Meldung der LDAP-Bibliothek auf. #016 muss sie filtern, bevor sie /// hier landet – rohe Bind-Fehler koennen das Passwort enthalten. - letzterAbgleichFehler String? - erstelltAm DateTime @default(now()) - geaendertAm DateTime @default(now()) @updatedAt + letzterAbgleichFehler String? + erstelltAm DateTime @default(now()) + geaendertAm DateTime @default(now()) @updatedAt kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict) mitarbeiter Mitarbeiter[] diff --git a/src/app/(app)/admin/verzeichnis/page.tsx b/src/app/(app)/admin/verzeichnis/page.tsx index 5534802..8a3db20 100644 --- a/src/app/(app)/admin/verzeichnis/page.tsx +++ b/src/app/(app)/admin/verzeichnis/page.tsx @@ -1,9 +1,13 @@ +import { headers } from "next/headers"; import { redirect } from "next/navigation"; -import { AlertTriangle, CheckCircle2, Clock, Server } from "lucide-react"; +import { AlertTriangle, CheckCircle2, Clock, Inbox, Server } from "lucide-react"; import { Betriebsmodus } from "@/generated/prisma/enums"; import { JetztAbgleichen } from "@/components/verzeichnis/jetzt-abgleichen"; import { QuelleFormular } from "@/components/verzeichnis/quelle-formular"; +import { ScimToken } from "@/components/verzeichnis/scim-token"; import { VerbindungTesten } from "@/components/verzeichnis/verbindung-testen"; +import { basisAdresse } from "@/lib/adressen"; +import { QUELLEN_ART_TEXT } from "@/lib/verzeichnis"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { betriebsmodus } from "@/server/mandant"; import { abgleichLog, type AbgleichLogZeile, quellenListe } from "@/server/queries/verzeichnis"; @@ -30,6 +34,13 @@ const ZEITPUNKT = new Intl.DateTimeFormat("de-DE", { }); function Status({ quelle }: { quelle: QuellenSicht }) { + /* + * **Bei SCIM sagt „noch nie abgeglichen“ etwas Falsches** (#029): dort wird + * nie abgeglichen, und das ist kein Rückstand. Was an diese Stelle gehört, + * steht im Token-Abschnitt — „letzte Zustellung“. + */ + if (quelle.art === "SCIM") return null; + if (quelle.letzterAbgleichAm === null) { return (

@@ -123,11 +134,26 @@ export default async function VerzeichnisSeite() { // daran, und ein hart gesetzter „ADMIN“ waere eine Hintertuer. const handelnder = { id: benutzer.id, rolle: benutzer.rolle }; const quellen = await quellenListe(handelnder); + + /* + * Die Adresse, die beim Anbieter eingetragen wird. Über `basisAdresse`, weil + * sie jemand **behält** — ein Unterpfad aus `APP_URL` darf dabei nicht + * herausfallen, sonst trägt der Administrator eine Adresse ein, die ins Leere + * zeigt (derselbe Grund wie bei den Etiketten, #014). + */ + const kopfzeilen = await headers(); + const scimAdresse = new URL( + "api/scim/v2", + basisAdresse(`http://${kopfzeilen.get("host") ?? "127.0.0.1:3000"}`), + ).toString(); // Je Quelle ein Log. Die Abfragen laufen parallel; es sind so viele wie Quellen, // und das sind im Modus INTERN eine oder zwei. const logs = new Map( await Promise.all( - quellen.map(async (quelle) => [quelle.id, await abgleichLog(quelle.id, handelnder)] as const), + quellen + // SCIM-Quellen haben kein Abgleich-Log: sie werden nicht abgeglichen. + .filter((quelle) => quelle.art !== "SCIM") + .map(async (quelle) => [quelle.id, await abgleichLog(quelle.id, handelnder)] as const), ), ); @@ -135,9 +161,10 @@ export default async function VerzeichnisSeite() { <>

Verzeichnis

- Die Personen der eigenen Firma kommen aus dem Active Directory, damit beim Start eines Laufs nichts - abgetippt werden muss. Wer dort verschwindet oder deaktiviert wird, wird hier inaktiv{" "} - — gelöscht wird nie, sonst verlöre die Historie ihre Namen. + Die Personen der eigenen Firma kommen aus dem Verzeichnis, damit beim Start eines Laufs nichts + abgetippt werden muss. Laufzettel liest sie entweder selbst — aus einem Active Directory oder aus + Entra ID — oder lässt sich per SCIM beliefern. Wer dort verschwindet oder deaktiviert wird, wird hier{" "} + inaktiv — gelöscht wird nie, sonst verlöre die Historie ihre Namen.

{quellen.length === 0 ? ( @@ -152,7 +179,23 @@ export default async function VerzeichnisSeite() {