feat(verzeichnis): SCIM-Endpunkt als dritte Quellenart (#029)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

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) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-10 19:50:54 +02:00
co-authored by Claude Opus 5
parent 76a61ec771
commit fcd67018b8
32 changed files with 3672 additions and 141 deletions
+28
View File
@@ -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,
+25 -5
View File
@@ -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.
+126 -9
View File
@@ -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.
+28
View File
@@ -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:
+2
View File
@@ -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
+159
View File
@@ -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://<APP_URL>/api/scim/v2` |
| Anmeldung | `Authorization: Bearer <Token>`, 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“.
+98 -2
View File
@@ -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.
+115 -19
View File
@@ -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`.
+2 -2
View File
@@ -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** |
@@ -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 $$;
+22 -8
View File
@@ -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[]
+91 -18
View File
@@ -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 (
<p className="mt-2 text-sm text-nebentext">
@@ -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() {
<>
<h1 className="text-2xl font-bold">Verzeichnis</h1>
<p className="mt-2 max-w-prose text-nebentext">
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 <strong>inaktiv</strong>{" "}
— 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{" "}
<strong>inaktiv</strong> — gelöscht wird nie, sonst verlöre die Historie ihre Namen.
</p>
{quellen.length === 0 ? (
@@ -152,7 +179,23 @@ export default async function VerzeichnisSeite() {
<div className="flex flex-wrap items-center gap-2">
<Server className="size-5 shrink-0 text-akzent" strokeWidth={2} aria-hidden="true" />
<h2 className="text-lg font-semibold break-words">{quelle.name}</h2>
{quelle.aktiv ? (
{/*
* Bei SCIM gibt es keinen Zeitplan, und „kein Zeitplan“ wäre
* trotzdem die falsche Auskunft — es liest sich wie ein Mangel.
* Dort zählt, ob Zustellungen angenommen werden.
*/}
{quelle.art === "SCIM" ? (
<span
className={
quelle.aktiv
? "flex items-center gap-1 rounded-pille bg-akzent-flaeche px-2.5 py-1 text-xs text-akzent"
: "rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext"
}
>
<Inbox className="size-3" strokeWidth={2} aria-hidden="true" />
{quelle.aktiv ? "nimmt Zustellungen an" : "nimmt nichts an"}
</span>
) : quelle.aktiv ? (
<span className="flex items-center gap-1 rounded-pille bg-akzent-flaeche px-2.5 py-1 text-xs text-akzent">
<Clock className="size-3" strokeWidth={2} aria-hidden="true" />
alle {quelle.abgleichIntervallMinuten} min
@@ -172,11 +215,21 @@ export default async function VerzeichnisSeite() {
<p className="mono mt-2 text-xs break-all text-nebentext">
{quelle.art === "GRAPH" ? (
<>
Microsoft Graph · {quelle.tenantId} · App {quelle.clientId}
{QUELLEN_ART_TEXT.GRAPH} · {quelle.tenantId} · App {quelle.clientId}
</>
) : quelle.art === "SCIM" ? (
/*
* Bei SCIM steht hier die Adresse, die beim Anbieter
* eingetragen wird — die Angabe, die ein Administrator
* tatsächlich braucht, und die er sonst aus der Anleitung
* zusammensetzen müsste.
*/
<>
{QUELLEN_ART_TEXT.SCIM} · {scimAdresse}
</>
) : (
<>
LDAP · {quelle.url} · {quelle.basisDn}
{QUELLEN_ART_TEXT.LDAP} · {quelle.url} · {quelle.basisDn}
</>
)}
</p>
@@ -184,16 +237,35 @@ export default async function VerzeichnisSeite() {
<Status quelle={quelle} />
<div className="mt-4 flex flex-col gap-4">
<VerbindungTesten quelleId={quelle.id} />
<JetztAbgleichen quelleId={quelle.id} />
{/*
* **Zwei Arten, zwei Arbeitsweisen.** „Verbindung testen“ und
* „Jetzt abgleichen“ setzen voraus, dass Laufzettel holt; bei
* einem Push gibt es beides nicht, und ein Knopf, der nur eine
* Erklärung auswirft, ist kein Knopf.
*/}
{quelle.art === "SCIM" ? (
<ScimToken
quelleId={quelle.id}
vorhanden={quelle.scimTokenVorhanden}
gesetztAm={quelle.scimTokenGesetztAm}
letzteZustellungAm={quelle.letzteScimZustellungAm}
/>
) : (
<>
<VerbindungTesten quelleId={quelle.id} />
<JetztAbgleichen quelleId={quelle.id} />
</>
)}
</div>
<details className="mt-4 border-t border-linie pt-4">
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Abgleich-Log
</summary>
<Log zeilen={logs.get(quelle.id) ?? []} />
</details>
{quelle.art === "SCIM" ? null : (
<details className="mt-4 border-t border-linie pt-4">
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Abgleich-Log
</summary>
<Log zeilen={logs.get(quelle.id) ?? []} />
</details>
)}
<details className="mt-4 border-t border-linie pt-4">
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
@@ -211,8 +283,9 @@ export default async function VerzeichnisSeite() {
<section className="mt-8 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Quelle anlegen</h2>
<p className="mt-1 mb-5 max-w-prose text-sm text-nebentext">
Ein Dienstkonto mit Leserechten genügt. Laufzettel schreibt nie ins Verzeichnis und speichert nur
die zugeordneten Felder — keine Passwörter, keine Gruppenmitgliedschaften.
Zum Lesen genügt ein Dienstkonto mit Leserechten; bei SCIM braucht es gar keine Zugangsdaten, nur
ein Token für den Anbieter. Laufzettel schreibt nie ins Verzeichnis und speichert nur die
zugeordneten Felder — keine Passwörter, keine Gruppenmitgliedschaften.
</p>
<QuelleFormular />
</section>
@@ -0,0 +1,37 @@
import { SCHEMA_ENTERPRISE, SCHEMA_USER } from "@/lib/scim";
import { quelleAusAnfrage, scimAntwort } from "@/server/scim/handler";
/**
* SCIM: `/ResourceTypes` (#029).
*
* Nur `User`. **Keine `Group`** — und das ist eine Entscheidung, nicht eine
* Luecke: Laufzettel fuehrt keine Gruppen. Die Abteilung kommt als Attribut der
* Person, und die Rollen kommen aus den OIDC-Gruppen beim Anmelden, nicht aus
* einer Zuweisung. Wuerde `Group` hier stehen, schickte ein Anbieter
* Gruppenzuweisungen, die niemand verarbeitet.
*/
export async function GET(anfrage: Request): Promise<Response> {
const zugang = await quelleAusAnfrage(anfrage);
if ("antwort" in zugang) return zugang.antwort;
const typen = [
{
schemas: ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
id: "User",
name: "User",
endpoint: "/Users",
description: "Personen, die Laufzettel als Mitarbeiter fuehrt.",
schema: SCHEMA_USER,
schemaExtensions: [{ schema: SCHEMA_ENTERPRISE, required: false }],
meta: { resourceType: "ResourceType", location: `${zugang.basis}/ResourceTypes/User` },
},
];
return scimAntwort({
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
totalResults: typen.length,
itemsPerPage: typen.length,
startIndex: 1,
Resources: typen,
});
}
+89
View File
@@ -0,0 +1,89 @@
import { SCHEMA_ENTERPRISE, SCHEMA_USER } from "@/lib/scim";
import { quelleAusAnfrage, scimAntwort } from "@/server/scim/handler";
/**
* SCIM: `/Schemas` (#029).
*
* Gemeldet werden **genau die Attribute, die Laufzettel fuehrt** — nicht der
* ganze Standard. Ein Anbieter liest das und schickt danach nur noch, was hier
* steht; ein zu grosszuegiges Schema fuehrt zu Feldern, die stillschweigend
* verfallen.
*/
function attribut(
name: string,
typ: "string" | "boolean" | "complex" | "reference",
zusatz: Record<string, unknown> = {},
): Record<string, unknown> {
return {
name,
type: typ,
multiValued: false,
required: false,
caseExact: false,
mutability: "readWrite",
returned: "default",
uniqueness: "none",
...zusatz,
};
}
export async function GET(anfrage: Request): Promise<Response> {
const zugang = await quelleAusAnfrage(anfrage);
if ("antwort" in zugang) return zugang.antwort;
const schemas = [
{
schemas: ["urn:ietf:params:scim:schemas:core:2.0:Schema"],
id: SCHEMA_USER,
name: "User",
description: "Eine Person, die Laufzettel als Mitarbeiter fuehrt.",
attributes: [
attribut("userName", "string", { required: true, uniqueness: "server" }),
attribut("externalId", "string"),
attribut("displayName", "string"),
attribut("title", "string"),
attribut("active", "boolean"),
attribut("name", "complex", {
subAttributes: [attribut("givenName", "string"), attribut("familyName", "string")],
}),
attribut("emails", "complex", {
multiValued: true,
subAttributes: [
attribut("value", "string"),
attribut("type", "string"),
attribut("primary", "boolean"),
],
}),
],
meta: { resourceType: "Schema", location: `${zugang.basis}/Schemas/${SCHEMA_USER}` },
},
{
schemas: ["urn:ietf:params:scim:schemas:core:2.0:Schema"],
id: SCHEMA_ENTERPRISE,
name: "EnterpriseUser",
description: "Die Erweiterung, aus der Laufzettel die Abteilung und den Vorgesetzten liest.",
attributes: [
attribut("department", "string"),
/*
* `manager` gehoert hierher, auch wenn Laufzettel daraus nur `value`
* braucht: ein Anbieter liest dieses Schema und schickt nur, was darin
* steht. Fehlte es, bliebe der Vorgesetzte dauerhaft leer — und zwar
* ohne Fehlermeldung auf beiden Seiten.
*/
attribut("manager", "complex", {
subAttributes: [attribut("value", "string"), attribut("$ref", "reference")],
}),
],
meta: { resourceType: "Schema", location: `${zugang.basis}/Schemas/${SCHEMA_ENTERPRISE}` },
},
];
return scimAntwort({
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
totalResults: schemas.length,
itemsPerPage: schemas.length,
startIndex: 1,
Resources: schemas,
});
}
@@ -0,0 +1,45 @@
import { SCHEMA_PATCH, SCHEMA_USER } from "@/lib/scim";
import { quelleAusAnfrage, scimAntwort } from "@/server/scim/handler";
/**
* SCIM: `/ServiceProviderConfig` (#029).
*
* **Ohne diesen Endpunkt faengt Entra ID nicht an.** Er sagt, was der Anbieter
* erwarten darf — und das Entscheidende daran sind die **Verneinungen**: `bulk`,
* `sort` und `filter` stehen auf `false`, weil Laufzettel sie nicht kann. Ein
* Anbieter, der sie fuer unterstuetzt haelt, schickt Anfragen, die niemand
* beantwortet.
*
* `filter.supported: false` sieht dabei strenger aus, als es ist: der eine
* Filter `userName eq "…"` wird beantwortet. Ihn hier als unterstuetzt zu
* melden, hiesse aber, die ganze Filtersprache zu versprechen — und ein
* Anbieter, der das glaubt, schickt `emails[type eq "work"].value co "@"`.
*/
export async function GET(anfrage: Request): Promise<Response> {
// Auch dieser Endpunkt verlangt ein Token: er verraet, dass hier ein
// SCIM-Dienst laeuft, und das muss nicht jeder wissen.
const zugang = await quelleAusAnfrage(anfrage);
if ("antwort" in zugang) return zugang.antwort;
return scimAntwort({
schemas: ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
documentationUri: "https://git.wilde-server.de/florian.wilde/Laufzettel",
patch: { supported: true },
bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 },
filter: { supported: false, maxResults: 200 },
changePassword: { supported: false },
sort: { supported: false },
etag: { supported: false },
authenticationSchemes: [
{
type: "oauthbearertoken",
name: "OAuth Bearer Token",
description: "Ein Token je Verzeichnisquelle, in Laufzettel erzeugt und einmal angezeigt.",
primary: true,
},
],
meta: { resourceType: "ServiceProviderConfig", location: `${zugang.basis}/ServiceProviderConfig` },
// Nur zur Orientierung: welche Schemas dieser Dienst kennt.
...{ _schemas: [SCHEMA_USER, SCHEMA_PATCH] },
});
}
+130
View File
@@ -0,0 +1,130 @@
import { benutzerLesen, patchAnwenden, patchLesen, personDarstellen } from "@/lib/scim";
import { alsScimAntwort, quelleAusAnfrage, rumpfLesen, scimAntwort, scimFehler } from "@/server/scim/handler";
import { personAendern, personDeaktivieren, personHolen, type ScimFehler } from "@/server/services/scim";
/**
* SCIM: `/Users/{id}` (#029).
*
* Vier Verben, und das wichtigste ist `DELETE`: es **deaktiviert**. An einer
* Person haengen Laeufe und Geraete; geloescht wird in diesem Projekt nichts.
* Der Anbieter bekommt `204` und haelt sie damit fuer entfernt — aus seiner
* Sicht richtig, denn sie wird ihm nicht mehr zugewiesen.
*/
export async function GET(
anfrage: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const zugang = await quelleAusAnfrage(anfrage);
if ("antwort" in zugang) return zugang.antwort;
try {
const { id } = await params;
const person = await personHolen(zugang.quelle, id);
if (!person) return scimFehler(404, "Diese Person gibt es in dieser Quelle nicht.");
return scimAntwort(personDarstellen(person, zugang.basis));
} catch (fehler) {
return alsScimAntwort(fehler);
}
}
export async function PUT(
anfrage: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const zugang = await quelleAusAnfrage(anfrage);
if ("antwort" in zugang) return zugang.antwort;
const gelesen = await rumpfLesen(anfrage);
if ("antwort" in gelesen) return gelesen.antwort;
try {
const { id } = await params;
/*
* `PUT` ersetzt die Darstellung vollstaendig: ein Feld, das nicht mitkommt,
* wird geleert. Genau das steht im gelesenen Rumpf, deshalb geht er
* unveraendert an den Dienst.
*/
const person = await personAendern(zugang.quelle, id, benutzerLesen(gelesen.rumpf));
return scimAntwort(personDarstellen(person, zugang.basis));
} catch (fehler) {
return alsScimAntwort(fehler);
}
}
export async function PATCH(
anfrage: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const zugang = await quelleAusAnfrage(anfrage);
if ("antwort" in zugang) return zugang.antwort;
const gelesen = await rumpfLesen(anfrage);
if ("antwort" in gelesen) return gelesen.antwort;
try {
const { id } = await params;
const schritte = patchLesen(gelesen.rumpf);
if (!schritte) {
return scimFehler(
400,
"Der Rumpf ist keine PatchOp-Anfrage: erwartet wird „Operations“ mit op add, replace oder remove.",
"invalidSyntax",
);
}
const vorhanden = await personHolen(zugang.quelle, id);
if (!vorhanden) return scimFehler(404, "Diese Person gibt es in dieser Quelle nicht.");
/*
* Gepatcht wird auf dem **vorhandenen** Stand, nicht auf einer leeren
* Eingabe: ein `PATCH` auf `active` darf die uebrigen Felder nicht leeren.
* Was danach herauskommt, ist der vollstaendige Zielzustand — ein `null`
* darin ist eine Leerung und keine Luecke, und so liest der Dienst es auch.
*/
const { eingabe, uebergangen } = patchAnwenden(schritte, {
externeId: vorhanden.externeId,
upn: vorhanden.upn,
vorname: vorhanden.vorname,
nachname: vorhanden.nachname,
anzeigename: vorhanden.anzeigename,
mail: vorhanden.mail,
abteilung: vorhanden.abteilung,
titel: vorhanden.titel,
aktiv: vorhanden.aktiv,
vorgesetzterScimId: vorhanden.vorgesetzterId,
});
if (uebergangen.length > 0) {
/*
* Uebergangene Pfade sind kein Fehler — Anbieter schicken Felder, die
* Laufzettel nicht fuehrt. Ins Protokoll des Prozesses gehoeren sie
* trotzdem: sonst sucht beim naechsten „warum steht das nicht drin“
* jemand an der falschen Stelle.
*/
console.info(`SCIM-PATCH: übergangene Pfade: ${uebergangen.join(", ")}`);
}
const person = await personAendern(zugang.quelle, id, eingabe);
return scimAntwort(personDarstellen(person, zugang.basis));
} catch (fehler) {
return alsScimAntwort(fehler);
}
}
export async function DELETE(
anfrage: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const zugang = await quelleAusAnfrage(anfrage);
if ("antwort" in zugang) return zugang.antwort;
try {
const { id } = await params;
await personDeaktivieren(zugang.quelle, id);
// 204 ohne Rumpf – und die Zeile bleibt, siehe Kopfkommentar.
return new Response(null, { status: 204, headers: { "Cache-Control": "no-store" } });
} catch (fehler) {
return alsScimAntwort(fehler as ScimFehler);
}
}
+61
View File
@@ -0,0 +1,61 @@
import { benutzerLesen, benutzernameAusFilter, listeDarstellen, personDarstellen } from "@/lib/scim";
import { alsScimAntwort, quelleAusAnfrage, rumpfLesen, scimAntwort, scimFehler } from "@/server/scim/handler";
import { personAnlegen, personenSuchen } from "@/server/services/scim";
/**
* SCIM: `/Users` (#029).
*
* `GET` mit dem Filter `userName eq "…"` ist die Frage, die ein Anbieter **vor
* jedem Anlegen** stellt. `POST` legt an.
*
* Dieser Handler prueft sein Token selbst — die Huelle unter `(app)` liegt hier
* nicht darueber, und der Proxy laesst `/api/scim` ohne Cookie durch
* (CLAUDE.md).
*/
export async function GET(anfrage: Request): Promise<Response> {
const zugang = await quelleAusAnfrage(anfrage);
if ("antwort" in zugang) return zugang.antwort;
try {
const filter = new URL(anfrage.url).searchParams.get("filter");
const gesucht = benutzernameAusFilter(filter);
if (gesucht === null) {
/*
* Ein Filter, den wir nicht verstehen, wird **abgewiesen** und nicht
* ignoriert. Ihn zu ignorieren hiesse, alle Personen zu liefern — und der
* Anbieter schloesse daraus, die gesuchte sei darunter. Er legte sie dann
* nicht an.
*/
return scimFehler(
400,
`Von diesem Endpunkt wird nur der Filter userName eq "…" unterstützt, nicht „${filter}“.`,
"invalidFilter",
);
}
const personen = await personenSuchen(zugang.quelle, gesucht);
return scimAntwort(listeDarstellen(personen, zugang.basis));
} catch (fehler) {
return alsScimAntwort(fehler);
}
}
export async function POST(anfrage: Request): Promise<Response> {
const zugang = await quelleAusAnfrage(anfrage);
if ("antwort" in zugang) return zugang.antwort;
const gelesen = await rumpfLesen(anfrage);
if ("antwort" in gelesen) return gelesen.antwort;
try {
const person = await personAnlegen(zugang.quelle, benutzerLesen(gelesen.rumpf));
// 201 mit `Location`, wie RFC 7644 es verlangt.
return scimAntwort(personDarstellen(person, zugang.basis), 201, {
Location: `${zugang.basis}/Users/${person.id}`,
});
} catch (fehler) {
return alsScimAntwort(fehler);
}
}
+118 -53
View File
@@ -8,6 +8,7 @@ import {
GRAPH_ZUORDNUNG,
INTERVALL_HOECHSTENS,
INTERVALL_MINDESTENS,
QUELLEN_ART_TEXT,
ZUORDNUNGS_BESCHRIFTUNG,
ZUORDNUNGS_FELDER,
} from "@/lib/verzeichnis";
@@ -43,8 +44,16 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
* wuerde kein Mitarbeiter wiedererkannt. Der Dienst weist einen Wechsel
* ohnehin ab; hier gibt es ihn gar nicht erst zur Auswahl.
*/
const [art, setzeArt] = useState<"LDAP" | "GRAPH">(quelle?.art === "GRAPH" ? "GRAPH" : "LDAP");
const [art, setzeArt] = useState<"LDAP" | "GRAPH" | "SCIM">(quelle?.art ?? "LDAP");
const istGraph = art === "GRAPH";
/*
* SCIM ist die Art ohne Zugangsdaten: Laufzettel verbindet sich nicht, es wird
* beliefert (#029). Deshalb faellt hier alles weg, was zum Holen gehoert —
* Adresse, Geheimnis, Filter, Zeitplan und die Attributzuordnung. Letztere
* bestimmt bei einem Push der Anbieter, sie steht in RFC 7643.
*/
const istScim = art === "SCIM";
const istLdap = art === "LDAP";
return (
<form action={aktion} className="flex flex-col gap-4">
@@ -65,9 +74,9 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
<>
<input type="hidden" name="art" value={art} />
<p className="text-sm text-nebentext">
Art: <strong className="text-text">{istGraph ? "Microsoft Graph" : "LDAP"}</strong> — sie lässt
sich nicht ändern. Daran hängt, was die dauerhafte Kennung bedeutet; nach einem Wechsel würde kein
Mitarbeiter wiedererkannt.
Art: <strong className="text-text">{QUELLEN_ART_TEXT[art]}</strong> — sie lässt sich nicht ändern.
Daran hängt, was die dauerhafte Kennung bedeutet; nach einem Wechsel würde kein Mitarbeiter
wiedererkannt.
</p>
</>
) : (
@@ -85,7 +94,14 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
titel: "Microsoft Graph",
text: "Entra ID (Microsoft 365). Für Häuser ohne lokales AD.",
},
] satisfies readonly { wert: "LDAP" | "GRAPH"; titel: string; text: string }[]
{
wert: "SCIM" as const,
titel: "SCIM (wird geliefert)",
text:
"Der Anbieter schickt Änderungen, sobald sie dort entstehen — authentik, Entra ID, Okta. " +
"Laufzettel holt dabei nichts ab und braucht keine Zugangsdaten.",
},
] satisfies readonly { wert: "LDAP" | "GRAPH" | "SCIM"; titel: string; text: string }[]
).map((wahl) => (
<label
key={wahl.wert}
@@ -162,7 +178,7 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
</>
) : null}
{!istGraph ? (
{istLdap ? (
<>
{/*
* Die Erklärung steht **außerhalb** des `label` und hängt über
@@ -269,29 +285,59 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
</>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Abgleich alle … Minuten</span>
{istScim ? (
<>
{/*
* Das Intervall gilt fuer SCIM nicht — es wird nichts geholt. Es geht
* trotzdem mit, weil die Spalte einen Wert braucht; sichtbar waere es
* eine Einstellung, die nichts tut.
*/}
<input
type="hidden"
name="abgleichIntervallMinuten"
type="number"
min={INTERVALL_MINDESTENS}
max={INTERVALL_HOECHSTENS}
defaultValue={quelle?.abgleichIntervallMinuten ?? 60}
className={FELD}
value={quelle?.abgleichIntervallMinuten ?? 60}
/>
</label>
<label className="flex min-h-11 items-center gap-2 self-end text-sm text-nebentext md:min-h-10">
<input
type="checkbox"
name="aktiv"
value="1"
defaultChecked={quelle?.aktiv ?? false}
className={KAESTCHEN}
/>
Nach Zeitplan abgleichen
</label>
</div>
<label className="flex min-h-11 items-center gap-2 text-sm text-nebentext md:min-h-10">
<input
type="checkbox"
name="aktiv"
value="1"
defaultChecked={quelle?.aktiv ?? false}
className={KAESTCHEN}
/>
Zustellungen annehmen
</label>
<p className="max-w-prose text-xs text-nebentext">
Ohne Haken antwortet der Endpunkt mit <span className="mono">401</span>, auch mit gültigem Token —
so lässt sich eine Quelle stilllegen, ohne das Token beim Anbieter zu löschen. Das Token selbst
erzeugst du nach dem Speichern an der Quelle.
</p>
</>
) : (
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Abgleich alle … Minuten</span>
<input
name="abgleichIntervallMinuten"
type="number"
min={INTERVALL_MINDESTENS}
max={INTERVALL_HOECHSTENS}
defaultValue={quelle?.abgleichIntervallMinuten ?? 60}
className={FELD}
/>
</label>
<label className="flex min-h-11 items-center gap-2 self-end text-sm text-nebentext md:min-h-10">
<input
type="checkbox"
name="aktiv"
value="1"
defaultChecked={quelle?.aktiv ?? false}
className={KAESTCHEN}
/>
Nach Zeitplan abgleichen
</label>
</div>
)}
{/*
* Die Attributzuordnung ist einklappbar, aber vollständig da. Vorher stand
@@ -299,35 +345,54 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
* OpenLDAP anbindet, ändert einzelne Zeilen“, und genau das ging über die
* Oberfläche nicht (Reviewbefund zu #016).
*/}
<details open={zuordnungOffen} onToggle={(e) => setzeZuordnungOffen(e.currentTarget.open)}>
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Attributzuordnung anpassen
</summary>
<p className="mt-2 max-w-prose text-xs text-nebentext">
Die Vorgaben sind die Attributnamen eines Active Directory. Ein OpenLDAP heißt an einigen Stellen
anders — dort ist die dauerhafte Kennung etwa <span className="mono">entryUUID</span>.
{istScim ? (
<p className="max-w-prose text-xs text-nebentext">
Eine Attributzuordnung gibt es hier nicht: bei SCIM bestimmt der Anbieter die Feldnamen, und sie
stehen in RFC 7643 fest. Abteilung und Titel kommen aus der Erweiterung{" "}
<span className="mono">enterprise:2.0:User</span>; eine OU kennt SCIM nicht, deshalb bleibt{" "}
<span className="mono">{"{{ou}}"}</span> bei dieser Art leer.
</p>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
{ZUORDNUNGS_FELDER.map((feld) => (
<label key={feld} className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">{ZUORDNUNGS_BESCHRIFTUNG[feld]}</span>
<input
name={`attr.${feld}`}
defaultValue={quelle?.attributZuordnung[feld] ?? AD_ZUORDNUNG[feld]}
placeholder={(istGraph ? GRAPH_ZUORDNUNG : AD_ZUORDNUNG)[feld]}
className={`${FELD} mono`}
/>
</label>
))}
</div>
<p className="mt-2 max-w-prose text-xs text-nebentext">
Die Kennung ist das eine Feld, das später nicht mehr geändert werden sollte: an ihr erkennt der
Abgleich eine Person wieder. Ein anderer Wert legt beim nächsten Lauf alle neu an.
</p>
</details>
) : null}
{/* Ein eigenes CA-Zertifikat gilt nur fuer LDAP – Graph laeuft ueber oeffentliches TLS. */}
{!istGraph ? (
{/*
* Bei SCIM gar nicht gerendert und nicht nur verborgen: ein verstecktes
* Feld wird mitgeschickt, und dann staende in der Datenbank eine
* Zuordnung, die nie etwas bedeutet.
*/}
{istScim ? null : (
<details open={zuordnungOffen} onToggle={(e) => setzeZuordnungOffen(e.currentTarget.open)}>
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Attributzuordnung anpassen
</summary>
<p className="mt-2 max-w-prose text-xs text-nebentext">
Die Vorgaben sind die Attributnamen eines Active Directory. Ein OpenLDAP heißt an einigen Stellen
anders — dort ist die dauerhafte Kennung etwa <span className="mono">entryUUID</span>.
</p>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
{ZUORDNUNGS_FELDER.map((feld) => (
<label key={feld} className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">{ZUORDNUNGS_BESCHRIFTUNG[feld]}</span>
<input
name={`attr.${feld}`}
defaultValue={quelle?.attributZuordnung[feld] ?? AD_ZUORDNUNG[feld]}
placeholder={(istGraph ? GRAPH_ZUORDNUNG : AD_ZUORDNUNG)[feld]}
className={`${FELD} mono`}
/>
</label>
))}
</div>
<p className="mt-2 max-w-prose text-xs text-nebentext">
Die Kennung ist das eine Feld, das später nicht mehr geändert werden sollte: an ihr erkennt der
Abgleich eine Person wieder. Ein anderer Wert legt beim nächsten Lauf alle neu an.
</p>
</details>
)}
{/*
* Ein eigenes CA-Zertifikat gilt nur fuer LDAP – Graph laeuft ueber
* oeffentliches TLS, und bei SCIM baut Laufzettel keine Verbindung auf.
*/}
{istLdap ? (
<details open={zertifikatOffen} onToggle={(e) => setzeZertifikatOffen(e.currentTarget.open)}>
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Eigenes CA-Zertifikat hinterlegen
+181
View File
@@ -0,0 +1,181 @@
"use client";
import { useActionState, useState } from "react";
import { Check, Copy, KeyRound, ShieldAlert, Trash2 } from "lucide-react";
import {
scimTokenErzeugenAktion,
scimTokenWiderrufenAktion,
type TokenErgebnis,
} from "@/server/actions/verzeichnis";
/**
* Das SCIM-Token einer Quelle: erzeugen, einmal zeigen, widerrufen (#029).
*
* **Einmal zeigen ist die ganze Schwierigkeit.** Das Token steht in der
* Datenbank nur als argon2-Hash, es gibt also keinen zweiten Weg, es anzusehen —
* und das ist Absicht, denn es ist der einzige Mandantenbeweis, den eine
* Zustellung mitbringt. Wer es verliert, erzeugt ein neues; das alte gilt dann
* nicht mehr.
*
* Deshalb steht es hier vollständig und auswählbar da und nicht nur hinter einem
* Kopier-Knopf: ohne Recht auf die Zwischenablage wäre es sonst unerreichbar,
* und eine zweite Anzeige gibt es nicht.
*/
export function ScimToken({
quelleId,
vorhanden,
gesetztAm,
letzteZustellungAm,
}: {
quelleId: string;
vorhanden: boolean;
gesetztAm: Date | null;
letzteZustellungAm: Date | null;
}) {
const [erzeugt, erzeugen, erzeugtLaeuft] = useActionState<TokenErgebnis, FormData>(
scimTokenErzeugenAktion,
{},
);
const [widerrufen, widerrufenAktion, widerrufenLaeuft] = useActionState<TokenErgebnis, FormData>(
scimTokenWiderrufenAktion,
{},
);
const [kopiert, setzeKopiert] = useState(false);
const zeitpunkt = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
async function kopieren(wert: string) {
try {
await navigator.clipboard.writeText(wert);
setzeKopiert(true);
window.setTimeout(() => setzeKopiert(false), 2000);
} catch {
setzeKopiert(false);
}
}
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1 text-sm">
<p className="flex flex-wrap items-center gap-2">
<KeyRound className="size-4 shrink-0 text-akzent" strokeWidth={2} aria-hidden="true" />
{vorhanden ? (
<span className="text-nebentext">
Ein Token gilt{gesetztAm ? <> seit {zeitpunkt.format(gesetztAm)}</> : null}.
</span>
) : (
<span className="text-nebentext">
Kein Token. Ohne eines antwortet der Endpunkt auf jede Zustellung mit{" "}
<span className="mono">401</span>.
</span>
)}
</p>
{/*
* „Letzte Zustellung“ sitzt hier, wo bei LDAP „Verbindung testen“ steht —
* es ist die Auskunft, die an deren Stelle tritt. Bei einem Push gibt es
* nichts zu testen: ob die Einrichtung stimmt, zeigt erst die erste
* Zustellung.
*/}
<p className="text-nebentext">
{letzteZustellungAm ? (
<>Letzte Zustellung am {zeitpunkt.format(letzteZustellungAm)}.</>
) : (
<>
Noch keine Zustellung angekommen. Trag das Token beim Anbieter ein und weise dort eine Person zu
— danach steht hier der Zeitpunkt.
</>
)}
</p>
</div>
{/*
* Das Token steht nur in dieser einen Antwort. Ein Neuladen der Seite
* nimmt es mit, und es kommt nicht wieder — deshalb der Hinweis daneben
* und nicht darunter.
*/}
{erzeugt.token ? (
<div className="flex flex-col gap-2 rounded-karte border border-akzent bg-akzent-flaeche p-3">
<p className="flex items-start gap-2 text-sm text-text">
<ShieldAlert className="mt-0.5 size-4 shrink-0 text-akzent" strokeWidth={2} aria-hidden="true" />
<span>
Kopier es jetzt. Es ist nur hier zu sehen — gespeichert wird es als Hash, und ein zweites
Ansehen gibt es nicht.
</span>
</p>
<code className="mono rounded-karte bg-grund p-3 text-xs break-all text-text select-all">
{erzeugt.token}
</code>
<button
type="button"
onClick={() => void kopieren(erzeugt.token ?? "")}
className="flex min-h-11 w-fit items-center gap-2 rounded-pille border border-linie px-4 text-sm text-text md:min-h-10"
>
{kopiert ? (
<Check className="size-[18px] text-erledigt" strokeWidth={2} aria-hidden="true" />
) : (
<Copy className="size-[18px]" strokeWidth={2} aria-hidden="true" />
)}
{kopiert ? "Kopiert" : "Token kopieren"}
</button>
</div>
) : null}
<div className="flex flex-wrap gap-2">
<form action={erzeugen}>
<input type="hidden" name="id" value={quelleId} />
<button
type="submit"
disabled={erzeugtLaeuft}
className="flex min-h-11 w-fit items-center gap-2 rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
<KeyRound className="size-[18px]" strokeWidth={2} aria-hidden="true" />
{erzeugtLaeuft ? "Wird erzeugt …" : vorhanden ? "Neues Token erzeugen" : "Token erzeugen"}
</button>
</form>
{vorhanden ? (
<form action={widerrufenAktion}>
<input type="hidden" name="id" value={quelleId} />
<button
type="submit"
disabled={widerrufenLaeuft}
className="flex min-h-11 w-fit items-center gap-2 rounded-pille border border-linie px-5 text-sm text-nebentext hover:text-text disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
<Trash2 className="size-[18px]" strokeWidth={2} aria-hidden="true" />
{widerrufenLaeuft ? "Wird widerrufen …" : "Token widerrufen"}
</button>
</form>
) : null}
</div>
{vorhanden ? (
<p className="max-w-prose text-xs text-nebentext">
Ein neues Token ersetzt das bestehende: es gibt genau eines je Quelle. Bis es beim Anbieter
eingetragen ist, kommen dessen Zustellungen nicht an — und werden dort als Fehler vermerkt, nicht
verworfen.
</p>
) : null}
{[erzeugt, widerrufen].map((ergebnis, nummer) => (
<div key={nummer}>
{ergebnis.fehler ? (
<p role="alert" className="max-w-prose text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg ? (
<p role="status" className="max-w-prose text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
</div>
))}
</div>
);
}
+324
View File
@@ -0,0 +1,324 @@
/**
* SCIM 2.0: Formen und Zuordnung (#029).
*
* Framework-frei, weil hier zwei Dinge zusammenkommen, die beide einzeln
* prüfbar sein müssen: **was ein Anbieter schickt** und **was RFC 7644 als
* Antwort verlangt**. Beides ist reine Textarbeit; ein Test dafür braucht keinen
* Server und keine Datenbank.
*
* **Umgesetzt ist nicht der ganze Standard, sondern was die Anbieter schicken.**
* authentik, Entra ID und Okta benutzen einen kleinen, gut dokumentierten
* Ausschnitt. Der Rest — komplexe Filter, Sortierung, `bulk` — wäre Code ohne
* Aufrufer, und ungenutzter Code ist ungeprüfter Code.
*/
export const SCHEMA_USER = "urn:ietf:params:scim:schemas:core:2.0:User";
export const SCHEMA_ENTERPRISE = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User";
export const SCHEMA_LIST = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
export const SCHEMA_ERROR = "urn:ietf:params:scim:api:messages:2.0:Error";
export const SCHEMA_PATCH = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
/** Der Medientyp, den SCIM verlangt. Nicht `application/json`. */
export const SCIM_TYP = "application/scim+json";
/**
* Eine Person, wie Laufzettel sie nach außen zeigt.
*
* Bewusst **nicht** die ganze `Mitarbeiter`-Zeile: ein Identitätsanbieter hat
* die Daten selbst geschickt, er braucht sie nicht zurück, und was hier steht,
* steht auch in jedem Protokoll des Anbieters.
*/
export type ScimPerson = {
readonly id: string;
readonly externeId: string | null;
readonly upn: string | null;
readonly vorname: string;
readonly nachname: string;
readonly anzeigename: string;
readonly mail: string | null;
readonly abteilung: string | null;
readonly titel: string | null;
readonly aktiv: boolean;
/** Die Laufzettel-Kennung des Vorgesetzten, wenn er bekannt ist. */
readonly vorgesetzterId: string | null;
readonly erstelltAm: Date;
readonly geaendertAm: Date;
};
/** Was aus einer eingehenden Darstellung gelesen wird. Mehr nimmt Laufzettel nicht. */
export type ScimEingabe = {
readonly externeId: string | null;
readonly upn: string | null;
readonly vorname: string | null;
readonly nachname: string | null;
readonly anzeigename: string | null;
readonly mail: string | null;
readonly abteilung: string | null;
readonly titel: string | null;
readonly aktiv: boolean | null;
/**
* Der Vorgesetzte, wie der Anbieter ihn nennt: die `id`, die Laufzettel ihm
* beim Anlegen zurueckgegeben hat — **nicht** die Kennung im Verzeichnis des
* Anbieters. Sie aufzuloesen ist Sache des Dienstes, denn sie kann auf
* jemanden zeigen, der noch nicht zugewiesen wurde.
*/
readonly vorgesetzterScimId: string | null;
};
function text(wert: unknown): string | null {
return typeof wert === "string" && wert.trim() ? wert.trim() : null;
}
/**
* Die Kennung des Vorgesetzten aus `manager`.
*
* Drei Formen kommen vor, und alle drei stehen so in Mitschnitten der Anbieter:
* `{ value: "…" }` (RFC 7643), `{ $ref: "…/Users/…" }` und die nackte
* Zeichenkette. Die letzte ist nicht standardkonform, aber authentik schickt sie
* bei manchen Zuordnungen — und ein Vorgesetzter, der daran scheitert, fehlt
* stillschweigend.
*/
export function vorgesetzterAus(wert: unknown): string | null {
if (typeof wert === "string") return text(wert);
if (typeof wert !== "object" || wert === null) return null;
const eintrag = wert as Record<string, unknown>;
const direkt = text(eintrag["value"]);
if (direkt) return direkt;
// Aus `$ref` das letzte Pfadstueck: `https://…/Users/abc` → `abc`.
const verweis = text(eintrag["$ref"]);
return verweis ? (text(verweis.split("/").pop()) ?? null) : null;
}
/**
* Die primäre Mailadresse aus `emails`.
*
* `primary: true` gewinnt; ohne Kennzeichnung die erste. Das ist die Reihenfolge
* aus RFC 7643 — und die Anbieter halten sich unterschiedlich daran: authentik
* setzt `primary`, Entra ID schickt oft nur einen Eintrag ohne das Feld.
*/
export function mailAus(wert: unknown): string | null {
if (!Array.isArray(wert)) return null;
const eintraege = wert.filter((e): e is Record<string, unknown> => typeof e === "object" && e !== null);
const primaer = eintraege.find((e) => e["primary"] === true);
return text((primaer ?? eintraege[0])?.["value"]);
}
/**
* Liest eine eingehende Benutzer-Darstellung.
*
* Fehlende Felder werden `null` und **nicht** zu leeren Zeichenketten: der
* Unterschied entscheidet beim Aktualisieren darüber, ob ein Wert gelöscht oder
* nicht mitgeschickt wurde.
*/
export function benutzerLesen(rumpf: unknown): ScimEingabe {
const daten = (typeof rumpf === "object" && rumpf !== null ? rumpf : {}) as Record<string, unknown>;
const name = (typeof daten["name"] === "object" && daten["name"] !== null ? daten["name"] : {}) as Record<
string,
unknown
>;
const erweiterung = (
typeof daten[SCHEMA_ENTERPRISE] === "object" && daten[SCHEMA_ENTERPRISE] !== null
? daten[SCHEMA_ENTERPRISE]
: {}
) as Record<string, unknown>;
return {
externeId: text(daten["externalId"]),
upn: text(daten["userName"]),
vorname: text(name["givenName"]),
nachname: text(name["familyName"]),
anzeigename: text(daten["displayName"]),
mail: mailAus(daten["emails"]),
abteilung: text(erweiterung["department"]),
titel: text(daten["title"]) ?? text(erweiterung["jobTitle"]),
/*
* `active` fehlt bei `POST` manchmal. `null` heisst „nicht mitgeschickt“ —
* der Dienst legt dann aktiv an, aendert aber beim Aktualisieren nichts.
*/
aktiv: typeof daten["active"] === "boolean" ? daten["active"] : null,
vorgesetzterScimId: vorgesetzterAus(erweiterung["manager"]),
};
}
/**
* Eine Person als SCIM-Darstellung.
*
* `meta.location` ist Pflicht nach RFC 7644 und nicht Zierde: manche Anbieter
* folgen ihr, statt die Adresse selbst zu bauen.
*/
export function personDarstellen(person: ScimPerson, basis: string): Record<string, unknown> {
return {
schemas: [SCHEMA_USER, SCHEMA_ENTERPRISE],
id: person.id,
...(person.externeId ? { externalId: person.externeId } : {}),
userName: person.upn ?? person.id,
name: { givenName: person.vorname, familyName: person.nachname },
displayName: person.anzeigename,
...(person.mail ? { emails: [{ value: person.mail, primary: true, type: "work" }] } : {}),
...(person.titel ? { title: person.titel } : {}),
active: person.aktiv,
[SCHEMA_ENTERPRISE]: {
...(person.abteilung ? { department: person.abteilung } : {}),
...(person.vorgesetzterId
? { manager: { value: person.vorgesetzterId, $ref: `${basis}/Users/${person.vorgesetzterId}` } }
: {}),
},
meta: {
resourceType: "User",
created: person.erstelltAm.toISOString(),
lastModified: person.geaendertAm.toISOString(),
location: `${basis}/Users/${person.id}`,
},
};
}
/** Eine Liste nach RFC 7644. `startIndex` ist **1**-basiert, nicht 0. */
export function listeDarstellen(
personen: readonly ScimPerson[],
basis: string,
gesamt = personen.length,
startIndex = 1,
): Record<string, unknown> {
return {
schemas: [SCHEMA_LIST],
totalResults: gesamt,
itemsPerPage: personen.length,
startIndex,
Resources: personen.map((person) => personDarstellen(person, basis)),
};
}
/** Ein Fehler nach RFC 7644. `status` ist dort eine **Zeichenkette**. */
export function fehlerDarstellen(status: number, detail: string, scimType?: string): Record<string, unknown> {
return {
schemas: [SCHEMA_ERROR],
status: String(status),
...(scimType ? { scimType } : {}),
detail,
};
}
/**
* Liest den Filter `userName eq "wert"`.
*
* **Nur dieser eine Filter**, und das ist Absicht: er ist der einzige, den die
* Anbieter benutzen — sie fragen damit vor jedem Anlegen, ob die Person schon da
* ist. Einen allgemeinen Filter-Parser zu bauen hieße, eine kleine Abfragesprache
* zu stützen, die niemand schickt.
*
* `undefined`, wenn kein Filter dasteht; `null`, wenn einer dasteht, den wir
* nicht verstehen — der Aufrufer antwortet dann mit `400` statt stillschweigend
* alles zu liefern. Alles zu liefern wäre die gefährlichere Antwort: der Anbieter
* schlösse daraus, die Person gäbe es schon.
*/
export function benutzernameAusFilter(filter: string | null): string | undefined | null {
if (!filter?.trim()) return undefined;
const treffer = /^\s*userName\s+eq\s+"([^"]*)"\s*$/iu.exec(filter);
return treffer?.[1] ?? null;
}
export type PatchSchritt = {
readonly op: "add" | "replace" | "remove";
readonly path: string | undefined;
readonly value: unknown;
};
/**
* Liest eine `PatchOp`-Anfrage.
*
* `op` ist nach RFC unabhängig von Groß- und Kleinschreibung, und die Anbieter
* nutzen das aus: authentik schickt `replace`, Entra ID `Replace`.
*/
export function patchLesen(rumpf: unknown): readonly PatchSchritt[] | null {
const daten = (typeof rumpf === "object" && rumpf !== null ? rumpf : {}) as Record<string, unknown>;
const rohe = daten["Operations"] ?? daten["operations"];
if (!Array.isArray(rohe)) return null;
const schritte: PatchSchritt[] = [];
for (const roh of rohe) {
if (typeof roh !== "object" || roh === null) return null;
const eintrag = roh as Record<string, unknown>;
const op = text(eintrag["op"])?.toLowerCase();
if (op !== "add" && op !== "replace" && op !== "remove") return null;
schritte.push({ op, path: text(eintrag["path"]) ?? undefined, value: eintrag["value"] });
}
return schritte;
}
/**
* Wendet die Patch-Schritte auf eine Eingabe an.
*
* **Die Pfade, die die Anbieter wirklich schicken** — nicht der ganze Standard.
* Der wichtigste ist `active`: so deaktiviert Entra ID ein Konto, und so
* aktiviert es es wieder.
*
* Ein Schritt ohne `path` trägt ein ganzes Objekt in `value` (so macht es
* authentik beim `replace`); dann wird es wie ein `PUT`-Rumpf gelesen.
*
* Ein unbekannter Pfad wird **übergangen** und nicht als Fehler gemeldet. Das
* ist die Stelle, an der Nachsicht richtig ist: Anbieter schicken Felder, die
* Laufzettel nicht führt (`preferredLanguage`, `addresses`), und ein `400`
* darauf brächte die ganze Zuweisung zum Stehen — wegen eines Feldes, das
* niemand braucht.
*/
export function patchAnwenden(
schritte: readonly PatchSchritt[],
vorher: ScimEingabe,
): { readonly eingabe: ScimEingabe; readonly uebergangen: readonly string[] } {
let eingabe = { ...vorher };
const uebergangen: string[] = [];
for (const schritt of schritte) {
if (!schritt.path) {
// Ganzes Objekt: wie ein PUT-Rumpf lesen, aber nur die gesetzten Felder.
const gelesen = benutzerLesen(schritt.value);
eingabe = {
externeId: gelesen.externeId ?? eingabe.externeId,
upn: gelesen.upn ?? eingabe.upn,
vorname: gelesen.vorname ?? eingabe.vorname,
nachname: gelesen.nachname ?? eingabe.nachname,
anzeigename: gelesen.anzeigename ?? eingabe.anzeigename,
mail: gelesen.mail ?? eingabe.mail,
abteilung: gelesen.abteilung ?? eingabe.abteilung,
titel: gelesen.titel ?? eingabe.titel,
aktiv: gelesen.aktiv ?? eingabe.aktiv,
vorgesetzterScimId: gelesen.vorgesetzterScimId ?? eingabe.vorgesetzterScimId,
};
continue;
}
const pfad = schritt.path.toLowerCase();
const entfernen = schritt.op === "remove";
const wert = entfernen ? null : schritt.value;
if (pfad === "active") {
eingabe = {
...eingabe,
aktiv: entfernen ? false : wert === true || wert === "True" || wert === "true",
};
} else if (pfad === "username") {
eingabe = { ...eingabe, upn: text(wert) };
} else if (pfad === "displayname") {
eingabe = { ...eingabe, anzeigename: text(wert) };
} else if (pfad === "name.givenname") {
eingabe = { ...eingabe, vorname: text(wert) };
} else if (pfad === "name.familyname") {
eingabe = { ...eingabe, nachname: text(wert) };
} else if (pfad === "title") {
eingabe = { ...eingabe, titel: text(wert) };
} else if (pfad === "externalid") {
eingabe = { ...eingabe, externeId: text(wert) };
} else if (pfad.startsWith("emails")) {
// `emails[type eq "work"].value` und `emails` – beide kommen vor.
eingabe = { ...eingabe, mail: text(wert) ?? mailAus(wert) };
} else if (pfad.endsWith("manager") || pfad.endsWith("manager.value")) {
eingabe = { ...eingabe, vorgesetzterScimId: entfernen ? null : vorgesetzterAus(wert) };
} else if (pfad.endsWith("department")) {
eingabe = { ...eingabe, abteilung: text(wert) };
} else {
uebergangen.push(schritt.path);
}
}
return { eingabe, uebergangen };
}
+14
View File
@@ -118,6 +118,20 @@ export const ZUORDNUNGS_BESCHRIFTUNG: Record<keyof AttributZuordnung, string> =
*/
export const ATTRIBUT_MUSTER = /^[A-Za-z][A-Za-z0-9-]*$/u;
/**
* Wie eine Quellenart heisst, wo ein Mensch sie liest.
*
* Liegt in `src/lib`, weil beide Seiten sie brauchen: das Formular ist eine
* Client-Komponente, und die darf nichts aus `src/server/*` importieren
* (CLAUDE.md). „LDAP“ und „SCIM“ bleiben als Lehnwoerter stehen — so heissen sie
* im Haus und in den Einstellungen der Anbieter.
*/
export const QUELLEN_ART_TEXT: Record<"LDAP" | "GRAPH" | "SCIM", string> = {
LDAP: "LDAP",
GRAPH: "Microsoft Graph",
SCIM: "SCIM",
};
/**
* Liest eine gespeicherte Zuordnung aus dem Json-Feld.
*
+8
View File
@@ -25,6 +25,14 @@ const OEFFENTLICH: readonly string[] = [
"/api/auth",
// Healthcheck fuer Docker und den Reverse Proxy.
"/api/gesundheit",
/*
* Der SCIM-Endpunkt (#029). Ein Identitaetsanbieter hat kein Sitzungscookie —
* er weist sich mit einem Bearer-Token aus, und **jeder** Handler darunter
* prueft es selbst, samt der Quelle und damit des Kunden. Ohne diesen Eintrag
* bekaeme der Anbieter 401 vom Proxy, bevor sein Token ueberhaupt gelesen
* wird — und die Meldung waere „nicht angemeldet“ statt „Token ungueltig“.
*/
"/api/scim",
"/manifest.webmanifest",
"/icon.svg",
];
+74 -7
View File
@@ -14,6 +14,7 @@ import { z } from "@/lib/validierung";
import { MandantFehler, type HandelnderBenutzer } from "@/server/mandant";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { abgleichAusfuehren, type AbgleichErgebnis } from "@/server/services/abgleich";
import { scimTokenErzeugen, scimTokenWiderrufen } from "@/server/services/scim";
import {
VerzeichnisFehler,
quelleAnlegen,
@@ -95,8 +96,8 @@ function zuordnungAusFormular(formular: FormData) {
*/
const GemeinsamSchema = z.object({
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
art: z.enum(["LDAP", "GRAPH"], {
message: "Bitte die Art der Quelle wählen: LDAP oder Microsoft Graph.",
art: z.enum(["LDAP", "GRAPH", "SCIM"], {
message: "Bitte die Art der Quelle wählen: LDAP, Microsoft Graph oder SCIM.",
}),
caZertifikatPem: z.string().trim().optional(),
// Dieselben Grenzen wie im Dienst, aus derselben Quelle.
@@ -130,9 +131,16 @@ const GraphSchema = z.object({
/** Die artabhaengigen Felder aus dem Formular, geprueft. */
function artFelder(
art: "LDAP" | "GRAPH",
art: "LDAP" | "GRAPH" | "SCIM",
formular: FormData,
): { readonly werte: Record<string, unknown> } | { readonly fehler: string } {
/*
* **SCIM hat keine.** Eine SCIM-Quelle braucht einen Namen und sonst nichts:
* Laufzettel verbindet sich nicht, es wird beliefert, und das Token entsteht
* erst danach (#029).
*/
if (art === "SCIM") return { werte: {} };
if (art === "LDAP") {
const gelesen = LdapSchema.safeParse({
url: text(formular, "url"),
@@ -183,9 +191,11 @@ export async function quelleAnlegenAktion(
const geheimnis =
eingabe.data.art === "LDAP"
? { bindPasswort: text(formular, "bindPasswort") }
: { clientSecret: text(formular, "clientSecret") };
: eingabe.data.art === "GRAPH"
? { clientSecret: text(formular, "clientSecret") }
: {};
const geheimnisWert = Object.values(geheimnis)[0];
if (!geheimnisWert) {
if (eingabe.data.art !== "SCIM" && !geheimnisWert) {
return {
fehler:
eingabe.data.art === "LDAP"
@@ -203,7 +213,12 @@ export async function quelleAnlegenAktion(
zugang.benutzer,
);
revalidatePath("/admin/verzeichnis");
return { erfolg: `„${quelle.name}“ ist angelegt. Prüf als Nächstes die Verbindung.` };
return {
erfolg:
eingabe.data.art === "SCIM"
? `„${quelle.name}“ ist angelegt. Erzeug als Nächstes das Token und trag es beim Anbieter ein.`
: `„${quelle.name}“ ist angelegt. Prüf als Nächstes die Verbindung.`,
};
} catch (fehler) {
return alsErgebnis(fehler, "Die Quelle konnte nicht angelegt werden.");
}
@@ -300,6 +315,58 @@ export async function quelleLoeschenAktion(
}
}
export type TokenErgebnis = AktionsErgebnis & {
/**
* Das Token – **genau dieses eine Mal**.
*
* Es steht in der Datenbank nur als argon2-Hash, es gibt also keinen zweiten
* Weg, es anzusehen. Wer es verliert, erzeugt ein neues; das alte gilt dann
* nicht mehr.
*/
readonly token?: string;
};
export async function scimTokenErzeugenAktion(
_vorher: TokenErgebnis,
formular: FormData,
): Promise<TokenErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const id = text(formular, "id");
if (!id) return { fehler: "Es fehlt die Quelle." };
try {
const { token } = await scimTokenErzeugen(id, zugang.benutzer);
revalidatePath("/admin/verzeichnis");
return {
token,
erfolg:
"Das Token gilt ab jetzt. Kopier es in die Einstellungen des Anbieters — danach ist es " +
"nicht mehr ansehbar.",
};
} catch (fehler) {
return alsErgebnis(fehler, "Das Token konnte nicht erzeugt werden.");
}
}
export async function scimTokenWiderrufenAktion(
_vorher: TokenErgebnis,
formular: FormData,
): Promise<TokenErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const id = text(formular, "id");
if (!id) return { fehler: "Es fehlt die Quelle." };
try {
await scimTokenWiderrufen(id, zugang.benutzer);
revalidatePath("/admin/verzeichnis");
return { erfolg: "Das Token ist widerrufen. Die Quelle nimmt ab jetzt nichts mehr an." };
} catch (fehler) {
return alsErgebnis(fehler, "Das Token konnte nicht widerrufen werden.");
}
}
export type TestErgebnis = AktionsErgebnis & {
readonly treffer?: readonly GemappterEintrag[];
readonly gesamtGelesen?: number;
@@ -326,7 +393,7 @@ export async function verbindungTestenAktion(
const ergebnis = await verbindungTesten(id, zugang.benutzer);
return {
erfolg:
ergebnis.treffer.length === 0
ergebnis.treffer.length === 0 && ergebnis.gesamtGelesen === 0
? "Die Verbindung steht, aber der Filter findet niemanden."
: ergebnis.treffer.length === 0
? /*
+115
View File
@@ -0,0 +1,115 @@
import type { Verzeichnisquelle } from "@/generated/prisma/client";
import { oeffentlicheAdresse } from "@/lib/adressen";
import { SCIM_TYP, fehlerDarstellen } from "@/lib/scim";
import { ScimFehler, quelleZuToken } from "@/server/services/scim";
/**
* Was jeder SCIM-Handler gemeinsam braucht (#029).
*
* **Jeder Handler prüft selbst** — die Hülle unter `(app)` liegt hier nicht
* darüber, und der Proxy lässt `/api/scim` absichtlich ohne Cookie durch
* (CLAUDE.md). Diese Datei ist die eine Stelle, an der das Token zur Quelle
* wird; jeder Handler beginnt mit `quelleAusAnfrage`.
*/
/** Jede Antwort trägt `application/scim+json` — nicht `application/json`. */
export function scimAntwort(daten: unknown, status = 200, kopfzeilen: HeadersInit = {}): Response {
return new Response(JSON.stringify(daten), {
status,
headers: {
"Content-Type": SCIM_TYP,
// Nichts zwischenspeichern: eine Zuweisung ist kein Inhalt, der altert.
"Cache-Control": "no-store",
...kopfzeilen,
},
});
}
export function scimFehler(status: number, detail: string, scimType?: string): Response {
const kopfzeilen: HeadersInit =
status === 401
? /*
* `WWW-Authenticate` bei 401, weil RFC 7235 es verlangt und manche
* Anbieter daran erkennen, dass ein Token erwartet wird — ohne die
* Kopfzeile melden sie „unbekannter Fehler“ statt „Token fehlt“.
*/
{ "WWW-Authenticate": 'Bearer realm="Laufzettel SCIM"' }
: {};
return scimAntwort(fehlerDarstellen(status, detail, scimType), status, kopfzeilen);
}
/** Aus einem geworfenen Fehler eine SCIM-Antwort. */
export function alsScimAntwort(fehler: unknown): Response {
if (fehler instanceof ScimFehler) {
return scimFehler(fehler.status, fehler.message, fehler.scimType);
}
/*
* Alles andere ist ein Fehler bei uns, nicht beim Anbieter. Die Meldung geht
* ins Protokoll des Prozesses und **nicht** in die Antwort: eine
* Datenbankmeldung nennt Host, Port und Benutzernamen (#039).
*/
console.error(
"SCIM-Anfrage fehlgeschlagen:",
fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : String(fehler),
);
return scimFehler(500, "Unerwarteter Fehler. Einzelheiten stehen im Protokoll des Dienstes.");
}
/**
* Das Bearer-Token aus der Anfrage.
*
* Verglichen wird das Schema ohne Rücksicht auf Groß- und Kleinschreibung: RFC
* 7235 schreibt das so vor, und die Anbieter schicken `Bearer` wie `bearer`.
*/
export function tokenAus(anfrage: Request): string | undefined {
const kopf = anfrage.headers.get("authorization")?.trim();
if (!kopf) return undefined;
const trenner = kopf.indexOf(" ");
if (trenner < 0) return undefined;
if (kopf.slice(0, trenner).toLowerCase() !== "bearer") return undefined;
return kopf.slice(trenner + 1).trim() || undefined;
}
export type ScimZugang =
{ readonly quelle: Verzeichnisquelle; readonly basis: string } | { readonly antwort: Response };
/**
* Die Quelle zu dieser Anfrage – oder eine fertige 401.
*
* **Dieselbe Antwort für „kein Token“ und „falsches Token“.** Die
* Unterscheidung wäre eine Auskunft darüber, ob ein geratenes Token die richtige
* Form hat.
*/
export async function quelleAusAnfrage(anfrage: Request): Promise<ScimZugang> {
const token = tokenAus(anfrage);
if (!token) {
return { antwort: scimFehler(401, "Dieser Endpunkt verlangt ein Bearer-Token.") };
}
const quelle = await quelleZuToken(token);
if (!quelle) {
return { antwort: scimFehler(401, "Das Token gilt nicht (mehr).") };
}
/*
* `meta.location` muss eine **absolute** Adresse sein. Gebaut wird sie über
* `oeffentlicheAdresse`, damit hinter einem Reverse Proxy die öffentliche
* Adresse herauskommt und nicht `127.0.0.1:3000` — ein Anbieter, der dieser
* Adresse folgt, landete sonst bei sich selbst.
*/
const basis = oeffentlicheAdresse("/api/scim/v2", new URL(anfrage.url).origin)
.toString()
.replace(/\/+$/, "");
return { quelle, basis };
}
/** Den Rumpf als JSON lesen – oder eine fertige 400. */
export async function rumpfLesen(
anfrage: Request,
): Promise<{ readonly rumpf: unknown } | { readonly antwort: Response }> {
try {
return { rumpf: await anfrage.json() };
} catch {
return { antwort: scimFehler(400, "Der Rumpf ist kein gültiges JSON.", "invalidSyntax") };
}
}
+8
View File
@@ -105,11 +105,19 @@ export async function faelligeQuellen(
* `psql` und eine Migration schon.
*/
const eigene = await eigeneOrganisation();
/*
* **Eine SCIM-Quelle wird nie abgeglichen** (#029). Bei ihr holt Laufzettel
* nicht, sie bekommt geschickt — und der Zeitplan haette jede Minute versucht
* zu lesen, waere an der Weiche gescheitert und haette „kann noch nicht
* abgeglichen werden“ an die Quelle geschrieben. Dauerhaft ein Fehler, der
* keiner ist, und `letzterAbgleichFehler` ist in der Oberflaeche sichtbar.
*/
return prisma.$queryRaw<{ id: string; name: string }[]>`
SELECT "id", "name"
FROM "Verzeichnisquelle"
WHERE "aktiv" = true
AND "kundeId" = ${eigene.id}
AND "art" <> 'SCIM'
AND ("letzterAbgleichAm" IS NULL
OR "letzterAbgleichAm" + make_interval(mins => "abgleichIntervallMinuten") <= ${jetzt})
ORDER BY "letzterAbgleichAm" ASC NULLS FIRST, "name" ASC
+478
View File
@@ -0,0 +1,478 @@
import { randomBytes, timingSafeEqual } from "node:crypto";
import { hash, verify } from "@node-rs/argon2";
import type { Mitarbeiter, Prisma, Verzeichnisquelle } from "@/generated/prisma/client";
import { Herkunft } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import type { ScimEingabe, ScimPerson } from "@/lib/scim";
import { darf } from "@/server/auth/rollen";
import { eigeneOrganisation, type HandelnderBenutzer } from "@/server/mandant";
import { VerzeichnisFehler } from "@/server/ldap/fehler";
/**
* SCIM: Token und Schreiboperationen (#029).
*
* Der Abgleich holt, SCIM bekommt — aber **danach gelten dieselben Regeln**:
* anlegen, übernehmen, aktualisieren, deaktivieren, **nie löschen**. Ein
* `DELETE` vom Anbieter setzt `aktiv = false`; die Zeile bleibt, weil Läufe und
* Geräte an ihr hängen.
*
* **Das Token ist der einzige Mandantenbeweis, den ein Push mitbringt.** Es
* identifiziert die Quelle, und die Quelle den Kunden. Deshalb steht es als
* argon2-Hash in der Datenbank und wird genau einmal angezeigt.
*/
export class ScimFehler extends Error {
readonly status: number;
readonly scimType: string | undefined;
constructor(status: number, meldung: string, scimType?: string) {
super(meldung);
this.name = "ScimFehler";
this.status = status;
this.scimType = scimType;
}
}
/* ------------------------------------------------------------------ */
/* Token */
/* ------------------------------------------------------------------ */
/**
* Wie lang ein Token ist.
*
* 32 Byte aus `randomBytes`, base64url — 43 Zeichen. Das ist weit jenseits
* dessen, was sich raten lässt, und kurz genug, dass es in ein Formularfeld
* eines Anbieters passt, ohne umzubrechen.
*/
const TOKEN_BYTES = 32;
/** Das Präfix macht ein gefundenes Token erkennbar — in einem Log, in einer Zwischenablage. */
export const TOKEN_PRAEFIX = "lzscim_";
export function tokenErzeugen(): string {
return `${TOKEN_PRAEFIX}${randomBytes(TOKEN_BYTES).toString("base64url")}`;
}
function rechtPruefen(benutzer: HandelnderBenutzer): void {
if (!darf(benutzer.rolle, "verwaltung")) {
throw new VerzeichnisFehler(
`Ein SCIM-Token zu verwalten verlangt das Recht "verwaltung"; die Rolle ${benutzer.rolle} hat es nicht.`,
);
}
}
/**
* Erzeugt ein neues Token für eine SCIM-Quelle und gibt es **einmal** zurück.
*
* Ein vorhandenes wird dabei ersetzt — es gibt genau eines je Quelle. Das ist
* eine Entscheidung gegen Bequemlichkeit: zwei gültige Token wären zwei Dinge,
* die man widerrufen muss, und eines davon vergisst man.
*/
export async function scimTokenErzeugen(
quelleId: string,
benutzer: HandelnderBenutzer,
): Promise<{ readonly token: string }> {
rechtPruefen(benutzer);
const quelle = await prisma.verzeichnisquelle.findUnique({ where: { id: quelleId } });
if (!quelle) throw new VerzeichnisFehler("Diese Verzeichnisquelle gibt es nicht (mehr).");
const eigene = await eigeneOrganisation();
if (quelle.kundeId !== eigene.id) {
throw new VerzeichnisFehler(
`„${quelle.name}“ gehört nicht zur eigenen Organisation. Der Verzeichnisabgleich gilt nur für sie.`,
);
}
if (quelle.art !== "SCIM") {
throw new VerzeichnisFehler(
`„${quelle.name}“ ist eine Quelle der Art ${quelle.art}. Ein SCIM-Token gilt nur für die Art SCIM.`,
);
}
const token = tokenErzeugen();
await prisma.verzeichnisquelle.update({
where: { id: quelleId },
data: { scimTokenHash: await hash(token), scimTokenGesetztAm: new Date() },
});
await prisma.protokoll.create({
data: {
kundeId: quelle.kundeId,
entitaet: "Verzeichnisquelle",
entitaetId: quelle.id,
aktion: quelle.scimTokenHash ? "SCIM-Token ersetzt" : "SCIM-Token erzeugt",
benutzerId: benutzer.id,
// Kein Token, auch nicht gekürzt: das Protokoll wird gelesen (#023).
nachher: { name: quelle.name },
},
});
return { token };
}
/** Widerruft das Token. Danach nimmt die Quelle nichts mehr an. */
export async function scimTokenWiderrufen(quelleId: string, benutzer: HandelnderBenutzer): Promise<void> {
rechtPruefen(benutzer);
const quelle = await prisma.verzeichnisquelle.findUnique({ where: { id: quelleId } });
if (!quelle) throw new VerzeichnisFehler("Diese Verzeichnisquelle gibt es nicht (mehr).");
const eigene = await eigeneOrganisation();
if (quelle.kundeId !== eigene.id) {
throw new VerzeichnisFehler(`„${quelle.name}“ gehört nicht zur eigenen Organisation.`);
}
if (!quelle.scimTokenHash) return;
await prisma.verzeichnisquelle.update({
where: { id: quelleId },
data: { scimTokenHash: null, scimTokenGesetztAm: null },
});
await prisma.protokoll.create({
data: {
kundeId: quelle.kundeId,
entitaet: "Verzeichnisquelle",
entitaetId: quelle.id,
aktion: "SCIM-Token widerrufen",
benutzerId: benutzer.id,
nachher: { name: quelle.name },
},
});
}
/**
* Die Quelle zu einem Token – oder `undefined`.
*
* **Durchsucht wird, nicht nachgeschlagen.** Ein argon2-Hash ist je Aufruf
* anders, es gibt also keinen Index, über den sich ein Token finden ließe. Bei
* einer Handvoll SCIM-Quellen ist das gleichgültig; bei hunderten wäre es ein
* Grund, zusätzlich eine Kennung im Token zu führen.
*
* **Ohne Vorab-Prüfung auf das Präfix wäre das ein Zeitorakel**: ein Token mit
* falschem Präfix würde gegen jeden Hash geprüft, eines mit richtigem nur bis
* zum Treffer. Deshalb erst die Form, dann der Hash — und das Präfix ist kein
* Geheimnis.
*/
export async function quelleZuToken(token: string): Promise<Verzeichnisquelle | undefined> {
const wert = token.trim();
if (!wert.startsWith(TOKEN_PRAEFIX)) return undefined;
const quellen = await prisma.verzeichnisquelle.findMany({
where: { art: "SCIM", aktiv: true, scimTokenHash: { not: null } },
});
for (const quelle of quellen) {
if (!quelle.scimTokenHash) continue;
try {
if (await verify(quelle.scimTokenHash, wert)) return quelle;
} catch {
/*
* Ein unlesbarer Hash ist ein Datenfehler, nicht ein falsches Token. Die
* uebrigen Quellen werden weiter geprueft — eine kaputte Zeile darf nicht
* die Zuweisung aller anderen anhalten.
*/
console.error(`SCIM-Token-Hash der Quelle ${quelle.id} ist unlesbar.`);
}
}
return undefined;
}
/**
* Zeitkonstanter Textvergleich – für Stellen, an denen kein Hash im Spiel ist.
*
* Steht hier, weil der Handler das `Bearer`-Schema prüft und ein `===` darauf
* zwar harmlos wäre, aber das Muster vorgibt. Ein Vergleich, der früh abbricht,
* gehört in dieser Datei nirgends hin.
*/
export function textGleich(a: string, b: string): boolean {
const links = Buffer.from(a, "utf8");
const rechts = Buffer.from(b, "utf8");
if (links.length !== rechts.length) return false;
return timingSafeEqual(links, rechts);
}
/* ------------------------------------------------------------------ */
/* Personen */
/* ------------------------------------------------------------------ */
function alsPerson(zeile: Mitarbeiter): ScimPerson {
return {
id: zeile.id,
externeId: zeile.externeId,
upn: zeile.upn,
vorname: zeile.vorname,
nachname: zeile.nachname,
anzeigename: zeile.anzeigename,
mail: zeile.mail,
abteilung: zeile.abteilung,
titel: zeile.titel,
aktiv: zeile.aktiv,
vorgesetzterId: zeile.vorgesetzterId,
erstelltAm: zeile.erstelltAm,
geaendertAm: zeile.geaendertAm,
};
}
/** Der Anzeigename, wenn der Anbieter keinen schickt. Wie beim Abgleich. */
function anzeigenameBauen(eingabe: ScimEingabe): string {
if (eingabe.anzeigename) return eingabe.anzeigename;
const gebaut = [eingabe.nachname, eingabe.vorname].filter((teil) => teil).join(", ");
return gebaut || eingabe.upn || "Ohne Namen";
}
/**
* Den Vorgesetzten auflösen: aus der Kennung des Anbieters eine Zeile.
*
* **Drei Gründe, warum das `null` ergeben darf, und keiner davon ist ein
* Fehler:** der Vorgesetzte wurde Laufzettel noch nicht zugewiesen (der
* Anbieter schickt die Leute in beliebiger Reihenfolge), er gehört zu einer
* anderen Quelle, oder jemand ist sein eigener Vorgesetzter. Beim nächsten
* `PATCH` auf diese Person wird es nachgezogen — der Anbieter schickt die
* Zuordnung erneut, sobald sie sich ändert.
*
* Eine fremde Quelle wird ausgeschlossen, weil `manager` sonst ein Weg über die
* Mandantengrenze wäre: eine Kennung aus einer anderen Quelle zeigt auf jemanden,
* den diese hier nicht sehen darf.
*/
async function vorgesetzterAufloesen(
quelle: Verzeichnisquelle,
eigeneId: string | null,
scimId: string | null,
): Promise<{ readonly vorgesetzterId: string | null; readonly vorgesetzterExterneId: string | null }> {
if (!scimId || scimId === eigeneId) return { vorgesetzterId: null, vorgesetzterExterneId: null };
const zeile = await prisma.mitarbeiter.findFirst({
where: { id: scimId, quelleId: quelle.id },
select: { id: true, externeId: true },
});
return zeile
? { vorgesetzterId: zeile.id, vorgesetzterExterneId: zeile.externeId }
: { vorgesetzterId: null, vorgesetzterExterneId: null };
}
async function zustellungVermerken(quelleId: string): Promise<void> {
await prisma.verzeichnisquelle.update({
where: { id: quelleId },
data: { letzteScimZustellungAm: new Date() },
});
}
/**
* Protokolleintrag mit der **Quelle** als Urheber.
*
* `benutzerId` bleibt leer — das ist ein Systemlauf, wie beim Zeitplan des
* Abgleichs. Ein erfundener Administrator wäre eine Lüge im Protokoll, und das
* Protokoll ist ansehbar (#023).
*/
async function vermerken(
quelle: Verzeichnisquelle,
mitarbeiterId: string,
aktion: string,
nachher: Prisma.InputJsonObject,
): Promise<void> {
await prisma.protokoll.create({
data: {
kundeId: quelle.kundeId,
entitaet: "Mitarbeiter",
entitaetId: mitarbeiterId,
aktion: `${aktion} (SCIM: ${quelle.name})`,
nachher,
},
});
}
/** Eine Person dieser Quelle, über die Laufzettel-Kennung. */
export async function personHolen(quelle: Verzeichnisquelle, id: string): Promise<ScimPerson | undefined> {
/*
* **Immer mit `quelleId`.** Die Kennung allein wäre genug, um die Zeile zu
* finden — und genau das ist der Fehler, der einem fremden Token Zugriff auf
* die Personen einer anderen Quelle gäbe.
*/
const zeile = await prisma.mitarbeiter.findFirst({ where: { id, quelleId: quelle.id } });
return zeile ? alsPerson(zeile) : undefined;
}
/** Personen dieser Quelle, gefiltert nach `userName` oder alle. */
export async function personenSuchen(
quelle: Verzeichnisquelle,
upn?: string,
): Promise<readonly ScimPerson[]> {
const zeilen = await prisma.mitarbeiter.findMany({
where: {
quelleId: quelle.id,
...(upn ? { upn: { equals: upn, mode: "insensitive" } } : {}),
},
orderBy: { anzeigename: "asc" },
// Eine Obergrenze, damit ein `GET /Users` ohne Filter nicht das ganze
// Verzeichnis in eine Antwort schreibt. Die Anbieter fragen gefiltert.
take: 200,
});
return zeilen.map(alsPerson);
}
/**
* Legt eine Person an – oder weist ab, wenn es sie schon gibt.
*
* **Zweimal dasselbe `POST` ergibt `409`**, nicht eine zweite Person. Das ist
* kein Komfort, sondern der Normalfall: ein Anbieter, der eine Zuweisung
* wiederholt (nach einem Fehler, nach einem Neustart), schickt dasselbe `POST`
* erneut.
*
* Erkannt wird eine Dublette an `externalId` **oder** am `userName` — der
* Anbieter schickt nicht immer beides, und beides identifiziert dieselbe Person.
*/
export async function personAnlegen(quelle: Verzeichnisquelle, eingabe: ScimEingabe): Promise<ScimPerson> {
if (!eingabe.upn && !eingabe.externeId) {
throw new ScimFehler(
400,
"Ohne userName und ohne externalId lässt sich niemand anlegen.",
"invalidValue",
);
}
const vorhanden = await prisma.mitarbeiter.findFirst({
where: {
quelleId: quelle.id,
OR: [
...(eingabe.externeId ? [{ externeId: eingabe.externeId }] : []),
...(eingabe.upn ? [{ upn: { equals: eingabe.upn, mode: "insensitive" as const } }] : []),
],
},
});
if (vorhanden) {
throw new ScimFehler(409, `„${vorhanden.anzeigename}“ gibt es in dieser Quelle schon.`, "uniqueness");
}
/*
* **Übernahme statt Dublette**, wie beim Abgleich (#016): beim Onboarding legt
* jemand die Person von Hand an, das Konto beim Anbieter entsteht danach.
* Gesucht wird nur unter Zeilen **ohne** Quelle — eine Person, die eine andere
* Quelle führt, gehört der.
*/
const vonHand = eingabe.upn
? await prisma.mitarbeiter.findFirst({
where: {
kundeId: quelle.kundeId,
quelleId: null,
upn: { equals: eingabe.upn, mode: "insensitive" },
},
})
: null;
// Beim Anlegen gibt es noch keine eigene Kennung, auf die `manager` zeigen
// koennte – deshalb `null` als eigene.
const vorgesetzter = await vorgesetzterAufloesen(quelle, null, eingabe.vorgesetzterScimId);
const daten = {
externeId: eingabe.externeId,
upn: eingabe.upn,
...vorgesetzter,
vorname: eingabe.vorname ?? "",
nachname: eingabe.nachname ?? "",
anzeigename: anzeigenameBauen(eingabe),
mail: eingabe.mail,
abteilung: eingabe.abteilung,
titel: eingabe.titel,
// Ohne `active` wird aktiv angelegt: ein Anbieter weist niemandem ein
// deaktiviertes Konto zu.
aktiv: eingabe.aktiv ?? true,
herkunft: Herkunft.ABGLEICH,
zuletztGesehenAm: new Date(),
};
const zeile = vonHand
? await prisma.mitarbeiter.update({
where: { id: vonHand.id },
data: { ...daten, quelleId: quelle.id },
})
: await prisma.mitarbeiter.create({
data: { ...daten, kundeId: quelle.kundeId, quelleId: quelle.id },
});
await vermerken(quelle, zeile.id, vonHand ? "Person übernommen" : "Person angelegt", {
upn: zeile.upn,
anzeigename: zeile.anzeigename,
});
await zustellungVermerken(quelle.id);
return alsPerson(zeile);
}
/**
* Ändert eine Person. `eingabe` ist der **vollständige Zielzustand**.
*
* Was hier `null` ist, wird geleert — auch bei `PATCH`. Das ist der
* entscheidende Punkt, und er hat einen Anlauf gekostet (#029): diese Funktion
* hatte einen Schalter `nurGesetzte`, der bei `PATCH` jedes `null` als „nicht
* mitgeschickt“ las und den alten Wert stehen ließ. Damit wirkte ein
* `remove` **gar nicht** — jemand nimmt beim Anbieter die Abteilung aus dem
* Profil, und in Laufzettel stand sie weiter.
*
* Nötig war der Schalter nie: `patchAnwenden` setzt auf dem vorhandenen Stand
* auf und liefert deshalb immer einen vollständigen Zustand, in dem „nicht
* mitgeschickt“ schon als alter Wert steht. Der Unterschied zwischen `PUT` und
* `PATCH` liegt also **vor** dieser Funktion und nicht in ihr.
*/
export async function personAendern(
quelle: Verzeichnisquelle,
id: string,
eingabe: ScimEingabe,
): Promise<ScimPerson> {
const vorhanden = await prisma.mitarbeiter.findFirst({ where: { id, quelleId: quelle.id } });
if (!vorhanden) throw new ScimFehler(404, "Diese Person gibt es in dieser Quelle nicht.");
const vorgesetzter = await vorgesetzterAufloesen(quelle, id, eingabe.vorgesetzterScimId);
const zeile = await prisma.mitarbeiter.update({
where: { id },
data: {
externeId: eingabe.externeId,
upn: eingabe.upn,
...vorgesetzter,
vorname: eingabe.vorname ?? "",
nachname: eingabe.nachname ?? "",
anzeigename: eingabe.anzeigename ?? anzeigenameBauen(eingabe),
mail: eingabe.mail,
abteilung: eingabe.abteilung,
titel: eingabe.titel,
// Ohne `active` im Rumpf bleibt es aktiv: ein `PUT` ohne das Feld ist
// keine Abschaltung, und bei `PATCH` steht hier ohnehin der alte Wert.
aktiv: eingabe.aktiv ?? true,
zuletztGesehenAm: new Date(),
},
});
const wurdeDeaktiviert = vorhanden.aktiv && !zeile.aktiv;
const wurdeAktiviert = !vorhanden.aktiv && zeile.aktiv;
await vermerken(
quelle,
zeile.id,
wurdeDeaktiviert ? "Person deaktiviert" : wurdeAktiviert ? "Person wieder aktiv" : "Person aktualisiert",
{ upn: zeile.upn, anzeigename: zeile.anzeigename, aktiv: zeile.aktiv },
);
await zustellungVermerken(quelle.id);
return alsPerson(zeile);
}
/**
* `DELETE` vom Anbieter: **deaktiviert, löscht nicht.**
*
* Die Regel des ganzen Projekts. 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.
* Der Anbieter bekommt `204` und hält die Person damit für gelöscht — das ist
* aus seiner Sicht richtig, denn sie wird ihm nicht mehr zugewiesen.
*
* **Das muss in der Anleitung stehen**, sonst wirkt es wie ein Fehler: jemand
* entfernt eine Person beim Anbieter, und sie steht in Laufzettel weiter in der
* Liste — inaktiv, aber sichtbar.
*/
export async function personDeaktivieren(quelle: Verzeichnisquelle, id: string): Promise<void> {
const vorhanden = await prisma.mitarbeiter.findFirst({ where: { id, quelleId: quelle.id } });
if (!vorhanden) throw new ScimFehler(404, "Diese Person gibt es in dieser Quelle nicht.");
if (vorhanden.aktiv) {
await prisma.mitarbeiter.update({
where: { id },
data: { aktiv: false, zuletztGesehenAm: new Date() },
});
await vermerken(quelle, id, "Person deaktiviert (DELETE)", {
upn: vorhanden.upn,
anzeigename: vorhanden.anzeigename,
hinweis: "Die Zeile bleibt: Läufe und Geräte hängen daran.",
});
}
await zustellungVermerken(quelle.id);
}
+56 -3
View File
@@ -76,6 +76,10 @@ export type QuellenSicht = {
readonly tenantId: string | null;
readonly clientId: string | null;
readonly clientSecretVorhanden: boolean;
/** Nur bei `art = SCIM`. Das Token **nie**, nur ob eines gilt und seit wann. */
readonly scimTokenVorhanden: boolean;
readonly scimTokenGesetztAm: Date | null;
readonly letzteScimZustellungAm: Date | null;
readonly attributZuordnung: AttributZuordnung;
readonly caZertifikatVorhanden: boolean;
readonly abgleichIntervallMinuten: number;
@@ -187,6 +191,15 @@ function alsSicht(zeile: Verzeichnisquelle): QuellenSicht {
* nicht in eine Antwort schafft, kann auch nicht versehentlich hinausgehen.
*/
clientSecretVorhanden: Boolean(zeile.clientSecretVerschluesselt?.trim()),
/*
* Dasselbe fuer das SCIM-Token (#029), und hier waere es noch teurer: es ist
* der einzige Mandantenbeweis, den ein Push mitbringt. Es steht als Hash in
* der Datenbank und geht aus dem Dienst genau einmal heraus — in der
* Antwort von `scimTokenErzeugen`, nie in einer Sicht.
*/
scimTokenVorhanden: Boolean(zeile.scimTokenHash?.trim()),
scimTokenGesetztAm: zeile.scimTokenGesetztAm,
letzteScimZustellungAm: zeile.letzteScimZustellungAm,
attributZuordnung: zuordnungLesen(zeile.attributZuordnung, zuordnungsArt(zeile.art)),
// Der Inhalt bleibt hier: die Oberflaeche muss nur wissen, ob eines
// hinterlegt ist, und braucht dafuer keinen Zertifikatstext.
@@ -254,9 +267,34 @@ function feldernJeArt(art: QuellenArt, eingabe: NeueQuelle) {
};
}
throw new VerzeichnisFehler(
`Quellen der Art ${art} lassen sich noch nicht anlegen. Der SCIM-Endpunkt kommt mit #029.`,
);
if (art === "SCIM") {
/*
* **Eine SCIM-Quelle hat keine Zugangsdaten — sie hat ein Token, und das
* entsteht danach.** Erst steht die Quelle, dann erzeugt der Administrator
* das Token und traegt es beim Anbieter ein. Eine Quelle ohne Token nimmt
* nichts an und antwortet mit 401; das ist der richtige Anfangszustand.
*/
if (
eingabe.url?.trim() ||
eingabe.bindDn?.trim() ||
eingabe.basisDn?.trim() ||
eingabe.tenantId?.trim() ||
eingabe.clientId?.trim()
) {
throw new VerzeichnisFehler(
"Eine SCIM-Quelle hat weder LDAP- noch Graph-Zugangsdaten: Laufzettel verbindet sich nicht, " +
"sondern wird beliefert. Sie braucht nur einen Namen — das Token kommt danach.",
);
}
return {};
}
/*
* Unerreichbar, solange `QuellenArt` drei Werte hat — und genau deshalb hier:
* eine vierte Art faellt beim Uebersetzen auf und nicht im Betrieb.
*/
const unbekannt: never = art;
throw new VerzeichnisFehler(`Quellen der Art ${String(unbekannt)} lassen sich nicht anlegen.`);
}
export async function quelleAnlegen(
@@ -564,6 +602,21 @@ export async function verbindungTesten(
await nurIntern();
rechtPruefen(benutzer);
const quelle = await quelleZeileHolen(quelleId);
/*
* **Bei SCIM gibt es nichts zu testen, und das ist keine Lücke** (#029):
* Laufzettel baut keine Verbindung auf, sondern nimmt eine an. Ob sie steht,
* zeigt die erste Zustellung — deshalb steht in der Oberfläche „Letzte
* Zustellung“ dort, wo bei LDAP der Verbindungstest sitzt.
*/
if (quelle.art === "SCIM") {
throw new VerzeichnisFehler(
`„${quelle.name}“ ist eine SCIM-Quelle: es gibt keine Verbindung, die sich von hier aus ` +
"testen ließe. Trag das Token beim Anbieter ein und lass ihn eine Zuweisung schicken — " +
"danach steht hier, wann die letzte angekommen ist.",
);
}
const wieViele = Math.min(Math.max(Math.trunc(grenze), 1), PROBE_GRENZE_HOECHSTENS);
/*
+11 -7
View File
@@ -52,14 +52,18 @@ export async function eintraegeDerQuelleStroemen(
}
/*
* `SCIM` steht im Enum, weil #029 dieselbe Weiche braucht — behandelt wird es
* dort. **Bis dahin eine Meldung und kein stilles Nichts:** ein Abgleich, der
* null Einträge liest und `ERFOLG` meldet, würde über die Abschaltregel in
* `abgleichAusfuehren` die ganze Belegschaft deaktivieren. Genau dieser Fall
* hat in #016 einen Reviewbefund gekostet.
* **Eine SCIM-Quelle wird nicht gelesen, sie liefert** (#029) — es gibt hier
* nichts abzuholen, und das bleibt so. Trotzdem eine Meldung und kein stilles
* Nichts: ein Abgleich, der null Einträge liest und `ERFOLG` meldet, würde
* über die Abschaltregel in `abgleichAusfuehren` die ganze Belegschaft
* deaktivieren. Genau dieser Fall hat in #016 einen Reviewbefund gekostet.
*
* Der Zeitplan kommt hier nicht an: `faelligeQuellen` nimmt SCIM-Quellen
* nicht auf. Diese Stelle fängt den Weg über „Jetzt abgleichen“.
*/
throw new VerzeichnisFehler(
`Quellen der Art ${quelle.art} können noch nicht abgeglichen werden. ` +
"Der SCIM-Endpunkt kommt mit einem eigenen Meilenstein (#029).",
`„${quelle.name}“ ist eine SCIM-Quelle: Laufzettel holt dort nichts ab, sondern wird ` +
"beliefert. Ein Abgleich gibt es für sie nicht — der Anbieter schickt Änderungen, sobald " +
"sie dort entstehen.",
);
}
+4 -3
View File
@@ -64,9 +64,10 @@ export function ldapVorgabeAus(quelle: Verzeichnisquelle): {
/**
* Welche Zuordnungs-Vorgaben fuer diese Art gelten.
*
* `SCIM` hat noch keine (#029) und faellt auf die LDAP-Namen zurueck — die Art
* wird vom Dienst ohnehin abgewiesen, und ein Wurf an dieser Stelle machte jede
* Liste unbenutzbar, in der eine SCIM-Zeile steht.
* `SCIM` hat keine und faellt auf die LDAP-Namen zurueck (#029): bei einem Push
* bestimmt der Anbieter die Feldnamen, sie stehen in RFC 7643 und sind nicht
* einstellbar. Ein Wurf an dieser Stelle machte jede Liste unbenutzbar, in der
* eine SCIM-Zeile steht.
*/
export function zuordnungsArt(art: QuellenArt): "LDAP" | "GRAPH" {
return art === "GRAPH" ? "GRAPH" : "LDAP";
+8
View File
@@ -151,6 +151,14 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
*/
"Verzeichnisquelle_clientsecret_verschluesselt",
"Verzeichnisquelle_felder_je_art",
/*
* Das SCIM-Token steht nur als argon2-Hash da (#029). Ein Token, das
* jemand versehentlich roh speichert, faellt hier auf — und zwar in der
* Datenbank, also auch dann, wenn jemand am Dienst vorbei schreibt.
*/
"Verzeichnisquelle_scimtoken_gehasht",
// Und ein Token gehoert zu einem Zeitpunkt, in beide Richtungen.
"Verzeichnisquelle_scimtoken_mit_zeitpunkt",
"VorlagenVersion_veroeffentlicht_mit_zeitpunkt",
"Zustellung_bezug_passt_zum_anlass",
"Zustellung_empfaenger_nicht_leer",
+9 -5
View File
@@ -552,10 +552,13 @@ describe.runIf(datenbankVerfuegbar)("Der Abgleich einer Graph-Quelle", () => {
it("weist eine SCIM-Quelle ab, statt stillschweigend nichts zu tun", async () => {
/*
* `SCIM` steht im Enum, weil #029 dieselbe Weiche braucht. Ein Abgleich, der
* null Einträge liest und ERFOLG meldet, hätte über die Abschaltregel die
* ganze Belegschaft deaktiviert — genau der Fall, der in #016 einen
* Reviewbefund gekostet hat.
* Dieselbe Weiche, dritter Fall. Ein Abgleich, der null Einträge liest und
* ERFOLG meldet, hätte über die Abschaltregel die ganze Belegschaft
* deaktiviert — genau der Fall, der in #016 einen Reviewbefund gekostet hat.
*
* Seit #029 ist SCIM umgesetzt, und die Antwort bleibt dieselbe: dort wird
* nicht geholt, sondern geliefert. Die Meldung sagt das und nennt keine
* Issue-Nummer mehr — sie steht auch in der Oberfläche.
*/
const { eigene } = await graphQuelleAnlegen();
const scim = await testPrisma.verzeichnisquelle.create({
@@ -564,7 +567,8 @@ describe.runIf(datenbankVerfuegbar)("Der Abgleich einer Graph-Quelle", () => {
const ergebnis = await abgleichAusfuehren(scim.id, ADMIN);
expect(ergebnis.status).not.toBe("ERFOLG");
expect(ergebnis.fehler).toContain("#029");
expect(ergebnis.fehler).toMatch(/beliefert/);
expect(ergebnis.fehler).toContain("SCIM später");
});
it("liest eine LDAP-Quelle weiter über den LDAP-Weg", async () => {
+1111
View File
File diff suppressed because it is too large Load Diff