feat(verzeichnis): SCIM-Endpunkt als dritte Quellenart (#029)
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:
co-authored by
Claude Opus 5
parent
76a61ec771
commit
fcd67018b8
@@ -54,6 +54,10 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
### Behoben
|
### 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
|
- **`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
|
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
|
`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
|
### 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
|
- **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`
|
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,
|
oder `GRAPH` —, und beide laufen durch **denselben** Abgleich: anlegen, aktualisieren,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Mitarbeiter. Einstieg über QR-Aufkleber: Code scannen, Geräteseite öffnen, lo
|
|||||||
|
|
||||||
| Modus | Für wen | Kunden-Ebene | Verzeichnis |
|
| 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 |
|
| `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
|
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)
|
Die Anbindung an authentik ist in [`docs/betrieb/authentik.md`](docs/betrieb/authentik.md)
|
||||||
Schritt für Schritt beschrieben.
|
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,
|
Unter **Admin → Verzeichnis** wird eine Quelle angelegt. Es gibt drei Arten, und sie unterscheiden
|
||||||
`ldap+starttls://` und `ldap://` gehen auch), ein Dienstkonto mit **Leserecht**, der Basis-DN und
|
sich in der **Richtung**:
|
||||||
optional ein eigener Filter. Ein Dienstkonto genügt — Laufzettel schreibt nie ins Verzeichnis.
|
|
||||||
|
| 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**,
|
- **„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.
|
ohne etwas zu speichern. Erst daran sieht man, ob die Zuordnung stimmt.
|
||||||
|
|||||||
@@ -16,18 +16,24 @@ Zwei Dinge vorweg:
|
|||||||
Wer im Verzeichnis verschwindet oder dort deaktiviert wird, wird hier **inaktiv**. Gelöscht wird
|
Wer im Verzeichnis verschwindet oder dort deaktiviert wird, wird hier **inaktiv**. Gelöscht wird
|
||||||
nie, sonst verlöre die Historie ihre Namen.
|
nie, sonst verlöre die Historie ihre Namen.
|
||||||
|
|
||||||
## Zwei Arten von Quelle
|
## Drei Arten von Quelle
|
||||||
|
|
||||||
Laufzettel liest die Personen aus einem Verzeichnis. Dafür gibt es zwei Wege, und du wählst ihn beim
|
Laufzettel braucht die Personen aus einem Verzeichnis. Dafür gibt es drei Wege, und du wählst ihn
|
||||||
Anlegen der Quelle:
|
beim Anlegen der Quelle:
|
||||||
|
|
||||||
| Art | Für wen |
|
| Art | Für wen | Richtung |
|
||||||
|---|---|
|
|---|---|---|
|
||||||
| **LDAP** | ein lokales Active Directory (oder ein anderes LDAP-Verzeichnis) |
|
| **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 |
|
| **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
|
Die ersten zwei sind dieselbe Arbeitsweise mit anderen Zugangsdaten: Laufzettel verbindet sich nach
|
||||||
unterscheidet, sind die Zugangsdaten und zwei Platzhalter:
|
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
|
- **`{{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.
|
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).
|
[`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
|
## Eine Quelle anlegen
|
||||||
|
|
||||||

|

|
||||||
@@ -116,6 +225,10 @@ Namensänderung der Quelle ihre Anmeldung.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
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
|
**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
|
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).
|
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
|
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
|
einlesen — ein Verzeichnis nimmt nur die Arbeit ab. Siehe
|
||||||
[Kapitel 05](05-mitarbeiter-geraete.md).
|
[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.
|
||||||
|
|||||||
@@ -378,6 +378,34 @@ was darauf steht, ist aus der Hand gegeben.
|
|||||||
|
|
||||||
## 5g. Der Verzeichnis-Abgleich: vier Schichten und eine Notbremse
|
## 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
|
Das LDAP-Modul liegt unter `src/server/ldap/` und ist absichtlich in vier Dateien
|
||||||
geteilt, von denen nur eine das Netz anfasst:
|
geteilt, von denen nur eine das Netz anfasst:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
Administratoreinwilligung, und was anders ist als bei einem lokalen AD
|
||||||
- **[Benachrichtigungen](benachrichtigungen.md)** — SMTP einrichten, den Webhook prüfen, die
|
- **[Benachrichtigungen](benachrichtigungen.md)** — SMTP einrichten, den Webhook prüfen, die
|
||||||
Signatur nachrechnen, und wo man nachsieht, wenn keine Mail mehr ankommt
|
Signatur nachrechnen, und wo man nachsieht, wenn keine Mail mehr ankommt
|
||||||
|
- **[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
|
## Wo sonst noch etwas steht
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -35,12 +35,13 @@ als Unterpunkte mit Aufgabe und Dauer.
|
|||||||
| #034 | M21 – Benachrichtigungen per Mail und Webhook | 3 h 30 min |
|
| #034 | M21 – Benachrichtigungen per Mail und Webhook | 3 h 30 min |
|
||||||
| #035 | M22 – Mitarbeiter-Selbstauskunft | 2 h 50 min |
|
| #035 | M22 – Mitarbeiter-Selbstauskunft | 2 h 50 min |
|
||||||
| #028 | M15 – Quellenarten und Microsoft-Graph-Quelle | 4 h 10 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 |
|
| Meilenstein | Tatsächlich | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| v0.1.0 | 35 h 20 min | veröffentlicht |
|
| v0.1.0 | 35 h 20 min | veröffentlicht |
|
||||||
| v0.2.0 | 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
|
- 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
|
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.
|
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.
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# #029 M16 – SCIM-Endpunkt als Alternative zum Abgleich
|
# #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)
|
**Im Tracker:** [#49](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/49)
|
||||||
**Meilenstein:** v0.2.0
|
**Meilenstein:** v0.2.0
|
||||||
**Labels:** phase-2, backend
|
**Labels:** phase-2, backend
|
||||||
**Geschätzt:** 12 h **Tatsächlich:** –
|
**Geschätzt:** 12 h **Tatsächlich:** 4 h 05 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
|
|
||||||
@@ -16,41 +16,137 @@ Setzt [#028](028-graph-quelle.md) voraus: die Quellenart `SCIM` braucht die Weic
|
|||||||
|
|
||||||
## Zu entscheiden
|
## 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
|
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
|
Token identifiziert die Quelle und damit den Kunden; es ist der einzige Mandantenbeweis, den
|
||||||
ein Push mitbringt.
|
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
|
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
|
Zeile bleibt, weil Läufe an ihr hängen. Das muss in der Anleitung stehen, sonst wirkt es wie
|
||||||
ein Fehler.
|
ein Fehler.
|
||||||
|
|
||||||
## Aufgaben
|
## 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`,
|
`Users/{id}` (GET, PUT, PATCH, DELETE), dazu `ServiceProviderConfig`, `Schemas`,
|
||||||
`ResourceTypes` — ohne die drei weigern sich manche Anbieter, überhaupt anzufangen
|
`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)
|
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`
|
`emails[primary]` → `mail`, `active` → `aktiv`, `urn:ietf:params:scim:schemas:extension:enterprise:2.0:User`
|
||||||
für `department` und `manager`
|
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`
|
`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
|
nicht der ganze Standard
|
||||||
- [ ] Idempotenz: zweimal dasselbe `POST` ergibt `409`, nicht eine zweite Person
|
- [x] Idempotenz: zweimal dasselbe `POST` ergibt `409`, nicht eine zweite Person
|
||||||
- [ ] Protokolleintrag je Vorgang, mit der Quelle als Urheber (Systemlauf, kein Benutzer)
|
- [x] Protokolleintrag je Vorgang, mit der Quelle als Urheber (Systemlauf, kein Benutzer)
|
||||||
- [ ] Admin → Verzeichnis: Token erzeugen, einmal anzeigen, widerrufen; letzte Zustellung sichtbar
|
- [x] Admin → Verzeichnis: Token erzeugen, einmal anzeigen, widerrufen; letzte Zustellung sichtbar
|
||||||
- [ ] Anleitung Kapitel 06: Einrichtung in authentik und in Entra ID
|
- [x] Anleitung Kapitel 06: Einrichtung in authentik und in Entra ID
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## 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
|
anlegen, aktualisieren, deaktivieren, wieder aktivieren
|
||||||
- [ ] Test: ohne Token `401`, mit fremdem Token kein Zugriff auf die Personen einer anderen Quelle
|
- [x] Test: ohne Token `401`, mit fremdem Token kein Zugriff auf die Personen einer anderen Quelle
|
||||||
- [ ] Test: `DELETE` setzt `aktiv = false` und löscht nichts
|
- [x] Test: `DELETE` setzt `aktiv = false` und löscht nichts
|
||||||
- [ ] Test: zweimal dasselbe `POST` ergibt `409` und keine zweite Zeile
|
- [x] Test: zweimal dasselbe `POST` ergibt `409` und keine zweite Zeile
|
||||||
- [ ] Test: das Token steht nur als Hash in der Datenbank und in keiner Antwort
|
- [x] 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: eine Quelle der Art `SCIM` wird vom Zeitplan **nicht** abgeglichen — sie bekommt
|
||||||
geschickt
|
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`.
|
||||||
|
|||||||
@@ -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 |
|
| #026 | [Zwei Konventionen präzisieren, die strenger klingen als sie gemeint sind](026-konventionen-praezisieren.md) | erledigt | v0.2.0 | 1 h | 20 min |
|
||||||
| #027 | [Die Editor-Abläufe lassen ihre Vorlagen stehen](027-e2e-reste-vorlagen.md) | offen | v0.2.0 | 1 h | – |
|
| #027 | [Die Editor-Abläufe lassen ihre Vorlagen stehen](027-e2e-reste-vorlagen.md) | offen | v0.2.0 | 1 h | – |
|
||||||
| #028 | [M15 – Quellenarten und Microsoft-Graph-Quelle](028-graph-quelle.md) | erledigt | v0.2.0 | 10 h | 4 h 10 min |
|
| #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 |
|
| #030 | [M17 – Wiederverwendbare Bausteine](030-bausteine.md) | erledigt | v0.2.0 | 12 h | 2 h 25 min |
|
||||||
| #031 | [M18 – Abteilungsspezifische Zusatzabschnitte](031-abteilungsabschnitte.md) | erledigt | v0.2.0 | 8 h | 1 h 35 min |
|
| #031 | [M18 – Abteilungsspezifische Zusatzabschnitte](031-abteilungsabschnitte.md) | erledigt | v0.2.0 | 8 h | 1 h 35 min |
|
||||||
| #032 | [M19 – Anhänge und Fotos an Schritten](032-anhaenge.md) | erledigt | v0.2.0 | 10 h | 3 h 05 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 |
|
| #033 | [M20 – Live-Aktualisierung per SSE statt Polling](033-sse-live.md) | erledigt | v0.2.0 | 6 h | 1 h 40 min |
|
||||||
| #034 | [M21 – Benachrichtigungen per Mail und Webhook](034-benachrichtigungen.md) | erledigt | v0.2.0 | 10 h | 3 h 30 min |
|
| #034 | [M21 – Benachrichtigungen per Mail und Webhook](034-benachrichtigungen.md) | erledigt | v0.2.0 | 10 h | 3 h 30 min |
|
||||||
| #035 | [M22 – Mitarbeiter-Selbstauskunft](035-selbstauskunft.md) | erledigt | v0.2.0 | 8 h | 2 h 50 min |
|
| #035 | [M22 – Mitarbeiter-Selbstauskunft](035-selbstauskunft.md) | erledigt | v0.2.0 | 8 h | 2 h 50 min |
|
||||||
| | **Summe** | | | **229 h** | **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 $$;
|
||||||
@@ -350,6 +350,20 @@ model Verzeichnisquelle {
|
|||||||
/// Verschluesselt wie das Bind-Passwort und mit demselben Vorsatz: es darf
|
/// Verschluesselt wie das Bind-Passwort und mit demselben Vorsatz: es darf
|
||||||
/// keine Antwort und keinen Protokolleintrag erreichen.
|
/// keine Antwort und keinen Protokolleintrag erreichen.
|
||||||
clientSecretVerschluesselt String?
|
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.
|
/// Zuordnung der LDAP-Attribute auf unsere Felder, mit AD-Vorgaben.
|
||||||
attributZuordnung Json
|
attributZuordnung Json
|
||||||
/// Eigenes CA-Zertifikat, falls ldaps gegen eine interne CA laeuft.
|
/// Eigenes CA-Zertifikat, falls ldaps gegen eine interne CA laeuft.
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
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 { Betriebsmodus } from "@/generated/prisma/enums";
|
||||||
import { JetztAbgleichen } from "@/components/verzeichnis/jetzt-abgleichen";
|
import { JetztAbgleichen } from "@/components/verzeichnis/jetzt-abgleichen";
|
||||||
import { QuelleFormular } from "@/components/verzeichnis/quelle-formular";
|
import { QuelleFormular } from "@/components/verzeichnis/quelle-formular";
|
||||||
|
import { ScimToken } from "@/components/verzeichnis/scim-token";
|
||||||
import { VerbindungTesten } from "@/components/verzeichnis/verbindung-testen";
|
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 { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
import { betriebsmodus } from "@/server/mandant";
|
import { betriebsmodus } from "@/server/mandant";
|
||||||
import { abgleichLog, type AbgleichLogZeile, quellenListe } from "@/server/queries/verzeichnis";
|
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 }) {
|
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) {
|
if (quelle.letzterAbgleichAm === null) {
|
||||||
return (
|
return (
|
||||||
<p className="mt-2 text-sm text-nebentext">
|
<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.
|
// daran, und ein hart gesetzter „ADMIN“ waere eine Hintertuer.
|
||||||
const handelnder = { id: benutzer.id, rolle: benutzer.rolle };
|
const handelnder = { id: benutzer.id, rolle: benutzer.rolle };
|
||||||
const quellen = await quellenListe(handelnder);
|
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,
|
// Je Quelle ein Log. Die Abfragen laufen parallel; es sind so viele wie Quellen,
|
||||||
// und das sind im Modus INTERN eine oder zwei.
|
// und das sind im Modus INTERN eine oder zwei.
|
||||||
const logs = new Map(
|
const logs = new Map(
|
||||||
await Promise.all(
|
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>
|
<h1 className="text-2xl font-bold">Verzeichnis</h1>
|
||||||
<p className="mt-2 max-w-prose text-nebentext">
|
<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
|
Die Personen der eigenen Firma kommen aus dem Verzeichnis, damit beim Start eines Laufs nichts
|
||||||
abgetippt werden muss. Wer dort verschwindet oder deaktiviert wird, wird hier <strong>inaktiv</strong>{" "}
|
abgetippt werden muss. Laufzettel liest sie entweder selbst — aus einem Active Directory oder aus
|
||||||
— gelöscht wird nie, sonst verlöre die Historie ihre Namen.
|
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>
|
</p>
|
||||||
|
|
||||||
{quellen.length === 0 ? (
|
{quellen.length === 0 ? (
|
||||||
@@ -152,7 +179,23 @@ export default async function VerzeichnisSeite() {
|
|||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Server className="size-5 shrink-0 text-akzent" strokeWidth={2} aria-hidden="true" />
|
<Server className="size-5 shrink-0 text-akzent" strokeWidth={2} aria-hidden="true" />
|
||||||
<h2 className="text-lg font-semibold break-words">{quelle.name}</h2>
|
<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">
|
<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" />
|
<Clock className="size-3" strokeWidth={2} aria-hidden="true" />
|
||||||
alle {quelle.abgleichIntervallMinuten} min
|
alle {quelle.abgleichIntervallMinuten} min
|
||||||
@@ -172,11 +215,21 @@ export default async function VerzeichnisSeite() {
|
|||||||
<p className="mono mt-2 text-xs break-all text-nebentext">
|
<p className="mono mt-2 text-xs break-all text-nebentext">
|
||||||
{quelle.art === "GRAPH" ? (
|
{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>
|
</p>
|
||||||
@@ -184,16 +237,35 @@ export default async function VerzeichnisSeite() {
|
|||||||
<Status quelle={quelle} />
|
<Status quelle={quelle} />
|
||||||
|
|
||||||
<div className="mt-4 flex flex-col gap-4">
|
<div className="mt-4 flex flex-col gap-4">
|
||||||
|
{/*
|
||||||
|
* **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} />
|
<VerbindungTesten quelleId={quelle.id} />
|
||||||
<JetztAbgleichen quelleId={quelle.id} />
|
<JetztAbgleichen quelleId={quelle.id} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{quelle.art === "SCIM" ? null : (
|
||||||
<details className="mt-4 border-t border-linie pt-4">
|
<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">
|
<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
|
Abgleich-Log
|
||||||
</summary>
|
</summary>
|
||||||
<Log zeilen={logs.get(quelle.id) ?? []} />
|
<Log zeilen={logs.get(quelle.id) ?? []} />
|
||||||
</details>
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
<details className="mt-4 border-t border-linie pt-4">
|
<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">
|
<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">
|
<section className="mt-8 rounded-karte border border-linie bg-flaeche p-5">
|
||||||
<h2 className="text-lg font-semibold">Quelle anlegen</h2>
|
<h2 className="text-lg font-semibold">Quelle anlegen</h2>
|
||||||
<p className="mt-1 mb-5 max-w-prose text-sm text-nebentext">
|
<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
|
Zum Lesen genügt ein Dienstkonto mit Leserechten; bei SCIM braucht es gar keine Zugangsdaten, nur
|
||||||
die zugeordneten Felder — keine Passwörter, keine Gruppenmitgliedschaften.
|
ein Token für den Anbieter. Laufzettel schreibt nie ins Verzeichnis und speichert nur die
|
||||||
|
zugeordneten Felder — keine Passwörter, keine Gruppenmitgliedschaften.
|
||||||
</p>
|
</p>
|
||||||
<QuelleFormular />
|
<QuelleFormular />
|
||||||
</section>
|
</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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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] },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
GRAPH_ZUORDNUNG,
|
GRAPH_ZUORDNUNG,
|
||||||
INTERVALL_HOECHSTENS,
|
INTERVALL_HOECHSTENS,
|
||||||
INTERVALL_MINDESTENS,
|
INTERVALL_MINDESTENS,
|
||||||
|
QUELLEN_ART_TEXT,
|
||||||
ZUORDNUNGS_BESCHRIFTUNG,
|
ZUORDNUNGS_BESCHRIFTUNG,
|
||||||
ZUORDNUNGS_FELDER,
|
ZUORDNUNGS_FELDER,
|
||||||
} from "@/lib/verzeichnis";
|
} from "@/lib/verzeichnis";
|
||||||
@@ -43,8 +44,16 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
|
|||||||
* wuerde kein Mitarbeiter wiedererkannt. Der Dienst weist einen Wechsel
|
* wuerde kein Mitarbeiter wiedererkannt. Der Dienst weist einen Wechsel
|
||||||
* ohnehin ab; hier gibt es ihn gar nicht erst zur Auswahl.
|
* 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";
|
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 (
|
return (
|
||||||
<form action={aktion} className="flex flex-col gap-4">
|
<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} />
|
<input type="hidden" name="art" value={art} />
|
||||||
<p className="text-sm text-nebentext">
|
<p className="text-sm text-nebentext">
|
||||||
Art: <strong className="text-text">{istGraph ? "Microsoft Graph" : "LDAP"}</strong> — sie lässt
|
Art: <strong className="text-text">{QUELLEN_ART_TEXT[art]}</strong> — sie lässt sich nicht ändern.
|
||||||
sich nicht ändern. Daran hängt, was die dauerhafte Kennung bedeutet; nach einem Wechsel würde kein
|
Daran hängt, was die dauerhafte Kennung bedeutet; nach einem Wechsel würde kein Mitarbeiter
|
||||||
Mitarbeiter wiedererkannt.
|
wiedererkannt.
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -85,7 +94,14 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
|
|||||||
titel: "Microsoft Graph",
|
titel: "Microsoft Graph",
|
||||||
text: "Entra ID (Microsoft 365). Für Häuser ohne lokales AD.",
|
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) => (
|
).map((wahl) => (
|
||||||
<label
|
<label
|
||||||
key={wahl.wert}
|
key={wahl.wert}
|
||||||
@@ -162,7 +178,7 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!istGraph ? (
|
{istLdap ? (
|
||||||
<>
|
<>
|
||||||
{/*
|
{/*
|
||||||
* Die Erklärung steht **außerhalb** des `label` und hängt über
|
* Die Erklärung steht **außerhalb** des `label` und hängt über
|
||||||
@@ -269,6 +285,35 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{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"
|
||||||
|
value={quelle?.abgleichIntervallMinuten ?? 60}
|
||||||
|
/>
|
||||||
|
<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">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<label className="flex flex-col gap-1.5 text-sm">
|
<label className="flex flex-col gap-1.5 text-sm">
|
||||||
<span className="text-nebentext">Abgleich alle … Minuten</span>
|
<span className="text-nebentext">Abgleich alle … Minuten</span>
|
||||||
@@ -292,6 +337,7 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
|
|||||||
Nach Zeitplan abgleichen
|
Nach Zeitplan abgleichen
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
* Die Attributzuordnung ist einklappbar, aber vollständig da. Vorher stand
|
* Die Attributzuordnung ist einklappbar, aber vollständig da. Vorher stand
|
||||||
@@ -299,6 +345,21 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
|
|||||||
* OpenLDAP anbindet, ändert einzelne Zeilen“, und genau das ging über die
|
* OpenLDAP anbindet, ändert einzelne Zeilen“, und genau das ging über die
|
||||||
* Oberfläche nicht (Reviewbefund zu #016).
|
* Oberfläche nicht (Reviewbefund zu #016).
|
||||||
*/}
|
*/}
|
||||||
|
{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>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/*
|
||||||
|
* 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)}>
|
<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">
|
<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
|
Attributzuordnung anpassen
|
||||||
@@ -325,9 +386,13 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
|
|||||||
Abgleich eine Person wieder. Ein anderer Wert legt beim nächsten Lauf alle neu an.
|
Abgleich eine Person wieder. Ein anderer Wert legt beim nächsten Lauf alle neu an.
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Ein eigenes CA-Zertifikat gilt nur fuer LDAP – Graph laeuft ueber oeffentliches TLS. */}
|
{/*
|
||||||
{!istGraph ? (
|
* 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)}>
|
<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">
|
<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
|
Eigenes CA-Zertifikat hinterlegen
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
||||||
|
}
|
||||||
@@ -118,6 +118,20 @@ export const ZUORDNUNGS_BESCHRIFTUNG: Record<keyof AttributZuordnung, string> =
|
|||||||
*/
|
*/
|
||||||
export const ATTRIBUT_MUSTER = /^[A-Za-z][A-Za-z0-9-]*$/u;
|
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.
|
* Liest eine gespeicherte Zuordnung aus dem Json-Feld.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ const OEFFENTLICH: readonly string[] = [
|
|||||||
"/api/auth",
|
"/api/auth",
|
||||||
// Healthcheck fuer Docker und den Reverse Proxy.
|
// Healthcheck fuer Docker und den Reverse Proxy.
|
||||||
"/api/gesundheit",
|
"/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",
|
"/manifest.webmanifest",
|
||||||
"/icon.svg",
|
"/icon.svg",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { z } from "@/lib/validierung";
|
|||||||
import { MandantFehler, type HandelnderBenutzer } from "@/server/mandant";
|
import { MandantFehler, type HandelnderBenutzer } from "@/server/mandant";
|
||||||
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
import { abgleichAusfuehren, type AbgleichErgebnis } from "@/server/services/abgleich";
|
import { abgleichAusfuehren, type AbgleichErgebnis } from "@/server/services/abgleich";
|
||||||
|
import { scimTokenErzeugen, scimTokenWiderrufen } from "@/server/services/scim";
|
||||||
import {
|
import {
|
||||||
VerzeichnisFehler,
|
VerzeichnisFehler,
|
||||||
quelleAnlegen,
|
quelleAnlegen,
|
||||||
@@ -95,8 +96,8 @@ function zuordnungAusFormular(formular: FormData) {
|
|||||||
*/
|
*/
|
||||||
const GemeinsamSchema = z.object({
|
const GemeinsamSchema = z.object({
|
||||||
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
|
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
|
||||||
art: z.enum(["LDAP", "GRAPH"], {
|
art: z.enum(["LDAP", "GRAPH", "SCIM"], {
|
||||||
message: "Bitte die Art der Quelle wählen: LDAP oder Microsoft Graph.",
|
message: "Bitte die Art der Quelle wählen: LDAP, Microsoft Graph oder SCIM.",
|
||||||
}),
|
}),
|
||||||
caZertifikatPem: z.string().trim().optional(),
|
caZertifikatPem: z.string().trim().optional(),
|
||||||
// Dieselben Grenzen wie im Dienst, aus derselben Quelle.
|
// Dieselben Grenzen wie im Dienst, aus derselben Quelle.
|
||||||
@@ -130,9 +131,16 @@ const GraphSchema = z.object({
|
|||||||
|
|
||||||
/** Die artabhaengigen Felder aus dem Formular, geprueft. */
|
/** Die artabhaengigen Felder aus dem Formular, geprueft. */
|
||||||
function artFelder(
|
function artFelder(
|
||||||
art: "LDAP" | "GRAPH",
|
art: "LDAP" | "GRAPH" | "SCIM",
|
||||||
formular: FormData,
|
formular: FormData,
|
||||||
): { readonly werte: Record<string, unknown> } | { readonly fehler: string } {
|
): { 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") {
|
if (art === "LDAP") {
|
||||||
const gelesen = LdapSchema.safeParse({
|
const gelesen = LdapSchema.safeParse({
|
||||||
url: text(formular, "url"),
|
url: text(formular, "url"),
|
||||||
@@ -183,9 +191,11 @@ export async function quelleAnlegenAktion(
|
|||||||
const geheimnis =
|
const geheimnis =
|
||||||
eingabe.data.art === "LDAP"
|
eingabe.data.art === "LDAP"
|
||||||
? { bindPasswort: text(formular, "bindPasswort") }
|
? { bindPasswort: text(formular, "bindPasswort") }
|
||||||
: { clientSecret: text(formular, "clientSecret") };
|
: eingabe.data.art === "GRAPH"
|
||||||
|
? { clientSecret: text(formular, "clientSecret") }
|
||||||
|
: {};
|
||||||
const geheimnisWert = Object.values(geheimnis)[0];
|
const geheimnisWert = Object.values(geheimnis)[0];
|
||||||
if (!geheimnisWert) {
|
if (eingabe.data.art !== "SCIM" && !geheimnisWert) {
|
||||||
return {
|
return {
|
||||||
fehler:
|
fehler:
|
||||||
eingabe.data.art === "LDAP"
|
eingabe.data.art === "LDAP"
|
||||||
@@ -203,7 +213,12 @@ export async function quelleAnlegenAktion(
|
|||||||
zugang.benutzer,
|
zugang.benutzer,
|
||||||
);
|
);
|
||||||
revalidatePath("/admin/verzeichnis");
|
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) {
|
} catch (fehler) {
|
||||||
return alsErgebnis(fehler, "Die Quelle konnte nicht angelegt werden.");
|
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 & {
|
export type TestErgebnis = AktionsErgebnis & {
|
||||||
readonly treffer?: readonly GemappterEintrag[];
|
readonly treffer?: readonly GemappterEintrag[];
|
||||||
readonly gesamtGelesen?: number;
|
readonly gesamtGelesen?: number;
|
||||||
@@ -326,7 +393,7 @@ export async function verbindungTestenAktion(
|
|||||||
const ergebnis = await verbindungTesten(id, zugang.benutzer);
|
const ergebnis = await verbindungTesten(id, zugang.benutzer);
|
||||||
return {
|
return {
|
||||||
erfolg:
|
erfolg:
|
||||||
ergebnis.treffer.length === 0
|
ergebnis.treffer.length === 0 && ergebnis.gesamtGelesen === 0
|
||||||
? "Die Verbindung steht, aber der Filter findet niemanden."
|
? "Die Verbindung steht, aber der Filter findet niemanden."
|
||||||
: ergebnis.treffer.length === 0
|
: ergebnis.treffer.length === 0
|
||||||
? /*
|
? /*
|
||||||
|
|||||||
@@ -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") };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,11 +105,19 @@ export async function faelligeQuellen(
|
|||||||
* `psql` und eine Migration schon.
|
* `psql` und eine Migration schon.
|
||||||
*/
|
*/
|
||||||
const eigene = await eigeneOrganisation();
|
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 }[]>`
|
return prisma.$queryRaw<{ id: string; name: string }[]>`
|
||||||
SELECT "id", "name"
|
SELECT "id", "name"
|
||||||
FROM "Verzeichnisquelle"
|
FROM "Verzeichnisquelle"
|
||||||
WHERE "aktiv" = true
|
WHERE "aktiv" = true
|
||||||
AND "kundeId" = ${eigene.id}
|
AND "kundeId" = ${eigene.id}
|
||||||
|
AND "art" <> 'SCIM'
|
||||||
AND ("letzterAbgleichAm" IS NULL
|
AND ("letzterAbgleichAm" IS NULL
|
||||||
OR "letzterAbgleichAm" + make_interval(mins => "abgleichIntervallMinuten") <= ${jetzt})
|
OR "letzterAbgleichAm" + make_interval(mins => "abgleichIntervallMinuten") <= ${jetzt})
|
||||||
ORDER BY "letzterAbgleichAm" ASC NULLS FIRST, "name" ASC
|
ORDER BY "letzterAbgleichAm" ASC NULLS FIRST, "name" ASC
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -76,6 +76,10 @@ export type QuellenSicht = {
|
|||||||
readonly tenantId: string | null;
|
readonly tenantId: string | null;
|
||||||
readonly clientId: string | null;
|
readonly clientId: string | null;
|
||||||
readonly clientSecretVorhanden: boolean;
|
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 attributZuordnung: AttributZuordnung;
|
||||||
readonly caZertifikatVorhanden: boolean;
|
readonly caZertifikatVorhanden: boolean;
|
||||||
readonly abgleichIntervallMinuten: number;
|
readonly abgleichIntervallMinuten: number;
|
||||||
@@ -187,6 +191,15 @@ function alsSicht(zeile: Verzeichnisquelle): QuellenSicht {
|
|||||||
* nicht in eine Antwort schafft, kann auch nicht versehentlich hinausgehen.
|
* nicht in eine Antwort schafft, kann auch nicht versehentlich hinausgehen.
|
||||||
*/
|
*/
|
||||||
clientSecretVorhanden: Boolean(zeile.clientSecretVerschluesselt?.trim()),
|
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)),
|
attributZuordnung: zuordnungLesen(zeile.attributZuordnung, zuordnungsArt(zeile.art)),
|
||||||
// Der Inhalt bleibt hier: die Oberflaeche muss nur wissen, ob eines
|
// Der Inhalt bleibt hier: die Oberflaeche muss nur wissen, ob eines
|
||||||
// hinterlegt ist, und braucht dafuer keinen Zertifikatstext.
|
// hinterlegt ist, und braucht dafuer keinen Zertifikatstext.
|
||||||
@@ -254,9 +267,34 @@ function feldernJeArt(art: QuellenArt, eingabe: NeueQuelle) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
throw new VerzeichnisFehler(
|
||||||
`Quellen der Art ${art} lassen sich noch nicht anlegen. Der SCIM-Endpunkt kommt mit #029.`,
|
"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(
|
export async function quelleAnlegen(
|
||||||
@@ -564,6 +602,21 @@ export async function verbindungTesten(
|
|||||||
await nurIntern();
|
await nurIntern();
|
||||||
rechtPruefen(benutzer);
|
rechtPruefen(benutzer);
|
||||||
const quelle = await quelleZeileHolen(quelleId);
|
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);
|
const wieViele = Math.min(Math.max(Math.trunc(grenze), 1), PROBE_GRENZE_HOECHSTENS);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -52,14 +52,18 @@ export async function eintraegeDerQuelleStroemen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* `SCIM` steht im Enum, weil #029 dieselbe Weiche braucht — behandelt wird es
|
* **Eine SCIM-Quelle wird nicht gelesen, sie liefert** (#029) — es gibt hier
|
||||||
* dort. **Bis dahin eine Meldung und kein stilles Nichts:** ein Abgleich, der
|
* nichts abzuholen, und das bleibt so. Trotzdem eine Meldung und kein stilles
|
||||||
* null Einträge liest und `ERFOLG` meldet, würde über die Abschaltregel in
|
* Nichts: ein Abgleich, der null Einträge liest und `ERFOLG` meldet, würde
|
||||||
* `abgleichAusfuehren` die ganze Belegschaft deaktivieren. Genau dieser Fall
|
* über die Abschaltregel in `abgleichAusfuehren` die ganze Belegschaft
|
||||||
* hat in #016 einen Reviewbefund gekostet.
|
* 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(
|
throw new VerzeichnisFehler(
|
||||||
`Quellen der Art ${quelle.art} können noch nicht abgeglichen werden. ` +
|
`„${quelle.name}“ ist eine SCIM-Quelle: Laufzettel holt dort nichts ab, sondern wird ` +
|
||||||
"Der SCIM-Endpunkt kommt mit einem eigenen Meilenstein (#029).",
|
"beliefert. Ein Abgleich gibt es für sie nicht — der Anbieter schickt Änderungen, sobald " +
|
||||||
|
"sie dort entstehen.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,9 +64,10 @@ export function ldapVorgabeAus(quelle: Verzeichnisquelle): {
|
|||||||
/**
|
/**
|
||||||
* Welche Zuordnungs-Vorgaben fuer diese Art gelten.
|
* Welche Zuordnungs-Vorgaben fuer diese Art gelten.
|
||||||
*
|
*
|
||||||
* `SCIM` hat noch keine (#029) und faellt auf die LDAP-Namen zurueck — die Art
|
* `SCIM` hat keine und faellt auf die LDAP-Namen zurueck (#029): bei einem Push
|
||||||
* wird vom Dienst ohnehin abgewiesen, und ein Wurf an dieser Stelle machte jede
|
* bestimmt der Anbieter die Feldnamen, sie stehen in RFC 7643 und sind nicht
|
||||||
* Liste unbenutzbar, in der eine SCIM-Zeile steht.
|
* einstellbar. Ein Wurf an dieser Stelle machte jede Liste unbenutzbar, in der
|
||||||
|
* eine SCIM-Zeile steht.
|
||||||
*/
|
*/
|
||||||
export function zuordnungsArt(art: QuellenArt): "LDAP" | "GRAPH" {
|
export function zuordnungsArt(art: QuellenArt): "LDAP" | "GRAPH" {
|
||||||
return art === "GRAPH" ? "GRAPH" : "LDAP";
|
return art === "GRAPH" ? "GRAPH" : "LDAP";
|
||||||
|
|||||||
@@ -151,6 +151,14 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
|
|||||||
*/
|
*/
|
||||||
"Verzeichnisquelle_clientsecret_verschluesselt",
|
"Verzeichnisquelle_clientsecret_verschluesselt",
|
||||||
"Verzeichnisquelle_felder_je_art",
|
"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",
|
"VorlagenVersion_veroeffentlicht_mit_zeitpunkt",
|
||||||
"Zustellung_bezug_passt_zum_anlass",
|
"Zustellung_bezug_passt_zum_anlass",
|
||||||
"Zustellung_empfaenger_nicht_leer",
|
"Zustellung_empfaenger_nicht_leer",
|
||||||
|
|||||||
+9
-5
@@ -552,10 +552,13 @@ describe.runIf(datenbankVerfuegbar)("Der Abgleich einer Graph-Quelle", () => {
|
|||||||
|
|
||||||
it("weist eine SCIM-Quelle ab, statt stillschweigend nichts zu tun", async () => {
|
it("weist eine SCIM-Quelle ab, statt stillschweigend nichts zu tun", async () => {
|
||||||
/*
|
/*
|
||||||
* `SCIM` steht im Enum, weil #029 dieselbe Weiche braucht. Ein Abgleich, der
|
* Dieselbe Weiche, dritter Fall. Ein Abgleich, der null Einträge liest und
|
||||||
* null Einträge liest und ERFOLG meldet, hätte über die Abschaltregel die
|
* ERFOLG meldet, hätte über die Abschaltregel die ganze Belegschaft
|
||||||
* ganze Belegschaft deaktiviert — genau der Fall, der in #016 einen
|
* deaktiviert — genau der Fall, der in #016 einen Reviewbefund gekostet hat.
|
||||||
* 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 { eigene } = await graphQuelleAnlegen();
|
||||||
const scim = await testPrisma.verzeichnisquelle.create({
|
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);
|
const ergebnis = await abgleichAusfuehren(scim.id, ADMIN);
|
||||||
expect(ergebnis.status).not.toBe("ERFOLG");
|
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 () => {
|
it("liest eine LDAP-Quelle weiter über den LDAP-Weg", async () => {
|
||||||
|
|||||||
+1111
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user