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() {