Files
Florian WildeandClaude Opus 5 fcd67018b8
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
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>
2026-10-10 19:50:54 +02:00

8.3 KiB

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.

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), 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) 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:

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“.