Compare commits
3
Commits
v0.2.0
..
2d3325ef20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d3325ef20 | ||
|
|
4aa79c93ff | ||
|
|
3cbd5026f8 |
-115
@@ -7,19 +7,8 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
## [Unveröffentlicht]
|
||||
|
||||
## [0.2.0] – 2026-10-11
|
||||
|
||||
### Geändert
|
||||
|
||||
- **Wer sich anmeldet und in keiner Rollengruppe steht, bekommt nicht mehr automatisch `LESER`.**
|
||||
Bisher hieß das: Lesezugriff auf **alle** Läufe des Hauses — auch für einen ausgeschiedenen
|
||||
Techniker, solange niemand sein Konto deaktivierte. Jetzt versucht Laufzettel, ihn über seinen UPN
|
||||
einer Person im Verzeichnis zuzuordnen: klappt das, bekommt er die Selbstauskunft; klappt es
|
||||
nicht, wird die Anmeldung mit einer Begründung abgewiesen.
|
||||
**Diese Änderung greift nur, wenn Rollengruppen konfiguriert sind** (`OIDC_GROUP_*`) — ohne sie
|
||||
bleibt alles wie vorher, denn ohne abgebildete Gruppen kann die Anwendung nicht unterscheiden, ob
|
||||
jemand Techniker oder Mitarbeiter ist. Wer das alte Verhalten für bestimmte Leute braucht, trägt
|
||||
sie in die Leser-Gruppe ein. Der lokale Notfallzugang hängt nicht an den Gruppen (#035)
|
||||
- **Die Abarbeitungsansicht aktualisiert sich jetzt sofort statt alle zehn Sekunden.** Hakt ein
|
||||
Kollege einen Schritt ab, schreibt einen Kommentar, hängt ein Foto an oder ändert den Titel,
|
||||
erscheint das beim anderen in weniger als einer halben Sekunde — gemessen 485 ms, vorher bis zu
|
||||
@@ -56,63 +45,6 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Die Zusage „jede Seite und jeder Route Handler prüft sein Recht selbst“ wird jetzt geprüft.**
|
||||
Sie steht seit #007 in den Konventionen und hielt bis hierher nur durch Nachsehen von Hand — das
|
||||
gilt für den Tag, an dem es lief. `tests/zugang.test.ts` liest jetzt jede `page.tsx` und jeden
|
||||
`route.ts`; die Ausnahmen (Auth, Healthcheck) stehen dort namentlich samt Begründung (#038)
|
||||
- Das Lesemodell der Anhänge liegt jetzt in `queries/` und wendet seine Mandantensicht **selbst** an.
|
||||
Vorher hing es daran, dass beide Aufrufer den Lauf vorher prüften — kein Schaden, aber die falsche
|
||||
Form: der dritte Aufrufer ist der, der es vergisst (#037)
|
||||
- `/meine-geraete` wird jetzt bei 390 px gemessen, mit einem echten `MITARBEITER`-Konto. Die Seite
|
||||
ist mit „am Handy kein horizontales Scrollen“ abgenommen worden — von Hand. Dazu ein Test, dass
|
||||
dort kein Link in die Verwaltung angeboten wird (#037)
|
||||
- **Der Link in der Mail „Verzeichnis-Abgleich fehlgeschlagen“ zeigte ins Leere.** Gebaut wurde
|
||||
`/verwaltung/verzeichnis`; `verwaltung` ist aber der Name des Rechts, die Seite liegt unter
|
||||
`/admin/verzeichnis`. Das ist die eine Meldung, die an alle Administratoren geht, und zwar im
|
||||
Moment eines Ausfalls — der Empfänger klickte und landete auf der Nicht-gefunden-Seite. Ein Test
|
||||
prüft jetzt jeden Pfad einer Meldung gegen die vorhandenen Routen (#036)
|
||||
- **Ein offener Ereignisstrom überlebte Abmelden, Deaktivieren und Sitzungswiderruf.** Das Recht
|
||||
wurde genau einmal geprüft, beim Aufbau des Stroms. Ein Administrator deaktivierte ein Konto, und
|
||||
eine offen gelassene Lasche bekam weiter live Status und Schrittzustände dieses einen Laufs, bis
|
||||
jemand sie schloss. Der Herzschlag prüft die Sitzung jetzt alle 25 Sekunden nach; eine
|
||||
Herabstufung während des Stroms engt ihn ein, statt ihn zu beenden (#036)
|
||||
- **Der Export eines Entwurfs mit Baustein lieferte einen leeren Abschnitt.** Ein Baustein-Abschnitt
|
||||
bekommt seine Schritte erst beim Veröffentlichen; wer eine noch unveröffentlichte Vorlage in eine
|
||||
zweite Installation brachte, fand den geteilten Abschnitt dort unter dem Namen „wird ersetzt“ und
|
||||
ohne einen einzigen Schritt. Beim Export werden Bausteine jetzt aufgelöst (#036)
|
||||
- **`docs/betrieb/sicherung.md` nannte für den nativen Weg einen Anhänge-Pfad, den es nirgends
|
||||
gibt** — als Rückfallwert in einem Skript, in dem ein `rm -rf` steht. Auf einer Installation ohne
|
||||
`ANHAENGE_PFAD` in der Env-Datei wäre in ein Verzeichnis zurückgespielt worden, das die Anwendung
|
||||
nie liest: „Rücksicherung gelaufen“, und danach fehlt jede Datei. Jetzt bricht der Befehl ab,
|
||||
statt zu raten (#036)
|
||||
- **`README.md` kannte die Rolle `MITARBEITER` nicht** und behauptete weiter, unbekannte Benutzer
|
||||
würden beim ersten Login angelegt. Seit #035 wird jemand ohne Rollengruppe über seinen UPN
|
||||
zugeordnet oder abgewiesen. Wer auf v0.2.0 hebt und nur das README liest, hätte genau diese Leute
|
||||
ausgesperrt (#036)
|
||||
- **Der Protokolleintrag „Anmeldung abgewiesen: UPN mehrdeutig“ nannte immer zwei Treffer**, auch bei
|
||||
sieben. Wer das Protokoll liest, um den Fall aufzulösen, braucht die wirkliche Zahl (#036)
|
||||
- **`anzahlSchritte` im Protokoll eines neuen Laufs zählte die Schritte der Version**, nicht die des
|
||||
Laufs — bei einer Vorlage mit abteilungsgebundenen Abschnitten also zu viele (#036)
|
||||
- Die Attributzuordnung einer Verzeichnisquelle wird jetzt **im Dienst** geprüft und nicht nur im
|
||||
Formular. Ein Attributname mit Komma und `$filter` kam zuvor durch und schrieb einen zweiten
|
||||
`$filter` in die Graph-Abfrage (#036)
|
||||
- Der Graph-Seitenabruf folgt `@odata.nextLink` nur noch, wenn die Adresse auf denselben Host zeigt
|
||||
wie `GRAPH_API_URL`. Jede Seite wird mit einem mandantenweit gültigen Token geholt (#036)
|
||||
- **Die Ende-zu-Ende-Abläufe räumen auf, was sie anlegen** — zentral, vor und nach dem Lauf. In der
|
||||
Entwicklungsdatenbank standen 105 Vorlagen aus früheren Durchgängen. Ein struktureller Test
|
||||
schlägt an, wenn ein neuer Ablauf das Präfix nicht benutzt (#027)
|
||||
- Vier Ende-zu-Ende-Abläufe waren seit #020, #030, #031 beziehungsweise #033 rot, ohne dass es
|
||||
auffiel: `pnpm test:e2e` steht in keinem Tor. Alle vier behoben, die Lücke ist als #038 benannt
|
||||
(#027)
|
||||
- Der Verbindungstest einer Verzeichnisquelle unterschied „der Filter findet niemanden“ nicht mehr
|
||||
von „gelesen, aber nichts zuordenbar“ — zwei verschiedene Ursachen, die den Administrator an
|
||||
verschiedene Stellen schicken. Die Unterscheidung war in #028 eingebaut worden, aber durch eine
|
||||
doppelte Bedingung seit dem ersten Tag unerreichbar (#029)
|
||||
- **`staerksteRolle` konnte mehr Rechte liefern als übergeben.** `LESER` war Startwert und damit
|
||||
zugleich Untergrenze: eine Rolle unterhalb davon kam nie heraus. Solange alle Rollen mindestens
|
||||
`LESER` waren, fiel das nicht auf — mit der neuen Rolle `MITARBEITER` ergab
|
||||
`staerksteRolle(["MITARBEITER"])` plötzlich `LESER`. Gefunden von einem Test, der seit #007
|
||||
unverändert dasteht (#035)
|
||||
- **nginx hätte ein 10-MB-Foto abgewiesen.** `docs/betrieb/reverse-proxy.md` empfahl
|
||||
`client_max_body_size 8m`, während `ANHANG_MAX_MB` 10 MB erlaubt: ein Handyfoto am oberen Rand
|
||||
wäre mit „413 Request Entity Too Large“ an der Wand gelandet, und die freundliche Meldung der
|
||||
@@ -121,53 +53,6 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **SCIM 2.0: Personen können jetzt auch geschickt werden, statt geholt zu werden.** Bisher
|
||||
verbindet sich Laufzettel nach Zeitplan mit einem Verzeichnis und liest. Das setzt zwei Dinge
|
||||
voraus, die nicht überall gegeben sind: Lesezugriff auf das Verzeichnis, und ein Netzweg dorthin.
|
||||
Mit der neuen Quellenart `SCIM` ruft stattdessen der Identitätsanbieter Laufzettel an — authentik,
|
||||
Entra ID, Okta — und zwar in dem Moment, in dem sich bei ihm etwas ändert, nicht erst beim
|
||||
nächsten Intervall.
|
||||
Umgesetzt ist, was die Anbieter wirklich schicken: `Users` mit dem Filter `userName eq "…"`,
|
||||
`POST`, `GET`, `PUT`, `PATCH`, `DELETE`, dazu `ServiceProviderConfig`, `Schemas` und
|
||||
`ResourceTypes` — ohne die drei weigern sich manche Anbieter, überhaupt anzufangen. Übernommen
|
||||
werden Anmeldename, Name, Mailadresse, Titel, Abteilung, der Vorgesetzte und ob das Konto aktiv
|
||||
ist; keine Gruppen, keine Rollen, keine Passwörter.
|
||||
**`DELETE` deaktiviert und löscht nicht.** Der Anbieter bekommt `204` und hält die Person damit
|
||||
für entfernt — aus seiner Sicht richtig. In Laufzettel bleibt sie inaktiv stehen, weil Läufe und
|
||||
Geräte an ihr hängen. Das steht in der Anleitung, weil es sonst wie ein Fehler wirkt.
|
||||
**Eine SCIM-Quelle wird nicht abgeglichen**, und das ist keine Kosmetik: ein Abgleich, der dort
|
||||
nichts liest und „Erfolg“ meldet, hätte über die Abschaltregel die ganze Belegschaft deaktiviert.
|
||||
Verbindungstest und „Jetzt abgleichen“ gibt es für sie deshalb nicht; an ihrer Stelle steht die
|
||||
letzte Zustellung.
|
||||
Das **Token** wird unter Admin → Verzeichnis erzeugt, **einmal** angezeigt und nur als
|
||||
argon2-Hash gespeichert — es gibt keinen zweiten Weg, es zu lesen. Es ist der einzige
|
||||
Mandantenbeweis, den eine Zustellung mitbringt: ein Token sieht nur die Personen seiner eigenen
|
||||
Quelle. Einrichtung in authentik und Entra ID steht in
|
||||
[Kapitel 06](docs/anleitung/06-verzeichnis.md#scim-der-anbieter-schickt), der Betrieb in
|
||||
[`docs/betrieb/scim.md`](docs/betrieb/scim.md) (#029)
|
||||
- **Entra ID als Verzeichnisquelle, über Microsoft Graph.** Ein Haus ohne lokales Active Directory
|
||||
hat kein LDAP, auf das sich zeigen ließe. Eine Verzeichnisquelle hat jetzt eine **Art** — `LDAP`
|
||||
oder `GRAPH` —, und beide laufen durch **denselben** Abgleich: anlegen, aktualisieren,
|
||||
deaktivieren, **nie löschen**. Nur das Holen der Einträge unterscheidet sich, sonst drifteten die
|
||||
Regeln auseinander.
|
||||
Gelesen wird mit dem Client-Credentials-Flow über `GET /v1.0/users`, seitenweise über
|
||||
`@odata.nextLink`, der Vorgesetzte über `$expand=manager($select=id)`. Das Client-Secret wird
|
||||
verschlüsselt gespeichert wie das LDAP-Bind-Passwort und erscheint in keiner Antwort und in keinem
|
||||
Protokolleintrag.
|
||||
**Zwei Platzhalter bleiben bei Graph bewusst leer:** `{{ou}}` (Entra ID hat keine OU — steht an der
|
||||
Abteilung eine Ziel-OU, greift die) und `{{sam}}` ohne AD-Synchronisierung. Was in Entra ID
|
||||
einzurichten ist, steht in `docs/betrieb/entra-id.md` — mit einer Prüfliste, denn gegen einen
|
||||
echten Mandanten ist dieser Weg **nicht** gelaufen (#028)
|
||||
- **Mitarbeiter-Selbstauskunft.** „Was ist eigentlich mit meinem Notebook passiert?“ musste bisher
|
||||
ein Techniker nachsehen. Ein Mitarbeiter kann die Arbeiten an **seinen** Geräten jetzt selbst
|
||||
ansehen, unter **Meine Geräte** — mit der neuen Rolle `MITARBEITER`, zugeordnet über den **UPN**
|
||||
beim Anmelden. Ein mehrdeutiger UPN wird abgewiesen und protokolliert: ein Zufallstreffer
|
||||
entschiede nach Sortierung darüber, wessen Geräte jemand sieht.
|
||||
**Was er sieht, sind Titel, Zeitraum und Status — und sonst nichts.** Keine Schritte, keine
|
||||
Notizen, keine Kommentare, keine Anhänge: in einem Lauf stehen Arbeitsnotizen über Menschen und
|
||||
Befehle mit Platzhaltern für Zugangsdaten. Eine Selbstauskunft ist keine Akteneinsicht, und das
|
||||
hält nicht die Seite fest, sondern das Lesemodell.
|
||||
Kapitel 09 der Anleitung richtet sich erstmals **an Mitarbeiter** und nicht an die IT (#035)
|
||||
- **Benachrichtigungen per Mail und Webhook.** Ein zugewiesener Lauf fiel bisher nur auf, wenn jemand
|
||||
die Übersicht öffnete; ein überfälliger überhaupt nicht. Vier Anlässe gehen jetzt hinaus —
|
||||
**zugewiesen**, **überfällig**, **abgeschlossen** und **Verzeichnisabgleich fehlgeschlagen** —, je
|
||||
|
||||
@@ -40,14 +40,13 @@ Der Auftrag nennt englische Modelle. Verbindlich gilt:
|
||||
| — (ergänzt) | `BenutzerKunde` | — (ergänzt) | `TagCodeVergabe` |
|
||||
| — (ergänzt) | `Zustellung` | | |
|
||||
|
||||
Enums: `Rolle {ADMIN REDAKTEUR TECHNIKER LESER MITARBEITER}` · `Betriebsmodus {INTERN DIENSTLEISTUNG}` ·
|
||||
Enums: `Rolle {ADMIN REDAKTEUR TECHNIKER LESER}` · `Betriebsmodus {INTERN DIENSTLEISTUNG}` ·
|
||||
`LaufStatus {OFFEN IN_ARBEIT ABGESCHLOSSEN ARCHIVIERT}` ·
|
||||
`SchrittZustand {OFFEN ERLEDIGT NICHT_ZUTREFFEND FEHLGESCHLAGEN}` ·
|
||||
`SchrittTyp {HAKEN TEXT ZAHL AUSWAHL DATUM BEFEHL}` (später `AKTION`) ·
|
||||
`VariablenTyp {TEXT ZAHL AUSWAHL DATUM}` ·
|
||||
`GeraeteTyp {PC NOTEBOOK SERVER FIREWALL SWITCH DRUCKER RAUM SONSTIGES}` ·
|
||||
`VersionStatus {ENTWURF VEROEFFENTLICHT}` · `Herkunft {ABGLEICH MANUELL}` ·
|
||||
`QuellenArt {LDAP GRAPH SCIM}` ·
|
||||
`Zustellweg {MAIL WEBHOOK}` · `Zustellzustand {OFFEN GESENDET FEHLGESCHLAGEN AUFGEGEBEN}` ·
|
||||
`Meldeanlass {LAUF_ZUGEWIESEN LAUF_UEBERFAELLIG LAUF_ABGESCHLOSSEN ABGLEICH_FEHLGESCHLAGEN}`.
|
||||
|
||||
@@ -80,19 +79,12 @@ Dreizeiler: Recht prüfen, Zod-Schema anwenden, Service rufen.
|
||||
- **Mandant:** Jede kundenbezogene Zeile trägt `kundeId`. `kundeErmitteln()` liefert im Modus
|
||||
`INTERN` immer die eigene Organisation und **verwirft** eine mitgeschickte `kundeId`.
|
||||
- **Rechte:** nur serverseitig, aus der `RECHTE`-Tabelle über `darf()` / `rechtErforderlich()`.
|
||||
**`lesen` hat nicht jede Rolle** — `MITARBEITER` darf es seit #035 nicht, und `selbstauskunft` ist
|
||||
dessen Ersatz und kein Zusatz. Jeder Navigationseintrag nennt deshalb sein Recht; ein Eintrag
|
||||
ohne `recht` ist die Ausnahme.
|
||||
Rolle und Aktiv-Status werden pro Anfrage **aus der Datenbank** gelesen, nicht aus dem Token.
|
||||
**Jede Seite prüft selbst** — die Hülle unter `src/app/(app)/layout.tsx` ist das zweite Netz,
|
||||
nicht das erste: Next rendert Layouts bei Navigation innerhalb desselben Layouts nicht neu.
|
||||
**Route Handler deckt die Hülle nicht ab**; alles unter `src/app/api/`, das mehr tut als Auth
|
||||
und Healthcheck, ruft selbst `rechtErforderlich()`. Der Proxy prüft das Cookie absichtlich nur
|
||||
auf Vorhandensein, nie inhaltlich.
|
||||
Beides prüft `tests/zugang.test.ts` seit #036 **strukturell**: es liest jede `page.tsx` und jeden
|
||||
`route.ts` und schlägt an, wenn eine Datei nichts prüft. Die Ausnahmen stehen dort namentlich,
|
||||
samt Begründung — ein neuer Handler ohne Prüfung soll jemanden zwingen, sie aufzuschreiben.
|
||||
Vorher hielt die Zusage nur durch Nachsehen von Hand, und das gilt für den Tag, an dem es lief.
|
||||
- **Server-Module gehören nicht ins Browser-Bündel.** Eine Client-Komponente importiert nichts aus
|
||||
`src/server/*`. Was beide Seiten brauchen (Beschriftungen, Formate), liegt in `src/lib` —
|
||||
`ROLLEN_TEXT` etwa in `src/lib/rollen-text.ts`, während `RECHTE` und `darf()` serverseitig
|
||||
@@ -193,53 +185,6 @@ Wer eine Oberfläche abnimmt, klickt sie im gebauten Stand durch — hinzufügen
|
||||
speichern, neu laden, und nachsehen, ob es wirklich in der Datenbank steht.
|
||||
`pnpm docs:screenshots` verlangt deshalb ebenfalls den gebauten Stand.
|
||||
|
||||
## Die Ende-zu-Ende-Abläufe laufen vor jedem Merge nach `main`
|
||||
|
||||
```bash
|
||||
pnpm build && PORT=3300 APP_URL=http://127.0.0.1:3300 pnpm start &
|
||||
E2E_APP_URL=http://127.0.0.1:3300 pnpm test:e2e
|
||||
```
|
||||
|
||||
**Nicht im Commit-Tor, und das ist eine Rechnung, keine Bequemlichkeit.** Gemessen am 2026-10-11:
|
||||
Bau 14 s, Start 7 s, Abläufe **256 s** — zusammen 4 min 37 s, zu den 4 min des Commit-Tors. Neun
|
||||
Minuten vor jedem Commit ist ein Tor, das umgangen wird, und ein umgangenes Tor prüft nichts.
|
||||
|
||||
**Vor jedem Merge ist die Regel, weil sie gereicht hätte.** In Phase 2 waren das acht Läufe. Beim
|
||||
ersten vollständigen Durchgang nach Monaten waren **sieben Abläufe rot** (#027): ein Test, der die
|
||||
Felder des Laufzustands aufzählt und `anzahlAnhaenge` nicht kannte (seit #033); ein Selektor, der
|
||||
seit #020 zweimal traf; eine feste Zugweite von 300 px bei 532 px Abstand (seit #030/#031); und
|
||||
Entwicklungsdaten, die vom Seed abwichen. Keiner davon war ein Produktfehler — und jeder wäre am Tag
|
||||
seiner Entstehung aufgefallen.
|
||||
|
||||
**Diese Zusagen stehen ausschließlich dort**, und keine davon lässt sich nach Vitest holen: sie
|
||||
brauchen einen echten Browser.
|
||||
|
||||
| Zusage | Warum nur im Browser |
|
||||
|---|---|
|
||||
| Kein horizontales Scrollen bei 390 px | verlangt Layout, nicht Logik |
|
||||
| Scannen über `BarcodeDetector` | Browser-API samt Polyfill |
|
||||
| Sortieren per Finger | Touch-Ereignisse und `dnd-kit` |
|
||||
| Zwei Sitzungen sehen einander live | zwei Browser-Kontexte gleichzeitig |
|
||||
| Rückfall auf Polling ohne `EventSource` | das Client-Verhalten; der Handler-Teil steht in Vitest |
|
||||
| „Alle Befehle kopieren“ zeichengenau | Zwischenablage |
|
||||
| Druckansicht und Etiketten | Print-CSS |
|
||||
| Eine Rolle sieht keine Schreib-Knöpfe | gerendertes DOM |
|
||||
| Dass überhaupt hydriert wird | der Grund für „gebauter Stand“, siehe oben |
|
||||
|
||||
**Was sich holen ließ, ist geholt.** Die Rechteprüfung der Route Handler steht seit #036 in
|
||||
`tests/anhang-route.test.ts`, `tests/lauf-ereignisse.test.ts` und `tests/scim.test.ts` — die Handler
|
||||
werden mit echten `Request`-Objekten gefahren. Dazu prüft `tests/zugang.test.ts` **strukturell**,
|
||||
dass jede Seite und jeder Route Handler sein Recht selbst prüft; die Ausnahmen (Auth und
|
||||
Healthcheck) stehen dort namentlich. Vorher hielt diese Zusage nur durch Nachsehen von Hand.
|
||||
|
||||
Die 35 übersprungenen Abläufe sind die modusabhängigen: sie laufen im jeweils anderen
|
||||
`Betriebsmodus` und sagen das beim Überspringen.
|
||||
|
||||
**Die Abläufe räumen auf, was sie anlegen** (#027). Alles trägt `ZZ` am Anfang;
|
||||
`tests/e2e/aufraeumen.ts` greift daran, **vor und nach** dem Lauf. Der Nachlauf fängt Testfehler und
|
||||
Zeitabläufe, der Vorlauf fängt `Strg+C` — ein Vorlauf lässt sich nicht überspringen. Er sagt laut,
|
||||
wenn er einen Rückstand findet.
|
||||
|
||||
## Vor jedem Commit
|
||||
|
||||
```bash
|
||||
|
||||
@@ -4,15 +4,15 @@ Checklisten für wiederkehrende IT-Arbeiten. Versionierte Vorlagen, daraus gesta
|
||||
Team abgearbeitet, abgeschlossen und unveränderlich archiviert — mit Historie je Gerät und je
|
||||
Mitarbeiter. Einstieg über QR-Aufkleber: Code scannen, Geräteseite öffnen, loslegen.
|
||||
|
||||
> **Stand:** `v0.1.0` ist veröffentlicht, Phase 2 liegt auf `main` und wird `v0.2.0`. Der
|
||||
> Fortschritt steht in [`docs/issues/README.md`](docs/issues/README.md), Entscheidungen im
|
||||
> **Stand:** in Entwicklung auf `v0.1.0` (MVP). Der Fortschritt steht in
|
||||
> [`docs/issues/README.md`](docs/issues/README.md), Entscheidungen im
|
||||
> [Entwicklungstagebuch](docs/devlog.md).
|
||||
|
||||
## Zwei Betriebsmodi
|
||||
|
||||
| Modus | Für wen | Kunden-Ebene | Verzeichnis |
|
||||
|---|---|---|---|
|
||||
| `INTERN` (Standard) | eine Firma, eigene Mitarbeiter | unsichtbar | eigenes Verzeichnis per LDAP, Microsoft Graph oder SCIM |
|
||||
| `INTERN` (Standard) | eine Firma, eigene Mitarbeiter | unsichtbar | eigenes AD per LDAP |
|
||||
| `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
|
||||
@@ -158,48 +158,15 @@ zugeordnet. Unbekannte Benutzer werden beim ersten Login angelegt.
|
||||
| `REDAKTEUR` | Vorlagen anlegen, bearbeiten, veröffentlichen; alles was `TECHNIKER` darf |
|
||||
| `TECHNIKER` | Läufe starten, abarbeiten, abschließen, archivieren; Geräte pflegen |
|
||||
| `LESER` | nur lesen |
|
||||
| `MITARBEITER` | **nur die eigenen Geräte und deren Läufe** — ohne Schritte, Notizen und Kommentare |
|
||||
|
||||
**`MITARBEITER` hat keine eigene Gruppe, und das ist Absicht** (seit #035). Wer sich anmeldet und in
|
||||
keiner der vier abgebildeten Gruppen steht, bekommt **nicht mehr** `LESER` — das hieße Lesezugriff
|
||||
auf alle Läufe des Hauses. Stattdessen versucht Laufzettel, ihn über seinen UPN einer Person im
|
||||
Verzeichnis zuzuordnen: klappt das, bekommt er die Selbstauskunft; klappt es nicht, wird die
|
||||
Anmeldung mit einer Begründung abgewiesen.
|
||||
|
||||
Das greift **nur, wenn Rollengruppen konfiguriert sind** — ohne `OIDC_GROUP_*` bleibt alles wie
|
||||
vorher, denn ohne abgebildete Gruppen kann die Anwendung nicht unterscheiden, ob jemand Techniker
|
||||
oder Mitarbeiter ist. Wer das alte Verhalten für bestimmte Leute braucht, trägt sie in die
|
||||
Leser-Gruppe ein. Einzelheiten 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.
|
||||
|
||||
## Verzeichnis (nur Modus `INTERN`)
|
||||
## Verzeichnis-Abgleich (nur Modus `INTERN`)
|
||||
|
||||
Unter **Admin → Verzeichnis** wird eine Quelle angelegt. Es gibt drei Arten, und sie unterscheiden
|
||||
sich in der **Richtung**:
|
||||
|
||||
| Art | Für wen | Richtung |
|
||||
|---|---|---|
|
||||
| `LDAP` | lokales Active Directory oder anderes LDAP-Verzeichnis | Laufzettel holt nach Zeitplan |
|
||||
| `GRAPH` | Entra ID über Microsoft Graph, für Häuser ohne lokales AD | Laufzettel holt nach Zeitplan |
|
||||
| `SCIM` | authentik, Entra ID, Okta — wenn Laufzettel nicht ins Verzeichnis sehen soll oder es nicht erreicht | der Anbieter schickt, sofort |
|
||||
|
||||
**Die Art ist unveränderlich.** An ihr hängt, was die dauerhafte Kennung einer Person bedeutet;
|
||||
nach einem Wechsel würde niemand wiedererkannt. Alle drei verhalten sich danach gleich: anlegen,
|
||||
übernehmen, aktualisieren, deaktivieren, **nie löschen**.
|
||||
|
||||
Für **LDAP** gehören ins Formular: Adresse (`ldaps://` empfohlen, `ldap+starttls://` und `ldap://`
|
||||
gehen auch), ein Dienstkonto mit **Leserecht**, der Basis-DN und optional ein eigener Filter. Ein
|
||||
Dienstkonto genügt — Laufzettel schreibt nie ins Verzeichnis. Für **Graph** eine App-Registrierung
|
||||
mit der Anwendungsberechtigung `User.Read.All`, siehe `docs/betrieb/entra-id.md`.
|
||||
|
||||
Für **SCIM** gibt es keine Zugangsdaten: Quelle anlegen, Token erzeugen (es erscheint **einmal**,
|
||||
gespeichert wird nur ein argon2-Hash), Token und die Adresse `…/api/scim/v2` beim Anbieter
|
||||
eintragen. Verbindungstest, Zeitplan und Abgleich-Log gibt es dort nicht — an ihrer Stelle steht
|
||||
die letzte Zustellung. Ein `DELETE` des Anbieters **deaktiviert** die Person und löscht nichts.
|
||||
Einzelheiten in `docs/betrieb/scim.md`.
|
||||
Unter **Admin → Verzeichnis** wird eine LDAP-Quelle angelegt: Adresse (`ldaps://` empfohlen,
|
||||
`ldap+starttls://` und `ldap://` gehen auch), ein Dienstkonto mit **Leserecht**, der Basis-DN und
|
||||
optional ein eigener Filter. Ein Dienstkonto genügt — Laufzettel schreibt nie ins Verzeichnis.
|
||||
|
||||
- **„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.
|
||||
|
||||
@@ -16,141 +16,6 @@ Zwei Dinge vorweg:
|
||||
Wer im Verzeichnis verschwindet oder dort deaktiviert wird, wird hier **inaktiv**. Gelöscht wird
|
||||
nie, sonst verlöre die Historie ihre Namen.
|
||||
|
||||
## Drei Arten von Quelle
|
||||
|
||||
Laufzettel braucht die Personen aus einem Verzeichnis. Dafür gibt es drei Wege, und du wählst ihn
|
||||
beim Anlegen der Quelle:
|
||||
|
||||
| Art | Für wen | Richtung |
|
||||
|---|---|---|
|
||||
| **LDAP** | ein lokales Active Directory (oder ein anderes LDAP-Verzeichnis) | Laufzettel holt |
|
||||
| **Microsoft Graph** | Entra ID, also Microsoft 365 — für Häuser ohne lokales AD | Laufzettel holt |
|
||||
| **SCIM** | authentik, Entra ID, Okta — wenn Laufzettel nicht ins Verzeichnis sehen soll oder es nicht erreicht | der Anbieter schickt |
|
||||
|
||||
Die ersten zwei sind dieselbe Arbeitsweise mit anderen Zugangsdaten: Laufzettel verbindet sich nach
|
||||
Zeitplan und liest. **SCIM ist die andere Richtung** — dort gibt es keine Zugangsdaten, keinen
|
||||
Zeitplan und keinen Verbindungstest; der Anbieter schickt jede Änderung in dem Moment, in dem sie
|
||||
dort entsteht. Für SCIM gibt es [einen eigenen Abschnitt](#scim-der-anbieter-schickt) weiter unten.
|
||||
|
||||
Alle drei verhalten sich danach gleich: anlegen, aktualisieren, deaktivieren, **nie löschen**. Was
|
||||
LDAP und Graph unterscheidet, sind die Zugangsdaten und zwei Platzhalter:
|
||||
|
||||
- **`{{ou}}` bleibt bei Graph leer.** Entra ID hat keine OU. Steht an der Abteilung eine Ziel-OU
|
||||
gepflegt, nimmt Laufzettel die; sonst bleibt der Platzhalter stehen.
|
||||
- **`{{sam}}` ist bei Graph leer**, wenn der Mandant kein lokales AD synchronisiert — dort gibt es
|
||||
keinen Anmeldenamen in diesem Sinn.
|
||||
|
||||
**Die Art lässt sich nachträglich nicht ändern.** An ihr hängt, was die dauerhafte Kennung einer
|
||||
Person bedeutet; nach einem Wechsel würde niemand mehr wiedererkannt, und der nächste Abgleich legte
|
||||
alle neu an. Wer wechseln muss, legt eine zweite Quelle an und lässt die erste stehen.
|
||||
|
||||
Was in Entra ID einzurichten ist — App-Registrierung, Berechtigung, Einwilligung — steht in
|
||||
[`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
|
||||
|
||||

|
||||
@@ -225,10 +90,6 @@ 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
|
||||
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).
|
||||
@@ -308,7 +169,3 @@ deshalb als Hinweis und nicht als Fehler da. Steigt die Zahl, prüf die Attribut
|
||||
Eine Quelle ist keine Voraussetzung. Personen lassen sich von Hand anlegen oder aus einer CSV-Datei
|
||||
einlesen — ein Verzeichnis nimmt nur die Arbeit ab. Siehe
|
||||
[Kapitel 05](05-mitarbeiter-geraete.md).
|
||||
|
||||
Von Hand angelegte Personen gehen dabei nicht verloren, wenn später eine Quelle dazukommt: taucht
|
||||
jemand mit demselben Anmeldenamen aus dem Verzeichnis oder per SCIM auf, **übernimmt** Laufzettel
|
||||
die vorhandene Zeile statt eine zweite anzulegen. Ihre Läufe und Geräte bleiben an ihr hängen.
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
# Meine Geräte
|
||||
|
||||
> **Dieses Kapitel ist für dich, wenn du kein IT-Mitarbeiter bist.** Alle anderen Kapitel richten
|
||||
> sich an die Leute, die Laufzettel bedienen — dieses hier an die, über die darin etwas steht.
|
||||
|
||||
Wenn die IT an deinem Rechner gearbeitet hat, hält sie das in Laufzettel fest: was gemacht wurde,
|
||||
wann und mit welchem Gerät. Unter **Meine Geräte** siehst du den Teil davon, der dich betrifft.
|
||||
|
||||
## Anmelden
|
||||
|
||||
Du meldest dich mit demselben Konto an wie überall sonst im Haus — über den Knopf **Mit dem Anbieter
|
||||
anmelden**. Ein eigenes Passwort für Laufzettel gibt es nicht.
|
||||
|
||||
Danach landest du direkt auf **Meine Geräte**. Mehr gibt es für dich nicht zu sehen, und das ist
|
||||
keine Einschränkung aus Versehen: alles andere in Laufzettel sind Arbeitsunterlagen der IT.
|
||||
|
||||
## Was dort steht
|
||||
|
||||
Für jedes Gerät, das auf dich läuft:
|
||||
|
||||
- **Name und Typ** — etwa „NB-042, Notebook“
|
||||
- **Seriennummer und Aufkleber-Code** — hilfreich, wenn du bei der IT anrufst
|
||||
- **Die Arbeiten daran**, neueste zuerst: Titel, Zeitraum und ob sie fertig sind
|
||||
|
||||
Darunter, falls es das gibt, ein Abschnitt **Zu mir persönlich**: Arbeiten, die an keinem Gerät
|
||||
hängen — etwa das Einrichten deines Kontos, als du angefangen hast.
|
||||
|
||||
Und ganz oben **Meine Daten**: dein Name, deine Funktion, deine Abteilung und deine Mailadresse, so
|
||||
wie das Verzeichnis sie führt. Stimmt da etwas nicht, sag es der IT — ändern lässt es sich hier
|
||||
nicht, denn diese Angaben kommen aus dem Verzeichnis des Hauses.
|
||||
|
||||
## Was dort nicht steht, und warum
|
||||
|
||||
Du siehst **Titel, Zeitraum und Status** einer Arbeit. Du siehst **nicht**:
|
||||
|
||||
- die einzelnen Schritte
|
||||
- Notizen der Techniker
|
||||
- Kommentare
|
||||
- angehängte Fotos
|
||||
|
||||
Das ist Absicht und keine Geheimniskrämerei. In einem Lauf stehen Arbeitsnotizen, die während der
|
||||
Arbeit entstehen, und Befehle mit Platzhaltern für Zugangsdaten. Beides ist nicht dafür gedacht,
|
||||
gelesen zu werden — weder von dir noch von sonst jemandem außerhalb der IT.
|
||||
|
||||
**Wenn du wissen willst, was genau gemacht wurde, frag die IT.** Dann antwortet ein Mensch, der den
|
||||
Zusammenhang kennt, statt einer Liste, die man missverstehen kann.
|
||||
|
||||
## Ein archiviertes Gerät steht weiter da
|
||||
|
||||
Geräte, die nicht mehr in Benutzung sind, bleiben in der Liste — mit dem Vermerk **nicht mehr in
|
||||
Benutzung**. „Was war eigentlich mit meinem alten Notebook?“ ist genau die Frage, um die es hier
|
||||
geht, und das alte Notebook ist archiviert.
|
||||
|
||||
## Die Liste ist leer
|
||||
|
||||
Dann hat die IT zu dir nichts festgehalten. Kein Fehler — es heißt nur, dass auf dich gerade kein
|
||||
Gerät läuft und keine Arbeit zu dir vermerkt ist.
|
||||
|
||||
## „Dein Konto ist keiner Person im Verzeichnis zugeordnet“
|
||||
|
||||
Diese Meldung bei der Anmeldung heißt: Laufzettel konnte dein Konto keiner Person zuordnen. Meist
|
||||
liegt es daran, dass dein Anmeldename im Verzeichnis anders geschrieben steht als beim
|
||||
Anmeldeanbieter. Das kann nur die IT auflösen — melde dich dort.
|
||||
|
||||
## Keine Mail, keine Benachrichtigung
|
||||
|
||||
Laufzettel schickt dir nichts. Die Benachrichtigungen, die es gibt, gehen an die IT: wenn eine
|
||||
Arbeit jemandem zugewiesen wird, überfällig ist oder fertig. Du erfährst von einer Arbeit an deinem
|
||||
Gerät auf dem üblichen Weg — der Techniker sagt es dir.
|
||||
@@ -29,14 +29,6 @@ nie an ein zweites Gerät vergeben — er ist die einzige Angabe am Gerät, die
|
||||
- [06 Verzeichnis anbinden](06-verzeichnis.md) — Active Directory als Quelle der Personen
|
||||
- [07 Verwaltung](07-admin.md) — Benutzer, Rollen, Einstellungen, Betriebsmodus, Kunden
|
||||
|
||||
**Mitarbeiter** — du bedienst Laufzettel nicht, sondern willst nur wissen, was mit deinem Gerät
|
||||
passiert ist:
|
||||
|
||||
- [09 Meine Geräte](09-selbstauskunft.md) — anmelden, was dort steht und was nicht
|
||||
|
||||
Dieses eine Kapitel ist **an Mitarbeiter gerichtet** und nicht an die IT. Wer die Selbstauskunft im
|
||||
Haus freigibt, kann es weitergeben, ohne den Rest der Anleitung mitzuschicken.
|
||||
|
||||
## Zwei Betriebsmodi
|
||||
|
||||
Laufzettel läuft in einem von zwei Modi. Der Modus entscheidet, ob es eine Kundenebene gibt, und
|
||||
|
||||
@@ -153,17 +153,6 @@ Zwei Folgen, beide verbindlich:
|
||||
klickt den Editor durch: anlegen, tippen, sortieren mit dem Finger bei 390 px, speichern, neu
|
||||
laden und nachsehen, ob es wirklich in der Datenbank steht.
|
||||
|
||||
Daraus folgt eine dritte, die erst #027 gelehrt hat: **diese Abläufe müssen regelmäßig laufen, weil
|
||||
sie die einzigen Prüfer ihrer Zusagen sind.** Sie stehen nicht im Commit-Tor — gemessen 256 s für
|
||||
die Abläufe, dazu Bau und Start, zu den vier Minuten des Tores; neun Minuten vor jedem Commit ist
|
||||
ein Tor, das umgangen wird. Verbindlich ist deshalb **vor jedem Merge nach `main`**, und `CLAUDE.md`
|
||||
führt auf, welche Zusagen ausschließlich dort hängen.
|
||||
|
||||
Beim ersten vollständigen Durchgang nach Monaten waren sieben Abläufe rot, vier Ursachen, **keine
|
||||
ein Produktfehler** — ein veralteter Feldvergleich, ein seit #020 mehrdeutiger Selektor, eine feste
|
||||
Pixelzahl bei gewachsenem Layout und abweichende Entwicklungsdaten. Dass keiner ein Produktfehler
|
||||
war, ist kein Trost: es heißt, dass die Abläufe in dieser Zeit **nichts** geprüft haben.
|
||||
|
||||
Die Content-Security-Policy in `src/proxy.ts` ist deshalb zweigeteilt: im Entwicklungsmodus mit
|
||||
`unsafe-eval` und WebSocket, im ausgelieferten Stand ohne beides. Die Entscheidung fällt an
|
||||
`process.env.NODE_ENV`, und `tests/sicherheitsrichtlinie.test.ts` prüft sie am Proxy selbst — eine
|
||||
@@ -276,41 +265,6 @@ Tabelle wie die des Abgleichs: hinter einem Lastverteiler laufen zwei Instanzen,
|
||||
*keine* Mail aus. Beides sieht die Live-Aktualisierung sofort (Abschnitt 5d); eine Mail dafür wäre
|
||||
Lärm. Mehr Anlässe wären ein Benachrichtigungsbaukasten, und der ist eine Phase für sich.
|
||||
|
||||
### Warum ein Mitarbeiter keine Akteneinsicht bekommt
|
||||
|
||||
Ein `Mitarbeiter` ist kein `Benutzer`. Die vier Rollen bis #035 waren für Leute, die **arbeiten**;
|
||||
ein Mitarbeiter ist Gegenstand der Arbeit, nicht ihr Träger. Mit der Selbstauskunft kommt die fünfte
|
||||
Rolle `MITARBEITER` dazu, und sie ist die einzige, die **nicht** `lesen` darf.
|
||||
|
||||
**`selbstauskunft` ist ein Ersatz für `lesen`, kein Zusatz.** Darum hat auch ein `ADMIN` dieses
|
||||
Recht nicht: er sieht über `lesen` ohnehin alles, und ein zweiter Weg zu denselben Daten wäre eine
|
||||
zweite Stelle, an der jemand die Einschränkung auf die eigene Person vergisst. Wer beides braucht,
|
||||
braucht zwei Konten — und genau das hält die Datenbank fest: der CHECK
|
||||
`Benutzer_mitarbeiter_nur_bei_rolle` verlangt, dass Rolle und Zuordnung zusammengehören, in beide
|
||||
Richtungen.
|
||||
|
||||
**Was zurückkommt, entscheidet das Lesemodell und nicht die Seite.**
|
||||
`src/server/queries/selbstauskunft.ts` liefert Titel, Status, Zeitraum und Gerät — keine Schritte,
|
||||
keine Notizen, keine Kommentare, keine Anhänge. In einem Lauf stehen Arbeitsnotizen über Menschen
|
||||
(„Nutzerin war unkooperativ“) und Befehle mit Platzhaltern für Zugangsdaten; beides ist nicht dafür
|
||||
gedacht, gelesen zu werden. Eine Seite, die filtert, wäre eine Seite, die es vergessen kann.
|
||||
|
||||
Geprüft wird das an der **Antwort** und nicht an der Seite: `tests/selbstauskunft.test.ts` hängt an
|
||||
einen Lauf eine Notiz, einen Kommentar, einen Anhang und einen Befehlsschritt mit
|
||||
`{{generiertes_passwort}}` und prüft, dass nichts davon durchkommt. Dazu ein struktureller Test, der
|
||||
das `select` selbst liest — denn eine Gegenprobe zeigte, dass der erste Test grün blieb, als das
|
||||
`select` versuchsweise Notizen mitholte: gerettet hatte ihn die Abbildungsfunktion, nicht die
|
||||
Abfrage.
|
||||
|
||||
**Zugeordnet wird über den UPN, und mehrdeutig ist ein Fehler.** Zwei Kunden können dieselbe Person
|
||||
führen; ein `findFirst` entschiede dann nach Sortierung darüber, wessen Geräte jemand sieht. Lieber
|
||||
kein Zugang als der falsche — die Anmeldung wird abgewiesen und der Fall protokolliert.
|
||||
|
||||
**Die Weiche greift nur mit eingerichteter Gruppenzuordnung.** Ohne abgebildete Gruppen kann die
|
||||
Anwendung nicht unterscheiden, ob jemand Techniker oder Mitarbeiter ist; dort bleibt alles wie vor
|
||||
#035. Das ist keine Vorsicht, sondern Notwendigkeit: ein Update, das jeden aussperrt, dem die
|
||||
Gruppen fehlen, wäre ein kaputtes Update.
|
||||
|
||||
## 5e. Der Aufkleber ist die Schnittstelle
|
||||
|
||||
Jedes Objekt traegt einen QR-Aufkleber mit einem `tagCode`, der **nie geaendert
|
||||
@@ -389,34 +343,6 @@ was darauf steht, ist aus der Hand gegeben.
|
||||
|
||||
## 5g. Der Verzeichnis-Abgleich: vier Schichten und eine Notbremse
|
||||
|
||||
Eine Verzeichnisquelle hat seit #028 eine **Art** — `LDAP`, `GRAPH` oder `SCIM` —,
|
||||
und die erste Frage ist nicht, wie gelesen wird, sondern **in welche Richtung**:
|
||||
|
||||
| Art | Richtung | Wo der Code liegt |
|
||||
|---|---|---|
|
||||
| `LDAP` | Laufzettel holt | `src/server/ldap/*` |
|
||||
| `GRAPH` | Laufzettel holt | `src/server/graph/*` |
|
||||
| `SCIM` | der Anbieter schickt | `src/app/api/scim/*`, `src/server/scim/*`, `src/server/services/scim.ts` |
|
||||
|
||||
Die ersten zwei laufen durch **denselben** Abgleich; die eine Stelle, die die Art
|
||||
kennt, ist `eintraegeDerQuelleStroemen` in
|
||||
`src/server/verzeichnis/quellenleser.ts`. Eine zweite Verzweigung waere eine
|
||||
zweite Stelle, die sich an eine neue Quellenart erinnern muss, und die vergisst
|
||||
man.
|
||||
|
||||
**Bei `SCIM` gibt es keinen Abgleich**, und das ist an zwei Stellen gehalten:
|
||||
`faelligeQuellen` nimmt solche Quellen nicht auf, und die Weiche wirft. Der Grund
|
||||
ist nicht Ordnungsliebe — ein Abgleich, der dort nichts liest und `ERFOLG` meldet,
|
||||
haette ueber die Abschaltregel die ganze Belegschaft deaktiviert. Dasselbe Muster
|
||||
wie der Reviewbefund aus #016, nur mit einer anderen Ursache.
|
||||
|
||||
Was beim Schreiben danach gilt, gilt fuer alle drei gleich: anlegen, uebernehmen,
|
||||
aktualisieren, deaktivieren, **nie loeschen** — auch ein `DELETE` eines
|
||||
SCIM-Anbieters setzt nur `aktiv = false`. Das **Token** einer SCIM-Quelle ist der
|
||||
einzige Mandantenbeweis, den eine Zustellung mitbringt; es steht als argon2-Hash
|
||||
in der Datenbank, wird einmal angezeigt und kommt in keiner Sicht und keinem
|
||||
Protokolleintrag vor.
|
||||
|
||||
Das LDAP-Modul liegt unter `src/server/ldap/` und ist absichtlich in vier Dateien
|
||||
geteilt, von denen nur eine das Netz anfasst:
|
||||
|
||||
|
||||
@@ -56,12 +56,8 @@ Sitzungen ungültig *und* die verschlüsselt gespeicherten LDAP-Bind-Passwörter
|
||||
- **[Anmeldung über authentik](authentik.md)** — OIDC-Provider, Gruppen auf Rollen, Notfallzugang
|
||||
- **[Sichern und Zurückspielen](sicherung.md)** — auf beiden Wegen, was in die Sicherung gehört und
|
||||
was nicht, und wie du prüfst, dass das Zurückspielen wirklich funktioniert
|
||||
- **[Entra ID als Verzeichnisquelle](entra-id.md)** — App-Registrierung, `User.Read.All`,
|
||||
Administratoreinwilligung, und was anders ist als bei einem lokalen AD
|
||||
- **[Benachrichtigungen](benachrichtigungen.md)** — SMTP einrichten, den Webhook prüfen, die
|
||||
Signatur nachrechnen, und wo man nachsieht, wenn keine Mail mehr ankommt
|
||||
- **[SCIM-Endpunkt](scim.md)** — die einzige Stelle, an der ein fremder Dienst von außen schreibend
|
||||
zugreift: das Token, was nicht unterstützt wird, und was der Reverse Proxy durchlassen muss
|
||||
|
||||
## Wo sonst noch etwas steht
|
||||
|
||||
|
||||
@@ -58,16 +58,9 @@ Lege in authentik vier Gruppen an und trage die Mitglieder ein:
|
||||
| `lz-redakteur` | `REDAKTEUR` | Vorlagen anlegen, bearbeiten, veröffentlichen |
|
||||
| `lz-techniker` | `TECHNIKER` | Läufe starten, abarbeiten, abschließen, archivieren; Geräte pflegen |
|
||||
| `lz-leser` | `LESER` | nur lesen |
|
||||
| *(keine davon)* | `MITARBEITER` | **nur die eigenen Geräte**, siehe unten |
|
||||
|
||||
Ist jemand in mehreren Gruppen, gewinnt die stärkere Rolle.
|
||||
|
||||
> **Die fünfte Zeile hat keine Gruppe, und das ist Absicht.** Wer sich anmeldet und in keiner der
|
||||
> vier Gruppen steht, ist für Laufzettel ein Mitarbeiter des Hauses und bekommt die
|
||||
> Selbstauskunft — vorausgesetzt, er lässt sich einer Person im Verzeichnis zuordnen. Eine eigene
|
||||
> Gruppe dafür anzulegen hieße, jeden Mitarbeiter einzeln einzutragen; „alle übrigen“ ist genau die
|
||||
> Menge, die gemeint ist.
|
||||
|
||||
## 5. Laufzettel konfigurieren
|
||||
|
||||
In der `.env`:
|
||||
@@ -100,47 +93,11 @@ docker compose up -d
|
||||
- Ein unbekannter Benutzer wird beim ersten Login **angelegt** (JIT-Provisioning)
|
||||
- Die Rolle wird bei **jedem** Login aus `groups` neu gesetzt — eine Änderung in authentik greift
|
||||
ab der nächsten Anmeldung
|
||||
- Ist der Benutzer in keiner der vier Gruppen, versucht Laufzettel, ihn über seinen **UPN** einer
|
||||
Person im Verzeichnis zuzuordnen. Klappt das, bekommt er `MITARBEITER` und sieht nur seine
|
||||
eigenen Geräte; klappt es nicht, wird die Anmeldung **abgewiesen** — mit einer Begründung auf der
|
||||
Anmeldeseite
|
||||
- **Bis Version 0.2.0 bekam er stattdessen `LESER`**, also Lesezugriff auf alle Läufe des Hauses.
|
||||
Wer das Verhalten von damals braucht, trägt die betreffenden Leute in `lz-leser` ein
|
||||
- Ist der Benutzer in keiner der vier Gruppen, bekommt er `LESER`
|
||||
- Wer vor dem Login eine bestimmte Seite aufgerufen hat (z. B. `/t/LZ-7K3F9Q` nach dem Scannen
|
||||
eines QR-Codes), landet danach genau dort
|
||||
|
||||
## 7. Selbstauskunft für Mitarbeiter
|
||||
|
||||
Ein Mitarbeiter, der in keiner Rollengruppe steht, sieht unter **Meine Geräte** die Arbeiten an
|
||||
seinen eigenen Geräten — Titel, Zeitraum, Status. **Keine Schritte, keine Notizen, keine Kommentare,
|
||||
keine Anhänge**; warum, steht in `docs/architektur.md` unter „Warum ein Mitarbeiter keine Akteneinsicht bekommt“.
|
||||
|
||||
Zugeordnet wird über den **UPN**. Laufzettel liest ihn in dieser Reihenfolge:
|
||||
|
||||
1. der Anspruch `upn` (so heißt er bei Entra ID)
|
||||
2. `preferred_username` (authentik und Keycloak schicken dort den Anmeldenamen)
|
||||
3. die Mailadresse
|
||||
|
||||
Verglichen wird mit `Mitarbeiter.upn`, unabhängig von Groß- und Kleinschreibung. Damit das greift,
|
||||
muss der UPN in authentik derselbe sein wie der im Active Directory — bei einem AD als Quelle von
|
||||
authentik ist er das meist von selbst.
|
||||
|
||||
**Zwei Fälle, die bewusst nicht durchgehen:**
|
||||
|
||||
| Fall | Was passiert |
|
||||
|---|---|
|
||||
| Zu dem UPN gibt es **keine aktive** Person | Anmeldung abgewiesen, mit Begründung auf der Anmeldeseite |
|
||||
| Zu dem UPN gibt es **mehr als eine** Person | Anmeldung abgewiesen und im Protokoll festgehalten |
|
||||
|
||||
Der zweite Fall kommt im Modus **Dienstleistung** vor, wenn zwei Kunden dieselbe Person führen. Ein
|
||||
Zufallstreffer wäre dort schlimmer als kein Zugang: er entschiede nach Sortierung darüber, wessen
|
||||
Geräte jemand sieht.
|
||||
|
||||
**Abschalten lässt sich das Ganze**, indem du keine Rollengruppen konfigurierst — dann greift die
|
||||
Weiche nicht, und alles bleibt wie vor 0.2.0. Oder indem du jeden, der hinein soll, in `lz-leser`
|
||||
einträgst.
|
||||
|
||||
## 8. Notfallzugang
|
||||
## 7. Notfallzugang
|
||||
|
||||
Fällt authentik aus, kommt niemand mehr in Laufzettel. Für diesen Fall gibt es den lokalen Login:
|
||||
|
||||
@@ -155,7 +112,7 @@ Passwörter liegen als argon2-Hash in der Datenbank.
|
||||
Empfehlung: ein einziges lokales Admin-Konto, Passwort im Passwortmanager, `AUTH_LOCAL_ENABLED`
|
||||
im Normalbetrieb auf `false` und nur im Notfall umgestellt.
|
||||
|
||||
## 9. Fehlersuche
|
||||
## 8. Fehlersuche
|
||||
|
||||
| Symptom | Ursache |
|
||||
|---|---|
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
# Entra ID als Verzeichnisquelle
|
||||
|
||||
Ein Haus ohne lokales Active Directory hat kein LDAP, auf das sich zeigen ließe. Für reine
|
||||
Microsoft-365-Umgebungen liest Laufzettel über **Microsoft Graph** — mit denselben Regeln wie der
|
||||
LDAP-Abgleich: anlegen, aktualisieren, deaktivieren, **nie löschen**.
|
||||
|
||||
> **Dieser Weg ist nicht gegen einen echten Mandanten gelaufen.** Geprüft ist er gegen einen
|
||||
> HTTP-Doppelgänger mit aufgezeichneten Graph-Antworten (`tests/graph.test.ts`), und die Form dieser
|
||||
> Antworten stammt aus der Dokumentation, nicht aus einem Mitschnitt. Die Prüfliste am Ende dieser
|
||||
> Seite gehört einmal abgearbeitet, so wie bei Proxmox (#024).
|
||||
|
||||
## Was in Entra ID einzurichten ist
|
||||
|
||||
### 1. App-Registrierung anlegen
|
||||
|
||||
Im Entra-Portal unter **Identität → Anwendungen → App-Registrierungen → Neue Registrierung**:
|
||||
|
||||
| Feld | Wert |
|
||||
|---|---|
|
||||
| Name | `Laufzettel Verzeichnisabgleich` |
|
||||
| Unterstützte Kontotypen | **Nur Konten in diesem Organisationsverzeichnis** |
|
||||
| Umleitungs-URI | **keiner** — Laufzettel meldet sich als Anwendung an, nicht im Namen eines Menschen |
|
||||
|
||||
Notiere von der Übersichtsseite die **Anwendungs-ID (Client)** und die **Verzeichnis-ID (Mandant)**.
|
||||
|
||||
### 2. Berechtigung erteilen
|
||||
|
||||
Unter **API-Berechtigungen → Berechtigung hinzufügen → Microsoft Graph →
|
||||
Anwendungsberechtigungen**:
|
||||
|
||||
| Berechtigung | Wofür |
|
||||
|---|---|
|
||||
| `User.Read.All` | die Benutzer lesen |
|
||||
|
||||
Dann **Administratoreinwilligung erteilen**. Ohne diesen Klick antwortet Graph mit `403` und
|
||||
`Authorization_RequestDenied` — Laufzettel nennt den Grund in der Fehlermeldung, weil ihn sonst
|
||||
niemand errät.
|
||||
|
||||
> **Anwendungsberechtigung, nicht delegierte.** Der Abgleich läuft nachts, wenn niemand angemeldet
|
||||
> ist; eine delegierte Berechtigung braucht einen angemeldeten Menschen und funktioniert dort nicht.
|
||||
|
||||
`Directory.Read.All` wird **nicht** gebraucht. `User.Read.All` deckt die Benutzer und über
|
||||
`$expand=manager` auch den Vorgesetzten ab — die weitergehende Berechtigung wäre mehr Zugriff, als
|
||||
die Aufgabe verlangt.
|
||||
|
||||
### 3. Geheimnis erzeugen
|
||||
|
||||
Unter **Zertifikate & Geheimnisse → Neuer geheimer Clientschlüssel**. Die Laufzeit ist eine
|
||||
Entscheidung mit Folgen: **läuft das Geheimnis ab, hört der Abgleich auf, und niemand merkt es**,
|
||||
weil jeder Lauf weiter funktioniert. Setz dir eine Erinnerung, und sieh unter **Verwaltung →
|
||||
Verzeichnis** nach dem Status, wenn Personen nicht mehr aktualisiert werden.
|
||||
|
||||
Der Wert ist **nur beim Anlegen sichtbar**. Kopier ihn sofort.
|
||||
|
||||
## In Laufzettel einrichten
|
||||
|
||||
Unter **Verwaltung → Verzeichnis → Quelle anlegen**, Art **Microsoft Graph**:
|
||||
|
||||
| Feld | Wert |
|
||||
|---|---|
|
||||
| Mandanten-ID | die Verzeichnis-ID oder der Domänenname (`contoso.onmicrosoft.com`) |
|
||||
| Anwendungs-ID | die Anwendungs-ID (Client) aus Schritt 1 |
|
||||
| Client-Secret | der Wert aus Schritt 3 — wird verschlüsselt gespeichert und nie wieder angezeigt |
|
||||
|
||||
Dann **Verbindung testen**: es erscheinen die ersten zehn zugeordneten Personen. Steht dort nichts,
|
||||
hilft die Tabelle unter „Wenn es klemmt“.
|
||||
|
||||
## Was anders ist als bei einem lokalen AD
|
||||
|
||||
| | LDAP (AD) | Microsoft Graph (Entra ID) |
|
||||
|---|---|---|
|
||||
| Dauerhafte Kennung | `objectGUID` (binär, umsortiert) | `id` (GUID als Text) |
|
||||
| Nachname | `sn` | `surname` |
|
||||
| Funktion | `title` | `jobTitle` |
|
||||
| Anmeldename | `sAMAccountName` | `onPremisesSamAccountName` |
|
||||
| Vorgesetzter | `manager` als DN, danach aufgelöst | `manager` direkt als Kennung |
|
||||
| Deaktivierte Konten | über den Filter ausgeschlossen | über `$filter=accountEnabled eq true` |
|
||||
| `{{ou}}` | aus dem DN | **leer** |
|
||||
|
||||
### `{{ou}}` bleibt leer, und das ist Absicht
|
||||
|
||||
Entra ID hat keinen DN und keine OU. Etwas hineinzudeuten — den Abteilungsnamen, eine erfundene
|
||||
Struktur — wäre ein Wert, den kein PowerShell-Befehl brauchen kann, und er sähe richtig aus.
|
||||
|
||||
Steht an der **Abteilung** eine Ziel-OU gepflegt, nimmt Laufzettel die (`{{ou}}` hat zwei Quellen,
|
||||
siehe `docs/architektur.md`). Wer OU-abhängige Befehle fährt, braucht ein lokales AD — oder pflegt
|
||||
die Ziel-OU je Abteilung von Hand.
|
||||
|
||||
### `{{sam}}` ist leer ohne AD-Synchronisierung
|
||||
|
||||
`onPremisesSamAccountName` kommt aus einem lokalen AD. Ein Mandant, der nie eines hatte, liefert dort
|
||||
nichts — eine reine Cloud-Umgebung hat keinen Anmeldenamen in diesem Sinn. Vorlagen, die `{{sam}}`
|
||||
benutzen, zeigen dann einen leeren Wert; gemeint ist meist ohnehin der UPN.
|
||||
|
||||
### Die Art einer Quelle lässt sich nicht ändern
|
||||
|
||||
An ihr hängt, was die dauerhafte Kennung bedeutet. Nach einem Wechsel von LDAP auf Graph würde
|
||||
**kein einziger Mitarbeiter wiedererkannt**: der nächste Abgleich legte die ganze Belegschaft neu an
|
||||
und setzte die alten Zeilen auf `aktiv = false` — mit allen Läufen und Geräten daran.
|
||||
|
||||
Wer wechselt, legt eine **zweite** Quelle an und lässt die erste stehen, bis die Personen übernommen
|
||||
sind. Laufzettel übernimmt dabei von Hand angelegte und bereits vorhandene Personen über Name und
|
||||
Mailadresse, statt Dubletten anzulegen (#016).
|
||||
|
||||
## Wenn es klemmt
|
||||
|
||||
| Meldung | Ursache |
|
||||
|---|---|
|
||||
| `401` beim Anmelden | Mandant, Anwendungs-ID oder Geheimnis stimmen nicht — oder das Geheimnis ist abgelaufen. Die ausführliche Antwort von Entra ID steht im Protokoll des Dienstes, nicht in der Oberfläche: sie nennt Mandant und Anwendung. |
|
||||
| `403 Authorization_RequestDenied` | Die Administratoreinwilligung für `User.Read.All` fehlt. Erteilen und erneut testen. |
|
||||
| `Invalid expand clause` | Sollte nicht vorkommen — Laufzettel baut `$expand=manager($select=id)` mit unkodierten Klammern. Tritt es auf, steht ein Proxy dazwischen, der die Adresse umschreibt. |
|
||||
| Verbindung testen zeigt niemanden | Der Mandant hat keine Benutzer mit `accountEnabled = true`, oder alle wurden übersprungen. Die Liste der Übersprungenen steht unter dem Ergebnis. |
|
||||
| Personen werden nicht mehr aktualisiert | Zuerst den Status der Quelle ansehen. Fast immer ist das Client-Secret abgelaufen. |
|
||||
|
||||
## Prüfliste für den ersten echten Lauf
|
||||
|
||||
Diese Punkte sind **nicht** automatisiert geprüft — sie brauchen einen echten Mandanten:
|
||||
|
||||
- [ ] App-Registrierung angelegt, `User.Read.All` als **Anwendungs**berechtigung, Einwilligung erteilt
|
||||
- [ ] „Verbindung testen“ zeigt die ersten zehn Personen mit richtigen Namen und Abteilungen
|
||||
- [ ] Der Vorgesetzte steht bei jemandem, der einen hat (`manager` über `$expand`)
|
||||
- [ ] Erster Abgleich legt an; die Zahl passt zur Zahl der aktiven Konten im Mandanten
|
||||
- [ ] Zweiter Abgleich legt **nichts** an und meldet nur Änderungen
|
||||
- [ ] Ein Konto in Entra ID sperren → nächster Abgleich setzt `aktiv = false`, löscht aber nichts
|
||||
- [ ] Ein Mandant mit mehr als 999 Benutzern: der Seitenabruf über `@odata.nextLink` läuft durch
|
||||
- [ ] Nach einem abgelaufenen Geheimnis steht der Grund an der Quelle und nicht nur im Log
|
||||
@@ -1,159 +0,0 @@
|
||||
# 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“.
|
||||
@@ -17,7 +17,7 @@ Drei Dinge, und nur diese drei:
|
||||
|---|---|---|
|
||||
| **1** | **Die Datenbank.** Läufe, Vorlagen, Schrittergebnisse, Kommentare, Geräte, Personen, Tag-Codes, Protokoll. | `pg_dump`, siehe unten |
|
||||
| **2** | **Die Geheimnisse.** `APP_SECRET` und das Datenbank-Passwort. | Compose: die `.env` · Nativ: `/etc/laufzettel/laufzettel.env` |
|
||||
| **3** | **Die Anhänge.** Die Dateien und Fotos an den Schritten. | Volume `laufzettel_anhaenge` · Nativ: `ANHAENGE_PFAD`, von `install.sh` auf `/var/lib/laufzettel/anhaenge` gesetzt |
|
||||
| **3** | **Die Anhänge.** Die Dateien und Fotos an den Schritten. | Volume `laufzettel_anhaenge` · Nativ: `ANHAENGE_PFAD` |
|
||||
|
||||
> **Die Anhänge sind seit #032 dabei, und sie sind die Falle.** In der Datenbank stehen nur ihre
|
||||
> **Metadaten** — Name, Typ, Größe, Prüfsumme. Die Dateien selbst liegen im Dateisystem. Eine
|
||||
@@ -157,23 +157,11 @@ sudo -u postgres pg_dump -Fc laufzettel > /var/backups/laufzettel-$(date +%F).du
|
||||
Und die Anhänge dazu — gewöhnliche Dateien, also `tar`:
|
||||
|
||||
```bash
|
||||
# Der Pfad steht in ANHAENGE_PFAD; `deploy/install.sh` setzt ihn auf
|
||||
# /var/lib/laufzettel/anhaenge. **Kein Rückfallwert** – siehe den Kasten darunter.
|
||||
ANHAENGE=$(sudo sh -c '. /etc/laufzettel/laufzettel.env; echo "${ANHAENGE_PFAD:?ANHAENGE_PFAD fehlt in /etc/laufzettel/laufzettel.env}"')
|
||||
# Der Pfad steht in ANHAENGE_PFAD; ohne Angabe liegt er unter /opt/laufzettel/daten/anhaenge.
|
||||
ANHAENGE=$(sudo sh -c '. /etc/laufzettel/laufzettel.env; echo "${ANHAENGE_PFAD:-/opt/laufzettel/daten/anhaenge}"')
|
||||
sudo tar czf /var/backups/laufzettel-anhaenge-$(date +%F).tar.gz -C "$ANHAENGE" .
|
||||
```
|
||||
|
||||
> **Warum hier kein `${ANHAENGE_PFAD:-…}` steht.** Es stand einmal eines, und es war falsch:
|
||||
> `/opt/laufzettel/daten/anhaenge` passt zu keiner Installation. `deploy/install.sh` setzt
|
||||
> `/var/lib/laufzettel/anhaenge`, und ohne die Variable nimmt die Anwendung `./daten/anhaenge`
|
||||
> **relativ zu ihrem Arbeitsverzeichnis** `/opt/laufzettel/.next/standalone` — also noch etwas
|
||||
> anderes.
|
||||
>
|
||||
> Ein geratener Pfad ist beim Sichern eine leere Datei und beim Zurückspielen ein Schaden: dort
|
||||
> steht ein `rm -rf` davor. Fehlt die Variable, soll der Befehl **abbrechen** und nicht raten.
|
||||
> Deshalb `:?` und nicht `:-`. Gefunden bei der Durchsicht von Phase 2, nachdem derselbe Fehler in
|
||||
> dieser Datei schon einmal Daten gekostet hätte (#032).
|
||||
|
||||
Und das Geheimnis, einmalig und in den Passwortmanager:
|
||||
|
||||
```bash
|
||||
@@ -199,10 +187,7 @@ sudo -u postgres pg_restore -d laufzettel --no-owner /var/backups/laufzettel-202
|
||||
|
||||
# 4. Die Anhänge zurück – **vorher leeren**, sonst bleiben Dateien liegen, auf
|
||||
# die keine Zeile mehr zeigt
|
||||
# Ohne ANHAENGE_PFAD bricht das hier ab – und zwar **vor** dem rm -rf. Ein
|
||||
# geratener Pfad hiesse: das echte Verzeichnis bleibt leer, die Anwendung meldet
|
||||
# „Die Datei zu … fehlt im Dateisystem“, und niemand weiss, warum.
|
||||
ANHAENGE=$(sudo sh -c '. /etc/laufzettel/laufzettel.env; echo "${ANHAENGE_PFAD:?ANHAENGE_PFAD fehlt in /etc/laufzettel/laufzettel.env}"')
|
||||
ANHAENGE=$(sudo sh -c '. /etc/laufzettel/laufzettel.env; echo "${ANHAENGE_PFAD:-/opt/laufzettel/daten/anhaenge}"')
|
||||
sudo rm -rf "$ANHAENGE"
|
||||
sudo install -d -o laufzettel -g laufzettel -m 0750 "$ANHAENGE"
|
||||
sudo tar xzf /var/backups/laufzettel-anhaenge-2026-09-24.tar.gz -C "$ANHAENGE"
|
||||
@@ -262,9 +247,7 @@ runuser -u postgres -- pg_dump -Fc laufzettel > "/var/backups/laufzettel-$tag.du
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
. /etc/laufzettel/laufzettel.env
|
||||
# Kein Rueckfallwert: ein geratener Pfad ergibt eine leere Sicherung, und die
|
||||
# faellt erst beim Zuruckspielen auf.
|
||||
anhaenge="${ANHAENGE_PFAD:?ANHAENGE_PFAD fehlt in /etc/laufzettel/laufzettel.env}"
|
||||
anhaenge="${ANHAENGE_PFAD:-/opt/laufzettel/daten/anhaenge}"
|
||||
if [ -d "$anhaenge" ]; then
|
||||
tar czf "/var/backups/laufzettel-anhaenge-$tag.tar.gz" -C "$anhaenge" .
|
||||
fi
|
||||
|
||||
+2
-464
@@ -33,19 +33,12 @@ als Unterpunkte mit Aufgabe und Dauer.
|
||||
| #032 | M19 – Anhänge und Fotos an Schritten | 3 h 05 min |
|
||||
| #033 | M20 – Live-Aktualisierung per SSE statt Polling | 1 h 40 min |
|
||||
| #034 | M21 – Benachrichtigungen per Mail und Webhook | 3 h 30 min |
|
||||
| #035 | M22 – Mitarbeiter-Selbstauskunft | 2 h 50 min |
|
||||
| #028 | M15 – Quellenarten und Microsoft-Graph-Quelle | 4 h 10 min |
|
||||
| #029 | M16 – SCIM-Endpunkt als Alternative zum Abgleich | 4 h 05 min |
|
||||
| #036 | Reviewbefunde aus der Durchsicht von Phase 2 | 3 h 40 min |
|
||||
| #027 | Die Editor-Abläufe lassen ihre Vorlagen stehen | 2 h 10 min |
|
||||
| #038 | Die E2E-Abläufe laufen in keinem Tor | 1 h 35 min |
|
||||
| #037 | Zwei Nachzügler aus der Durchsicht von Phase 2 | 1 h 50 min |
|
||||
| | **Summe** | **73 h 55 min** |
|
||||
| | **Summe** | **53 h 35 min** |
|
||||
|
||||
| Meilenstein | Tatsächlich | Status |
|
||||
|---|---|---|
|
||||
| v0.1.0 | 35 h 20 min | veröffentlicht |
|
||||
| v0.2.0 | 38 h 35 min | in Arbeit |
|
||||
| v0.2.0 | 18 h 15 min | in Arbeit |
|
||||
|
||||
---
|
||||
|
||||
@@ -1309,458 +1302,3 @@ und nicht nach jeder einzelnen — das ist der Grund, warum er alles auf einmal
|
||||
eigenen Benachrichtigungen abschalten kann.
|
||||
- **M15 (#028)** Graph-Quelle und **M16 (#029)** SCIM sind die beiden großen verbliebenen.
|
||||
- Weiter offen und nicht von hier aus machbar: **#024** Proxmox-Einzeiler auf einem echten PVE-Host.
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-06 · Sitzung 20 · 06:00–08:50
|
||||
|
||||
**Issue:** #035 (M22)
|
||||
**Version:** 0.2.0 (unveröffentlicht)
|
||||
|
||||
### Erledigt
|
||||
|
||||
- **Die fünfte Rolle `MITARBEITER`** mit genau einem Recht: `selbstauskunft`. Zugeordnet beim
|
||||
Anmelden über den UPN, festgehalten in `Benutzer.mitarbeiterId`.
|
||||
- **`/meine-geraete`**: meine Geräte, je Gerät die Arbeiten chronologisch, dazu meine Stammdaten aus
|
||||
dem Verzeichnis. Titel, Zeitraum, Status — nichts weiter.
|
||||
- **Drei PRs von gestern sind auf `main`** (#59, #60, #61); die beiden Folgezweige mussten nach dem
|
||||
ersten Squash rebased werden.
|
||||
- 1341 Vitest-Tests (vorher 1302), davon 25 neue. Im gebauten Stand mit einem echten
|
||||
MITARBEITER-Konto durchgeklickt.
|
||||
|
||||
### Entscheidungen
|
||||
|
||||
- **`selbstauskunft` ist ein Ersatz für `lesen`, kein Zusatz** — deshalb hat auch ein ADMIN es
|
||||
nicht. Ein zweiter Weg zu denselben Daten wäre eine zweite Stelle, an der jemand die
|
||||
Einschränkung auf die eigene Person vergisst.
|
||||
- **Was zurückkommt, entscheidet das Lesemodell und nicht die Seite.** Eine Seite, die filtert, ist
|
||||
eine Seite, die es vergessen kann.
|
||||
- **Kein zusammengesetzter Fremdschlüssel**, obwohl die Aufgabe ihn verlangte: ein `Benutzer` trägt
|
||||
keine `kundeId`. Stattdessen ein CHECK, dass Rolle und Zuordnung zusammengehören, und ein
|
||||
eindeutiger Index, dass eine Person höchstens ein Konto hat.
|
||||
- **Ein mehrdeutiger UPN wird abgewiesen**, nicht geraten. Zwei Kunden können dieselbe Person
|
||||
führen; ein `findFirst` entschiede nach Sortierung darüber, wessen Geräte jemand sieht.
|
||||
- **Die Weiche greift nur mit konfigurierten Rollengruppen.** Ein Update, das jeden aussperrt, dem
|
||||
die Gruppen fehlen, wäre ein kaputtes Update.
|
||||
- **Nicht `/kein-zugriff` bei gescheiterter Anmeldung**, wie das Issue vorsah: diese Seite verlangt
|
||||
eine Sitzung, die es dann nicht gibt — das wäre eine Schleife. Die Begründung steht auf der
|
||||
Anmeldeseite, mit zwei eigenen Codes.
|
||||
|
||||
### Probleme
|
||||
|
||||
- **Eine Gegenprobe blieb grün, und das war der eigentliche Befund.** Der Test „sieht keine Notizen“
|
||||
prüft die Antwort, und die ist durch `laufBauen` sicher. Als ich im `select` versuchsweise
|
||||
`ergebnisse: { notiz }` ergänzte, blieb er deshalb grün — während der Kopfkommentar behauptet, das
|
||||
`select` hole es „gar nicht erst“. **Eine Zusage ohne eigenen Test ist keine Zusage**, auch wenn
|
||||
ein Nachbartest sie zufällig mitträgt. Jetzt liest ein Test die Datei selbst.
|
||||
- **Ein Test von #007 fand eine echte Lücke.** `staerksteRolle` benutzte `LESER` als Startwert und
|
||||
damit als Untergrenze; mit einer Rolle darunter lieferte sie **mehr** Rechte als übergeben. Drei
|
||||
Jahre Projektzeit hätte das niemand gemerkt, weil es keine Rolle unter `LESER` gab.
|
||||
- **Zwei Bedienfehler beim Durchklicken**, beide dieselbe Ursache: das Standardziel nach der
|
||||
Anmeldung ist `/`, und die Übersicht verlangt `lesen`. Ein Mitarbeiter sah als Erstes „Dafür fehlt
|
||||
dir die Berechtigung“ — und der Knopf dort führte zurück auf `/`, also im Kreis. Ohne Klickprobe
|
||||
wäre das bis zum ersten echten Nutzer unentdeckt geblieben: **alle Tests waren grün.**
|
||||
- **Die Navigation hätte sechs tote Links angeboten.** `recht` stand nur am Admin-Bereich, weil
|
||||
bisher jede Rolle `lesen` durfte.
|
||||
- **Ein falscher Satz in `docs/betrieb/authentik.md`:** „Ist der Benutzer in keiner der vier
|
||||
Gruppen, bekommt er `LESER`.“ Richtig war er bis zu dieser Sitzung.
|
||||
- **Drei eigene Testfehler**, keine Produktfehler: Tag-Codes müssen dem Alphabet ohne `0`, `1`, `I`
|
||||
und `O` folgen; die Kundenbindungstests gaben nur eine Kundengruppe mit und hingen damit an der
|
||||
neuen Weiche; und ein Einfüge-Skript meldete „ok“, ohne geschrieben zu haben — seitdem prüfe ich
|
||||
nach dem Einfügen mit `grep`.
|
||||
|
||||
### Nächster Schritt
|
||||
|
||||
- **Phase 2 ist bis auf die beiden großen Verzeichnis-Themen fertig:** **M15 (#028)**
|
||||
Microsoft-Graph-Quelle und **M16 (#029)** SCIM-Endpunkt.
|
||||
- Vor beiden wäre ein unabhängiger Review über die sechs Meilensteine dieser Phase angebracht —
|
||||
M17 bis M22 liegen ohne fremden Blick auf `main`.
|
||||
- Weiter offen und nicht von hier aus machbar: **#024** Proxmox-Einzeiler auf einem echten PVE-Host.
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-06 · Sitzung 21 · 09:00–13:10
|
||||
|
||||
**Issue:** #028 (M15)
|
||||
**Version:** 0.2.0 (unveröffentlicht)
|
||||
|
||||
### Erledigt
|
||||
|
||||
- **Eine Verzeichnisquelle hat eine Art**: `LDAP`, `GRAPH` oder `SCIM`. Bestehende Zeilen sind
|
||||
`LDAP` — das war vor #028 die einzige.
|
||||
- **Entra ID über Microsoft Graph**: Client-Credentials-Flow mit Token-Zwischenspeicher, Seitenabruf
|
||||
über `@odata.nextLink`, Vorgesetzter über `$expand`.
|
||||
- **Derselbe Abgleich für beide Arten.** Die Weiche sitzt in
|
||||
`src/server/verzeichnis/quellenleser.ts` und ist die einzige Stelle, die die Art kennt.
|
||||
- 1373 Vitest-Tests (vorher 1342), davon 31 neue gegen einen HTTP-Doppelgänger mit aufgezeichneten
|
||||
Graph-Antworten.
|
||||
- Im gebauten Stand eine Graph-Quelle wirklich angelegt: Art gespeichert, Geheimnis verschlüsselt,
|
||||
nicht im Quelltext der Seite.
|
||||
|
||||
### Entscheidungen
|
||||
|
||||
- **Ein Enum auf der Quelle, keine Tabelle je Art.** Drei Arten rechtfertigen keine Vererbung; was
|
||||
eine Art braucht, hält ein CHECK fest — in **beide** Richtungen, sonst wäre eine Graph-Quelle mit
|
||||
`basisDn` möglich.
|
||||
- **Die Art ist unveränderlich.** An ihr hängt, was `externeId` bedeutet; nach einem Wechsel würde
|
||||
kein Mitarbeiter wiedererkannt. Steht so nicht im Issue und ist die wichtigste Zusage dieses
|
||||
Meilensteins.
|
||||
- **`{{ou}}` bleibt bei Graph leer.** Etwas hineinzudeuten wäre ein Wert, den kein PowerShell-Befehl
|
||||
brauchen kann — und er sähe richtig aus.
|
||||
- **`SCIM` steht im Enum, wird aber abgewiesen.** Ein Wert, der stillschweigend nichts tut, hätte
|
||||
hier die ganze Belegschaft deaktiviert.
|
||||
|
||||
### Probleme
|
||||
|
||||
- **Der teuerste Fund: „deaktiviert“ als „übersprungen“ zu melden hätte Leute aktiv gelassen.**
|
||||
„Übersprungen“ heißt im Abgleich „nicht zuordenbar“, und solche Einträge werden seit #016
|
||||
absichtlich nicht abgeschaltet. Ein gesperrtes Entra-Konto wäre damit genau umgekehrt behandelt
|
||||
worden als ein gesperrtes AD-Konto. Jetzt filtert die Abfrage serverseitig, als Gegenstück zum
|
||||
LDAP-Standardfilter.
|
||||
**Gefunden hat es der Test — aber nur, weil der Doppelgänger den Filter wirklich auswertet.** Hätte
|
||||
er den Filter bloß empfangen und ignoriert, wäre der Test grün gewesen und der Fehler im Betrieb
|
||||
aufgefallen. Ein Doppelgänger, der nur antwortet, prüft die Anfrage nicht.
|
||||
- **`URLSearchParams` kodiert `$` zu `%24`** und die Klammern in `manager($select=id)`. Graph nimmt
|
||||
das teils an und antwortet teils mit „Invalid expand clause“. Jetzt von Hand zusammengesetzt.
|
||||
- **Ein Import-Zyklus drohte**: die Weiche braucht `zugangAus`, und der Dienst braucht die Weiche.
|
||||
Beides aufgelöst durch ein eigenes Modul. In ESM läuft so ein Zyklus oft — aber nicht zuverlässig.
|
||||
- **Zwei eigene Testfehler**: die Token-Grenze liegt bei +3540 s und nicht „ungefähr eine Stunde“,
|
||||
und die Klickprobe zählte die Felder der Bearbeiten-Formulare mit.
|
||||
|
||||
### Was offen bleibt, und das ist benannt
|
||||
|
||||
Gegen einen **echten Entra-Mandanten** ist nichts davon gelaufen. Die Form der Antworten im
|
||||
Doppelgänger stammt aus der Dokumentation, nicht aus einem Mitschnitt — stimmt sie nicht, stimmen
|
||||
Test und Wirklichkeit nicht. Die Prüfliste steht am Ende von `docs/betrieb/entra-id.md`, acht
|
||||
Punkte, darunter ein Mandant mit mehr als 999 Benutzern. Dieselbe Art offener Punkt wie #024.
|
||||
|
||||
### Nächster Schritt
|
||||
|
||||
- **M16 (#029)** SCIM-Endpunkt ist der letzte Meilenstein von Phase 2. Die Weiche dafür steht.
|
||||
- Vor #029 wäre ein unabhängiger Review angebracht: **sieben Meilensteine** dieser Phase liegen ohne
|
||||
fremden Blick auf `main`, und diese Sitzung hat wieder einen Fehler gezeigt, den die Suite nur
|
||||
deshalb gefunden hat, weil der Doppelgänger streng genug war.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-10 · Sitzung 22 · 17:40–21:45
|
||||
|
||||
**Issue:** #029 (M16)
|
||||
**Version:** 0.2.0 (unveröffentlicht)
|
||||
|
||||
### Erledigt
|
||||
|
||||
- **SCIM 2.0 als dritte Quellenart.** Neun Endpunkte unter `/api/scim/v2`, jeder prüft sein Token
|
||||
selbst — die Hülle unter `(app)` liegt dort nicht darüber, und der Proxy lässt `/api/scim`
|
||||
absichtlich ohne Cookie durch.
|
||||
- **Ein Token je Quelle**, argon2-gehasht, einmal angezeigt. Zwei CHECKs halten das in der
|
||||
Datenbank: `…_scimtoken_gehasht` (`LIKE '$argon2%'`) und `…_scimtoken_mit_zeitpunkt`.
|
||||
- **Oberfläche**: Art SCIM im Anlege-Formular, Token erzeugen und widerrufen, letzte Zustellung,
|
||||
Endpunktadresse an der Quelle. Verbindungstest, Zeitplan, Abgleich-Log und Attributzuordnung
|
||||
fallen dort weg — es gibt sie nicht, und ein Knopf, der nur eine Erklärung auswirft, ist kein
|
||||
Knopf.
|
||||
- 1432 Vitest-Tests (vorher 1373), davon 59 neue. Die Anfrageformen von authentik und Entra ID
|
||||
stehen als benannte Konstanten oben in `tests/scim.test.ts`.
|
||||
- **Klickprobe am gebauten Stand, zwanzig Prüfungen**, von „Art SCIM wählbar“ über eine echte
|
||||
Zustellung mit genau dem Token aus der Oberfläche bis „390 px ohne horizontales Scrollen“. Die
|
||||
Probe hat ihre Daten danach selbst entfernt — die Lehre aus #027.
|
||||
- Anleitung Kapitel 06 um den SCIM-Abschnitt erweitert (Einrichtung in authentik und Entra ID,
|
||||
Fehlerbilder), `docs/betrieb/scim.md` neu.
|
||||
|
||||
### Entscheidungen
|
||||
|
||||
- **Umgesetzt ist nicht der Standard, sondern was die Anbieter schicken.** Komplexe Filter,
|
||||
Sortierung, `bulk`, `/Groups` — nichts davon. `ServiceProviderConfig` **meldet** das als
|
||||
`supported: false`, und die Verneinungen sind der wichtigere Teil der Antwort: ein Anbieter, der
|
||||
`bulk` für unterstützt hält, schickt Anfragen, die niemand beantwortet.
|
||||
- **Ein unverstandener Filter ergibt `400`, nicht „alle Personen“.** Die wichtigste
|
||||
Einzelentscheidung hier. Alles zu liefern wäre die gefährlichere Antwort: der Anbieter schlösse
|
||||
daraus, die gesuchte Person gäbe es schon, und legte sie **nie** an.
|
||||
- **`404` und nicht `403`** für eine Person einer fremden Quelle. Ob es sie gibt, ist für ein
|
||||
fremdes Token keine Auskunft. Ebenso dieselbe `401` für „kein Token“ und „falsches Token“.
|
||||
- **`DELETE` deaktiviert.** Der Anbieter bekommt `204` und hält die Person für entfernt — aus seiner
|
||||
Sicht richtig. In der Anleitung steht das ausdrücklich, sonst wirkt die inaktive Zeile wie ein
|
||||
Fehler.
|
||||
- **SCIM-Quellen bleiben aus dem Zeitplan heraus**, an zwei Stellen gehalten. Ein Abgleich, der dort
|
||||
nichts liest und `ERFOLG` meldet, hätte die ganze Belegschaft deaktiviert.
|
||||
|
||||
### Probleme
|
||||
|
||||
- **Zwei von acht Gegenproben blieben grün — und das waren die wertvollen.** Sechs schlugen sofort
|
||||
an. Die beiden stillen:
|
||||
- **`PATCH remove` wirkte gar nicht.** Kein Testloch, ein Fehler: `personAendern` hatte einen
|
||||
Schalter `nurGesetzte`, der bei `PATCH` jedes `null` als „nicht mitgeschickt“ las. Nimmt jemand
|
||||
beim Anbieter die Abteilung aus dem Profil, stand sie in Laufzettel weiter — sichtbar, gepflegt
|
||||
aussehend, falsch. Der Schalter war nie nötig: `patchAnwenden` setzt auf dem vorhandenen Stand
|
||||
auf und liefert immer einen vollständigen Zielzustand. Der Unterschied zwischen `PUT` und
|
||||
`PATCH` liegt **vor** der Funktion, nicht in ihr.
|
||||
- **Entra ID schickt `active` als `"True"`/`"False"`.** Richtig umgesetzt, aber nur halb geprüft:
|
||||
beim Deaktivieren kommt mit einem reinen `value === true` **zufällig** das Richtige heraus. Nur
|
||||
das Aktivieren wäre kaputt gewesen — ein wieder freigeschaltetes Konto, das in Laufzettel für
|
||||
immer inaktiv bleibt. Ein Fehler, der nur in einer Richtung sichtbar ist, ist der teuerste.
|
||||
- **`manager` wäre fast untergegangen.** Es stand im Issue, und umgesetzt war zunächst nur
|
||||
`department`. Dazugekommen: drei Lesearten (`{value}`, `{$ref}`, nackte Zeichenkette), die
|
||||
Auflösung, und `manager` im `Schemas`-Endpunkt — das Letzte ist das Entscheidende, denn ein
|
||||
Anbieter schickt nur, was dort steht. Ohne den Eintrag bliebe der Vorgesetzte dauerhaft leer,
|
||||
**ohne Fehlermeldung auf einer der beiden Seiten.**
|
||||
- **`manager` war ein Weg über die Quellengrenze.** Eine Kennung aus einer fremden Quelle hätte über
|
||||
die Vorgesetzten-Anzeige einen fremden Namen in die Oberfläche gebracht. Die Auflösung sucht nur
|
||||
in der eigenen Quelle; die Gegenprobe ohne diese Einschränkung ist rot.
|
||||
- **Ein Fehler aus #028 im Nachbarcode**: in `verbindungTestenAktion` stand zweimal dieselbe
|
||||
Bedingung, der zweite Zweig war unerreichbar. Genau dieser Zweig war dort als Reviewbefund
|
||||
eingebaut worden — „gelesen, aber nichts zuordenbar“ sollte anders klingen als „der Filter findet
|
||||
niemanden“, weil die Ursachen den Administrator an verschiedene Stellen schicken. Wirkungslos
|
||||
seit dem Tag, an dem er geschrieben wurde. Das ist dasselbe Muster wie die stillen Gegenproben:
|
||||
**Code, der aussieht, als prüfte er etwas.**
|
||||
- **Ein eigener Probenfehler**: die Klickprobe suchte „Klick Probe“ in der Mitarbeiterliste. Ohne
|
||||
`displayName` baut Laufzettel „Nachname, Vorname“ — wie der Abgleich. Kein Produktfehler, aber
|
||||
ein Beleg dafür, dass ein roter Befund erst gelesen und dann bewertet gehört.
|
||||
- Ein Test aus #028 war **überholt**, nicht falsch: er verlangte, dass die Meldung der Weiche „#029“
|
||||
nennt.
|
||||
|
||||
### Was offen bleibt, und das ist benannt
|
||||
|
||||
Gegen **echte Mandanten** ist nichts davon gelaufen. Die Anfrageformen stammen aus der
|
||||
Dokumentation von authentik und Entra ID, nicht aus einem Mitschnitt — dieselbe Grenze wie bei #028,
|
||||
und sie steht im Kopfkommentar von `tests/scim.test.ts`. Die Prüfbefehle für den ersten echten Lauf
|
||||
stehen in `docs/betrieb/scim.md`.
|
||||
|
||||
### Nächster Schritt
|
||||
|
||||
- **Phase 2 ist damit vollständig.** Acht Meilensteine (M15–M22) liegen danach auf `main`.
|
||||
- Offen bleiben **#024** (Proxmox-Einzeiler auf echtem PVE-Host) und **#027** (die Editor-Abläufe
|
||||
lassen ihre Vorlagen in der Dev-Datenbank stehen).
|
||||
- **Ein unabhängiger Review ist jetzt überfällig.** Acht Meilensteine dieser Phase liegen ohne
|
||||
fremden Blick auf `main`, und diese Sitzung hat wieder zwei Dinge gezeigt, die eine grüne Suite
|
||||
nicht sieht: einen Schalter, der eine Änderung verschluckte, und einen Zweig, der nie erreicht
|
||||
wurde. Beide Befunde kamen aus Gegenproben, nicht aus Tests.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-11 · Sitzung 23 · 21:50–01:30
|
||||
|
||||
**Issues:** #036 (Reviewbefunde Phase 2), #027 (E2E-Reste)
|
||||
**Version:** 0.2.0 (unveröffentlicht)
|
||||
|
||||
### Erledigt
|
||||
|
||||
- **Der unabhängige Review ist gelaufen** — acht Meilensteine (M15–M22) lagen ohne fremden Blick auf
|
||||
`main`, und das stand seit Sitzung 21 als „überfällig“ im Devlog. Ein Agent mit eigener Datenbank,
|
||||
der den Code nicht geschrieben hat, gegen die Akzeptanzkriterien und gegen `CLAUDE.md`.
|
||||
- **Dreizehn Befunde, zwölf behoben**, zwei als #037 und #038 benannt. Alle lagen am Rand: zwei
|
||||
Route Handler, ein Export-Pfad, Betriebsdokumentation, Kommentare. **Die Kernregeln halten** —
|
||||
fünf von fünf Gegenproben gegen Versionierung, Mandantengrenze, Abschlussregel und
|
||||
Selbstauskunft wurden rot.
|
||||
- **#027 abgeschlossen**: die 105 Altreste sind weg, geräumt wird jetzt zentral vor und nach dem
|
||||
Lauf, und ein struktureller Test bewacht es.
|
||||
- 1454 Vitest-Tests (vorher 1432), 113 E2E-Abläufe grün.
|
||||
|
||||
### Entscheidungen
|
||||
|
||||
- **Eine Liste, die nur in einer Richtung geprüft wird, läuft dem Code hinterher.** `/api/scim` kam
|
||||
mit #029 in die Proxy-Allowlist und stand in keinem Test; die Zeile ließ sich entfernen, ohne
|
||||
dass etwas rot wurde. `OEFFENTLICH` ist jetzt exportiert, und der Test vergleicht **beide**
|
||||
Richtungen: wer die Allowlist erweitert, muss es aufschreiben. Dasselbe Muster wie die Zahlen in
|
||||
`CLAUDE.md`, die dreimal der Wirklichkeit nachliefen.
|
||||
- **Ein Rückspielskript rät nicht.** In `sicherung.md` stand `${ANHAENGE_PFAD:-/opt/…}` — ein
|
||||
Rückfallwert, der zu keiner Installation passte, in einem Skript mit `rm -rf`. Jetzt `:?`: fehlt
|
||||
die Variable, bricht es ab. Ein geratener Pfad ist beim Sichern eine leere Datei und beim
|
||||
Zurückspielen ein Schaden.
|
||||
- **Die Zugweite eines Sortiertests wird gemessen, nicht gesetzt.** Eine feste Pixelzahl ist eine
|
||||
Zusage über das Layout, die niemand pflegt — und sie war falsch, seit #030 und #031 jeden
|
||||
Abschnitt höher gemacht haben.
|
||||
- **Protokoll-Waisen werden nicht erraten.** Eine Zeile, deren Gegenstand fort ist, über den Namen
|
||||
im Json zu greifen wäre naheliegend und falsch: derselbe Name steht in echter Historie einer noch
|
||||
bestehenden Zeile. Dass eine Protokollzeile ihren Gegenstand überlebt, ist der Grund, warum
|
||||
`entitaetId` kein Fremdschlüssel ist.
|
||||
|
||||
### Probleme
|
||||
|
||||
- **Die drei wertvollsten Befunde waren Gegenproben, die grün blieben.** Das ist inzwischen das
|
||||
wiederkehrende Muster dieses Projekts: nicht der falsche Code fällt auf, sondern der Code, der
|
||||
*aussieht*, als prüfte er etwas.
|
||||
- Der **Link in der Abgleich-Meldung** war ein 404 (`/verwaltung/verzeichnis` statt
|
||||
`/admin/verzeichnis`) — die eine Meldung, die an alle Administratoren geht, im Moment eines
|
||||
Ausfalls. Der bestehende Test prüfte, dass der **übergebene** Link im Rumpf steht. Das ist
|
||||
richtig und sagt nichts darüber, ob der Pfad existiert.
|
||||
- Der **Anhang-Handler** hatte keinen einzigen Test. Die ganze Rechteprüfung ließ sich durch einen
|
||||
hartcodierten ADMIN ersetzen, Suite grün. Das ist die einzige Stelle, die rohe Dateibytes
|
||||
ausliefert. Bei SCIM war das Muster schon da — hier wurde es nicht angewandt.
|
||||
- **`/api/scim`** in der Allowlist, siehe oben.
|
||||
- **Ein offener Ereignisstrom überlebte das Deaktivieren eines Kontos.** Gefunden durch Lesen, nicht
|
||||
durch einen Test. Dabei fiel auf, warum: die Attrappe der Sitzung war `{}` — ohne `benutzerId`
|
||||
ließe sich eine Nachprüfung nicht einmal zeigen. **Eine Attrappe, die nicht die Form der
|
||||
Wirklichkeit hat, prüft etwas anderes als die Wirklichkeit.** Dieselbe Lehre wie beim
|
||||
Graph-Doppelgänger in #028, nur von der anderen Seite.
|
||||
- **Die E2E-Suite war seit Monaten rot**, vier Ursachen, keine ein Produktfehler: ein Test, der die
|
||||
Felder des Zustands aufzählt und `anzahlAnhaenge` nicht kannte (#033); ein Selektor, der seit #020
|
||||
zweimal traf; eine feste Zugweite von 300 px bei 532 px Abstand (#030/#031); und
|
||||
Entwicklungsdaten, die vom Seed abwichen. Niemand hat es gemerkt, weil `pnpm test:e2e` in keinem
|
||||
Tor steht — das ist jetzt #038 und keine Fußnote.
|
||||
- **Ein Befund des Reviews war falsch:** der Zustands-Handler habe keinen Vitest-Test. Er hat acht
|
||||
direkte Aufrufe. Nachgeprüft, bevor etwas geändert wurde — ein Agentenbericht ist eine Behauptung,
|
||||
keine Feststellung.
|
||||
- **Ein `DELETE` von 105 Zeilen wurde abgelehnt**, und das war am Ende richtig: der im Issue
|
||||
vorgesehene Weg erledigte es von selbst, weil das Aufräumen über das Präfix beim ersten Lauf auch
|
||||
die Altlast mitnimmt. Eine Sicherung der Entwicklungsdatenbank lag vorher.
|
||||
|
||||
### Nächster Schritt
|
||||
|
||||
- Offen bleiben **#024** (Proxmox-Einzeiler auf echtem PVE-Host), **#037** (zwei Nachzügler) und
|
||||
**#038** (E2E in ein Tor).
|
||||
- Danach ist v0.2.0 reif für einen Tag. Vorher gehört #038 entschieden: ein Release, dessen
|
||||
E2E-Suite nur gelegentlich läuft, ist ein Release mit ungeprüften Zusagen — und genau das hat
|
||||
diese Sitzung gezeigt.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-11 · Sitzung 24 · 02:00–06:10
|
||||
|
||||
**Issues:** #038 (E2E in ein Tor), #037 (zwei Nachzügler)
|
||||
**Version:** 0.2.0 (unveröffentlicht)
|
||||
|
||||
### Erledigt
|
||||
|
||||
- **#038 entschieden und festgeschrieben.** E2E läuft **vor jedem Merge nach `main`**, nicht vor
|
||||
jedem Commit. Die Regel steht in `CLAUDE.md` mit gemessenen Zahlen, `docs/architektur.md` §5b
|
||||
trägt sie mit.
|
||||
- **Die Liste der Zusagen, die nur in E2E hängen** — neun Stück, alle brauchen einen echten
|
||||
Browser, keine lässt sich nach Vitest holen. Steht als Tabelle in `CLAUDE.md`.
|
||||
- **Was sich holen ließ, ist geholt:** `tests/zugang.test.ts` prüft jetzt strukturell, dass jede
|
||||
`page.tsx` und jeder `route.ts` sein Recht selbst prüft.
|
||||
- **#037 beide Teile**: `anhaengeDesLaufs` liegt in `queries/` und engt selbst ein; `/meine-geraete`
|
||||
wird bei 390 px gemessen, mit einem echten `MITARBEITER`-Konto aus dem E2E-Vorlauf.
|
||||
- 1457 Vitest-Tests (vorher 1454), 115 E2E-Abläufe (vorher 113).
|
||||
|
||||
### Entscheidungen
|
||||
|
||||
- **Neun Minuten vor jedem Commit ist ein Tor, das umgangen wird.** Gemessen: Bau 14 s, Start 7 s,
|
||||
Abläufe 256 s — zu den vier Minuten des bestehenden Tores. Vor jedem Merge hätte in Phase 2 acht
|
||||
Läufe bedeutet und jeden der vier Befunde aus #027 am Tag seiner Entstehung gezeigt. Das ist die
|
||||
Rechnung, die die Regel trägt.
|
||||
- **Die Antwort auf #038 ist nicht „verlagern“.** Jeder der 16 Abläufe wurde gegen die Frage
|
||||
geprüft, ob es ein Vitest-Gegenstück gibt. Was dort hängt, braucht einen Browser: Layout bei
|
||||
390 px, `BarcodeDetector`, Touch, zwei Kontexte, Zwischenablage, Print-CSS, gerendertes DOM, und
|
||||
die Hydrierung selbst.
|
||||
- **Das MITARBEITER-Konto liegt im E2E-Vorlauf, nicht im Seed.** Ein Konto mit bekanntem Passwort
|
||||
gehört nicht in einen Seed, der auch produktiv läuft. Die Person wird gewählt (erste aktive mit
|
||||
Gerät), nicht gesetzt — eine feste Kennung wäre eine Annahme über den Seed.
|
||||
|
||||
### Probleme
|
||||
|
||||
- **Der eigentliche Fund steckte in #038 und war keine Testlücke, sondern eine Konvention ohne
|
||||
Prüfer.** „Jede Seite prüft selbst“ steht seit #007 in `CLAUDE.md`; geprüft hat es bis hierher
|
||||
niemand außer einem Menschen, der nachsieht. Der Review von Phase 2 hat alle 27 Seiten
|
||||
durchgesehen — ein Ergebnis, das für genau einen Tag gilt. Jetzt liest ein Test sie, und beide
|
||||
Gegenproben (eine Seite, ein Handler ohne Prüfung) werden rot.
|
||||
- **Ein gemeinsamer Helfer in einer Testsuite ist geteilter Zustand.** Für das zweite Konto brauchte
|
||||
`anmelden` eine Variante, die nicht auf `/` wartet. Ich habe das Warten dabei für **alle**
|
||||
gelockert — und damit zwei unverwandte Abläufe unzuverlässig gemacht, darunter einen in
|
||||
`laeufe.spec.ts`, der mit dem Thema nichts zu tun hat. **Sichtbar nur im vollen Durchgang**, nicht
|
||||
beim Einzellauf: einzeln waren beide grün. Behoben, indem `anmelden` wieder streng wartet und
|
||||
`anmeldenMit` das Ziel als Parameter nimmt; dazu beide auf `load`, denn ein `waitForURL` endet an
|
||||
der Adresse, nicht am geladenen Dokument.
|
||||
Dass genau dieser Fehler in derselben Sitzung passiert ist, in der die Regel „E2E vor jedem Merge“
|
||||
entstand, ist die beste Begründung für sie: ohne vollen Durchgang hätte ich zwei rote Abläufe
|
||||
gemergt.
|
||||
|
||||
### Nächster Schritt
|
||||
|
||||
- Offen bleibt nur noch **#024** (Proxmox-Einzeiler auf einem echten PVE-Host) — und das verlangt
|
||||
einen Host, den es hier nicht gibt.
|
||||
- **v0.2.0 ist damit reif für einen Tag**: Changelog-Abschnitt, Devlog-Phasenzusammenfassung, Tag,
|
||||
Release-Notiz. Die Bedingung aus Sitzung 23 („vorher gehört #038 entschieden“) ist erfüllt.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 abgeschlossen: was aus dem Plan wurde
|
||||
|
||||
**38 h 35 min**, geschätzt waren **90 h** für die zwölf Issues dieser Version. Acht davon waren
|
||||
Meilensteine (M15–M22), die übrigen entstanden **während** der Arbeit — und zwei der nützlichsten
|
||||
(#036, #038) stammen gar nicht aus einem Plan, sondern aus der Durchsicht.
|
||||
|
||||
### Was dazugekommen ist
|
||||
|
||||
| | v0.1.0 | v0.2.0 |
|
||||
|---|---|---|
|
||||
| Quelltext unter `src/` | rund 30.000 Zeilen | **40.781 Zeilen** (ohne erzeugten Prisma-Client) |
|
||||
| Datenmodell | 20 Tabellen, 9 Aufzählungen, 2 Migrationen | **25 Tabellen, 13 Aufzählungen, 14 Migrationen** |
|
||||
| Datenbankregeln namentlich | — | **56** (CHECKs, partielle Indizes, Trigger, zusammengesetzte Fremdschlüssel) |
|
||||
| Vitest | 1131 Tests | **1457 Tests** in 61 Dateien |
|
||||
| Ende-zu-Ende | 101 Abläufe | **115 Abläufe** in 16 Dateien |
|
||||
| Verzeichnisquellen | LDAP | **LDAP, Microsoft Graph, SCIM** |
|
||||
| Dokumentation | 9 Kapitel, 14 Issue-Dateien | 9 Kapitel, 8 Betriebsseiten, **38 Issue-Dateien** |
|
||||
|
||||
Der Seed ist unverändert bei 7 Vorlagen mit 155 Schritten — nachgezählt, nicht übernommen.
|
||||
|
||||
### Die acht Meilensteine
|
||||
|
||||
| | | Zeit |
|
||||
|---|---|---|
|
||||
| M15 | Quellenarten und Microsoft-Graph-Quelle (#028) | 4 h 10 |
|
||||
| M16 | SCIM-Endpunkt als Alternative zum Abgleich (#029) | 4 h 05 |
|
||||
| M17 | Wiederverwendbare Bausteine (#030) | 2 h 25 |
|
||||
| M18 | Abteilungsspezifische Zusatzabschnitte (#031) | 1 h 35 |
|
||||
| M19 | Anhänge und Fotos an Schritten (#032) | 3 h 05 |
|
||||
| M20 | Live-Aktualisierung per SSE statt Polling (#033) | 1 h 40 |
|
||||
| M21 | Benachrichtigungen per Mail und Webhook (#034) | 3 h 30 |
|
||||
| M22 | Mitarbeiter-Selbstauskunft (#035) | 2 h 50 |
|
||||
|
||||
### Was diese Phase gelehrt hat
|
||||
|
||||
**Die teuersten Fehler waren dreimal derselbe: eine Zusage ohne Prüfer.** Nicht falscher Code —
|
||||
Code, der *aussieht*, als prüfte er etwas.
|
||||
|
||||
- Ein Kommentar sagte, ein `select` hole Notizen „gar nicht erst“, während ein anderer Codepfad den
|
||||
Test rettete (#035).
|
||||
- `docs/betrieb/sicherung.md` sagte „kein Upload-Verzeichnis“, nachdem die Anhänge dazugekommen
|
||||
waren — eine Sicherung nach der alten Anleitung hätte still Daten verloren (#032). Dasselbe
|
||||
Dokument hatte in #036 wieder einen Satz, der zu keiner Installation passte.
|
||||
- „Jede Seite prüft selbst“ steht seit #007 in `CLAUDE.md` und hielt bis #038 **nur durch Nachsehen
|
||||
von Hand**. Der Review hat alle 27 Seiten durchgesehen — ein Ergebnis, das für genau einen Tag
|
||||
gilt.
|
||||
|
||||
**Die Gegenprobe ist das Werkzeug, das sie findet.** Regel brechen, Test muss rot werden. In dieser
|
||||
Phase blieben fünf Gegenproben grün, und jede davon war ein Befund: `PATCH remove` wirkte gar nicht,
|
||||
Entra IDs `"True"` wäre beim *Aktivieren* kaputt gewesen, der Anhang-Handler hatte keinen Test,
|
||||
`/api/scim` stand in keiner Allowlist-Prüfung, und der Link in der Abgleich-Meldung war ein 404.
|
||||
|
||||
**Ein Fehler, der nur in einer Richtung sichtbar ist, ist der teuerste.** Entra ID schickt `active`
|
||||
als `"True"`/`"False"`. 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 für
|
||||
immer inaktiv bleibt.
|
||||
|
||||
**Eine Attrappe ohne die Form der Wirklichkeit prüft etwas anderes als die Wirklichkeit.** Zweimal
|
||||
gelernt, von beiden Seiten: der Graph-Doppelgänger fand einen echten Fehler, **weil** er den Filter
|
||||
wirklich auswertete (#028); die Sitzungs-Attrappe `{}` verdeckte einen, weil sie keine `benutzerId`
|
||||
hatte (#036).
|
||||
|
||||
**Ein gemeinsamer Helfer in einer Testsuite ist geteilter Zustand.** Das Anmelde-Warten für einen
|
||||
neuen Fall aufzuweichen machte zwei unverwandte Abläufe unzuverlässig — sichtbar nur im vollen
|
||||
Durchgang (#037).
|
||||
|
||||
### Wo die Zeit hinging
|
||||
|
||||
Die beiden Verzeichnis-Meilensteine (#028, #029) waren mit 8 h 15 zusammen die teuersten, und zwar
|
||||
**nicht** wegen der Protokolle: beide liefen gegen Doppelgänger, und in beiden Fällen entschied die
|
||||
Strenge der Attrappe, ob ein Fehler gefunden wurde. Die eigentliche Arbeit war, die Weiche an genau
|
||||
einer Stelle zu halten und beide Richtungen eines CHECKs aufzuschreiben — was eine Art braucht, und
|
||||
was sie **nicht haben darf**.
|
||||
|
||||
### Was offen bleibt
|
||||
|
||||
**#024** — der Proxmox-Einzeiler ist auf einem echten PVE-Host nie gelaufen. Das steht seit v0.1.0
|
||||
so da und wandert mit dem Tag auf v0.3.0: es ist eine **Gegenprüfung** und kein fehlendes Stück. Das
|
||||
Skript ist `shellcheck`-geprüft und im `--dry-run` gefahren, der Installationsteil läuft vollständig
|
||||
in einem `debian:13`-Container. Was fehlt, ist `pct create` auf einem echten Host — und der verlangt
|
||||
einen Host, den diese Umgebung nicht hat. Die Prüfliste liegt bereit.
|
||||
|
||||
Zwei Grenzen gehören dazu genannt, beide in den Issues festgehalten: **gegen einen echten
|
||||
Entra-Mandanten** und **gegen echte SCIM-Anbieter** ist nichts gelaufen. Die Formen in den Tests
|
||||
stammen aus der Dokumentation, nicht aus Mitschnitten. Stimmt eine Form nicht, stimmen Test und
|
||||
Wirklichkeit nicht — die Prüflisten stehen in `docs/betrieb/entra-id.md` und
|
||||
`docs/betrieb/scim.md`.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Status:** offen
|
||||
**Im Tracker:** [#38](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/38)
|
||||
**Meilenstein:** v0.3.0
|
||||
**Meilenstein:** v0.2.0
|
||||
**Labels:** phase-2, infra
|
||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
||||
|
||||
@@ -53,13 +53,3 @@ nachprüfen musst“) abgehakt ist. Die Liste ist der Prüfplan, nicht dieses Is
|
||||
`status ok` liefert
|
||||
- [ ] `pct exec <ctid> -- ss -ltn` zeigt keinen Port 5432
|
||||
- [ ] Zweiter Aufruf: Daten, `APP_SECRET` und Datenbankpasswort unverändert
|
||||
|
||||
## Verschoben auf v0.3.0 (2026-10-11)
|
||||
|
||||
Stand bis hierher auf v0.2.0 und hat den Tag nicht aufgehalten, weil es eine **Gegenprüfung** ist
|
||||
und kein fehlendes Stück: `deploy/proxmox/laufzettel.sh` ist `shellcheck`-geprüft und im
|
||||
`--dry-run` gefahren, und der eigentliche Installationsteil (`deploy/install.sh`) läuft vollständig
|
||||
in einem `debian:13`-Container — PostgreSQL, Bau, Migration, Seed, Dienststart.
|
||||
|
||||
Was fehlt, ist der `pct create`-Teil auf einem echten PVE-Host, und der verlangt einen Host, den
|
||||
diese Umgebung nicht hat. Die Prüfliste steht oben; sie wartet unverändert.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# #027 Die Editor-Abläufe lassen ihre Vorlagen stehen
|
||||
|
||||
**Status:** erledigt
|
||||
**Status:** offen
|
||||
**Im Tracker:** [#46](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/46)
|
||||
**Meilenstein:** v0.2.0
|
||||
**Labels:** phase-2, test
|
||||
**Geschätzt:** 1 h **Tatsächlich:** 2 h 10 min
|
||||
**Geschätzt:** 1 h **Tatsächlich:** –
|
||||
|
||||
## Ziel
|
||||
|
||||
@@ -22,69 +22,14 @@ Vorlagenliste in der Oberfläche, Screenshots, und jede Abfrage von Hand. Bei ei
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [x] Die Abläufe unter `tests/e2e/`, die Vorlagen anlegen, räumen sie in einem Nachlauf wieder weg —
|
||||
- [ ] Die Abläufe unter `tests/e2e/`, die Vorlagen anlegen, räumen sie in einem Nachlauf wieder weg —
|
||||
über ihr Präfix, nicht über gemerkte Kennungen (ein abgebrochener Lauf merkt sich nichts)
|
||||
- [x] Dasselbe für Geräte, Personen und Läufe prüfen, die die Abläufe anlegen
|
||||
- [x] Einmalig aufräumen: die vorhandenen Reste aus der Entwicklungsdatenbank entfernen
|
||||
- [x] Prüfen, ob ein Nachlauf das überhaupt leisten kann — ein Ablauf, der mitten im Lauf scheitert,
|
||||
- [ ] Dasselbe für Geräte, Personen und Läufe prüfen, die die Abläufe anlegen
|
||||
- [ ] Einmalig aufräumen: die vorhandenen Reste aus der Entwicklungsdatenbank entfernen
|
||||
- [ ] Prüfen, ob ein Nachlauf das überhaupt leisten kann — ein Ablauf, der mitten im Lauf scheitert,
|
||||
darf den Nachlauf nicht überspringen
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [x] Nach `pnpm test:e2e` steht in `Vorlage` keine Zeile mit `ZZ E2E`
|
||||
- [x] Ein abgebrochener Lauf lässt ebenfalls nichts stehen
|
||||
|
||||
## Beim Umsetzen dazugekommen
|
||||
|
||||
- **Die Beschreibung oben war zur Hälfte überholt.** „Bei jedem Lauf kommen drei dazu“ stimmte beim
|
||||
Schreiben, aber nicht mehr: alle 105 Reste stammen aus einem Fenster von sechzehn Stunden am
|
||||
25./26. September, und seither ist keine dazugekommen. `tests/e2e/vorlagen-editor.spec.ts` räumt
|
||||
seit [#018](018-e2e-anleitung-pdf.md) über das Präfix auf, und das hält. Nachgewiesen an den
|
||||
Zeitstempeln der Zeilen, nicht vermutet.
|
||||
- **Dafür gab es eine neue Lücke, an anderer Stelle:** `anhaenge.spec.ts` (#032) und
|
||||
`live.spec.ts` (#033) legen `ZZ E2E`-Läufe an und hatten **keinen** Nachlauf. In der
|
||||
Entwicklungsdatenbank stand von ihnen nichts — nicht weil sie aufräumten, sondern weil die
|
||||
E2E-Suite dort seit dem 26. September nicht mehr gelaufen war.
|
||||
- **Geräumt wird jetzt zentral und an einer Stelle**: `tests/e2e/aufraeumen.ts`. Ein `afterAll` je
|
||||
Datei ist die falsche Form für diese Zusage — beim nächsten Meilenstein fehlt wieder einer.
|
||||
- **Vor *und* nach dem Lauf.** Ein `globalTeardown` läuft bei Testfehlern und bei Zeitabläufen, aber
|
||||
nicht bei `Strg+C`. Der Vorlauf fängt genau das: ein Vorlauf lässt sich nicht überspringen. Er
|
||||
**sagt es laut** („Rückstand aus einem früheren Lauf: …“), denn still wegzuräumen hieße, die
|
||||
Ursache zu verdecken.
|
||||
- **Das Präfix ist `ZZ` und nicht `ZZ E2E`.** Die Abläufe benutzen es in fünf Schreibweisen
|
||||
(`ZZ E2E Editor …`, `ZZ-E2E-<zeit>-PC-01` — ein Hostname darf kein Leerzeichen haben —,
|
||||
`ZZPruef…`, `ZZQuelle…`, `ZZ Abteilung …`). Alle fünf sind sinnvoll, alle fünf beginnen mit `ZZ`.
|
||||
Darauf zu greifen deckt sie ab, ohne sechzehn Dateien umzubenennen.
|
||||
- **Ein struktureller Test bewacht das** (`tests/betriebsstand.test.ts`): er liest die Abläufe und
|
||||
schlägt an, wenn einer Zeilen anlegt, ohne das Präfix zu benutzen — und prüft, dass Vor- und
|
||||
Nachlauf registriert sind. Er fand beim ersten Durchgang sofort zwei Dateien, die ich übersehen
|
||||
hätte.
|
||||
- **Protokoll-Waisen werden nicht erraten.** Eine Zeile, deren Gegenstand schon fort ist, bleibt
|
||||
stehen; sie über den Namen im Json zu greifen wäre naheliegend und falsch, weil derselbe Name in
|
||||
echter Historie einer noch bestehenden Zeile steht. Dass eine Protokollzeile ihren Gegenstand
|
||||
überlebt, ist der Grund, warum `entitaetId` kein Fremdschlüssel ist.
|
||||
|
||||
## Was der erste echte Lauf zutage brachte
|
||||
|
||||
Der E2E-Durchgang war die ganze Zeit **rot**, und niemand hat es gemerkt — `pnpm test:e2e` steht
|
||||
nicht im Tor vor dem Commit (Reviewbefund zu [#029](029-scim-endpunkt.md)). Sieben Fehlschläge, vier
|
||||
Ursachen, keine davon ein Produktfehler, alle vier behoben:
|
||||
|
||||
- `laeufe.spec.ts` zählte die Felder des Zustands auf und kannte `anzahlAnhaenge` nicht — das kam mit
|
||||
#033 dazu. Die Aufzählung ist Absicht und bleibt: der Zustand ist die Antwort, die am häufigsten
|
||||
hinausgeht, und ein Feld, das sich einschleicht, wandert ungesehen mit.
|
||||
- `stammdaten.spec.ts` suchte „kommt aus dem Verzeichnis“ — seit #020 steht derselbe Satz auch am
|
||||
Vorgesetzten-Feld, also zweimal auf der Seite. Playwright wies das als „strict mode violation“ ab.
|
||||
- `vorlagen-editor.spec.ts` zog am Handy feste 300 px. Der Abstand zwischen den Anfassern ist
|
||||
**532 px**, seit mit #030 und #031 Bausteinverweis und Abteilungsfilter in jeden Abschnitt kamen —
|
||||
gemessen, nicht geschätzt. Dazu lag der erste Anfasser bei `y = -115`, also außerhalb des
|
||||
Sichtfensters: der Touch-Start traf nichts. Jetzt wird erst gescrollt und die Zugweite aus dem
|
||||
Layout genommen. Eine feste Pixelzahl in einem Sortiertest ist eine Zusage über das Layout, die
|
||||
niemand pflegt.
|
||||
- `onboarding.spec.ts` prüft den `New-ADUser`-Befehl zeichengenau und scheiterte an
|
||||
`DC=firma,DC=de` statt `DC=firma,DC=local`: die **Entwicklungsdaten** wichen vom Seed ab, und der
|
||||
Seed setzt bestehende Abteilungen absichtlich nicht zurück. Der Vorlauf prüft das jetzt und sagt,
|
||||
was zu tun ist — statt einer Meldung, die nach einem Fehler im Befehlsbau aussieht.
|
||||
|
||||
**Daraus folgt ein eigenes Issue:** `pnpm test:e2e` gehört ins Tor oder mindestens in eine
|
||||
nachvollziehbare Regel, sonst wiederholt sich das. Siehe [#038](038-e2e-im-tor.md).
|
||||
- [ ] Nach `pnpm test:e2e` steht in `Vorlage` keine Zeile mit `ZZ E2E`
|
||||
- [ ] Ein abgebrochener Lauf lässt ebenfalls nichts stehen
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# #028 M15 – Quellenarten und Microsoft-Graph-Quelle
|
||||
|
||||
**Status:** erledigt
|
||||
**Status:** offen
|
||||
**Im Tracker:** [#48](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/48)
|
||||
**Meilenstein:** v0.2.0
|
||||
**Labels:** phase-2, backend, frontend
|
||||
**Geschätzt:** 10 h **Tatsächlich:** 4 h 10 min
|
||||
**Geschätzt:** 10 h **Tatsächlich:** –
|
||||
|
||||
## Ziel
|
||||
|
||||
@@ -17,94 +17,39 @@ Dieser Meilenstein legt dazu die Weiche, die auch [#029](029-scim-endpunkt.md) b
|
||||
|
||||
## Zu entscheiden
|
||||
|
||||
- [x] **Ein Enum `QuellenArt {LDAP GRAPH SCIM}` auf `Verzeichnisquelle`** — Empfehlung. Die Felder
|
||||
- [ ] **Ein Enum `QuellenArt {LDAP GRAPH SCIM}` auf `Verzeichnisquelle`** — Empfehlung. Die Felder
|
||||
`url`, `bindDn` und `basisDn` sind dann je Art unterschiedlich belegt; was eine Art braucht,
|
||||
prüft der Service, nicht die Datenbank. Die Alternative wäre eine Tabelle je Art mit
|
||||
gemeinsamer Oberklasse — mehr Struktur, als drei Arten rechtfertigen.
|
||||
- [x] **Wie `{{ou}}` in Entra ID aussieht.** Es gibt keinen DN und keine OU. Ehrlich ist, den
|
||||
- [ ] **Wie `{{ou}}` in Entra ID aussieht.** Es gibt keinen DN und keine OU. Ehrlich ist, den
|
||||
Platzhalter bei einer Graph-Quelle **leer** zu lassen, statt etwas hineinzudeuten — und das
|
||||
in der Anleitung zu sagen. Wer OU-abhängige Befehle fährt, braucht ein lokales AD.
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [x] `QuellenArt` als Enum, Migration mit `LDAP` für alle bestehenden Zeilen
|
||||
- [x] Graph-Zugang: `tenantId`, `clientId`, `clientSecret` (verschlüsselt wie das Bind-Passwort, nie
|
||||
- [ ] `QuellenArt` als Enum, Migration mit `LDAP` für alle bestehenden Zeilen
|
||||
- [ ] Graph-Zugang: `tenantId`, `clientId`, `clientSecret` (verschlüsselt wie das Bind-Passwort, nie
|
||||
in einer Antwort), Token über Client-Credentials-Flow, Zwischenspeicher bis kurz vor Ablauf
|
||||
- [x] Lesen über `GET /v1.0/users` mit `$select`, Seitenabruf über `@odata.nextLink`; der
|
||||
- [ ] Lesen über `GET /v1.0/users` mit `$select`, Seitenabruf über `@odata.nextLink`; der
|
||||
Vorgesetzte über `$expand=manager($select=id)`
|
||||
- [x] Attributzuordnung mit Graph-Vorgaben (`givenName`, `surname`, `displayName`, `mail`,
|
||||
- [ ] Attributzuordnung mit Graph-Vorgaben (`givenName`, `surname`, `displayName`, `mail`,
|
||||
`userPrincipalName`, `onPremisesSamAccountName`, `department`, `jobTitle`, `accountEnabled`)
|
||||
- [x] `externeId` ist die Entra-`id` (GUID). Sie ist stabil und übersteht eine Umbenennung
|
||||
- [x] „Verbindung testen“ wie bei LDAP: die ersten zehn zugeordneten Treffer zeigen
|
||||
- [x] Der Abgleich läuft durch **denselben** Dienst wie LDAP — nur das Holen der Einträge
|
||||
- [ ] `externeId` ist die Entra-`id` (GUID). Sie ist stabil und übersteht eine Umbenennung
|
||||
- [ ] „Verbindung testen“ wie bei LDAP: die ersten zehn zugeordneten Treffer zeigen
|
||||
- [ ] Der Abgleich läuft durch **denselben** Dienst wie LDAP — nur das Holen der Einträge
|
||||
unterscheidet sich. Sonst driften die Regeln auseinander
|
||||
- [x] Admin → Verzeichnis zeigt die Art je Quelle und bietet beim Anlegen die Wahl
|
||||
- [x] Anleitung Kapitel 06 und `docs/betrieb/`: was in Entra ID einzurichten ist
|
||||
- [ ] Admin → Verzeichnis zeigt die Art je Quelle und bietet beim Anlegen die Wahl
|
||||
- [ ] Anleitung Kapitel 06 und `docs/betrieb/`: was in Entra ID einzurichten ist
|
||||
(App-Registrierung, `User.Read.All`, `Directory.Read.All`, Administratoreinwilligung)
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [x] Test gegen einen **abspielenden HTTP-Doppelgänger** mit aufgezeichneten Graph-Antworten: erster
|
||||
- [ ] Test gegen einen **abspielenden HTTP-Doppelgänger** mit aufgezeichneten Graph-Antworten: erster
|
||||
Abgleich legt an, zweiter aktualisiert ohne Dubletten, ein `accountEnabled: false` setzt
|
||||
`aktiv = false`
|
||||
- [x] Test: der Seitenabruf folgt `@odata.nextLink` über mindestens drei Seiten
|
||||
- [x] Test: `clientSecret` erscheint in keiner Antwort und in keinem Protokolleintrag
|
||||
- [x] Test: eine Graph-Quelle lässt `{{ou}}` leer, statt einen Wert zu erfinden
|
||||
- [x] Der LDAP-Weg verhält sich unverändert — alle Tests aus #016 bleiben grün
|
||||
- [x] **Offen und benannt:** gegen einen echten Entra-Mandanten ist das nicht gelaufen; die
|
||||
- [ ] Test: der Seitenabruf folgt `@odata.nextLink` über mindestens drei Seiten
|
||||
- [ ] Test: `clientSecret` erscheint in keiner Antwort und in keinem Protokolleintrag
|
||||
- [ ] Test: eine Graph-Quelle lässt `{{ou}}` leer, statt einen Wert zu erfinden
|
||||
- [ ] Der LDAP-Weg verhält sich unverändert — alle Tests aus #016 bleiben grün
|
||||
- [ ] **Offen und benannt:** gegen einen echten Entra-Mandanten ist das nicht gelaufen; die
|
||||
Gegenprobe gehört in eine Prüfliste wie bei [#024](024-proxmox-echtlauf.md)
|
||||
|
||||
## Beim Umsetzen dazugekommen
|
||||
|
||||
- **„Deaktiviert in Entra ID“ als *übersprungen* zu melden war falsch — und hätte Leute aktiv
|
||||
gelassen.** „Übersprungen“ bedeutet im Abgleich „nicht zuordenbar“, und solche Einträge werden
|
||||
seit #016 absichtlich **nicht** abgeschaltet (ein Konto ohne GUID soll nicht die Person
|
||||
deaktivieren). Ein gesperrtes Entra-Konto wäre damit genau umgekehrt behandelt worden als im AD.
|
||||
Gefiltert wird jetzt serverseitig mit `$filter=accountEnabled eq true`, als Gegenstück zum
|
||||
LDAP-Standardfilter. Gefunden hat es der Test „setzt aktiv = false“ — und nur, weil der
|
||||
Doppelgänger den Filter wirklich auswertet statt ihn bloß zu empfangen.
|
||||
- **`URLSearchParams` kodiert `$` zu `%24`.** Die Abfrage hätte `%24select` und `%28` in
|
||||
`manager($select=id)` geschickt. Graph nimmt das teils an und antwortet teils mit „Invalid expand
|
||||
clause“ — und eine Adresse, die in einem Mitschnitt anders aussieht als in der Dokumentation,
|
||||
kostet bei der Fehlersuche eine Stunde. Die Parameter werden jetzt von Hand zusammengesetzt.
|
||||
- **Die Art einer Quelle ist unveränderlich**, und das steht nicht im Issue. An ihr hängt, was
|
||||
`externeId` bedeutet: im AD eine `objectGUID`, in Entra ID eine andere GUID. Nach einem Wechsel
|
||||
würde **kein einziger Mitarbeiter wiedererkannt** — der nächste Abgleich legte die ganze
|
||||
Belegschaft neu an und setzte die alten Zeilen auf `aktiv = false`, mit allen Läufen und Geräten
|
||||
daran. Der Dienst weist einen Wechsel ab, die Oberfläche bietet ihn nicht an, und die Anleitung
|
||||
sagt, was stattdessen zu tun ist.
|
||||
- **`zuordnungLesen` brauchte die Art als Parameter.** Ohne sie bekäme eine Graph-Quelle mit leerer
|
||||
Zuordnung die AD-Namen und fragte bei Graph nach `sn` und `objectGUID`, die es dort nicht gibt.
|
||||
Der Standardwert `LDAP` hält jeden Aufrufer von vor #028 bei seinem Verhalten.
|
||||
- **Die Zugangs-Helfer mussten in ein eigenes Modul.** `quellenleser.ts` (die Weiche) braucht
|
||||
`zugangAus`, und `services/verzeichnis.ts` braucht die Weiche für „Verbindung testen“ — zusammen
|
||||
ein Import-Zyklus. Der läuft in ESM oft, aber nicht zuverlässig, und ein Modul, das beim Laden
|
||||
halb leer ist, ergibt Fehler, die niemand liest. Jetzt liegt beides in
|
||||
`src/server/verzeichnis/zugang.ts`.
|
||||
- **`SCIM` steht im Enum, wird aber abgewiesen.** Die Weiche braucht #029, und ein Wert, der
|
||||
stillschweigend nichts tut, wäre hier besonders teuer: ein Abgleich, der null Einträge liest und
|
||||
`ERFOLG` meldet, hätte über die Abschaltregel die ganze Belegschaft deaktiviert — genau der Fall,
|
||||
der in #016 einen Reviewbefund gekostet hat. Mit eigenem Test.
|
||||
- **Die Felder je Art hält die Datenbank fest.** Ein CHECK verlangt bei `LDAP` die LDAP-Felder
|
||||
**und** verbietet die Graph-Felder, bei `GRAPH` umgekehrt. Ohne den zweiten Teil wäre eine
|
||||
Graph-Quelle mit `basisDn` möglich — eine Zeile, die zwei Dinge gleichzeitig behauptet.
|
||||
- **Der Vorgesetzte kommt bei Graph direkt**, nicht über eine DN-Auflösung. `RoherEintrag` hat dafür
|
||||
ein zweites Feld (`vorgesetzterDirekt`) und nicht ein Feld mit zwei Bedeutungen: ein DN und eine
|
||||
GUID sehen verschieden aus, aber ein Feld, das mal das eine und mal das andere trägt, verwechselt
|
||||
irgendwann jemand.
|
||||
- **Der Token-Zwischenspeicher hat 60 Sekunden Sicherheitsabstand.** Ein Token, das in zehn Sekunden
|
||||
abläuft, ist für eine Seite, die zwei Sekunden braucht, formal noch gültig — und in der Mitte der
|
||||
nächsten nicht mehr. Der Fehler wäre ein Abgleich, der nach der dritten Seite mit 401 abbricht.
|
||||
- **Eine Reißleine bei 1000 Seiten.** Ein `@odata.nextLink`, der auf sich selbst zeigt, wäre sonst
|
||||
eine Schleife bis zum Speicherende — still, weil jeder einzelne Abruf gelingt.
|
||||
- **Zwei eigene Testfehler**, keine Produktfehler: die Token-Grenze liegt bei +3540 s und nicht bei
|
||||
„so ungefähr einer Stunde“, und die Klickprobe zählte die Felder der Bearbeiten-Formulare der
|
||||
bestehenden Quellen mit.
|
||||
|
||||
## Noch offen, und benannt
|
||||
|
||||
Gegen einen **echten Entra-Mandanten** ist das nicht gelaufen. Geprüft ist es gegen einen
|
||||
HTTP-Doppelgänger mit aufgezeichneten Antworten, und deren Form stammt aus der Dokumentation, nicht
|
||||
aus einem Mitschnitt. Die Prüfliste steht am Ende von
|
||||
[`docs/betrieb/entra-id.md`](../betrieb/entra-id.md) — acht Punkte, darunter ein Mandant mit mehr als
|
||||
999 Benutzern für den Seitenabruf. Dieselbe Art offener Punkt wie [#024](024-proxmox-echtlauf.md).
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# #029 M16 – SCIM-Endpunkt als Alternative zum Abgleich
|
||||
|
||||
**Status:** erledigt
|
||||
**Status:** offen
|
||||
**Im Tracker:** [#49](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/49)
|
||||
**Meilenstein:** v0.2.0
|
||||
**Labels:** phase-2, backend
|
||||
**Geschätzt:** 12 h **Tatsächlich:** 4 h 05 min
|
||||
**Geschätzt:** 12 h **Tatsächlich:** –
|
||||
|
||||
## Ziel
|
||||
|
||||
@@ -16,137 +16,41 @@ Setzt [#028](028-graph-quelle.md) voraus: die Quellenart `SCIM` braucht die Weic
|
||||
|
||||
## Zu entscheiden
|
||||
|
||||
- [x] **Ein Token je Quelle, als Hash gespeichert** — Empfehlung, dasselbe Verfahren wie beim
|
||||
- [ ] **Ein Token je Quelle, als Hash gespeichert** — Empfehlung, dasselbe Verfahren wie beim
|
||||
lokalen Passwort (argon2). Es wird **einmal** angezeigt und ist danach nicht mehr lesbar. Das
|
||||
Token identifiziert die Quelle und damit den Kunden; es ist der einzige Mandantenbeweis, den
|
||||
ein Push mitbringt.
|
||||
- [x] **Was bei `DELETE /Users/{id}` geschieht:** `aktiv = false`, nicht löschen. Das ist die Regel
|
||||
- [ ] **Was bei `DELETE /Users/{id}` geschieht:** `aktiv = false`, nicht löschen. Das ist die Regel
|
||||
des ganzen Projekts, und ein Identitätsanbieter, der löschen will, bekommt `204` — aber die
|
||||
Zeile bleibt, weil Läufe an ihr hängen. Das muss in der Anleitung stehen, sonst wirkt es wie
|
||||
ein Fehler.
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [x] Route Handler unter `src/app/api/scim/v2/`: `Users` (GET mit Filter `userName eq`, POST),
|
||||
- [ ] Route Handler unter `src/app/api/scim/v2/`: `Users` (GET mit Filter `userName eq`, POST),
|
||||
`Users/{id}` (GET, PUT, PATCH, DELETE), dazu `ServiceProviderConfig`, `Schemas`,
|
||||
`ResourceTypes` — ohne die drei weigern sich manche Anbieter, überhaupt anzufangen
|
||||
- [x] **Jeder dieser Handler prüft selbst**: das Token, und daraus die Quelle und den Kunden. Die
|
||||
- [ ] **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)
|
||||
- [x] Zuordnung: `externalId` → `externeId`, `userName` → `upn`, `name.givenName`/`familyName`,
|
||||
- [ ] Zuordnung: `externalId` → `externeId`, `userName` → `upn`, `name.givenName`/`familyName`,
|
||||
`emails[primary]` → `mail`, `active` → `aktiv`, `urn:ietf:params:scim:schemas:extension:enterprise:2.0:User`
|
||||
für `department` und `manager`
|
||||
- [x] Antwortform nach RFC 7644: `schemas`, `id`, `meta.resourceType`, `meta.location`, bei Listen
|
||||
- [ ] 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`
|
||||
- [x] `PATCH` mit `op: add|replace|remove` auf den Pfaden, die die Anbieter wirklich schicken —
|
||||
- [ ] `PATCH` mit `op: add|replace|remove` auf den Pfaden, die die Anbieter wirklich schicken —
|
||||
nicht der ganze Standard
|
||||
- [x] Idempotenz: zweimal dasselbe `POST` ergibt `409`, nicht eine zweite Person
|
||||
- [x] Protokolleintrag je Vorgang, mit der Quelle als Urheber (Systemlauf, kein Benutzer)
|
||||
- [x] Admin → Verzeichnis: Token erzeugen, einmal anzeigen, widerrufen; letzte Zustellung sichtbar
|
||||
- [x] Anleitung Kapitel 06: Einrichtung in authentik und in Entra ID
|
||||
- [ ] Idempotenz: zweimal dasselbe `POST` ergibt `409`, nicht eine zweite Person
|
||||
- [ ] Protokolleintrag je Vorgang, mit der Quelle als Urheber (Systemlauf, kein Benutzer)
|
||||
- [ ] Admin → Verzeichnis: Token erzeugen, einmal anzeigen, widerrufen; letzte Zustellung sichtbar
|
||||
- [ ] Anleitung Kapitel 06: Einrichtung in authentik und in Entra ID
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [x] Test gegen **aufgezeichnete Anfragen** von authentik und Entra ID (Form, nicht erfunden):
|
||||
- [ ] Test gegen **aufgezeichnete Anfragen** von authentik und Entra ID (Form, nicht erfunden):
|
||||
anlegen, aktualisieren, deaktivieren, wieder aktivieren
|
||||
- [x] Test: ohne Token `401`, mit fremdem Token kein Zugriff auf die Personen einer anderen Quelle
|
||||
- [x] Test: `DELETE` setzt `aktiv = false` und löscht nichts
|
||||
- [x] Test: zweimal dasselbe `POST` ergibt `409` und keine zweite Zeile
|
||||
- [x] Test: das Token steht nur als Hash in der Datenbank und in keiner Antwort
|
||||
- [x] Test: eine Quelle der Art `SCIM` wird vom Zeitplan **nicht** abgeglichen — sie bekommt
|
||||
- [ ] Test: ohne Token `401`, mit fremdem Token kein Zugriff auf die Personen einer anderen Quelle
|
||||
- [ ] Test: `DELETE` setzt `aktiv = false` und löscht nichts
|
||||
- [ ] Test: zweimal dasselbe `POST` ergibt `409` und keine zweite Zeile
|
||||
- [ ] Test: das Token steht nur als Hash in der Datenbank und in keiner Antwort
|
||||
- [ ] Test: eine Quelle der Art `SCIM` wird vom Zeitplan **nicht** abgeglichen — sie bekommt
|
||||
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`.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# #035 M22 – Mitarbeiter-Selbstauskunft
|
||||
|
||||
**Status:** erledigt
|
||||
**Status:** offen
|
||||
**Im Tracker:** [#55](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/55)
|
||||
**Meilenstein:** v0.2.0
|
||||
**Labels:** phase-2, backend, frontend
|
||||
**Geschätzt:** 8 h **Tatsächlich:** 2 h 50 min
|
||||
**Geschätzt:** 8 h **Tatsächlich:** –
|
||||
|
||||
## Ziel
|
||||
|
||||
@@ -20,95 +20,42 @@ Anmeldung zu einer `Mitarbeiter`-Zeile zu kommen.
|
||||
|
||||
## Zu entscheiden
|
||||
|
||||
- [x] **Anmeldung über OIDC, Zuordnung über den UPN, neue Rolle `MITARBEITER`** — Empfehlung. Das
|
||||
- [ ] **Anmeldung über OIDC, Zuordnung über den UPN, neue Rolle `MITARBEITER`** — Empfehlung. Das
|
||||
Haus hat seine Leute ohnehin im Identitätsanbieter; wer sich anmeldet und keiner
|
||||
Rollengruppe angehört, wird über `upn` einer `Mitarbeiter`-Zeile zugeordnet und bekommt genau
|
||||
ein Recht: `selbstauskunft`. `Benutzer.mitarbeiterId` hält die Zuordnung fest.
|
||||
Die Alternative — ein Link mit Token je Person — braucht keine Anmeldung, aber jeder
|
||||
weitergeleitete Link ist dann ein Datenleck, und widerrufen müsste man je Person.
|
||||
- [x] **Was der Mitarbeiter sieht:** Titel, Status, Zeitraum und Gerät eines Laufs. **Nicht** die
|
||||
- [ ] **Was der Mitarbeiter sieht:** Titel, Status, Zeitraum und Gerät eines Laufs. **Nicht** die
|
||||
Schritte, nicht die Notizen, nicht die Kommentare, nicht die Anhänge. In einem Lauf stehen
|
||||
Arbeitsnotizen über Menschen und Zugangsdaten-Platzhalter; eine Selbstauskunft ist keine
|
||||
Akteneinsicht. Das ist die wichtigste Entscheidung dieses Meilensteins und gehört in die
|
||||
Anleitung, nicht nur in den Code.
|
||||
- [x] **Kein Zugang ohne Zuordnung.** Wer sich anmeldet und zu keiner `Mitarbeiter`-Zeile passt,
|
||||
- [ ] **Kein Zugang ohne Zuordnung.** Wer sich anmeldet und zu keiner `Mitarbeiter`-Zeile passt,
|
||||
bekommt `/kein-zugriff` mit Begründung — nicht eine leere Seite, die wie ein Fehler aussieht.
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [x] `Rolle` um `MITARBEITER` erweitern; `RECHTE` bekommt `selbstauskunft: ["MITARBEITER", …]`.
|
||||
- [ ] `Rolle` um `MITARBEITER` erweitern; `RECHTE` bekommt `selbstauskunft: ["MITARBEITER", …]`.
|
||||
**Jede** Rollenprüfung in `tests/rollen.test.ts` wächst um eine Spalte — die Matrix ist
|
||||
ausgeschrieben und nicht berechnet, genau damit das auffällt
|
||||
- [x] `Benutzer.mitarbeiterId` mit Mandantengrenze als zusammengesetzter Fremdschlüssel
|
||||
- [x] Zuordnung beim Login über `upn`, unabhängig von Groß- und Kleinschreibung; mehrdeutig ist ein
|
||||
- [ ] `Benutzer.mitarbeiterId` mit Mandantengrenze als zusammengesetzter Fremdschlüssel
|
||||
- [ ] Zuordnung beim Login über `upn`, unabhängig von Groß- und Kleinschreibung; mehrdeutig ist ein
|
||||
Fehler und kein Zufallstreffer
|
||||
- [x] Eigene Seite `/meine-geraete`: meine Geräte, je Gerät die Läufe chronologisch, Titel und Status
|
||||
- [x] **Jede Abfrage schränkt auf die eigene Person ein** — nicht die Seite. Ein Lesemodell trägt
|
||||
- [ ] Eigene Seite `/meine-geraete`: meine Geräte, je Gerät die Läufe chronologisch, Titel und Status
|
||||
- [ ] **Jede Abfrage schränkt auf die eigene Person ein** — nicht die Seite. Ein Lesemodell trägt
|
||||
seine Sicht selbst (CLAUDE.md)
|
||||
- [x] Die Navigation zeigt einem `MITARBEITER` nur diese Seite; alles andere ist weg, nicht grau
|
||||
- [x] Anleitung: ein eigenes kurzes Kapitel, an Mitarbeiter gerichtet und nicht an Techniker
|
||||
- [ ] Die Navigation zeigt einem `MITARBEITER` nur diese Seite; alles andere ist weg, nicht grau
|
||||
- [ ] Anleitung: ein eigenes kurzes Kapitel, an Mitarbeiter gerichtet und nicht an Techniker
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [x] Test: ein `MITARBEITER` sieht die Läufe seiner Geräte und keinen anderen
|
||||
- [x] Test: er sieht **keine** Schritte, Notizen, Kommentare oder Anhänge — geprüft über die Antwort,
|
||||
- [ ] Test: ein `MITARBEITER` sieht die Läufe seiner Geräte und keinen anderen
|
||||
- [ ] Test: er sieht **keine** Schritte, Notizen, Kommentare oder Anhänge — geprüft über die Antwort,
|
||||
nicht über die Seite
|
||||
- [x] Test: er kann nichts schreiben — jede Server Action weist ihn ab (`tests/zugang.test.ts`
|
||||
- [ ] Test: er kann nichts schreiben — jede Server Action weist ihn ab (`tests/zugang.test.ts`
|
||||
erkennt eine ungeprüfte Aktion ohnehin)
|
||||
- [x] Test: ohne passende `Mitarbeiter`-Zeile führt die Anmeldung auf `/kein-zugriff` mit Begründung
|
||||
- [x] Test: ein mehrdeutiger UPN wird abgewiesen und protokolliert
|
||||
- [x] Test: die Rollenmatrix ist vollständig — jede der fünf Rollen gegen jedes Recht
|
||||
- [x] Am Handy kein horizontales Scrollen
|
||||
|
||||
## Beim Umsetzen dazugekommen
|
||||
|
||||
- **Kein zusammengesetzter Fremdschlüssel, und zwar aus einem Grund.** Die Aufgabe verlangte
|
||||
`Benutzer.mitarbeiterId` „mit Mandantengrenze als zusammengesetzter Fremdschlüssel“. Das geht
|
||||
nicht: ein `Benutzer` trägt keine `kundeId`, gegen die man zusammensetzen könnte — ein
|
||||
Administrator sieht alle Kunden. Stattdessen hält die Datenbank das fest, was hier wirklich zählt:
|
||||
ein **CHECK**, dass Rolle und Zuordnung zusammengehören (in beide Richtungen), und ein
|
||||
**eindeutiger Index**, dass eine Person höchstens ein Konto hat. Die Zusage „sieht nur seine
|
||||
eigenen Läufe“ hängt an der einen Kennung, und stärker als ein Fremdschlüssel darauf wird sie
|
||||
nicht.
|
||||
- **Nicht `/kein-zugriff`, sondern die Anmeldeseite.** Das Kriterium sah vor, dass eine Anmeldung
|
||||
ohne passende Person auf `/kein-zugriff` führt. Diese Seite verlangt aber eine **Sitzung**, und
|
||||
bei gescheiterter Anmeldung gibt es keine — sie würde auf die Anmeldeseite zurückleiten, also im
|
||||
Kreis. Die Begründung steht deshalb dort, mit zwei eigenen Fehlercodes (`keine-person`,
|
||||
`upn-mehrdeutig`). Der Geist des Kriteriums ist damit erfüllt: keine leere Seite, die wie ein
|
||||
Fehler aussieht.
|
||||
- **Ein bestehender Test seit #007 fand eine echte Lücke.** `staerksteRolle` benutzte `LESER` als
|
||||
Startwert, und das war zugleich eine **Untergrenze**: `staerksteRolle(["MITARBEITER"])` ergab
|
||||
`LESER`, also **mehr** Rechte als übergeben. Solange alle Rollen mindestens `LESER` waren, fiel
|
||||
das nie auf. Der Test „gibt bei nur <Rolle> diese Rolle zurück“ war sofort rot.
|
||||
- **Jeder Navigationseintrag nennt jetzt sein Recht.** Bis hierher stand `recht` nur am
|
||||
Admin-Bereich — das genügte, weil jede Rolle `lesen` durfte. Mit `MITARBEITER` stimmt das nicht
|
||||
mehr, und ohne diese Änderung hätte die Seitenleiste ihm sechs Links angeboten, die alle auf
|
||||
`/kein-zugriff` führen.
|
||||
- **Zwei Bedienfehler beim Durchklicken gefunden**, beide dieselbe Ursache: das Standardziel nach
|
||||
der Anmeldung ist `/`, und die Übersicht verlangt `lesen`. Ein Mitarbeiter sah als Erstes „Dafür
|
||||
fehlt dir die Berechtigung“ — und der Knopf auf jener Seite führte zurück auf `/`, also im Kreis.
|
||||
Beides behebt `startseite(rolle)` in `src/server/auth/rollen.ts`, an einer Stelle und mit Test.
|
||||
- **Eine Gegenprobe blieb grün, und das war der Befund.** Der Test „sieht keine Notizen“ prüft die
|
||||
**Antwort**, und die ist sicher: `laufBauen` setzt sie aus den erlaubten Feldern zusammen. Als ich
|
||||
im `select` versuchsweise `ergebnisse: { notiz }` ergänzte, blieb er deshalb grün — während der
|
||||
Kopfkommentar der Datei behauptet, das `select` hole es „gar nicht erst“. Diese Behauptung hat
|
||||
jetzt ihren eigenen Test, der die Datei selbst liest. Ein `select`, das Notizen mitliest, zieht
|
||||
sie durch Protokolle und Fehlerberichte.
|
||||
- **`selbstauskunft` hat auch der ADMIN nicht.** Es ist ein **Ersatz** für `lesen`, kein Zusatz: ein
|
||||
zweiter Weg zu denselben Daten wäre eine zweite Stelle, an der jemand die Einschränkung auf die
|
||||
eigene Person vergisst. Wer beides braucht, braucht zwei Konten — und der CHECK verlangt das.
|
||||
- **Der Rang von `MITARBEITER` liegt unter `LESER`.** Der Rang entscheidet, wer gewinnt, wenn jemand
|
||||
in mehreren abgebildeten Gruppen steht. Stünde er darüber, verlöre ein Techniker seine Rechte,
|
||||
weil er auch Mitarbeiter des Hauses ist.
|
||||
- **Ein struktureller Test, dass keine Server Action `selbstauskunft` verlangt.** Die Zusage „kann
|
||||
nichts schreiben“ steht nicht in einer Prüfung, sondern in der **Abwesenheit** einer Aktion mit
|
||||
diesem Recht — und die muss jemand bewachen.
|
||||
- **Die Weiche greift nur mit eingerichteter Gruppenzuordnung.** Ohne abgebildete Gruppen kann die
|
||||
Anwendung nicht unterscheiden, ob jemand Techniker oder Mitarbeiter ist. Ein Update, das jeden
|
||||
aussperrt, dem die Gruppen fehlen, wäre ein kaputtes Update — mit eigenem Test.
|
||||
- **Die authentik-Doku enthielt jetzt einen falschen Satz:** „Ist der Benutzer in keiner der vier
|
||||
Gruppen, bekommt er `LESER`.“ Richtiggestellt, mit einem eigenen Abschnitt zur Selbstauskunft und
|
||||
dem Hinweis, wie man das alte Verhalten behält.
|
||||
- **Das Anleitungskapitel 09 ist an Mitarbeiter gerichtet**, nicht an die IT — das erste Kapitel
|
||||
dieser Anleitung, das sich an jemanden außerhalb der IT wendet. Es lässt sich weitergeben, ohne
|
||||
den Rest mitzuschicken, und sagt ausdrücklich, **warum** man die Schritte nicht sieht.
|
||||
- [ ] Test: ohne passende `Mitarbeiter`-Zeile führt die Anmeldung auf `/kein-zugriff` mit Begründung
|
||||
- [ ] Test: ein mehrdeutiger UPN wird abgewiesen und protokolliert
|
||||
- [ ] Test: die Rollenmatrix ist vollständig — jede der fünf Rollen gegen jedes Recht
|
||||
- [ ] Am Handy kein horizontales Scrollen
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
# #036 Reviewbefunde aus der Durchsicht von Phase 2
|
||||
|
||||
**Status:** erledigt
|
||||
**Im Tracker:** –
|
||||
**Meilenstein:** v0.2.0
|
||||
**Labels:** phase-2, backend, test, docs
|
||||
**Geschätzt:** 4 h **Tatsächlich:** 3 h 40 min
|
||||
|
||||
## Ziel
|
||||
|
||||
Acht Meilensteine von Phase 2 (M15–M22) lagen ohne fremden Blick auf `main`. Ein Review-Agent, der
|
||||
den Code nicht geschrieben hat, hat sie gegen die Akzeptanzkriterien und gegen `CLAUDE.md` geprüft —
|
||||
mit eigener Datenbank, Gegenproben und einem vollen Suitenlauf.
|
||||
|
||||
**Ergebnis:** Die Kernregeln halten. Fünf von fünf Gegenproben gegen Versionierung,
|
||||
Mandantengrenze, Abschlussregel und Selbstauskunft wurden rot. Alle Befunde lagen am Rand — in zwei
|
||||
Route Handlern, einem Export-Pfad, in Betriebsdokumentation und in Kommentaren.
|
||||
|
||||
**Die drei wertvollsten Befunde waren Gegenproben, die grün blieben.** Das ist das wiederkehrende
|
||||
Muster dieses Projekts: nicht der falsche Code fällt auf, sondern der Code, der *aussieht*, als
|
||||
prüfte er etwas.
|
||||
|
||||
## Behoben
|
||||
|
||||
### Schwer
|
||||
|
||||
- [x] **Der Link in der Mail „Verzeichnis-Abgleich fehlgeschlagen“ war ein 404.** Gebaut wurde
|
||||
`/verwaltung/verzeichnis` — `verwaltung` ist der Name des **Rechts**, die Seite liegt unter
|
||||
`/admin/verzeichnis`. Das ist die eine Meldung, die an **alle** Administratoren geht, und
|
||||
zwar im Moment eines Ausfalls: der Empfänger klickt und landet auf der
|
||||
Nicht-gefunden-Seite. Die Pfade stehen jetzt als `MELDUNGS_PFADE` an einer Stelle, und ein
|
||||
Test prüft **jeden gegen die Routen unter `src/app/`** — ein Pfad, den es nicht gibt, fällt
|
||||
beim Testlauf auf und nicht im Postfach.
|
||||
- [x] **Der Export eines Entwurfs mit Baustein lieferte einen leeren Abschnitt**, unter dem
|
||||
Editor-Platzhalter „wird ersetzt“, und der Import nahm das stillschweigend an. Ein Redakteur
|
||||
bringt eine noch unveröffentlichte Vorlage in eine zweite Installation — genau der Zweck des
|
||||
Formats —, und der geteilte Abschnitt kommt dort ohne einen einzigen Schritt an. Baustein-
|
||||
Abschnitte werden beim Export jetzt **aufgelöst**, wie beim Veröffentlichen; der Verweis
|
||||
wandert weiterhin nicht mit.
|
||||
|
||||
### Mittel
|
||||
|
||||
- [x] **Der Route Handler, der Anhänge ausliefert, hatte keinen einzigen Test.** Die ganze
|
||||
Rechteprüfung ließ sich durch einen hartcodierten ADMIN ersetzen, ohne dass ein Test rot
|
||||
wurde. Das ist die **einzige** Stelle der Anwendung, die rohe Dateibytes ausliefert. Neu:
|
||||
`tests/anhang-route.test.ts` fährt den Handler echt, acht Prüfungen — darunter das
|
||||
Akzeptanzkriterium von #032 („nicht abrufbar **über seine Adresse**“), das bis hierher nur
|
||||
auf Dienstebene geprüft war. Die Gegenprobe ist jetzt dreifach rot.
|
||||
- [x] **`/api/scim` in der Proxy-Allowlist stand in keinem Test.** Die Zeile ließ sich entfernen,
|
||||
ohne dass etwas rot wurde — und ohne sie leitet der Proxy jede Zustellung auf `/anmelden` um:
|
||||
der Anbieter meldet „Provisioning failed“, in Laufzettel steht nichts, das Provisioning hört
|
||||
still auf zu arbeiten. `OEFFENTLICH` ist jetzt exportiert, und ein Test prüft **in beide
|
||||
Richtungen**: wer die Liste erweitert, muss es im Test aufschreiben. Eine Aufzählung, die nur
|
||||
in einer Richtung geprüft wird, läuft dem Code hinterher.
|
||||
- [x] **Ein offener Ereignisstrom überlebte Abmelden, Deaktivieren und Sitzungswiderruf.** Das
|
||||
Recht wurde genau einmal geprüft, beim Aufbau, und Kennung samt Rolle für die Lebensdauer des
|
||||
Stroms festgehalten. Ein Administrator deaktiviert ein Konto — und eine offen gelassene
|
||||
Lasche bekommt weiter live Status, Schrittzustände und Zählwerte, bis jemand sie schließt.
|
||||
`docs/architektur.md` verspricht „ein deaktivierter Benutzer wird auch mit gültigem Cookie
|
||||
abgewiesen“; für einen Strom galt das nicht. Der Herzschlag prüft jetzt nach
|
||||
(`sitzungNochGueltig`), eine Herabstufung engt den Strom ein statt ihn zu beenden.
|
||||
- [x] **`README.md` kannte die Rolle `MITARBEITER` nicht**, und ein Satz daneben war seit #035
|
||||
falsch: „Unbekannte Benutzer werden beim ersten Login angelegt.“ Wer auf v0.2.0 hebt, liest
|
||||
das README, nimmt an, für Leute ohne Rollengruppe bleibe alles wie bisher — und sperrt danach
|
||||
genau diese Leute aus. #035 hatte den Satz in `docs/betrieb/authentik.md` richtiggestellt und
|
||||
das README stehengelassen. Dazu: der Stand stand weiter auf „in Entwicklung auf v0.1.0“.
|
||||
- [x] **`docs/betrieb/sicherung.md` nannte einen Anhänge-Pfad, den es nirgends gibt**
|
||||
(`/opt/laufzettel/daten/anhaenge`) — als Rückfallwert in einem Skript, in dem ein `rm -rf`
|
||||
steht. `deploy/install.sh` setzt `/var/lib/laufzettel/anhaenge`, und ohne die Variable nimmt
|
||||
die Anwendung `./daten/anhaenge` relativ zu ihrem Arbeitsverzeichnis. Der Rückfall passte zu
|
||||
**keinem** der drei. Jetzt `${ANHAENGE_PFAD:?…}`: fehlt die Variable, **bricht** der Befehl
|
||||
ab, statt zu raten — vor dem `rm -rf`. Dasselbe Dokument hat denselben Fehler in #032 schon
|
||||
einmal gehabt.
|
||||
- [x] **Attributnamen wurden nur in der Server Action geprüft**, während sich
|
||||
`src/server/graph/leser.ts` im Kommentar darauf berief („feste Feldnamen aus
|
||||
`ATTRIBUT_MUSTER`, also ist hier nichts zu maskieren“). Nachgewiesen: ein Name mit Komma und
|
||||
`$filter` kam durch den Dienst und schrieb einen **zweiten `$filter`** in die Graph-Adresse.
|
||||
Hereinkommen konnte er nur mit `verwaltung` oder per Skript, Seed und `psql`, die
|
||||
Sicherheitswirkung ist also klein — der Befund ist der **Ort** der Regel. Jetzt
|
||||
`zuordnungPruefen` im Dienst (wirft) und Verwerfen in `zuordnungLesen` (damit eine kaputte
|
||||
Zeile nicht die Quellenliste sprengt).
|
||||
|
||||
### Leicht
|
||||
|
||||
- [x] **Drei Kommentare versprachen mehr als der Code hält.** Der Kommentar am Konstanten-Block der
|
||||
Wartezeiten sagte „nach dem vierten Fehlschlag wird aufgegeben“, während es der fünfte ist —
|
||||
und das ist der Kommentar, den man zuerst liest. Die Migration der Benachrichtigungen
|
||||
behauptete „der Dienst prüft dasselbe noch einmal, nur mit besserer Meldung“; tut er nicht,
|
||||
und er soll es auch nicht (zwei Instanzen hinter einem Lastverteiler fragen beide und
|
||||
bekommen beide „nein“ — nur der partielle Index hält). **Die Migration selbst bleibt
|
||||
unverändert:** eine angewandte Migration wird nicht umgeschrieben, so wie eine
|
||||
veröffentlichte `VorlagenVersion` nicht. Die Richtigstellung steht bei `einreihen`, wo sie
|
||||
gelesen wird.
|
||||
- [x] **Der Protokolleintrag „UPN mehrdeutig“ nannte immer „2“**, auch bei sieben Treffern:
|
||||
`personZuUpn` hatte `take: 2`, und dieselbe Zahl ging in den Eintrag. Wer das Protokoll
|
||||
liest, liest es, **um den Fall aufzulösen** — er muss wissen, wie viele Zeilen zu bereinigen
|
||||
sind. `take` entfernt, Test mit vier Treffern.
|
||||
- [x] **`anzahlSchritte` im Protokoll eines neuen Laufs zählte die Version, nicht den Lauf.** Bei
|
||||
einer Vorlage mit abteilungsgebundenen Abschnitten (#031) behauptete der Eintrag mehr
|
||||
Schritte, als der Lauf hat. Das Protokoll ist seit #023 ansehbar.
|
||||
- [x] **Die Phase-2-Seiten fehlten in der 390-px-Prüfung.** Ergänzt: Bausteinliste, Schnellstart,
|
||||
Protokoll, Zustellungen — gerade die Tabellen, also die Stellen, an denen horizontales
|
||||
Scrollen entsteht. Jedes Issue berichtet eine Klickprobe von Hand; eine Probe von Hand ist
|
||||
kein Regressionstest.
|
||||
- [x] **Der Graph-Seitenabruf folgte `@odata.nextLink` ohne Host-Prüfung**, mit einem mandantenweit
|
||||
gültigen Token im Kopf. Graph über TLS zu trauen ist vertretbar — aber `GRAPH_API_URL` ist
|
||||
absichtlich einstellbar (nationale Clouds, Test-Doppelgänger), und ein Tippfehler darin
|
||||
schickte das Token woandershin. `naechsteSeite` vergleicht jetzt den Ursprung.
|
||||
|
||||
## Nicht behoben, bewusst
|
||||
|
||||
- **`anhaengeDesLaufs` liegt in `services/` und wendet keine Mandantensicht an.** Beide Aufrufer
|
||||
prüfen den Lauf vorher, und die Kommentare sagen das auch — aber es steht gegen die Regel aus
|
||||
`CLAUDE.md` („`queries/*` wenden ihre Sicht selbst an, damit kein Aufrufer sie vergessen kann“).
|
||||
Zwei Aufrufer in zwei Schichten gibt es schon; der dritte ist der, der es vergisst. Eigenes
|
||||
Issue: [#037](037-anhaenge-lesemodell.md).
|
||||
- **`/meine-geraete` fehlt weiter in der 390-px-Prüfung.** Die Seite verlangt `selbstauskunft`, und
|
||||
das hat nur `MITARBEITER`; der E2E-Ablauf meldet sich als Admin an und würde die
|
||||
Verweigerungsseite messen. Dafür braucht es ein eigenes Konto samt Personenbindung — siehe
|
||||
[#037](037-anhaenge-lesemodell.md).
|
||||
- **Das Graph-Token wird einmal je Abgleich geholt**, vor der Seitenschleife. Bei einem Mandanten,
|
||||
dessen Abgleich länger als die Token-Lebensdauer läuft, hilft der Sicherheitsabstand nicht. Das
|
||||
gehört auf die offene Prüfliste in `docs/betrieb/entra-id.md` und ist ohne echten Mandanten nicht
|
||||
zu beurteilen.
|
||||
|
||||
## Was der Review *nicht* gefunden hat
|
||||
|
||||
Benannt, weil es den Befunden ihr Gewicht gibt: keine `kundeId` oder `quelleId` aus der Eingabe
|
||||
statt aus Sitzung bzw. Token; kein Lesemodell, das seine Sicht ganz vergisst; keine
|
||||
Client-Komponente mit einem Wert-Import aus `src/server/*`; kein Geheimnis in einer Sicht, Antwort
|
||||
oder einem Protokolleintrag; kein gespeichertes `{{generiertes_passwort}}`; kein Codepfad, der
|
||||
annimmt, dass nur ein Mensch einen Schritt erledigt.
|
||||
|
||||
Ein Befund des Reviews war **falsch**: der Zustands-Handler habe keinen Vitest-Test. Er hat acht
|
||||
direkte Aufrufe in `tests/lauf-ereignisse.test.ts`, darunter „weist ohne Anmeldung ab“ und „liefert
|
||||
für einen fremden Kunden keinen Strom“. Nachgeprüft, bevor etwas geändert wurde.
|
||||
@@ -1,87 +0,0 @@
|
||||
# #037 Zwei Nachzügler aus der Durchsicht von Phase 2
|
||||
|
||||
**Status:** erledigt
|
||||
**Im Tracker:** –
|
||||
**Meilenstein:** v0.2.0
|
||||
**Labels:** phase-2, backend, test
|
||||
**Geschätzt:** 2 h **Tatsächlich:** 1 h 50 min
|
||||
|
||||
## Ziel
|
||||
|
||||
Zwei Befunde aus [#036](036-reviewbefunde-phase-2.md), die mehr sind als eine Korrektur: beide
|
||||
verlangen einen Umbau, und beide sind heute kein Schaden, sondern eine offene Flanke.
|
||||
|
||||
## 1. `anhaengeDesLaufs` ist ein Lesemodell am falschen Ort
|
||||
|
||||
`anhaengeDesLaufs(laufId)` liegt in `src/server/services/anhaenge.ts`, nimmt nur eine Lauf-Kennung
|
||||
und wendet **weder Rollen- noch Mandantensicht** an. Beide Aufrufer
|
||||
(`src/app/(app)/laeufe/[id]/page.tsx` und `src/server/queries/druck.ts`) prüfen den Lauf vorher, und
|
||||
die Kommentare sagen das auch — es ist also heute kein Fehler.
|
||||
|
||||
Es steht aber gegen die Regel aus `CLAUDE.md` und `docs/architektur.md` §2: Lesemodelle liegen in
|
||||
`queries/*` und **wenden ihre Sicht selbst an, damit kein Aufrufer sie vergessen kann**. Zwei
|
||||
Aufrufer in zwei Schichten gibt es schon; der dritte ist der, der es vergisst — und er wird nicht
|
||||
auffallen, weil die zwei bestehenden korrekt sind.
|
||||
|
||||
- [x] Nach `src/server/queries/anhaenge.ts` ziehen, mit `HandelnderBenutzer` als Parameter
|
||||
- [x] Mandanten- und Rollensicht dort anwenden, nicht beim Aufrufer
|
||||
- [x] Test: ein Benutzer, der auf andere Kunden beschränkt ist, bekommt eine leere Liste — **ohne**
|
||||
dass der Aufrufer vorher geprüft hat
|
||||
- [x] Gegenprobe: die Sicht entfernen, der Test muss rot werden
|
||||
|
||||
## 2. `/meine-geraete` fehlt in der 390-px-Prüfung
|
||||
|
||||
Die Seite ist das Ergebnis von #035 und ausdrücklich mit „am Handy kein horizontales Scrollen“
|
||||
abgenommen — von Hand. In `tests/e2e/mobil.spec.ts` steht sie nicht, und sie kann dort nicht
|
||||
einfach ergänzt werden: sie verlangt das Recht `selbstauskunft`, und das hat nur `MITARBEITER`. Der
|
||||
Ablauf meldet sich als Admin an und würde die Verweigerungsseite messen — die scrollt garantiert
|
||||
nicht.
|
||||
|
||||
- [x] Ein `MITARBEITER`-Konto für die Abläufe: lokales Passwort, verknüpft mit einer
|
||||
`Mitarbeiter`-Zeile (der CHECK `Benutzer_mitarbeiter_nur_bei_rolle` verlangt die Verknüpfung)
|
||||
- [x] Im Seed oder im E2E-Vorlauf? **Im Vorlauf**, denn ein Konto mit bekanntem Passwort gehört
|
||||
nicht in einen Seed, der auch produktiv läuft
|
||||
- [x] `/meine-geraete` in die Seitenliste von `mobil.spec.ts`
|
||||
- [x] Dabei prüfen, was ein `MITARBEITER` auf den **anderen** Seiten sieht: die Rollenmatrix ist in
|
||||
Vitest vollständig, aber kein Ablauf klickt sie durch
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [x] `anhaengeDesLaufs` liegt in `queries/`, und ein Test belegt die Sicht ohne Vorprüfung des
|
||||
Aufrufers
|
||||
- [x] `/meine-geraete` wird bei 390 px gemessen, mit einem echten `MITARBEITER`
|
||||
- [x] Beide Gegenproben werden rot
|
||||
|
||||
## Beim Umsetzen dazugekommen
|
||||
|
||||
- **Das Konto liegt im E2E-Vorlauf, nicht im Seed** — wie im Issue vorgezeichnet, und der Grund hat
|
||||
sich beim Schreiben bestätigt: ein Konto mit bekanntem Passwort gehört nicht in einen Seed, der
|
||||
auch produktiv läuft. Es trägt `ZZ` im Namen und fällt damit unter das Aufräumen aus
|
||||
[#027](027-e2e-reste-vorlagen.md); der Nachlauf meldet es mit („1 Benutzer“).
|
||||
- **Die Person wird gewählt, nicht gesetzt:** die erste aktive mit mindestens einem Gerät. Eine
|
||||
feste Kennung wäre eine Annahme über den Seed, und ohne Gerät zeigte die Seite nur ihren
|
||||
Leerzustand — der Test hätte dann nichts gemessen. Der Vorlauf bricht mit einer Erklärung ab, wenn
|
||||
es keine solche Person gibt.
|
||||
- **Ein zweiter Test dazu, den das Issue nicht verlangte:** dass ein `MITARBEITER` auf
|
||||
`/meine-geraete` **keinen** Link in die Verwaltung angeboten bekommt. Die Rollenmatrix ist in
|
||||
Vitest vollständig, und seit #035 nennt jeder Navigationseintrag sein Recht — dass das im
|
||||
**gerenderten** DOM ankommt, prüfte nichts. Gegenprobe: ein Eintrag ohne `recht`, und der Test
|
||||
wird rot.
|
||||
|
||||
## Was dabei schiefging, und warum es hier steht
|
||||
|
||||
Für das zweite Konto brauchte `anmelden` eine Variante, die nicht auf `/` wartet — ein
|
||||
`MITARBEITER` landet auf `/meine-geraete`, weil `startseite(rolle)` das Ziel an der Rolle
|
||||
entscheidet (#035). Dabei habe ich das Warten für **alle** gelockert: „irgendeine Adresse außer
|
||||
`/anmelden`“.
|
||||
|
||||
Das sah harmlos aus und machte **zwei unverwandte Abläufe** unzuverlässig — und zwar nur im vollen
|
||||
Durchgang, nicht beim Einzellauf: der nächste Schritt begann, bevor die Übersicht stand. Einer davon
|
||||
war `laeufe.spec.ts` („verlangt für nicht zutreffend eine Begründung“), der mit dem Thema nichts zu
|
||||
tun hat.
|
||||
|
||||
Behoben: `anmelden` wartet wieder streng auf `/`, `anmeldenMit` nimmt das Ziel als Parameter, und
|
||||
beide warten zusätzlich auf `load` — ein `waitForURL` endet an der Adresse, nicht am geladenen
|
||||
Dokument. **Die Lehre:** ein gemeinsamer Helfer in einer Testsuite ist geteilter Zustand. Ihn für
|
||||
einen neuen Fall aufzuweichen kostet die Zuverlässigkeit aller alten, und das zeigt sich erst im
|
||||
vollen Lauf.
|
||||
@@ -1,92 +0,0 @@
|
||||
# #038 Die E2E-Abläufe laufen in keinem Tor und waren monatelang rot
|
||||
|
||||
**Status:** erledigt
|
||||
**Im Tracker:** –
|
||||
**Meilenstein:** v0.2.0
|
||||
**Labels:** phase-2, test, infra
|
||||
**Geschätzt:** 3 h **Tatsächlich:** 1 h 35 min
|
||||
|
||||
## Ziel
|
||||
|
||||
Beim Abschluss von [#027](027-e2e-reste-vorlagen.md) lief `pnpm test:e2e` erstmals seit dem
|
||||
26. September wieder vollständig. **Sieben Abläufe waren rot**, und niemand hatte es gemerkt.
|
||||
|
||||
Keiner der sieben war ein Produktfehler — und genau das ist das Problem. Vier Ursachen:
|
||||
|
||||
| Ablauf | Ursache | Seit |
|
||||
|---|---|---|
|
||||
| `laeufe.spec.ts` | zählt die Felder des Zustands auf, `anzahlAnhaenge` fehlte | #033 |
|
||||
| `stammdaten.spec.ts` | „kommt aus dem Verzeichnis“ steht seit #020 zweimal auf der Seite | #020 |
|
||||
| `vorlagen-editor.spec.ts` | zieht am Handy feste 300 px, der Abstand ist 532 px | #030/#031 |
|
||||
| `onboarding.spec.ts` | die **Entwicklungsdaten** wichen vom Seed ab | unbekannt |
|
||||
|
||||
Alle vier sind behoben. Das Muster bleibt: **die E2E-Schicht trägt Zusagen, die das Commit-Tor nicht
|
||||
fährt** — Rückfall auf Polling, Herzschlag, 390 px, die Auslieferung eines Anhangs über seine
|
||||
Adresse. Steht eine Zusage nur in Playwright, und läuft Playwright nur gelegentlich, dann ist sie
|
||||
eine Notiz und keine Prüfung.
|
||||
|
||||
## Zu entscheiden
|
||||
|
||||
- [x] **`pnpm test:e2e` ins Tor vor dem Commit?** → **Nein.** Dagegen: es verlangt einen **gebauten,
|
||||
laufenden** Stand (`pnpm build && pnpm start`), dauert über vier Minuten und braucht eine
|
||||
Datenbank mit Seed-Bestand. Vier Minuten vor jedem Commit ist ein Tor, das umgangen wird.
|
||||
- [x] **Gewählt: eine kleinere, verbindliche Regel.** E2E läuft vor jedem Merge nach `main` — das wären
|
||||
acht Läufe in Phase 2 gewesen und hätte jeden der vier Befunde am Tag seiner Entstehung
|
||||
gezeigt. Das ist die Empfehlung.
|
||||
- [x] **Nicht im CI** (`.gitea/workflows/ci.yml`). Dort ist kein Runner registriert; das zu ändern
|
||||
ist eine eigene Entscheidung und gehört nicht in dieses Issue.
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [x] Die gewählte Regel in `CLAUDE.md` aufschreiben, mit **gemessener** Dauer und der Begründung,
|
||||
warum sie nicht im Commit-Tor steht
|
||||
- [x] Die Voraussetzungen, die `tests/e2e/global-setup.ts` prüft, in derselben Reihenfolge
|
||||
dokumentieren — der Vorlauf ist inzwischen die ehrlichste Beschreibung dessen, was E2E braucht
|
||||
- [x] Prüfen, welche Zusagen **ausschließlich** in E2E stehen, und für jede entscheiden: gehört sie
|
||||
zusätzlich in einen Vitest-Test, der den Handler direkt fährt? (Das Muster steht in
|
||||
`tests/scim.test.ts`, `tests/anhang-route.test.ts` und `tests/lauf-ereignisse.test.ts`.)
|
||||
- [x] Mindestens die Rechteprüfung der beiden Phase-2-Handler ist damit schon abgedeckt — bleiben
|
||||
Rückfall auf Polling, Herzschlag am echten Browser und 390 px
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [x] In `CLAUDE.md` steht, wann E2E läuft, und die Angabe ist gemessen, nicht geschätzt
|
||||
- [x] Eine Liste der Zusagen, die nur in E2E stehen — und zu jeder eine Entscheidung
|
||||
|
||||
## Die Entscheidung, und was sie gekostet hat
|
||||
|
||||
**Nicht im Commit-Tor.** Gemessen am 2026-10-11: Bau 14 s, Start 7 s, Abläufe **256 s** — zusammen
|
||||
4 min 37 s, zu den vier Minuten des bestehenden Tores. Neun Minuten vor jedem Commit ist ein Tor,
|
||||
das umgangen wird, und ein umgangenes Tor prüft nichts.
|
||||
|
||||
**Vor jedem Merge nach `main`.** In Phase 2 wären das acht Läufe gewesen, und jeder der vier
|
||||
Befunde aus #027 wäre am Tag seiner Entstehung aufgefallen. Die Regel steht in `CLAUDE.md` mit den
|
||||
gemessenen Zahlen und der Begründung; `docs/architektur.md` §5b trägt sie mit.
|
||||
|
||||
**Nicht im CI**, weil dort kein Runner registriert ist. Das zu ändern ist eine eigene Entscheidung
|
||||
und gehört nicht in dieses Issue.
|
||||
|
||||
## Die Liste der Zusagen — und was sich davon holen ließ
|
||||
|
||||
Geprüft wurde jeder der 16 Abläufe gegen die Frage „gibt es dafür ein Vitest-Gegenstück?“. Das
|
||||
Ergebnis steht als Tabelle in `CLAUDE.md`. Die Kurzfassung:
|
||||
|
||||
**Nichts davon lässt sich nach Vitest holen** — alle brauchen einen echten Browser: Layout bei
|
||||
390 px, `BarcodeDetector`, Touch-Ereignisse, zwei Browser-Kontexte gleichzeitig, Zwischenablage,
|
||||
Print-CSS, gerendertes DOM, und überhaupt die Hydrierung. Die Antwort auf #038 ist deshalb nicht
|
||||
„verlagern“, sondern „regelmäßig laufen lassen“.
|
||||
|
||||
**Was sich holen ließ, ist geholt** — und das war mehr als erwartet:
|
||||
|
||||
- Die **Rechteprüfung der Route Handler** steht seit [#036](036-reviewbefunde-phase-2.md) in
|
||||
`tests/anhang-route.test.ts`, `tests/lauf-ereignisse.test.ts` und `tests/scim.test.ts`: die
|
||||
Handler werden mit echten `Request`-Objekten gefahren, nicht über die Oberfläche.
|
||||
- Neu in diesem Issue: `tests/zugang.test.ts` prüft **strukturell**, dass jede `page.tsx` unter
|
||||
`(app)` und jeder `route.ts` sein Recht selbst prüft. Die Ausnahmen (Auth, Healthcheck) stehen
|
||||
dort namentlich samt Begründung, und ein Test prüft auch die **Gegenrichtung**: eine Ausnahme für
|
||||
einen Handler, den es nicht mehr gibt, verdeckt beim nächsten Mal einen echten Befund.
|
||||
|
||||
Dass das nötig war, ist der eigentliche Fund: die Zusage „jede Seite prüft selbst“ steht seit #007
|
||||
in `CLAUDE.md`, und sie hielt bis hierher **nur durch Nachsehen von Hand**. Der Review von Phase 2
|
||||
hat alle 27 Seiten durchgesehen — das gilt für den Tag, an dem es lief. Beide Gegenproben (eine
|
||||
Seite, ein Handler ohne Prüfung) werden jetzt rot.
|
||||
@@ -28,19 +28,16 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
|
||||
| #021 | [Stabiler Schlüssel für Seed-Vorlagen](021-stabiler-schluessel-vorlagen.md) | erledigt | v0.2.0 | 2 h | 55 min |
|
||||
| #022 | [Schnellstart je Gerätetyp pflegen](022-schnellstart-pflegen.md) | erledigt | v0.2.0 | 3 h | 1 h 10 min |
|
||||
| #023 | [Protokoll ansehen](023-protokoll-ansicht.md) | erledigt | v0.2.0 | 4 h | 1 h 50 min |
|
||||
| #024 | [Proxmox-Einzeiler auf einem echten PVE-Host gegenprüfen](024-proxmox-echtlauf.md) | offen | v0.3.0 | 2 h | – |
|
||||
| #024 | [Proxmox-Einzeiler auf einem echten PVE-Host gegenprüfen](024-proxmox-echtlauf.md) | offen | v0.2.0 | 2 h | – |
|
||||
| #025 | [Healthcheck gibt die rohe Datenbankfehlermeldung heraus](025-gesundheit-fehlermeldung.md) | erledigt | v0.2.0 | 1 h | 25 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) | erledigt | v0.2.0 | 1 h | 2 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) | erledigt | v0.2.0 | 12 h | 4 h 05 min |
|
||||
| #027 | [Die Editor-Abläufe lassen ihre Vorlagen stehen](027-e2e-reste-vorlagen.md) | offen | v0.2.0 | 1 h | – |
|
||||
| #028 | [M15 – Quellenarten und Microsoft-Graph-Quelle](028-graph-quelle.md) | offen | v0.2.0 | 10 h | – |
|
||||
| #029 | [M16 – SCIM-Endpunkt als Alternative zum Abgleich](029-scim-endpunkt.md) | offen | v0.2.0 | 12 h | – |
|
||||
| #030 | [M17 – Wiederverwendbare Bausteine](030-bausteine.md) | erledigt | v0.2.0 | 12 h | 2 h 25 min |
|
||||
| #031 | [M18 – Abteilungsspezifische Zusatzabschnitte](031-abteilungsabschnitte.md) | erledigt | v0.2.0 | 8 h | 1 h 35 min |
|
||||
| #032 | [M19 – Anhänge und Fotos an Schritten](032-anhaenge.md) | erledigt | v0.2.0 | 10 h | 3 h 05 min |
|
||||
| #033 | [M20 – Live-Aktualisierung per SSE statt Polling](033-sse-live.md) | erledigt | v0.2.0 | 6 h | 1 h 40 min |
|
||||
| #034 | [M21 – Benachrichtigungen per Mail und Webhook](034-benachrichtigungen.md) | erledigt | v0.2.0 | 10 h | 3 h 30 min |
|
||||
| #035 | [M22 – Mitarbeiter-Selbstauskunft](035-selbstauskunft.md) | erledigt | v0.2.0 | 8 h | 2 h 50 min |
|
||||
| #036 | [Reviewbefunde aus der Durchsicht von Phase 2](036-reviewbefunde-phase-2.md) | erledigt | v0.2.0 | 4 h | 3 h 40 min |
|
||||
| #037 | [Zwei Nachzügler aus der Durchsicht von Phase 2](037-anhaenge-lesemodell.md) | erledigt | v0.2.0 | 2 h | 1 h 50 min |
|
||||
| #038 | [Die E2E-Abläufe laufen in keinem Tor und waren monatelang rot](038-e2e-im-tor.md) | erledigt | v0.2.0 | 3 h | 1 h 35 min |
|
||||
| | **Summe** | | | **238 h** | **73 h 55 min** |
|
||||
| #035 | [M22 – Mitarbeiter-Selbstauskunft](035-selbstauskunft.md) | offen | v0.2.0 | 8 h | – |
|
||||
| | **Summe** | | | **229 h** | **53 h 35 min** |
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "laufzettel",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Checklisten fuer wiederkehrende IT-Arbeiten: versionierte Vorlagen, Laeufe, Archiv, QR-Tags",
|
||||
|
||||
@@ -11,12 +11,6 @@ export default defineConfig({
|
||||
* erst mitten im Lauf auf — als Zeitablauf oder als „0 Treffer“ (#018).
|
||||
*/
|
||||
globalSetup: "./tests/e2e/global-setup.ts",
|
||||
/*
|
||||
* Raeumt weg, was die Ablaeufe angelegt haben (#027). Laeuft auch nach
|
||||
* fehlgeschlagenen Ablaeufen; ein harter Abbruch wird vom Vorlauf gefangen,
|
||||
* der dasselbe tut.
|
||||
*/
|
||||
globalTeardown: "./tests/e2e/global-teardown.ts",
|
||||
// Die Ablaeufe aendern gemeinsame Daten, deshalb nacheinander.
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 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.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Mitarbeiter-Selbstauskunft (#035).
|
||||
|
||||
/*
|
||||
* Die fuenfte Rolle.
|
||||
*
|
||||
* `ALTER TYPE ... ADD VALUE` laeuft in PostgreSQL seit 12 auch innerhalb einer
|
||||
* Transaktion — Prisma fuehrt jede Migration in einer. Der neue Wert kommt
|
||||
* **ans Ende**: die Reihenfolge eines Enums ist seine Sortierreihenfolge, und
|
||||
* ein `MITARBEITER` zwischen `ADMIN` und `REDAKTEUR` wuerde jede Liste, die nach
|
||||
* Rolle sortiert, still umstellen.
|
||||
*/
|
||||
ALTER TYPE "Rolle" ADD VALUE 'MITARBEITER';
|
||||
|
||||
ALTER TABLE "Benutzer" ADD COLUMN "mitarbeiterId" TEXT;
|
||||
|
||||
/*
|
||||
* **Eindeutig**: eine Person hat hoechstens ein Konto, mit dem sie sich selbst
|
||||
* ansieht. Ohne das koennten zwei Anmeldungen auf dieselbe Person zeigen, und
|
||||
* ein entzogener Zugang waere nicht entzogen, solange das zweite Konto bleibt.
|
||||
*
|
||||
* `NULL` gilt in Postgres als verschieden, die vielen Konten ohne Zuordnung
|
||||
* stoeren hier also nicht.
|
||||
*/
|
||||
CREATE UNIQUE INDEX "Benutzer_mitarbeiterId_key" ON "Benutzer"("mitarbeiterId");
|
||||
|
||||
ALTER TABLE "Benutzer"
|
||||
ADD CONSTRAINT "Benutzer_mitarbeiterId_fkey" FOREIGN KEY ("mitarbeiterId")
|
||||
REFERENCES "Mitarbeiter"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
/*
|
||||
* **Die eigentliche Zusage dieses Meilensteins, in der Datenbank.**
|
||||
*
|
||||
* Die Rolle `MITARBEITER` und die Zuordnung gehoeren zusammen, in beide
|
||||
* Richtungen:
|
||||
*
|
||||
* - Ein `MITARBEITER` **ohne** `mitarbeiterId` haette ein Recht und niemanden,
|
||||
* auf den es sich bezieht. Die Lesemodelle schraenken auf diese Kennung ein;
|
||||
* fehlte sie, waere die Einschraenkung leer — und „leer“ heisst in SQL je nach
|
||||
* Formulierung „nichts“ oder „alles“. Das ist genau die Art Zweideutigkeit,
|
||||
* die hier nicht entstehen darf.
|
||||
* - Eine `mitarbeiterId` an einem `ADMIN` waere die Umkehrung: ein Konto, das
|
||||
* alles sieht und zusaetzlich als eine bestimmte Person gilt. Wer das braucht,
|
||||
* braucht zwei Konten.
|
||||
*
|
||||
* `SET NULL` am Fremdschluessel oben wuerde diese Bedingung verletzen, wenn ein
|
||||
* Mitarbeiter geloescht wird — deshalb der Trigger darunter, der die Rolle
|
||||
* mitzieht. Geloescht wird ein Mitarbeiter ohnehin nie (CLAUDE.md: nur
|
||||
* `aktiv = false`), aber eine Regel, die sich auf „kommt nicht vor“ verlaesst,
|
||||
* ist keine Regel.
|
||||
*/
|
||||
ALTER TABLE "Benutzer"
|
||||
ADD CONSTRAINT "Benutzer_mitarbeiter_nur_bei_rolle"
|
||||
CHECK (("rolle" = 'MITARBEITER') = ("mitarbeiterId" IS NOT NULL));
|
||||
|
||||
/*
|
||||
* Verliert ein Konto seine Person, verliert es auch die Rolle.
|
||||
*
|
||||
* Ohne das schlaegt das `ON DELETE SET NULL` des Fremdschluessels am CHECK
|
||||
* darueber fehl, und das Loeschen eines Mitarbeiters waere unmoeglich statt
|
||||
* folgerichtig. `LESER` und nicht `aktiv = false`: das Konto soll sich weiter
|
||||
* anmelden koennen, nur nichts Fremdes sehen — und wer sich anmeldet, bekommt
|
||||
* bei der naechsten OIDC-Anmeldung ohnehin eine neue Rolle zugewiesen.
|
||||
*/
|
||||
CREATE OR REPLACE FUNCTION laufzettel_konto_ohne_person() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
UPDATE "Benutzer"
|
||||
SET "rolle" = 'LESER', "mitarbeiterId" = NULL
|
||||
WHERE "mitarbeiterId" = OLD."id";
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER "Mitarbeiter_konto_mitziehen"
|
||||
BEFORE DELETE ON "Mitarbeiter"
|
||||
FOR EACH ROW EXECUTE FUNCTION laufzettel_konto_ohne_person();
|
||||
@@ -1,97 +0,0 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 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.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Quellenarten und Microsoft-Graph-Quelle (#028).
|
||||
|
||||
CREATE TYPE "QuellenArt" AS ENUM ('LDAP', 'GRAPH', 'SCIM');
|
||||
|
||||
/*
|
||||
* **Die Reihenfolge der drei Schritte ist nicht beliebig.**
|
||||
*
|
||||
* Erst die Spalte mit Vorgabe `LDAP` anlegen, dann die bisherigen
|
||||
* Pflichtfelder lockern, und erst danach den CHECK setzen. Umgekehrt waere der
|
||||
* CHECK fuer die Dauer einer Anweisung gegen Zeilen gerichtet, die ihn noch
|
||||
* nicht erfuellen koennen.
|
||||
*
|
||||
* Die Vorgabe `LDAP` ist zugleich die Nachfuehrung fuer alle bestehenden Zeilen:
|
||||
* vor #028 war jede Quelle implizit LDAP, und `ADD COLUMN ... DEFAULT` schreibt
|
||||
* den Wert in jede vorhandene Zeile. Ein eigenes `UPDATE` braucht es deshalb
|
||||
* nicht — aber die Pruefung unten steht trotzdem da.
|
||||
*/
|
||||
ALTER TABLE "Verzeichnisquelle"
|
||||
ADD COLUMN "art" "QuellenArt" NOT NULL DEFAULT 'LDAP';
|
||||
|
||||
-- Die LDAP-Felder gelten nur noch fuer LDAP-Quellen.
|
||||
ALTER TABLE "Verzeichnisquelle"
|
||||
ALTER COLUMN "url" DROP NOT NULL,
|
||||
ALTER COLUMN "bindDn" DROP NOT NULL,
|
||||
ALTER COLUMN "bindPasswortVerschluesselt" DROP NOT NULL,
|
||||
ALTER COLUMN "basisDn" DROP NOT NULL,
|
||||
ALTER COLUMN "filter" DROP NOT NULL;
|
||||
|
||||
-- Und die Felder einer Graph-Quelle.
|
||||
ALTER TABLE "Verzeichnisquelle"
|
||||
ADD COLUMN "tenantId" TEXT,
|
||||
ADD COLUMN "clientId" TEXT,
|
||||
ADD COLUMN "clientSecretVerschluesselt" TEXT;
|
||||
|
||||
/*
|
||||
* **Welche Felder eine Art braucht, haelt die Datenbank fest.**
|
||||
*
|
||||
* Ohne das waere eine Graph-Quelle mit `basisDn` moeglich — eine Zeile, die zwei
|
||||
* Dinge gleichzeitig behauptet, und bei der niemand mehr sagen kann, was gilt.
|
||||
* Der Dienst prueft dasselbe noch einmal, nur mit besserer Meldung.
|
||||
*
|
||||
* `SCIM` fehlt hier mit Absicht: die Art steht im Enum, weil #029 dieselbe
|
||||
* Weiche braucht, hat aber noch keine Felder. Eine Bedingung auf Felder, die es
|
||||
* nicht gibt, waere eine Zeile, die beim naechsten Meilenstein umgeschrieben
|
||||
* werden muss — und der Dienst weist `SCIM` bis dahin ohnehin ab.
|
||||
*/
|
||||
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)
|
||||
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)
|
||||
OR
|
||||
("art" = 'SCIM')
|
||||
);
|
||||
|
||||
-- Das Client-Secret verschluesselt, dieselbe Pruefung wie beim Bind-Passwort.
|
||||
ALTER TABLE "Verzeichnisquelle"
|
||||
ADD CONSTRAINT "Verzeichnisquelle_clientsecret_verschluesselt"
|
||||
CHECK ("clientSecretVerschluesselt" ~ '^v[0-9]+\.');
|
||||
|
||||
-- Der Zeitplan fragt nach faelligen **aktiven** Quellen; die Art kommt jetzt in
|
||||
-- dieselbe Abfrage, weil das Holen der Eintraege je Art ein anderes ist.
|
||||
CREATE INDEX "Verzeichnisquelle_art_idx" ON "Verzeichnisquelle"("art");
|
||||
|
||||
/*
|
||||
* Gegenprobe zur Nachfuehrung: nach dem `ADD COLUMN ... DEFAULT` darf keine
|
||||
* Zeile ohne Art dastehen. `art` ist NOT NULL, das kann also nicht schiefgehen —
|
||||
* aber eine Migration, die ihre eigene Nachfuehrung behauptet, ohne sie zu
|
||||
* pruefen, ist genau die Art Zusage, die dieses Projekt teuer bezahlt hat.
|
||||
*/
|
||||
DO $$
|
||||
DECLARE
|
||||
ohne_art INTEGER;
|
||||
BEGIN
|
||||
SELECT count(*) INTO ohne_art FROM "Verzeichnisquelle" WHERE "art" IS NULL;
|
||||
IF ohne_art > 0 THEN
|
||||
RAISE EXCEPTION 'Nachfuehrung fehlgeschlagen: % Quellen ohne Art.', ohne_art;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -1,95 +0,0 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 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 $$;
|
||||
+5
-62
@@ -31,12 +31,6 @@ enum Rolle {
|
||||
REDAKTEUR
|
||||
TECHNIKER
|
||||
LESER
|
||||
/// Selbstauskunft (#035): sieht die Laeufe zu **seinen** Geraeten und zu
|
||||
/// seiner Person, und nichts sonst. Keine Schritte, keine Notizen, keine
|
||||
/// Kommentare, keine Anhaenge — eine Selbstauskunft ist keine Akteneinsicht.
|
||||
/// Haengt an `Benutzer.mitarbeiterId`; ohne Zuordnung gibt es die Rolle nicht
|
||||
/// (CHECK `Benutzer_mitarbeiter_nur_bei_rolle`).
|
||||
MITARBEITER
|
||||
}
|
||||
|
||||
enum Betriebsmodus {
|
||||
@@ -260,15 +254,6 @@ model Benutzer {
|
||||
/// Ein Konto ohne Mailadresse gibt es nicht — `email` ist Pflicht —, aber ein
|
||||
/// Dienstkonto will keine Post.
|
||||
meldungenAktiv Boolean @default(true)
|
||||
/// Die Person im Verzeichnis, als die dieses Konto sich selbst sehen darf
|
||||
/// (#035). Gesetzt **genau dann**, wenn die Rolle `MITARBEITER` ist.
|
||||
///
|
||||
/// Hier steht **kein** zusammengesetzter Fremdschluessel, anders als sonst bei
|
||||
/// jedem kundenbezogenen Verweis: ein `Benutzer` traegt keine `kundeId`, gegen
|
||||
/// die man zusammensetzen koennte — ein Administrator sieht alle Kunden. Die
|
||||
/// Zusage „sieht nur seine eigenen Laeufe“ haengt an dieser einen Kennung, und
|
||||
/// staerker als ein Fremdschluessel darauf wird sie nicht.
|
||||
mitarbeiterId String? @unique
|
||||
erstelltAm DateTime @default(now())
|
||||
geaendertAm DateTime @default(now()) @updatedAt
|
||||
|
||||
@@ -283,7 +268,6 @@ model Benutzer {
|
||||
veroeffentlichteVersionen VorlagenVersion[] @relation("VersionVeroeffentlichtVon")
|
||||
anhaenge Anhang[]
|
||||
zustellungen Zustellung[]
|
||||
mitarbeiter Mitarbeiter? @relation(fields: [mitarbeiterId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([aktiv])
|
||||
@@index([rolle])
|
||||
@@ -314,56 +298,18 @@ model BenutzerKunde {
|
||||
/// genau eine (das Firmen-AD); mehrere sind erlaubt, etwa eine zweite Domaene.
|
||||
/// Nur fuer die eigene Organisation – im Modus DIENSTLEISTUNG gibt es keinen
|
||||
/// Abgleich zu Kunden-ADs.
|
||||
/// Woher eine Verzeichnisquelle liest (#028).
|
||||
///
|
||||
/// Bis #028 war jede Quelle implizit LDAP. `SCIM` steht hier bereits, weil #029
|
||||
/// dieselbe Weiche braucht — behandelt wird es noch nicht, und der Dienst weist
|
||||
/// es mit einer Meldung ab, statt stillschweigend nichts zu tun.
|
||||
enum QuellenArt {
|
||||
LDAP
|
||||
GRAPH
|
||||
SCIM
|
||||
}
|
||||
|
||||
model Verzeichnisquelle {
|
||||
id String @id @default(cuid())
|
||||
kundeId String
|
||||
name String
|
||||
aktiv Boolean @default(false)
|
||||
/// Bestehende Zeilen sind LDAP – das war vor #028 die einzige Art.
|
||||
art QuellenArt @default(LDAP)
|
||||
/// Ab hier: **nur bei `art = LDAP`** gesetzt. Welche Felder eine Art braucht,
|
||||
/// haelt der CHECK `Verzeichnisquelle_felder_je_art` fest — eine Graph-Quelle
|
||||
/// mit einem `basisDn` waere eine Zeile, die zwei Dinge gleichzeitig behauptet.
|
||||
url String?
|
||||
bindDn String?
|
||||
url String
|
||||
bindDn String
|
||||
/// AES-256-GCM aus APP_SECRET. Darf keine API-Antwort erreichen; dafuer sorgt
|
||||
/// der Serializer in der Service-Schicht, abgedeckt durch einen eigenen Test.
|
||||
bindPasswortVerschluesselt String?
|
||||
basisDn String?
|
||||
filter String?
|
||||
|
||||
/// Ab hier: **nur bei `art = GRAPH`**. Der Mandant in Entra ID und die
|
||||
/// App-Registrierung, mit der gelesen wird (Client-Credentials-Flow).
|
||||
tenantId String?
|
||||
clientId String?
|
||||
/// Verschluesselt wie das Bind-Passwort und mit demselben Vorsatz: es darf
|
||||
/// keine Antwort und keinen Protokolleintrag erreichen.
|
||||
clientSecretVerschluesselt String?
|
||||
|
||||
/// Ab hier: **nur bei `art = SCIM`** (#029).
|
||||
///
|
||||
/// Das Token als **argon2-Hash**, nicht verschluesselt — anders als die
|
||||
/// Geheimnisse darueber. Der Unterschied ist der Zweck: ein Bind-Passwort
|
||||
/// muss Laufzettel **benutzen**, also wieder lesbar machen. Ein SCIM-Token
|
||||
/// muss es nur **wiedererkennen**, und was man nie lesen muss, speichert man
|
||||
/// so, dass es niemand lesen kann. Gezeigt wird es genau einmal.
|
||||
scimTokenHash String?
|
||||
/// Wann das Token erzeugt wurde — fuer „seit wann gilt das“ in der Oberflaeche.
|
||||
scimTokenGesetztAm DateTime?
|
||||
/// Wann zuletzt etwas geschickt wurde. Das Gegenstueck zu `letzterAbgleichAm`:
|
||||
/// bei SCIM holt Laufzettel nicht, es bekommt.
|
||||
letzteScimZustellungAm DateTime?
|
||||
bindPasswortVerschluesselt String
|
||||
basisDn String
|
||||
filter String
|
||||
/// Zuordnung der LDAP-Attribute auf unsere Felder, mit AD-Vorgaben.
|
||||
attributZuordnung Json
|
||||
/// Eigenes CA-Zertifikat, falls ldaps gegen eine interne CA laeuft.
|
||||
@@ -430,9 +376,6 @@ model Mitarbeiter {
|
||||
untergebene Mitarbeiter[] @relation("Vorgesetzter")
|
||||
geraete Geraet[]
|
||||
laeufe Lauf[]
|
||||
/// Das Konto, mit dem diese Person sich selbst ansehen darf (#035).
|
||||
/// Hoechstens eines — `Benutzer.mitarbeiterId` ist eindeutig.
|
||||
konto Benutzer?
|
||||
|
||||
/// Postgres behandelt NULL als verschieden, deshalb stoeren mehrere manuell
|
||||
/// angelegte Mitarbeiter ohne Quelle und externe Id hier nicht.
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AlertTriangle, CheckCircle2, Clock, Inbox, Server } from "lucide-react";
|
||||
import { AlertTriangle, CheckCircle2, Clock, Server } from "lucide-react";
|
||||
import { Betriebsmodus } from "@/generated/prisma/enums";
|
||||
import { JetztAbgleichen } from "@/components/verzeichnis/jetzt-abgleichen";
|
||||
import { QuelleFormular } from "@/components/verzeichnis/quelle-formular";
|
||||
import { ScimToken } from "@/components/verzeichnis/scim-token";
|
||||
import { VerbindungTesten } from "@/components/verzeichnis/verbindung-testen";
|
||||
import { basisAdresse } from "@/lib/adressen";
|
||||
import { QUELLEN_ART_TEXT } from "@/lib/verzeichnis";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { betriebsmodus } from "@/server/mandant";
|
||||
import { abgleichLog, type AbgleichLogZeile, quellenListe } from "@/server/queries/verzeichnis";
|
||||
@@ -34,13 +30,6 @@ const ZEITPUNKT = new Intl.DateTimeFormat("de-DE", {
|
||||
});
|
||||
|
||||
function Status({ quelle }: { quelle: QuellenSicht }) {
|
||||
/*
|
||||
* **Bei SCIM sagt „noch nie abgeglichen“ etwas Falsches** (#029): dort wird
|
||||
* nie abgeglichen, und das ist kein Rückstand. Was an diese Stelle gehört,
|
||||
* steht im Token-Abschnitt — „letzte Zustellung“.
|
||||
*/
|
||||
if (quelle.art === "SCIM") return null;
|
||||
|
||||
if (quelle.letzterAbgleichAm === null) {
|
||||
return (
|
||||
<p className="mt-2 text-sm text-nebentext">
|
||||
@@ -134,26 +123,11 @@ export default async function VerzeichnisSeite() {
|
||||
// daran, und ein hart gesetzter „ADMIN“ waere eine Hintertuer.
|
||||
const handelnder = { id: benutzer.id, rolle: benutzer.rolle };
|
||||
const quellen = await quellenListe(handelnder);
|
||||
|
||||
/*
|
||||
* Die Adresse, die beim Anbieter eingetragen wird. Über `basisAdresse`, weil
|
||||
* sie jemand **behält** — ein Unterpfad aus `APP_URL` darf dabei nicht
|
||||
* herausfallen, sonst trägt der Administrator eine Adresse ein, die ins Leere
|
||||
* zeigt (derselbe Grund wie bei den Etiketten, #014).
|
||||
*/
|
||||
const kopfzeilen = await headers();
|
||||
const scimAdresse = new URL(
|
||||
"api/scim/v2",
|
||||
basisAdresse(`http://${kopfzeilen.get("host") ?? "127.0.0.1:3000"}`),
|
||||
).toString();
|
||||
// Je Quelle ein Log. Die Abfragen laufen parallel; es sind so viele wie Quellen,
|
||||
// und das sind im Modus INTERN eine oder zwei.
|
||||
const logs = new Map(
|
||||
await Promise.all(
|
||||
quellen
|
||||
// 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),
|
||||
quellen.map(async (quelle) => [quelle.id, await abgleichLog(quelle.id, handelnder)] as const),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -161,10 +135,9 @@ export default async function VerzeichnisSeite() {
|
||||
<>
|
||||
<h1 className="text-2xl font-bold">Verzeichnis</h1>
|
||||
<p className="mt-2 max-w-prose text-nebentext">
|
||||
Die Personen der eigenen Firma kommen aus dem Verzeichnis, damit beim Start eines Laufs nichts
|
||||
abgetippt werden muss. Laufzettel liest sie entweder selbst — aus einem Active Directory oder aus
|
||||
Entra ID — oder lässt sich per SCIM beliefern. Wer dort verschwindet oder deaktiviert wird, wird hier{" "}
|
||||
<strong>inaktiv</strong> — gelöscht wird nie, sonst verlöre die Historie ihre Namen.
|
||||
Die Personen der eigenen Firma kommen aus dem Active Directory, damit beim Start eines Laufs nichts
|
||||
abgetippt werden muss. Wer dort verschwindet oder deaktiviert wird, wird hier <strong>inaktiv</strong>{" "}
|
||||
— gelöscht wird nie, sonst verlöre die Historie ihre Namen.
|
||||
</p>
|
||||
|
||||
{quellen.length === 0 ? (
|
||||
@@ -179,23 +152,7 @@ export default async function VerzeichnisSeite() {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Server className="size-5 shrink-0 text-akzent" strokeWidth={2} aria-hidden="true" />
|
||||
<h2 className="text-lg font-semibold break-words">{quelle.name}</h2>
|
||||
{/*
|
||||
* 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 ? (
|
||||
{quelle.aktiv ? (
|
||||
<span className="flex items-center gap-1 rounded-pille bg-akzent-flaeche px-2.5 py-1 text-xs text-akzent">
|
||||
<Clock className="size-3" strokeWidth={2} aria-hidden="true" />
|
||||
alle {quelle.abgleichIntervallMinuten} min
|
||||
@@ -207,65 +164,23 @@ export default async function VerzeichnisSeite() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
* Die Art steht **an jeder Quelle** (#028). Ohne sie waere bei
|
||||
* zwei Zeilen nicht zu sehen, welche aus dem AD liest und welche
|
||||
* aus Entra ID — und die Felder darunter heissen je Art anders.
|
||||
*/}
|
||||
<p className="mono mt-2 text-xs break-all text-nebentext">
|
||||
{quelle.art === "GRAPH" ? (
|
||||
<>
|
||||
{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}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{QUELLEN_ART_TEXT.LDAP} · {quelle.url} · {quelle.basisDn}
|
||||
</>
|
||||
)}
|
||||
{quelle.url} · {quelle.basisDn}
|
||||
</p>
|
||||
|
||||
<Status quelle={quelle} />
|
||||
|
||||
<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} />
|
||||
<JetztAbgleichen quelleId={quelle.id} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{quelle.art === "SCIM" ? null : (
|
||||
<details className="mt-4 border-t border-linie pt-4">
|
||||
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
|
||||
Abgleich-Log
|
||||
</summary>
|
||||
<Log zeilen={logs.get(quelle.id) ?? []} />
|
||||
</details>
|
||||
)}
|
||||
|
||||
<details className="mt-4 border-t border-linie pt-4">
|
||||
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
|
||||
@@ -283,9 +198,8 @@ export default async function VerzeichnisSeite() {
|
||||
<section className="mt-8 rounded-karte border border-linie bg-flaeche p-5">
|
||||
<h2 className="text-lg font-semibold">Quelle anlegen</h2>
|
||||
<p className="mt-1 mb-5 max-w-prose text-sm text-nebentext">
|
||||
Zum Lesen genügt ein Dienstkonto mit Leserechten; bei SCIM braucht es gar keine Zugangsdaten, nur
|
||||
ein Token für den Anbieter. Laufzettel schreibt nie ins Verzeichnis und speichert nur die
|
||||
zugeordneten Felder — keine Passwörter, keine Gruppenmitgliedschaften.
|
||||
Ein Dienstkonto mit Leserechten genügt. Laufzettel schreibt nie ins Verzeichnis und speichert nur
|
||||
die zugeordneten Felder — keine Passwörter, keine Gruppenmitgliedschaften.
|
||||
</p>
|
||||
<QuelleFormular />
|
||||
</section>
|
||||
|
||||
@@ -2,7 +2,6 @@ import Link from "next/link";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import { ROLLEN_TEXT } from "@/lib/rollen-text";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { startseite } from "@/server/auth/rollen";
|
||||
|
||||
/**
|
||||
* „Dafür fehlt dir die Berechtigung“ — für jemanden, der **angemeldet** ist (#018).
|
||||
@@ -31,7 +30,6 @@ export default async function KeinZugriffSeite({
|
||||
// Nur eigene Pfade anzeigen: ein fremder Wert in der Adresse waere sonst ein
|
||||
// Text, den jemand anderes auf diese Seite schreibt.
|
||||
const gezeigt = bereich?.startsWith("/") && !bereich.startsWith("//") ? bereich : undefined;
|
||||
const einstieg = startseite(benutzer.rolle);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-prose flex-col items-start gap-5 py-10">
|
||||
@@ -58,16 +56,11 @@ export default async function KeinZugriffSeite({
|
||||
Anmeldung übernommen.
|
||||
</p>
|
||||
|
||||
{/*
|
||||
* Der Knopf fuehrt zum **eigenen** Einstieg und nicht stur auf `/`. Fuer
|
||||
* einen `MITARBEITER` war das eine Schleife: `/` verlangt `lesen`, also
|
||||
* landete er wieder hier (#035).
|
||||
*/}
|
||||
<Link
|
||||
href={einstieg}
|
||||
href="/"
|
||||
className="flex min-h-11 w-fit items-center rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent md:min-h-10"
|
||||
>
|
||||
{einstieg === "/" ? "Zur Übersicht" : "Zu meinen Geräten"}
|
||||
Zur Übersicht
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -20,7 +20,7 @@ import { SchrittWert } from "@/components/laeufe/schritt-wert";
|
||||
import { SchrittZustandWaehlen, ZustandKennzeichen } from "@/components/laeufe/schritt-zustand";
|
||||
import { darf } from "@/server/auth/rollen";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { anhaengeDesLaufs } from "@/server/queries/anhaenge";
|
||||
import { anhaengeDesLaufs } from "@/server/services/anhaenge";
|
||||
import { protokollDesLaufs } from "@/server/queries/protokoll";
|
||||
import {
|
||||
kommentareDesLaufs,
|
||||
@@ -322,7 +322,7 @@ export default async function LaufSeite({
|
||||
const laufProtokoll = await protokollDesLaufs(id);
|
||||
// Die Sicht hat `laufHolen` oben schon angewandt; hier nur die Metadaten der
|
||||
// Anhaenge dieses Laufs (#032).
|
||||
const anhaenge = await anhaengeDesLaufs(id, benutzer);
|
||||
const anhaenge = await anhaengeDesLaufs(id);
|
||||
/*
|
||||
* Die Personenliste nur fuer den Onboarding-Fall holen — sonst stuende in
|
||||
* jedem Seitenaufbau eine Abfrage, die niemand braucht.
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
import { Info, Laptop } from "lucide-react";
|
||||
import { LaufStatus } from "@/generated/prisma/enums";
|
||||
import { GERAETE_TYP_TEXT } from "@/lib/geraete-text";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import {
|
||||
meineDaten,
|
||||
meineGeraete,
|
||||
meineLaeufeOhneGeraet,
|
||||
type MeinLauf,
|
||||
} from "@/server/queries/selbstauskunft";
|
||||
|
||||
/**
|
||||
* Selbstauskunft: meine Geräte und was mit ihnen geschehen ist (#035).
|
||||
*
|
||||
* **Diese Seite zeigt keine Schritte, Notizen, Kommentare oder Anhänge** — und
|
||||
* zwar nicht, weil sie sie weglässt, sondern weil das Lesemodell sie nicht
|
||||
* liefert. Die Einschränkung sitzt in `src/server/queries/selbstauskunft.ts`;
|
||||
* eine Seite, die filtert, wäre eine Seite, die es vergessen kann (CLAUDE.md).
|
||||
*
|
||||
* Jede Seite prüft selbst — die Hülle ist das zweite Netz, nicht das erste.
|
||||
*/
|
||||
|
||||
const STATUS_TEXT: Record<LaufStatus, string> = {
|
||||
OFFEN: "offen",
|
||||
IN_ARBEIT: "in Arbeit",
|
||||
ABGESCHLOSSEN: "abgeschlossen",
|
||||
ARCHIVIERT: "abgeschlossen",
|
||||
};
|
||||
|
||||
/*
|
||||
* `ARCHIVIERT` heisst hier „abgeschlossen“, nicht „archiviert“.
|
||||
*
|
||||
* Der Unterschied ist eine Frage der Aktenfuehrung und interessiert den
|
||||
* Mitarbeiter nicht — fuer ihn ist beides „fertig“. „Archiviert“ klaenge
|
||||
* ausserdem, als waere etwas weggelegt worden.
|
||||
*/
|
||||
|
||||
const STATUS_FARBE: Record<LaufStatus, string> = {
|
||||
OFFEN: "text-nebentext",
|
||||
IN_ARBEIT: "text-faellig",
|
||||
ABGESCHLOSSEN: "text-erledigt",
|
||||
ARCHIVIERT: "text-erledigt",
|
||||
};
|
||||
|
||||
function datum(wert: Date): string {
|
||||
return wert.toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
timeZone: "Europe/Berlin",
|
||||
});
|
||||
}
|
||||
|
||||
/** Der Zeitraum eines Laufs, so kurz wie möglich. */
|
||||
function zeitraum(lauf: MeinLauf): string {
|
||||
const beginn = lauf.begonnenAm ?? lauf.erstelltAm;
|
||||
if (!lauf.abgeschlossenAm) return `seit ${datum(beginn)}`;
|
||||
const anfang = datum(beginn);
|
||||
const ende = datum(lauf.abgeschlossenAm);
|
||||
return anfang === ende ? anfang : `${anfang} – ${ende}`;
|
||||
}
|
||||
|
||||
function LaufZeile({ lauf }: { lauf: MeinLauf }) {
|
||||
return (
|
||||
<li className="flex flex-wrap items-baseline gap-x-3 gap-y-1 border-t border-linie py-3 first:border-t-0">
|
||||
<span className="min-w-0 flex-1 break-words">{lauf.titel}</span>
|
||||
<span className="text-xs text-nebentext">{zeitraum(lauf)}</span>
|
||||
<span className={`text-xs font-semibold ${STATUS_FARBE[lauf.status]}`}>{STATUS_TEXT[lauf.status]}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function MeineGeraeteSeite() {
|
||||
const { benutzer } = await sitzungErforderlich("selbstauskunft", "/meine-geraete");
|
||||
|
||||
/*
|
||||
* Ohne Zuordnung gibt es die Rolle nicht — das hält die Datenbank fest
|
||||
* (CHECK `Benutzer_mitarbeiter_nur_bei_rolle`). Der Fall kann hier also nicht
|
||||
* eintreten; die Prüfung steht trotzdem da, weil ein `!` an dieser Stelle eine
|
||||
* Behauptung wäre und kein Beweis.
|
||||
*/
|
||||
if (!benutzer.mitarbeiterId) {
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-2xl font-bold">Meine Geräte</h1>
|
||||
<p role="alert" className="mt-6 max-w-prose rounded-karte border border-fehler/40 bg-flaeche p-4">
|
||||
Dieses Konto ist keiner Person im Verzeichnis zugeordnet. Ein Administrator muss das einrichten —
|
||||
bis dahin gibt es hier nichts zu zeigen.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const [geraete, ohneGeraet, daten] = await Promise.all([
|
||||
meineGeraete(benutzer.mitarbeiterId),
|
||||
meineLaeufeOhneGeraet(benutzer.mitarbeiterId),
|
||||
meineDaten(benutzer.mitarbeiterId),
|
||||
]);
|
||||
|
||||
const nichts = geraete.length === 0 && ohneGeraet.length === 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-2xl font-bold">Meine Geräte</h1>
|
||||
<p className="mt-2 max-w-prose text-sm text-nebentext">
|
||||
Die Geräte, die auf {daten?.anzeigename ?? "dich"} laufen, und was die IT daran gemacht hat.
|
||||
</p>
|
||||
|
||||
<p className="mt-4 flex max-w-prose items-start gap-2 rounded-karte border border-linie bg-flaeche p-3 text-sm text-nebentext">
|
||||
<Info className="mt-0.5 size-4 shrink-0" strokeWidth={1.75} aria-hidden="true" />
|
||||
<span>
|
||||
Hier stehen <strong>Titel, Zeitraum und Status</strong> einer Arbeit — nicht die einzelnen Schritte,
|
||||
Notizen oder Fotos. Wenn du wissen willst, was genau gemacht wurde, frag die IT.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{daten ? (
|
||||
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
|
||||
<h2 className="text-lg font-semibold">Meine Daten</h2>
|
||||
<p className="mt-1 mb-4 max-w-prose text-sm text-nebentext">
|
||||
So steht es im Verzeichnis. Stimmt etwas nicht, meldet das die IT dem Personalbüro — ändern lässt
|
||||
es sich hier nicht.
|
||||
</p>
|
||||
<dl className="grid gap-x-8 gap-y-2 text-sm sm:grid-cols-[auto_1fr]">
|
||||
<dt className="text-nebentext">Name</dt>
|
||||
<dd>{daten.anzeigename}</dd>
|
||||
{daten.titel ? (
|
||||
<>
|
||||
<dt className="text-nebentext">Funktion</dt>
|
||||
<dd>{daten.titel}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{daten.abteilung ? (
|
||||
<>
|
||||
<dt className="text-nebentext">Abteilung</dt>
|
||||
<dd>{daten.abteilung}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{daten.mail ? (
|
||||
<>
|
||||
<dt className="text-nebentext">E-Mail</dt>
|
||||
<dd className="break-all">{daten.mail}</dd>
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{nichts ? (
|
||||
<p className="mt-6 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
|
||||
Auf dich läuft gerade kein Gerät, und es ist auch keine Arbeit zu dir vermerkt. Das ist kein Fehler
|
||||
— es heißt nur, dass die IT nichts zu dir festgehalten hat.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{geraete.length > 0 ? (
|
||||
<ul className="mt-6 flex flex-col gap-4">
|
||||
{geraete.map((geraet) => (
|
||||
<li key={geraet.id} className="rounded-karte border border-linie bg-flaeche p-5">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<Laptop
|
||||
className="size-[18px] shrink-0 self-center text-nebentext"
|
||||
strokeWidth={1.75}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h2 className="text-lg font-semibold">{geraet.bezeichnung}</h2>
|
||||
<span className="rounded-pille border border-linie px-2 py-0.5 text-xs text-nebentext">
|
||||
{GERAETE_TYP_TEXT[geraet.typ]}
|
||||
</span>
|
||||
{geraet.archiviert ? (
|
||||
<span className="rounded-pille border border-linie px-2 py-0.5 text-xs text-nebentext">
|
||||
nicht mehr in Benutzung
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<dl className="mt-2 flex flex-wrap gap-x-6 gap-y-1 text-xs text-nebentext">
|
||||
{geraet.seriennummer ? (
|
||||
<div>
|
||||
<dt className="inline">Seriennummer: </dt>
|
||||
<dd className="mono inline">{geraet.seriennummer}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<dt className="inline">Aufkleber: </dt>
|
||||
<dd className="mono inline">{geraet.tagCode}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{geraet.laeufe.length === 0 ? (
|
||||
<p className="mt-4 text-sm text-nebentext">
|
||||
Zu diesem Gerät ist noch keine Arbeit festgehalten.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-4 text-sm">
|
||||
{geraet.laeufe.map((lauf) => (
|
||||
<LaufZeile key={lauf.id} lauf={lauf} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{ohneGeraet.length > 0 ? (
|
||||
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
|
||||
<h2 className="text-lg font-semibold">Zu mir persönlich</h2>
|
||||
<p className="mt-1 mb-2 max-w-prose text-sm text-nebentext">
|
||||
Arbeiten, die nicht an einem Gerät hängen — etwa das Einrichten deines Kontos.
|
||||
</p>
|
||||
<ul className="text-sm">
|
||||
{ohneGeraet.map((lauf) => (
|
||||
<LaufZeile key={lauf.id} lauf={lauf} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+1
-13
@@ -2,8 +2,7 @@ import Link from "next/link";
|
||||
import { ArrowRight, Clock, ListChecks, Rocket, TriangleAlert } from "lucide-react";
|
||||
import { LaufStatus } from "@/generated/prisma/enums";
|
||||
import { fristVerstrichen } from "@/lib/fristen";
|
||||
import { darf, startseite } from "@/server/auth/rollen";
|
||||
import { redirect } from "next/navigation";
|
||||
import { darf } from "@/server/auth/rollen";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import {
|
||||
UEBERSICHT_JE_BLOCK,
|
||||
@@ -224,17 +223,6 @@ function Schnellstart({ vorlagen }: { vorlagen: readonly HaeufigeVorlage[] }) {
|
||||
}
|
||||
|
||||
export default async function Uebersichtsseite() {
|
||||
/*
|
||||
* **Erst nur die Anmeldung, dann das Recht.** Wer hier kein `lesen` hat, aber
|
||||
* einen eigenen Einstieg (seit #035 der `MITARBEITER`), wird dorthin
|
||||
* geschickt — statt auf „Dafuer fehlt dir die Berechtigung“ zu landen, obwohl
|
||||
* er gerade nur die Anwendung geoeffnet hat.
|
||||
*/
|
||||
const angemeldeter = await sitzungErforderlich(undefined, "/");
|
||||
const einstieg = startseite(angemeldeter.benutzer.rolle);
|
||||
if (einstieg !== "/") redirect(einstieg);
|
||||
|
||||
// Und jetzt das Recht, mit derselben Wirkung wie vorher.
|
||||
const { benutzer } = await sitzungErforderlich("lesen", "/");
|
||||
|
||||
const darfArbeiten = darf(benutzer.rolle, "laeufeBearbeiten");
|
||||
|
||||
@@ -22,23 +22,6 @@ const MELDUNGEN = new Map<string, string>(
|
||||
"oidc-aus": "Die Anmeldung über den Anbieter ist nicht eingerichtet.",
|
||||
"oidc-start": "Der Anbieter war nicht erreichbar. Bitte später erneut versuchen.",
|
||||
"oidc-rueckruf": "Die Anmeldung beim Anbieter ist fehlgeschlagen.",
|
||||
/*
|
||||
* Zwei eigene Codes fuer die Selbstauskunft (#035). Nicht `oidc-rueckruf`:
|
||||
* „fehlgeschlagen“ laesst jemanden es noch dreimal versuchen, obwohl sich
|
||||
* ohne Zutun eines Administrators nichts aendert.
|
||||
*
|
||||
* **Nicht auf `/kein-zugriff`**, obwohl das Issue es so vorsah: diese Seite
|
||||
* verlangt eine Sitzung, und bei gescheiterter Anmeldung gibt es keine — sie
|
||||
* wuerde auf die Anmeldeseite zurueckleiten, also im Kreis. Die Begruendung
|
||||
* gehoert deshalb hierhin, und genau das ist der Punkt des Kriteriums: keine
|
||||
* leere Seite, die wie ein Fehler aussieht.
|
||||
*/
|
||||
"keine-person":
|
||||
"Dein Konto ist keiner Person im Verzeichnis zugeordnet und gehört keiner Rollengruppe an. " +
|
||||
"Damit gibt es nichts, was angezeigt werden könnte. Bitte wende dich an die IT.",
|
||||
"upn-mehrdeutig":
|
||||
"Dein Anmeldename gehört zu mehr als einer Person im Verzeichnis. Das muss die IT auflösen — " +
|
||||
"welche Person gemeint ist, lässt sich hier nicht raten.",
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { oeffentlicheAdresse } from "@/lib/adressen";
|
||||
import { sicheresZiel, sitzungSetzen } from "@/server/auth/anmeldung";
|
||||
import { AnmeldeFehler, benutzerAusOidcUebernehmen } from "@/server/auth/benutzer";
|
||||
import { benutzerAusOidcUebernehmen } from "@/server/auth/benutzer";
|
||||
import { OIDC_FLUSS_COOKIE, anmeldungAbschliessen } from "@/server/auth/oidc";
|
||||
|
||||
/**
|
||||
@@ -35,27 +35,8 @@ export async function GET(anfrage: NextRequest): Promise<Response> {
|
||||
`OIDC-Rueckruf fehlgeschlagen:`,
|
||||
fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : String(fehler),
|
||||
);
|
||||
/*
|
||||
* Ein `AnmeldeFehler` ist etwas anderes als ein kaputter OIDC-Fluss: am
|
||||
* Anbieter hat alles funktioniert, nur darf oder kann dieses Konto hier
|
||||
* nichts sehen (#035). „Die Anmeldung ist fehlgeschlagen“ liesse jemanden es
|
||||
* dreimal versuchen, obwohl sich ohne Zutun eines Administrators nichts
|
||||
* aendert — deshalb ein eigener Code mit einer Begruendung, die weiterhilft.
|
||||
*
|
||||
* Unterschieden wird am **Grund**, nicht an der Meldung: der Text wird hier
|
||||
* nie durchgereicht, sonst stuende eine Dienstmeldung in einer Adresse.
|
||||
*/
|
||||
const schluessel =
|
||||
fehler instanceof AnmeldeFehler
|
||||
? fehler.grund === "upn-mehrdeutig"
|
||||
? "upn-mehrdeutig"
|
||||
: fehler.grund === "keine-person"
|
||||
? "keine-person"
|
||||
: "oidc-rueckruf"
|
||||
: "oidc-rueckruf";
|
||||
|
||||
const antwort = NextResponse.redirect(
|
||||
oeffentlicheAdresse(`/anmelden?fehler=${schluessel}`, anfrage.nextUrl.origin),
|
||||
oeffentlicheAdresse("/anmelden?fehler=oidc-rueckruf", anfrage.nextUrl.origin),
|
||||
);
|
||||
antwort.cookies.delete(OIDC_FLUSS_COOKIE);
|
||||
return antwort;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { RechteFehler, rechtErforderlich, sitzungNochGueltig } from "@/server/auth/sitzungspflicht";
|
||||
import type { Sitzung } from "@/lib/sitzung";
|
||||
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { laufZustand } from "@/server/queries/laeufe";
|
||||
import { HERZSCHLAG, herzschlagMs, maxStroeme, sseEreignis, willStrom } from "@/lib/sse";
|
||||
import { aufLaufHoeren, stroemeOffen } from "@/server/lauf-ereignisse";
|
||||
@@ -41,14 +40,10 @@ export async function GET(
|
||||
): Promise<Response> {
|
||||
let benutzerId: string;
|
||||
let rolle: Parameters<typeof laufZustand>[1]["rolle"];
|
||||
// Die Sitzung wird festgehalten, weil der Strom sie spaeter noch braucht:
|
||||
// `cookies()` gibt es in seinen Rueckrufen nicht mehr.
|
||||
let sitzung: Sitzung;
|
||||
try {
|
||||
const zugang = await rechtErforderlich("lesen");
|
||||
benutzerId = zugang.benutzer.id;
|
||||
rolle = zugang.benutzer.rolle;
|
||||
sitzung = zugang.sitzung;
|
||||
const { benutzer } = await rechtErforderlich("lesen");
|
||||
benutzerId = benutzer.id;
|
||||
rolle = benutzer.rolle;
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof RechteFehler) {
|
||||
// 401 und nicht 403: der Client soll die Aktualisierung einstellen und
|
||||
@@ -59,7 +54,7 @@ export async function GET(
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
let benutzer = { id: benutzerId, rolle };
|
||||
const benutzer = { id: benutzerId, rolle };
|
||||
const zustand = await laufZustand(id, benutzer);
|
||||
if (!zustand) {
|
||||
// Nicht vorhanden und nicht sichtbar sind dieselbe Antwort – die
|
||||
@@ -156,31 +151,7 @@ export async function GET(
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* **Der Herzschlag prueft auch die Sitzung nach** (Reviewbefund zu #029).
|
||||
* Vorher wurde das Recht genau einmal geprueft, beim Aufbau, und das Paar
|
||||
* aus Kennung und Rolle fuer die Lebensdauer des Stroms festgehalten — ein
|
||||
* deaktiviertes Konto bekam weiter live Daten, bis jemand die Lasche
|
||||
* schloss. Die Pruefung kostet eine Abfrage je offenem Strom und
|
||||
* Herzschlag (25 s); dieselbe Abfrage macht jede gewoehnliche Anfrage
|
||||
* ohnehin.
|
||||
*
|
||||
* Eine **Herabstufung** beendet den Strom nicht, sondern engt ihn ein: die
|
||||
* neue Rolle gilt ab dem naechsten Ereignis. Das ist dieselbe Regel wie
|
||||
* „Rolle pro Anfrage aus der Datenbank“ (CLAUDE.md).
|
||||
*/
|
||||
herzschlag = setInterval(() => {
|
||||
void (async () => {
|
||||
const noch = await sitzungNochGueltig(sitzung, "lesen");
|
||||
if (!noch) {
|
||||
schicken(sseEreignis("ende", JSON.stringify({ grund: "nicht-angemeldet" })));
|
||||
aufraeumen();
|
||||
return;
|
||||
}
|
||||
if (noch.rolle !== benutzer.rolle) benutzer = { id: benutzerId, rolle: noch.rolle };
|
||||
schicken(HERZSCHLAG);
|
||||
})();
|
||||
}, herzschlagMs());
|
||||
herzschlag = setInterval(() => schicken(HERZSCHLAG), herzschlagMs());
|
||||
anfrage.signal.addEventListener("abort", aufraeumen);
|
||||
},
|
||||
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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] },
|
||||
});
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Building2,
|
||||
FileText,
|
||||
LayoutDashboard,
|
||||
Laptop,
|
||||
ListChecks,
|
||||
LogOut,
|
||||
Menu,
|
||||
@@ -34,7 +33,6 @@ const SYMBOLE: Record<string, LucideIcon> = {
|
||||
Building2,
|
||||
Archive,
|
||||
Settings,
|
||||
Laptop,
|
||||
};
|
||||
|
||||
function NavListe({ modus, aktiv, rolle }: { modus: Betriebsmodus; aktiv: string; rolle?: Rolle }) {
|
||||
@@ -186,15 +184,6 @@ export function Huelle({
|
||||
aktiv?: string;
|
||||
benutzer?: AngemeldeterBenutzer;
|
||||
}) {
|
||||
/*
|
||||
* Scannen setzt voraus, dass man mit dem Gefundenen etwas tun darf. Ein
|
||||
* `MITARBEITER` darf das nicht (#035) — ihm den Knopf zu zeigen hiesse, ihn
|
||||
* auf eine Seite zu schicken, die ihn abweist. `lesen` ist dabei das richtige
|
||||
* Recht und nicht `geraeteBearbeiten`: ein LESER darf das gescannte Geraet
|
||||
* ansehen, nur nichts aendern.
|
||||
*/
|
||||
const darfScannen = benutzer ? darf(benutzer.rolle, "lesen") : false;
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh md:flex">
|
||||
{/* Handy: Kopfzeile mit Menue */}
|
||||
@@ -202,7 +191,7 @@ export function Huelle({
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<Bildmarke className="size-6 shrink-0 text-akzent" />
|
||||
<Wortmarke className="text-lg" />
|
||||
{darfScannen ? <ScannenKnopf className="ml-auto" /> : null}
|
||||
<ScannenKnopf className="ml-auto" />
|
||||
<details className="relative">
|
||||
<summary
|
||||
className="flex size-11 cursor-pointer list-none items-center justify-center rounded-pille text-nebentext"
|
||||
@@ -228,7 +217,7 @@ export function Huelle({
|
||||
<Bildmarke className="size-7 shrink-0 text-akzent" />
|
||||
<Wortmarke className="text-xl" />
|
||||
</Link>
|
||||
{darfScannen ? <ScannenKnopf className="mb-6" /> : null}
|
||||
<ScannenKnopf className="mb-6" />
|
||||
<nav aria-label="Hauptnavigation">
|
||||
<NavListe modus={modus} aktiv={aktiv} rolle={benutzer?.rolle} />
|
||||
</nav>
|
||||
|
||||
@@ -5,10 +5,8 @@ import { KeyRound, Save, Server, ShieldAlert, ShieldCheck } from "lucide-react";
|
||||
import { FELD, KAESTCHEN } from "@/lib/formular";
|
||||
import {
|
||||
AD_ZUORDNUNG,
|
||||
GRAPH_ZUORDNUNG,
|
||||
INTERVALL_HOECHSTENS,
|
||||
INTERVALL_MINDESTENS,
|
||||
QUELLEN_ART_TEXT,
|
||||
ZUORDNUNGS_BESCHRIFTUNG,
|
||||
ZUORDNUNGS_FELDER,
|
||||
} from "@/lib/verzeichnis";
|
||||
@@ -38,22 +36,6 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
|
||||
const [zuordnungOffen, setzeZuordnungOffen] = useState(false);
|
||||
const [adresse, setzeAdresse] = useState(quelle?.url ?? "");
|
||||
const klartext = adresse.trim().startsWith("ldap://");
|
||||
/*
|
||||
* Die Art ist beim Anlegen waehlbar und beim Bearbeiten **fest** (#028): an
|
||||
* ihr haengt, was die dauerhafte Kennung bedeutet, und nach einem Wechsel
|
||||
* wuerde kein Mitarbeiter wiedererkannt. Der Dienst weist einen Wechsel
|
||||
* ohnehin ab; hier gibt es ihn gar nicht erst zur Auswahl.
|
||||
*/
|
||||
const [art, setzeArt] = useState<"LDAP" | "GRAPH" | "SCIM">(quelle?.art ?? "LDAP");
|
||||
const istGraph = art === "GRAPH";
|
||||
/*
|
||||
* SCIM ist die Art ohne Zugangsdaten: Laufzettel verbindet sich nicht, es wird
|
||||
* beliefert (#029). Deshalb faellt hier alles weg, was zum Holen gehoert —
|
||||
* Adresse, Geheimnis, Filter, Zeitplan und die Attributzuordnung. Letztere
|
||||
* bestimmt bei einem Push der Anbieter, sie steht in RFC 7643.
|
||||
*/
|
||||
const istScim = art === "SCIM";
|
||||
const istLdap = art === "LDAP";
|
||||
|
||||
return (
|
||||
<form action={aktion} className="flex flex-col gap-4">
|
||||
@@ -70,116 +52,6 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
|
||||
/>
|
||||
</label>
|
||||
|
||||
{bearbeiten ? (
|
||||
<>
|
||||
<input type="hidden" name="art" value={art} />
|
||||
<p className="text-sm text-nebentext">
|
||||
Art: <strong className="text-text">{QUELLEN_ART_TEXT[art]}</strong> — sie lässt sich nicht ändern.
|
||||
Daran hängt, was die dauerhafte Kennung bedeutet; nach einem Wechsel würde kein Mitarbeiter
|
||||
wiedererkannt.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<fieldset className="flex flex-col gap-2 text-sm">
|
||||
<legend className="mb-1 text-nebentext">Art</legend>
|
||||
{(
|
||||
[
|
||||
{
|
||||
wert: "LDAP" as const,
|
||||
titel: "LDAP",
|
||||
text: "Ein lokales Active Directory oder ein anderes LDAP-Verzeichnis.",
|
||||
},
|
||||
{
|
||||
wert: "GRAPH" as const,
|
||||
titel: "Microsoft Graph",
|
||||
text: "Entra ID (Microsoft 365). Für Häuser ohne lokales AD.",
|
||||
},
|
||||
{
|
||||
wert: "SCIM" as const,
|
||||
titel: "SCIM (wird geliefert)",
|
||||
text:
|
||||
"Der Anbieter schickt Änderungen, sobald sie dort entstehen — authentik, Entra ID, Okta. " +
|
||||
"Laufzettel holt dabei nichts ab und braucht keine Zugangsdaten.",
|
||||
},
|
||||
] satisfies readonly { wert: "LDAP" | "GRAPH" | "SCIM"; titel: string; text: string }[]
|
||||
).map((wahl) => (
|
||||
<label
|
||||
key={wahl.wert}
|
||||
className="flex min-h-11 cursor-pointer items-start gap-3 rounded-karte border border-linie p-3 md:min-h-10"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="art"
|
||||
value={wahl.wert}
|
||||
checked={art === wahl.wert}
|
||||
onChange={() => setzeArt(wahl.wert)}
|
||||
className="mt-0.5 size-4 accent-akzent"
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">{wahl.titel}</span>
|
||||
<span className="block text-xs text-nebentext">{wahl.text}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{istGraph ? (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Mandanten-ID</span>
|
||||
<input
|
||||
name="tenantId"
|
||||
defaultValue={quelle?.tenantId ?? ""}
|
||||
required
|
||||
placeholder="contoso.onmicrosoft.com oder die GUID"
|
||||
className={`${FELD} mono`}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Anwendungs-ID</span>
|
||||
<input
|
||||
name="clientId"
|
||||
defaultValue={quelle?.clientId ?? ""}
|
||||
required
|
||||
placeholder="GUID der App-Registrierung"
|
||||
className={`${FELD} mono`}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 text-sm">
|
||||
<label htmlFor="quelle-secret" className="flex items-center gap-2 text-nebentext">
|
||||
<KeyRound className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||
Client-Secret
|
||||
</label>
|
||||
<input
|
||||
id="quelle-secret"
|
||||
name="clientSecret"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
aria-describedby="quelle-secret-hinweis"
|
||||
placeholder={quelle?.clientSecretVorhanden ? "unverändert lassen" : ""}
|
||||
required={!bearbeiten}
|
||||
className={FELD}
|
||||
/>
|
||||
<span id="quelle-secret-hinweis" className="text-xs text-nebentext">
|
||||
{quelle?.clientSecretVorhanden
|
||||
? "Leer lassen behält das gespeicherte Geheimnis."
|
||||
: "Wird verschlüsselt gespeichert und nie wieder angezeigt."}
|
||||
</span>
|
||||
</div>
|
||||
<p className="max-w-prose text-xs text-nebentext">
|
||||
Die App-Registrierung braucht die <strong>Anwendungsberechtigung</strong>{" "}
|
||||
<span className="mono">User.Read.All</span> mit Administratoreinwilligung. Wie das eingerichtet
|
||||
wird, steht in <span className="mono">docs/betrieb/entra-id.md</span>. Entra ID kennt keine OU,
|
||||
deshalb bleibt <span className="mono">{"{{ou}}"}</span> bei dieser Art leer.
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{istLdap ? (
|
||||
<>
|
||||
{/*
|
||||
* Die Erklärung steht **außerhalb** des `label` und hängt über
|
||||
* `aria-describedby` am Feld. Im `label` wäre sie Teil des Feldnamens:
|
||||
@@ -202,9 +74,9 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
|
||||
className={`${FELD} mono`}
|
||||
/>
|
||||
<span id="quelle-url-hinweis" className="text-xs text-nebentext">
|
||||
Drei Formen: <span className="mono">ldaps://</span> (verschlüsselt ab dem ersten Byte, die
|
||||
richtige Wahl), <span className="mono">ldap+starttls://</span> (unverschlüsselt verbunden und
|
||||
dann hochgestuft) und <span className="mono">ldap://</span>.
|
||||
Drei Formen: <span className="mono">ldaps://</span> (verschlüsselt ab dem ersten Byte, die richtige
|
||||
Wahl), <span className="mono">ldap+starttls://</span> (unverschlüsselt verbunden und dann
|
||||
hochgestuft) und <span className="mono">ldap://</span>.
|
||||
</span>
|
||||
{klartext ? (
|
||||
/*
|
||||
@@ -214,8 +86,8 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
|
||||
<span role="status" className="flex items-start gap-2 text-xs text-faellig">
|
||||
<ShieldAlert className="mt-0.5 size-3.5 shrink-0" strokeWidth={2} aria-hidden="true" />
|
||||
<span>
|
||||
Mit <span className="mono">ldap://</span> geht auch das Bind-Passwort im Klartext über das
|
||||
Netz. Für ein Dienstkonto im Active Directory ist das keine gute Idee.
|
||||
Mit <span className="mono">ldap://</span> geht auch das Bind-Passwort im Klartext über das Netz.
|
||||
Für ein Dienstkonto im Active Directory ist das keine gute Idee.
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
@@ -278,42 +150,11 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
|
||||
className={`${FELD} mono`}
|
||||
/>
|
||||
<span id="quelle-filter-hinweis" className="text-xs text-nebentext">
|
||||
Leer lassen nimmt den Standard: aktive Personenkonten. Er schließt deaktivierte Konten aus — wer
|
||||
aus dem Ergebnis verschwindet, wird hier deaktiviert und nie gelöscht.
|
||||
Leer lassen nimmt den Standard: aktive Personenkonten. Er schließt deaktivierte Konten aus — wer aus
|
||||
dem Ergebnis verschwindet, wird hier deaktiviert und nie gelöscht.
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : 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">
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Abgleich alle … Minuten</span>
|
||||
@@ -337,7 +178,6 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
|
||||
Nach Zeitplan abgleichen
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/*
|
||||
* Die Attributzuordnung ist einklappbar, aber vollständig da. Vorher stand
|
||||
@@ -345,21 +185,6 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
|
||||
* OpenLDAP anbindet, ändert einzelne Zeilen“, und genau das ging über die
|
||||
* 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)}>
|
||||
<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
|
||||
@@ -375,7 +200,7 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
|
||||
<input
|
||||
name={`attr.${feld}`}
|
||||
defaultValue={quelle?.attributZuordnung[feld] ?? AD_ZUORDNUNG[feld]}
|
||||
placeholder={(istGraph ? GRAPH_ZUORDNUNG : AD_ZUORDNUNG)[feld]}
|
||||
placeholder={AD_ZUORDNUNG[feld]}
|
||||
className={`${FELD} mono`}
|
||||
/>
|
||||
</label>
|
||||
@@ -386,13 +211,7 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
|
||||
Abgleich eine Person wieder. Ein anderer Wert legt beim nächsten Lauf alle neu an.
|
||||
</p>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/*
|
||||
* Ein eigenes CA-Zertifikat gilt nur fuer LDAP – Graph laeuft ueber
|
||||
* oeffentliches TLS, und bei SCIM baut Laufzettel keine Verbindung auf.
|
||||
*/}
|
||||
{istLdap ? (
|
||||
<details open={zertifikatOffen} onToggle={(e) => setzeZertifikatOffen(e.currentTarget.open)}>
|
||||
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
|
||||
Eigenes CA-Zertifikat hinterlegen
|
||||
@@ -435,7 +254,6 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
|
||||
</label>
|
||||
) : null}
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
{ergebnis.fehler ? (
|
||||
<p role="alert" className="max-w-prose text-sm text-fehler">
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
+4
-28
@@ -30,26 +30,15 @@ export type NavEintrag = {
|
||||
readonly ausIssue?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* **Jeder Eintrag nennt sein Recht.**
|
||||
*
|
||||
* Bis #035 stand `recht` nur am Admin-Bereich, und das genuegte: jede Rolle
|
||||
* durfte `lesen`, also war alles andere fuer jeden sichtbar. Mit `MITARBEITER`
|
||||
* stimmt das nicht mehr — diese Rolle darf **nicht** `lesen`, und ohne das Recht
|
||||
* an jedem Eintrag haette die Seitenleiste ihr sechs Links angeboten, die alle
|
||||
* auf `/kein-zugriff` fuehren. Ein Eintrag ohne `recht` ist seitdem die Ausnahme
|
||||
* und nicht die Regel.
|
||||
*/
|
||||
export const NAVIGATION: readonly NavEintrag[] = [
|
||||
{ pfad: "/", beschriftung: "Übersicht", symbol: "LayoutDashboard", recht: "lesen", verfuegbar: true },
|
||||
{ pfad: "/laeufe", beschriftung: "Läufe", symbol: "ListChecks", recht: "lesen", verfuegbar: true },
|
||||
{ pfad: "/vorlagen", beschriftung: "Vorlagen", symbol: "FileText", recht: "lesen", verfuegbar: true },
|
||||
{ pfad: "/", beschriftung: "Übersicht", symbol: "LayoutDashboard", verfuegbar: true },
|
||||
{ pfad: "/laeufe", beschriftung: "Läufe", symbol: "ListChecks", verfuegbar: true },
|
||||
{ pfad: "/vorlagen", beschriftung: "Vorlagen", symbol: "FileText", verfuegbar: true },
|
||||
{
|
||||
pfad: "/mitarbeiter",
|
||||
beschriftung: "Mitarbeiter & Geräte",
|
||||
beschriftungDienstleistung: "Personen & Geräte",
|
||||
symbol: "Users",
|
||||
recht: "lesen",
|
||||
verfuegbar: true,
|
||||
},
|
||||
{
|
||||
@@ -57,23 +46,10 @@ export const NAVIGATION: readonly NavEintrag[] = [
|
||||
beschriftung: "Kunden",
|
||||
nurModus: "DIENSTLEISTUNG",
|
||||
symbol: "Building2",
|
||||
recht: "lesen",
|
||||
verfuegbar: true,
|
||||
},
|
||||
{ pfad: "/archiv", beschriftung: "Archiv", symbol: "Archive", recht: "lesen", verfuegbar: true },
|
||||
{ pfad: "/archiv", beschriftung: "Archiv", symbol: "Archive", verfuegbar: true },
|
||||
{ pfad: "/admin", beschriftung: "Admin", symbol: "Settings", recht: "verwaltung", verfuegbar: true },
|
||||
/*
|
||||
* Die eine Seite der Selbstauskunft (#035). Sie steht am Ende, weil die
|
||||
* Reihenfolge fuer alle anderen Rollen gilt — und fuer einen `MITARBEITER` ist
|
||||
* sie der einzige Eintrag, da ist die Position gleichgueltig.
|
||||
*/
|
||||
{
|
||||
pfad: "/meine-geraete",
|
||||
beschriftung: "Meine Geräte",
|
||||
symbol: "Laptop",
|
||||
recht: "selbstauskunft",
|
||||
verfuegbar: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,7 +13,6 @@ export const ROLLEN_TEXT: Record<Rolle, string> = {
|
||||
[Rolle.REDAKTEUR]: "Redakteur",
|
||||
[Rolle.TECHNIKER]: "Techniker",
|
||||
[Rolle.LESER]: "Leser",
|
||||
[Rolle.MITARBEITER]: "Mitarbeiter (Selbstauskunft)",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -25,11 +24,4 @@ export const ROLLEN_RANG: Record<Rolle, number> = {
|
||||
[Rolle.REDAKTEUR]: 30,
|
||||
[Rolle.TECHNIKER]: 20,
|
||||
[Rolle.LESER]: 10,
|
||||
/*
|
||||
* **Unter LESER, und zwar deutlich.** Ein MITARBEITER sieht weniger als jede
|
||||
* andere Rolle: nur sich selbst. Der Rang entscheidet in `staerksteRolle`, wer
|
||||
* gewinnt, wenn jemand in mehreren abgebildeten Gruppen steht — und dort darf
|
||||
* die Selbstauskunft nie eine echte Rolle verdraengen.
|
||||
*/
|
||||
[Rolle.MITARBEITER]: 5,
|
||||
};
|
||||
|
||||
-324
@@ -1,324 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
+3
-95
@@ -60,40 +60,6 @@ export const AD_ZUORDNUNG: AttributZuordnung = {
|
||||
externeId: "objectGUID",
|
||||
};
|
||||
|
||||
/**
|
||||
* Dieselben Felder mit den Namen, die **Microsoft Graph** benutzt (#028).
|
||||
*
|
||||
* Vier Unterschiede zum AD, und jeder hat einen Grund:
|
||||
*
|
||||
* - `surname` statt `sn` — Graph schreibt die Namen aus.
|
||||
* - `jobTitle` statt `title` — `title` gibt es bei Graph auch, es ist aber die
|
||||
* Anrede („Dr.“), nicht die Funktion.
|
||||
* - `onPremisesSamAccountName` statt `sAMAccountName` — der Wert kommt aus dem
|
||||
* lokalen AD und ist **leer**, wenn der Mandant kein AD synchronisiert. Eine
|
||||
* reine Cloud-Umgebung hat keinen Anmeldenamen in diesem Sinn; `{{sam}}`
|
||||
* bleibt dort leer, statt dass etwas hineingedeutet wird.
|
||||
* - `id` statt `objectGUID` — dieselbe GUID, nur schon als Text. Das Umsortieren
|
||||
* der Bytes (`src/lib/ldap-guid.ts`) braucht es hier nicht.
|
||||
*/
|
||||
export const GRAPH_ZUORDNUNG: AttributZuordnung = {
|
||||
vorname: "givenName",
|
||||
nachname: "surname",
|
||||
anzeigename: "displayName",
|
||||
mail: "mail",
|
||||
upn: "userPrincipalName",
|
||||
sam: "onPremisesSamAccountName",
|
||||
abteilung: "department",
|
||||
titel: "jobTitle",
|
||||
vorgesetzter: "manager",
|
||||
externeId: "id",
|
||||
};
|
||||
|
||||
/** Die Vorgaben je Quellenart. `SCIM` hat noch keine (#029). */
|
||||
export const ZUORDNUNGS_VORGABE: Record<"LDAP" | "GRAPH", AttributZuordnung> = {
|
||||
LDAP: AD_ZUORDNUNG,
|
||||
GRAPH: GRAPH_ZUORDNUNG,
|
||||
};
|
||||
|
||||
/** Die Felder in der Reihenfolge, in der das Formular sie zeigt. */
|
||||
export const ZUORDNUNGS_FELDER = Object.keys(AD_ZUORDNUNG) as (keyof AttributZuordnung)[];
|
||||
|
||||
@@ -118,20 +84,6 @@ export const ZUORDNUNGS_BESCHRIFTUNG: Record<keyof AttributZuordnung, string> =
|
||||
*/
|
||||
export const ATTRIBUT_MUSTER = /^[A-Za-z][A-Za-z0-9-]*$/u;
|
||||
|
||||
/**
|
||||
* Wie eine Quellenart heisst, wo ein Mensch sie liest.
|
||||
*
|
||||
* Liegt in `src/lib`, weil beide Seiten sie brauchen: das Formular ist eine
|
||||
* Client-Komponente, und die darf nichts aus `src/server/*` importieren
|
||||
* (CLAUDE.md). „LDAP“ und „SCIM“ bleiben als Lehnwoerter stehen — so heissen sie
|
||||
* im Haus und in den Einstellungen der Anbieter.
|
||||
*/
|
||||
export const QUELLEN_ART_TEXT: Record<"LDAP" | "GRAPH" | "SCIM", string> = {
|
||||
LDAP: "LDAP",
|
||||
GRAPH: "Microsoft Graph",
|
||||
SCIM: "SCIM",
|
||||
};
|
||||
|
||||
/**
|
||||
* Liest eine gespeicherte Zuordnung aus dem Json-Feld.
|
||||
*
|
||||
@@ -140,60 +92,16 @@ export const QUELLEN_ART_TEXT: Record<"LDAP" | "GRAPH" | "SCIM", string> = {
|
||||
* mitschreiben muessen, und eine von Hand geleerte Zeile darf kein Attribut mit
|
||||
* leerem Namen anfragen.
|
||||
*/
|
||||
export function zuordnungLesen(roh: unknown, art: "LDAP" | "GRAPH" = "LDAP"): AttributZuordnung {
|
||||
export function zuordnungLesen(roh: unknown): AttributZuordnung {
|
||||
const felder = (typeof roh === "object" && roh !== null ? roh : {}) as Record<string, unknown>;
|
||||
/*
|
||||
* Die Vorgabe haengt an der **Art** (#028). Ohne diesen Parameter bekaeme eine
|
||||
* Graph-Quelle mit leerer Zuordnung die AD-Namen — und fragte dann bei Graph
|
||||
* nach `sn` und `objectGUID`, die es dort nicht gibt. Der Standardwert `LDAP`
|
||||
* haelt jeden Aufrufer von vor #028 bei seinem bisherigen Verhalten.
|
||||
*/
|
||||
const gelesen = { ...ZUORDNUNGS_VORGABE[art] };
|
||||
const gelesen = { ...AD_ZUORDNUNG };
|
||||
for (const schluessel of ZUORDNUNGS_FELDER) {
|
||||
const wert = felder[schluessel];
|
||||
if (typeof wert !== "string" || !wert.trim()) continue;
|
||||
/*
|
||||
* **Was kein Attributname ist, wird beim Lesen verworfen** und nicht
|
||||
* weitergegeben. Hier zu werfen waere falsch: eine einzige kaputte Zeile in
|
||||
* der Datenbank machte sonst die ganze Quellenliste unbenutzbar. Dass so
|
||||
* etwas gar nicht erst hineinkommt, haelt `zuordnungPruefen` beim Schreiben.
|
||||
*
|
||||
* Der Grund fuer beides: aus diesen Namen entstehen eine LDAP-Attributliste
|
||||
* und ein `$select` in der Graph-Adresse. `src/server/graph/leser.ts` berief
|
||||
* sich im Kommentar auf diese Pruefung, waehrend sie nur in der Server Action
|
||||
* stand — ein Name mit Komma und `$filter` kam durch den Dienst und landete
|
||||
* in der Abfrage (Reviewbefund zu #029).
|
||||
*/
|
||||
if (!ATTRIBUT_MUSTER.test(wert.trim())) continue;
|
||||
gelesen[schluessel] = wert.trim();
|
||||
if (typeof wert === "string" && wert.trim()) gelesen[schluessel] = wert.trim();
|
||||
}
|
||||
return gelesen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft eine Zuordnung, die **geschrieben** werden soll. Wirft nicht, sondern
|
||||
* meldet — der Aufrufer entscheidet, ob daraus eine Fehlermeldung oder eine
|
||||
* Ausnahme wird.
|
||||
*
|
||||
* Getrennt von `zuordnungAusEingabe`, weil die aus **Formularfeldern** liest und
|
||||
* dabei leere Felder herausfallen laesst. Diese hier prueft eine fertige
|
||||
* Zuordnung, egal woher sie kommt: Formular, Skript, Seed oder Import.
|
||||
*/
|
||||
export function zuordnungPruefen(zuordnung: Partial<Record<string, unknown>>): string | undefined {
|
||||
for (const [schluessel, wert] of Object.entries(zuordnung)) {
|
||||
if (wert === undefined || wert === null) continue;
|
||||
if (typeof wert !== "string" || !ATTRIBUT_MUSTER.test(wert.trim())) {
|
||||
const beschriftung =
|
||||
(ZUORDNUNGS_BESCHRIFTUNG as Record<string, string | undefined>)[schluessel] ?? schluessel;
|
||||
return (
|
||||
`„${String(wert)}“ ist kein Attributname (${beschriftung}). ` +
|
||||
"Erwartet werden Buchstaben, Ziffern und Bindestriche, beginnend mit einem Buchstaben."
|
||||
);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Attributzuordnung aus den Formularfeldern, geprueft.
|
||||
*
|
||||
|
||||
+2
-19
@@ -18,30 +18,13 @@ import { SITZUNG_COOKIE } from "@/lib/sitzung";
|
||||
* den Proxy zu haengen.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Pfade, die ohne Anmeldung erreichbar sein muessen.
|
||||
*
|
||||
* **Exportiert, damit der Test die Liste nicht abschreiben muss.** Vorher zaehlte
|
||||
* `tests/zugang.test.ts` die Pfade von Hand auf, und seit #029 fehlte dort
|
||||
* `/api/scim` — eine Aufzaehlung laeuft dem Code hinterher (Reviewbefund zu
|
||||
* #029). Jetzt prueft ein Test, dass **jeder** Eintrag hier auch im Test steht:
|
||||
* ein neuer oeffentlicher Pfad ist eine Sicherheitsentscheidung und soll jemanden
|
||||
* zwingen, sie aufzuschreiben.
|
||||
*/
|
||||
export const OEFFENTLICH: readonly string[] = [
|
||||
/** Pfade, die ohne Anmeldung erreichbar sein muessen. */
|
||||
const OEFFENTLICH: readonly string[] = [
|
||||
"/anmelden",
|
||||
// Der Anmeldevorgang selbst – ohne ihn koennte sich niemand anmelden.
|
||||
"/api/auth",
|
||||
// Healthcheck fuer Docker und den Reverse Proxy.
|
||||
"/api/gesundheit",
|
||||
/*
|
||||
* Der SCIM-Endpunkt (#029). Ein Identitaetsanbieter hat kein Sitzungscookie —
|
||||
* er weist sich mit einem Bearer-Token aus, und **jeder** Handler darunter
|
||||
* prueft es selbst, samt der Quelle und damit des Kunden. Ohne diesen Eintrag
|
||||
* bekaeme der Anbieter 401 vom Proxy, bevor sein Token ueberhaupt gelesen
|
||||
* wird — und die Meldung waere „nicht angemeldet“ statt „Token ungueltig“.
|
||||
*/
|
||||
"/api/scim",
|
||||
"/manifest.webmanifest",
|
||||
"/icon.svg",
|
||||
];
|
||||
|
||||
@@ -14,7 +14,6 @@ import { z } from "@/lib/validierung";
|
||||
import { MandantFehler, type HandelnderBenutzer } from "@/server/mandant";
|
||||
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { abgleichAusfuehren, type AbgleichErgebnis } from "@/server/services/abgleich";
|
||||
import { scimTokenErzeugen, scimTokenWiderrufen } from "@/server/services/scim";
|
||||
import {
|
||||
VerzeichnisFehler,
|
||||
quelleAnlegen,
|
||||
@@ -86,30 +85,8 @@ function zuordnungAusFormular(formular: FormData) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Was fuer **jede** Art gilt.
|
||||
*
|
||||
* Seit #028 haengen die uebrigen Felder an der Art (`QuelleSchema` je Art
|
||||
* darunter). Zwei Schemas und nicht eines mit lauter `optional`: ein Schema, das
|
||||
* alles zulaesst, pruefte nichts — und die Meldung „Bitte die Adresse angeben“
|
||||
* fuer eine Graph-Quelle waere schlicht falsch.
|
||||
*/
|
||||
const GemeinsamSchema = z.object({
|
||||
const QuelleSchema = z.object({
|
||||
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
|
||||
art: z.enum(["LDAP", "GRAPH", "SCIM"], {
|
||||
message: "Bitte die Art der Quelle wählen: LDAP, Microsoft Graph oder SCIM.",
|
||||
}),
|
||||
caZertifikatPem: z.string().trim().optional(),
|
||||
// Dieselben Grenzen wie im Dienst, aus derselben Quelle.
|
||||
abgleichIntervallMinuten: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(INTERVALL_MINDESTENS, intervallMeldung())
|
||||
.max(INTERVALL_HOECHSTENS, intervallMeldung()),
|
||||
aktiv: z.boolean(),
|
||||
});
|
||||
|
||||
const LdapSchema = z.object({
|
||||
url: z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -122,46 +99,16 @@ const LdapSchema = z.object({
|
||||
bindDn: z.string().trim().min(1, "Bitte den Benutzer für die Anmeldung angeben."),
|
||||
basisDn: z.string().trim().min(1, "Bitte angeben, ab welchem Knoten gesucht wird."),
|
||||
filter: z.string().trim().optional(),
|
||||
caZertifikatPem: z.string().trim().optional(),
|
||||
// Dieselben Grenzen wie im Dienst, aus derselben Quelle.
|
||||
abgleichIntervallMinuten: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(INTERVALL_MINDESTENS, intervallMeldung())
|
||||
.max(INTERVALL_HOECHSTENS, intervallMeldung()),
|
||||
aktiv: z.boolean(),
|
||||
});
|
||||
|
||||
const GraphSchema = z.object({
|
||||
tenantId: z.string().trim().min(1, "Bitte die Mandanten-ID oder den Domänennamen angeben."),
|
||||
clientId: z.string().trim().min(1, "Bitte die Anwendungs-ID der App-Registrierung angeben."),
|
||||
});
|
||||
|
||||
/** Die artabhaengigen Felder aus dem Formular, geprueft. */
|
||||
function artFelder(
|
||||
art: "LDAP" | "GRAPH" | "SCIM",
|
||||
formular: FormData,
|
||||
): { readonly werte: Record<string, unknown> } | { readonly fehler: string } {
|
||||
/*
|
||||
* **SCIM hat keine.** Eine SCIM-Quelle braucht einen Namen und sonst nichts:
|
||||
* Laufzettel verbindet sich nicht, es wird beliefert, und das Token entsteht
|
||||
* erst danach (#029).
|
||||
*/
|
||||
if (art === "SCIM") return { werte: {} };
|
||||
|
||||
if (art === "LDAP") {
|
||||
const gelesen = LdapSchema.safeParse({
|
||||
url: text(formular, "url"),
|
||||
bindDn: text(formular, "bindDn"),
|
||||
basisDn: text(formular, "basisDn"),
|
||||
filter: text(formular, "filter") || undefined,
|
||||
});
|
||||
return gelesen.success
|
||||
? { werte: gelesen.data }
|
||||
: { fehler: gelesen.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
|
||||
}
|
||||
|
||||
const gelesen = GraphSchema.safeParse({
|
||||
tenantId: text(formular, "tenantId"),
|
||||
clientId: text(formular, "clientId"),
|
||||
});
|
||||
return gelesen.success
|
||||
? { werte: gelesen.data }
|
||||
: { fehler: gelesen.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
|
||||
}
|
||||
|
||||
export async function quelleAnlegenAktion(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
@@ -169,9 +116,12 @@ export async function quelleAnlegenAktion(
|
||||
const zugang = await angemeldetOderMeldung();
|
||||
if ("fehler" in zugang) return zugang;
|
||||
|
||||
const eingabe = GemeinsamSchema.safeParse({
|
||||
const eingabe = QuelleSchema.safeParse({
|
||||
name: text(formular, "name"),
|
||||
art: text(formular, "art") || "LDAP",
|
||||
url: text(formular, "url"),
|
||||
bindDn: text(formular, "bindDn"),
|
||||
basisDn: text(formular, "basisDn"),
|
||||
filter: text(formular, "filter") || undefined,
|
||||
caZertifikatPem: text(formular, "caZertifikatPem") || undefined,
|
||||
abgleichIntervallMinuten: text(formular, "abgleichIntervallMinuten") || "60",
|
||||
aktiv: formular.get("aktiv") === "1",
|
||||
@@ -180,45 +130,19 @@ export async function quelleAnlegenAktion(
|
||||
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
|
||||
}
|
||||
|
||||
const felder = artFelder(eingabe.data.art, formular);
|
||||
if ("fehler" in felder) return felder;
|
||||
|
||||
/*
|
||||
* Das Geheimnis heisst je Art anders, und die Meldung soll das auch sagen.
|
||||
* „Ohne Passwort kommt keine Anmeldung zustande“ ist fuer eine
|
||||
* App-Registrierung die falsche Auskunft.
|
||||
*/
|
||||
const geheimnis =
|
||||
eingabe.data.art === "LDAP"
|
||||
? { bindPasswort: text(formular, "bindPasswort") }
|
||||
: eingabe.data.art === "GRAPH"
|
||||
? { clientSecret: text(formular, "clientSecret") }
|
||||
: {};
|
||||
const geheimnisWert = Object.values(geheimnis)[0];
|
||||
if (eingabe.data.art !== "SCIM" && !geheimnisWert) {
|
||||
return {
|
||||
fehler:
|
||||
eingabe.data.art === "LDAP"
|
||||
? "Ohne Passwort kommt keine Anmeldung am Verzeichnis zustande."
|
||||
: "Ohne Client-Secret kommt keine Anmeldung am Mandanten zustande.",
|
||||
};
|
||||
}
|
||||
const bindPasswort = text(formular, "bindPasswort");
|
||||
if (!bindPasswort) return { fehler: "Ohne Passwort kommt keine Anmeldung am Verzeichnis zustande." };
|
||||
|
||||
const zuordnung = zuordnungAusFormular(formular);
|
||||
if ("fehler" in zuordnung) return zuordnung;
|
||||
|
||||
try {
|
||||
const quelle = await quelleAnlegen(
|
||||
{ ...eingabe.data, ...felder.werte, ...geheimnis, attributZuordnung: zuordnung.zuordnung },
|
||||
{ ...eingabe.data, bindPasswort, attributZuordnung: zuordnung.zuordnung },
|
||||
zugang.benutzer,
|
||||
);
|
||||
revalidatePath("/admin/verzeichnis");
|
||||
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.`,
|
||||
};
|
||||
return { erfolg: `„${quelle.name}“ ist angelegt. Prüf als Nächstes die Verbindung.` };
|
||||
} catch (fehler) {
|
||||
return alsErgebnis(fehler, "Die Quelle konnte nicht angelegt werden.");
|
||||
}
|
||||
@@ -234,9 +158,12 @@ export async function quelleBearbeitenAktion(
|
||||
const id = text(formular, "id");
|
||||
if (!id) return { fehler: "Es fehlt die Quelle." };
|
||||
|
||||
const eingabe = GemeinsamSchema.safeParse({
|
||||
const eingabe = QuelleSchema.safeParse({
|
||||
name: text(formular, "name"),
|
||||
art: text(formular, "art") || "LDAP",
|
||||
url: text(formular, "url"),
|
||||
bindDn: text(formular, "bindDn"),
|
||||
basisDn: text(formular, "basisDn"),
|
||||
filter: text(formular, "filter") || undefined,
|
||||
// Das Zertifikat entscheidet sich weiter unten: es kennt drei Faelle, das
|
||||
// Schema nur zwei.
|
||||
caZertifikatPem: undefined,
|
||||
@@ -247,9 +174,6 @@ export async function quelleBearbeitenAktion(
|
||||
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
|
||||
}
|
||||
|
||||
const felder = artFelder(eingabe.data.art, formular);
|
||||
if ("fehler" in felder) return felder;
|
||||
|
||||
/*
|
||||
* Drei Faelle, und das Formular kann sie nicht auf ein Feld abbilden: ein neues
|
||||
* Zertifikat eintragen, das hinterlegte behalten (Feld leer) und das hinterlegte
|
||||
@@ -276,15 +200,11 @@ export async function quelleBearbeitenAktion(
|
||||
if ("fehler" in zuordnung) return zuordnung;
|
||||
|
||||
try {
|
||||
const clientSecret = text(formular, "clientSecret");
|
||||
const quelle = await quelleBearbeiten(
|
||||
id,
|
||||
{
|
||||
...eingabe.data,
|
||||
...felder.werte,
|
||||
// Leer heisst unveraendert — bei beiden Geheimnissen.
|
||||
...(bindPasswort ? { bindPasswort } : {}),
|
||||
...(clientSecret ? { clientSecret } : {}),
|
||||
...(caZertifikatPem !== undefined ? { caZertifikatPem } : {}),
|
||||
attributZuordnung: zuordnung.zuordnung,
|
||||
},
|
||||
@@ -315,58 +235,6 @@ export async function quelleLoeschenAktion(
|
||||
}
|
||||
}
|
||||
|
||||
export type TokenErgebnis = AktionsErgebnis & {
|
||||
/**
|
||||
* Das Token – **genau dieses eine Mal**.
|
||||
*
|
||||
* Es steht in der Datenbank nur als argon2-Hash, es gibt also keinen zweiten
|
||||
* Weg, es anzusehen. Wer es verliert, erzeugt ein neues; das alte gilt dann
|
||||
* nicht mehr.
|
||||
*/
|
||||
readonly token?: string;
|
||||
};
|
||||
|
||||
export async function scimTokenErzeugenAktion(
|
||||
_vorher: TokenErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<TokenErgebnis> {
|
||||
const zugang = await angemeldetOderMeldung();
|
||||
if ("fehler" in zugang) return zugang;
|
||||
const id = text(formular, "id");
|
||||
if (!id) return { fehler: "Es fehlt die Quelle." };
|
||||
|
||||
try {
|
||||
const { token } = await scimTokenErzeugen(id, zugang.benutzer);
|
||||
revalidatePath("/admin/verzeichnis");
|
||||
return {
|
||||
token,
|
||||
erfolg:
|
||||
"Das Token gilt ab jetzt. Kopier es in die Einstellungen des Anbieters — danach ist es " +
|
||||
"nicht mehr ansehbar.",
|
||||
};
|
||||
} catch (fehler) {
|
||||
return alsErgebnis(fehler, "Das Token konnte nicht erzeugt werden.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function scimTokenWiderrufenAktion(
|
||||
_vorher: TokenErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<TokenErgebnis> {
|
||||
const zugang = await angemeldetOderMeldung();
|
||||
if ("fehler" in zugang) return zugang;
|
||||
const id = text(formular, "id");
|
||||
if (!id) return { fehler: "Es fehlt die Quelle." };
|
||||
|
||||
try {
|
||||
await scimTokenWiderrufen(id, zugang.benutzer);
|
||||
revalidatePath("/admin/verzeichnis");
|
||||
return { erfolg: "Das Token ist widerrufen. Die Quelle nimmt ab jetzt nichts mehr an." };
|
||||
} catch (fehler) {
|
||||
return alsErgebnis(fehler, "Das Token konnte nicht widerrufen werden.");
|
||||
}
|
||||
}
|
||||
|
||||
export type TestErgebnis = AktionsErgebnis & {
|
||||
readonly treffer?: readonly GemappterEintrag[];
|
||||
readonly gesamtGelesen?: number;
|
||||
@@ -393,7 +261,7 @@ export async function verbindungTestenAktion(
|
||||
const ergebnis = await verbindungTesten(id, zugang.benutzer);
|
||||
return {
|
||||
erfolg:
|
||||
ergebnis.treffer.length === 0 && ergebnis.gesamtGelesen === 0
|
||||
ergebnis.treffer.length === 0
|
||||
? "Die Verbindung steht, aber der Filter findet niemanden."
|
||||
: ergebnis.treffer.length === 0
|
||||
? /*
|
||||
|
||||
+5
-134
@@ -5,7 +5,7 @@ import type { Rolle } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
kundenkuerzelAusGruppen,
|
||||
rolleAusGruppenGenau,
|
||||
rolleAusGruppen,
|
||||
rollenZuordnungAktiv,
|
||||
type OidcAnspruecke,
|
||||
} from "@/server/auth/oidc";
|
||||
@@ -17,23 +17,10 @@ import {
|
||||
* Verzeichnis. Hier geht es um die Menschen, die Laufzettel bedienen.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gruende, die der Aufrufer **unterscheiden** muss.
|
||||
*
|
||||
* Nicht am Meldungstext festmachen: der ist fuer Menschen und aendert sich. Die
|
||||
* beiden Gruende aus #035 fuehren auf eigene Hinweise an der Anmeldeseite, denn
|
||||
* „fehlgeschlagen“ liesse jemanden es dreimal versuchen, obwohl sich ohne Zutun
|
||||
* eines Administrators nichts aendert.
|
||||
*/
|
||||
export type AnmeldeGrund = "keine-person" | "upn-mehrdeutig";
|
||||
|
||||
export class AnmeldeFehler extends Error {
|
||||
readonly grund: AnmeldeGrund | undefined;
|
||||
|
||||
constructor(nachricht: string, grund?: AnmeldeGrund) {
|
||||
constructor(nachricht: string) {
|
||||
super(nachricht);
|
||||
this.name = "AnmeldeFehler";
|
||||
this.grund = grund;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,121 +102,6 @@ export async function passwortHashen(passwort: string): Promise<string> {
|
||||
* Aenderung im Verzeichnis greift damit ab der naechsten Anmeldung, ohne dass
|
||||
* jemand hier nachpflegen muss.
|
||||
*/
|
||||
/**
|
||||
* Die Person im Verzeichnis zu einem UPN – oder ein Fehler, wenn es mehrere sind.
|
||||
*
|
||||
* Verglichen wird **ohne** Ruecksicht auf Gross- und Kleinschreibung: ein UPN
|
||||
* ist technisch nicht case-sensitiv, und ein Anbieter schickt ihn nicht immer so,
|
||||
* wie er im Verzeichnis steht.
|
||||
*
|
||||
* **Mehrdeutig ist ein Fehler, kein Zufallstreffer.** Zwei Kunden koennen
|
||||
* dieselbe Person fuehren (dieselbe Firma, zwei Mandanten), und ein `findFirst`
|
||||
* entschiede dann nach Sortierung darueber, wessen Geraete jemand sieht. Lieber
|
||||
* kein Zugang als der falsche.
|
||||
*/
|
||||
async function personZuUpn(
|
||||
upn: string,
|
||||
): Promise<
|
||||
| { readonly treffer: "einer"; readonly id: string; readonly kundeId: string }
|
||||
| { readonly treffer: "keiner" }
|
||||
| { readonly treffer: "mehrere"; readonly anzahl: number }
|
||||
> {
|
||||
const gesucht = upn.trim();
|
||||
if (!gesucht) return { treffer: "keiner" };
|
||||
|
||||
/*
|
||||
* **Ohne `take`, damit die gemeldete Zahl die Zahl ist.** Vorher stand hier
|
||||
* `take: 2` — fuer die Entscheidung genuegt das, aber `anzahl` ging damit in
|
||||
* den Protokolleintrag „Anmeldung abgewiesen: UPN mehrdeutig“, und der sagte
|
||||
* dann immer „2“, auch bei sieben Treffern (Reviewbefund zu #029). Wer das
|
||||
* Protokoll liest, um den Fall aufzuloesen, bekam eine Zahl, die keine war.
|
||||
*
|
||||
* Die Abfrage laeuft gegen einen Index auf `upn` und liefert im Normalfall
|
||||
* **einen** Treffer; mehrere sind ein Datenfehler, den jemand beheben muss.
|
||||
*/
|
||||
const passende = await prisma.mitarbeiter.findMany({
|
||||
where: { upn: { equals: gesucht, mode: "insensitive" }, aktiv: true },
|
||||
select: { id: true, kundeId: true },
|
||||
});
|
||||
|
||||
const erster = passende[0];
|
||||
if (!erster) return { treffer: "keiner" };
|
||||
if (passende.length > 1) return { treffer: "mehrere", anzahl: passende.length };
|
||||
return { treffer: "einer", id: erster.id, kundeId: erster.kundeId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Welche Rolle bekommt diese Anmeldung, und auf welche Person zeigt sie?
|
||||
*
|
||||
* Drei Faelle, in dieser Reihenfolge:
|
||||
*
|
||||
* 1. **Keine Gruppen-Zuordnung eingerichtet** — alles bleibt wie bisher. Die
|
||||
* Selbstauskunft greift dann **gar nicht**: ohne abgebildete Gruppen kann die
|
||||
* Anwendung nicht unterscheiden, ob jemand Techniker oder Mitarbeiter ist, und
|
||||
* ein Update duerfte niemandem seinen Zugang nehmen.
|
||||
* 2. **Eine Gruppe hat getroffen** — diese Rolle, und keine Person. Wer eine
|
||||
* echte Rolle hat, sieht ueber `lesen` ohnehin alles.
|
||||
* 3. **Zuordnung aktiv, aber keine Gruppe getroffen** — der Fall, um den es in
|
||||
* #035 geht: ueber den UPN wird eine Person gesucht. Gibt es sie, wird daraus
|
||||
* ein `MITARBEITER`; gibt es sie nicht, gibt es **keinen Zugang**.
|
||||
*
|
||||
* Fall 3 ist eine Verschaerfung gegenueber dem Stand vor #035, wo dieselbe Lage
|
||||
* `LESER` ergab — also Lesezugriff auf alle Laeufe des Hauses. Das war fuer eine
|
||||
* Person ohne jede Rollengruppe zu viel, und genau deshalb steht es im Changelog
|
||||
* als Aenderung und nicht als Ergaenzung.
|
||||
*/
|
||||
async function rolleUndPersonBestimmen(
|
||||
anspruecke: OidcAnspruecke,
|
||||
vorhanden: Benutzer | null,
|
||||
): Promise<{ readonly rolle: Rolle; readonly mitarbeiterId: string | null }> {
|
||||
if (!rollenZuordnungAktiv()) {
|
||||
// Wie bisher – und eine bestehende Zuordnung bleibt, wie sie ist.
|
||||
return {
|
||||
rolle: vorhanden?.rolle ?? "LESER",
|
||||
mitarbeiterId: vorhanden?.mitarbeiterId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const ausGruppen = rolleAusGruppenGenau(anspruecke.gruppen);
|
||||
if (ausGruppen) return { rolle: ausGruppen, mitarbeiterId: null };
|
||||
|
||||
const person = await personZuUpn(anspruecke.upn);
|
||||
|
||||
if (person.treffer === "mehrere") {
|
||||
/*
|
||||
* Protokolliert, **bevor** geworfen wird: wer das in der Oberflaeche sieht,
|
||||
* soll nicht raten muessen, warum sich jemand nicht anmelden kann. Ohne
|
||||
* Benutzerbezug, denn das Konto gibt es an dieser Stelle vielleicht noch
|
||||
* nicht — der Eintrag erscheint im Protokoll als Systemlauf.
|
||||
*/
|
||||
await prisma.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Benutzer",
|
||||
entitaetId: vorhanden?.id ?? anspruecke.sub,
|
||||
aktion: "Anmeldung abgewiesen: UPN mehrdeutig",
|
||||
nachher: { upn: anspruecke.upn, treffer: person.anzahl },
|
||||
},
|
||||
});
|
||||
throw new AnmeldeFehler(
|
||||
`Der Anmeldename ${anspruecke.upn} gehoert zu mehr als einer Person im Verzeichnis. ` +
|
||||
"Ein Administrator muss das aufloesen – welche Person gemeint ist, laesst sich hier nicht raten.",
|
||||
"upn-mehrdeutig",
|
||||
);
|
||||
}
|
||||
|
||||
if (person.treffer === "keiner") {
|
||||
throw new AnmeldeFehler(
|
||||
`Zu dem Anmeldenamen ${anspruecke.upn} gibt es keine aktive Person im Verzeichnis, und ` +
|
||||
"dieses Konto gehoert keiner Rollengruppe an. Damit gibt es nichts, was angezeigt werden " +
|
||||
"koennte – ein Administrator muss entweder eine Rolle zuweisen oder die Person im " +
|
||||
"Verzeichnis pruefen.",
|
||||
"keine-person",
|
||||
);
|
||||
}
|
||||
|
||||
return { rolle: "MITARBEITER", mitarbeiterId: person.id };
|
||||
}
|
||||
|
||||
export async function benutzerAusOidcUebernehmen(anspruecke: OidcAnspruecke): Promise<Benutzer> {
|
||||
// Zuerst ueber sub suchen: die Adresse eines Kontos kann sich aendern, die
|
||||
// Kennung beim Anbieter nicht.
|
||||
@@ -261,8 +133,9 @@ export async function benutzerAusOidcUebernehmen(anspruecke: OidcAnspruecke): Pr
|
||||
throw new AnmeldeFehler("Dieses Konto ist deaktiviert.");
|
||||
}
|
||||
|
||||
const zuweisung = await rolleUndPersonBestimmen(anspruecke, vorhanden);
|
||||
const { rolle, mitarbeiterId } = zuweisung;
|
||||
// Ohne eingerichtete Gruppen-Zuordnung bleibt die Rolle, wie sie ist – sonst
|
||||
// waere jede Anmeldung eine stille Herabstufung auf LESER.
|
||||
const rolle = rollenZuordnungAktiv() ? rolleAusGruppen(anspruecke.gruppen) : (vorhanden?.rolle ?? "LESER");
|
||||
|
||||
const benutzer = vorhanden
|
||||
? await prisma.benutzer.update({
|
||||
@@ -272,7 +145,6 @@ export async function benutzerAusOidcUebernehmen(anspruecke: OidcAnspruecke): Pr
|
||||
email: anspruecke.email,
|
||||
name: anspruecke.name,
|
||||
rolle,
|
||||
mitarbeiterId,
|
||||
letzteAnmeldungAm: new Date(),
|
||||
},
|
||||
})
|
||||
@@ -282,7 +154,6 @@ export async function benutzerAusOidcUebernehmen(anspruecke: OidcAnspruecke): Pr
|
||||
email: anspruecke.email,
|
||||
name: anspruecke.name,
|
||||
rolle,
|
||||
mitarbeiterId,
|
||||
aktiv: true,
|
||||
letzteAnmeldungAm: new Date(),
|
||||
},
|
||||
|
||||
+3
-26
@@ -1,5 +1,5 @@
|
||||
import * as oidc from "openid-client";
|
||||
import { Rolle } from "@/generated/prisma/enums";
|
||||
import type { Rolle } from "@/generated/prisma/enums";
|
||||
import { kurzzeitTokenErzeugen, kurzzeitTokenLesen } from "@/lib/kurzzeit-token";
|
||||
import { staerksteRolle } from "@/server/auth/rollen";
|
||||
|
||||
@@ -139,16 +139,6 @@ export type OidcAnspruecke = {
|
||||
readonly emailBestaetigt: boolean;
|
||||
readonly name: string;
|
||||
readonly gruppen: readonly string[];
|
||||
/**
|
||||
* Der UPN, an dem die Selbstauskunft haengt (#035).
|
||||
*
|
||||
* Reihenfolge mit Absicht: `upn` (so heisst der Anspruch bei Entra ID),
|
||||
* sonst `preferred_username` (authentik und Keycloak schicken dort den
|
||||
* Anmeldenamen), sonst die Mailadresse. Die Mailadresse zuletzt, weil sie im
|
||||
* AD oft **nicht** der UPN ist — `vorname.nachname@firma.de` gegen
|
||||
* `vnachname@firma.local`.
|
||||
*/
|
||||
readonly upn: string;
|
||||
/** Ziel, das vor der Anmeldung angefordert wurde. */
|
||||
readonly ziel: string;
|
||||
};
|
||||
@@ -209,7 +199,6 @@ export async function anmeldungAbschliessen(
|
||||
emailBestaetigt: anspruecke["email_verified"] === true,
|
||||
name: textAus(anspruecke["name"]) ?? textAus(anspruecke["preferred_username"]) ?? email,
|
||||
gruppen: gruppenAus(anspruecke["groups"]),
|
||||
upn: textAus(anspruecke["upn"]) ?? textAus(anspruecke["preferred_username"]) ?? email,
|
||||
ziel: fluss["ziel"] ?? "/",
|
||||
};
|
||||
}
|
||||
@@ -234,15 +223,7 @@ export function rollenZuordnungAktiv(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die abgebildete Rolle – oder `undefined`, wenn **keine** Gruppe getroffen hat.
|
||||
*
|
||||
* Die Unterscheidung ist der Angelpunkt der Selbstauskunft (#035): „keine
|
||||
* Gruppe getroffen“ und „die Gruppe fuer LESER getroffen“ sahen bis dahin
|
||||
* gleich aus, weil beides `LESER` ergab. Erst wer sie trennt, kann aus dem
|
||||
* ersten Fall eine Selbstauskunft machen, ohne dem zweiten etwas zu nehmen.
|
||||
*/
|
||||
export function rolleAusGruppenGenau(gruppen: readonly string[]): Rolle | undefined {
|
||||
export function rolleAusGruppen(gruppen: readonly string[]): Rolle {
|
||||
const zuordnung: readonly (readonly [string | undefined, Rolle])[] = [
|
||||
[umgebung("OIDC_GROUP_ADMIN"), "ADMIN"],
|
||||
[umgebung("OIDC_GROUP_EDITOR"), "REDAKTEUR"],
|
||||
@@ -253,11 +234,7 @@ export function rolleAusGruppenGenau(gruppen: readonly string[]): Rolle | undefi
|
||||
const getroffen = zuordnung
|
||||
.filter(([gruppe]) => gruppe !== undefined && vorhanden.has(gruppe))
|
||||
.map(([, rolle]) => rolle);
|
||||
return getroffen.length === 0 ? undefined : staerksteRolle(getroffen);
|
||||
}
|
||||
|
||||
export function rolleAusGruppen(gruppen: readonly string[]): Rolle {
|
||||
return rolleAusGruppenGenau(gruppen) ?? Rolle.LESER;
|
||||
return staerksteRolle(getroffen);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,24 +20,8 @@ import { ROLLEN_RANG, ROLLEN_TEXT } from "@/lib/rollen-text";
|
||||
* des Cookies.
|
||||
*/
|
||||
export const RECHTE = {
|
||||
/**
|
||||
* Vorlagen, Laeufe, Geraete und Verzeichnis ansehen.
|
||||
*
|
||||
* **Nicht mehr „jede Rolle“** (#035): `MITARBEITER` fehlt hier, und das ist
|
||||
* der ganze Punkt dieser Rolle. Wer sich selbst auskunftsberechtigt anmeldet,
|
||||
* darf nicht die Laeufe des Hauses lesen — dafuer gibt es `selbstauskunft`.
|
||||
*/
|
||||
/** Vorlagen, Laeufe, Geraete und Verzeichnis ansehen. Jede Rolle darf das. */
|
||||
lesen: ["ADMIN", "REDAKTEUR", "TECHNIKER", "LESER"],
|
||||
/**
|
||||
* Die eigenen Geraete und die Laeufe dazu ansehen (#035).
|
||||
*
|
||||
* **Nur `MITARBEITER`.** Die anderen Rollen sehen ueber `lesen` ohnehin alles;
|
||||
* sie hier mit aufzunehmen waere ein zweiter Weg zu denselben Daten und damit
|
||||
* eine zweite Stelle, an der jemand die Einschraenkung auf die eigene Person
|
||||
* vergessen kann. Ein Techniker, der seine eigenen Geraete sehen will, nimmt
|
||||
* die gewoehnliche Geraeteliste.
|
||||
*/
|
||||
selbstauskunft: ["MITARBEITER"],
|
||||
/** Laeufe starten, abarbeiten, abschliessen und archivieren. */
|
||||
laeufeBearbeiten: ["ADMIN", "REDAKTEUR", "TECHNIKER"],
|
||||
/** Geraete und deren Stammdaten pflegen, Tags registrieren. */
|
||||
@@ -73,59 +57,18 @@ export function darf(rolle: Rolle, recht: Recht): boolean {
|
||||
* Die staerkste der uebergebenen Rollen.
|
||||
*
|
||||
* Der OIDC-Login braucht das: ist jemand in mehreren abgebildeten Gruppen, gewinnt die
|
||||
* staerkere Rolle. Eine leere Liste ergibt `LESER` -- nie ein Fehler und nie `ADMIN`.
|
||||
*
|
||||
* **Das gilt weiter fuer eine eingerichtete Zuordnung ohne Treffer in dieser
|
||||
* Funktion.** Ob aus „keine Gruppe getroffen“ eine Selbstauskunft wird, entscheidet
|
||||
* seit #035 der Login ueber `rolleAusGruppenGenau` — hier bleibt der Standard
|
||||
* `LESER`, damit ein Aufrufer ohne diese Unterscheidung nie versehentlich mehr
|
||||
* bekommt als vorher.
|
||||
* staerkere Rolle. Eine leere Liste ergibt `LESER` -- nie ein Fehler und nie `ADMIN`. Wer
|
||||
* sich anmelden darf, aber keiner abgebildeten Gruppe angehoert, soll hineinsehen koennen
|
||||
* und nichts aendern; ein Fehler an dieser Stelle wuerde stattdessen den Login blockieren.
|
||||
*/
|
||||
export function staerksteRolle(rollen: readonly Rolle[]): Rolle {
|
||||
/*
|
||||
* `undefined` als Startwert, nicht `LESER`.
|
||||
*
|
||||
* Mit `LESER` als Startwert war der Wert zugleich **Untergrenze**: eine
|
||||
* uebergebene Rolle unterhalb davon kam nie heraus. Solange alle Rollen
|
||||
* mindestens `LESER` waren, fiel das nicht auf — mit `MITARBEITER` (#035) gab
|
||||
* `staerksteRolle(["MITARBEITER"])` ploetzlich `LESER`, also **mehr** Rechte
|
||||
* als uebergeben. Der Test „gibt bei nur <Rolle> diese Rolle zurueck“ stand
|
||||
* seit #007 da und hat es sofort gezeigt.
|
||||
*
|
||||
* Der Rueckfall auf `LESER` gilt jetzt nur noch fuer die **leere** Liste, und
|
||||
* genau so steht es auch im Kommentar darueber.
|
||||
*/
|
||||
let staerkste: Rolle | undefined;
|
||||
let staerkste: Rolle = Rolle.LESER;
|
||||
for (const rolle of rollen) {
|
||||
if (staerkste === undefined || ROLLEN_RANG[rolle] > ROLLEN_RANG[staerkste]) {
|
||||
if (ROLLEN_RANG[rolle] > ROLLEN_RANG[staerkste]) {
|
||||
staerkste = rolle;
|
||||
}
|
||||
}
|
||||
return staerkste ?? Rolle.LESER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wo diese Rolle nach der Anmeldung landet (#035).
|
||||
*
|
||||
* **Gefunden beim Durchklicken:** ein `MITARBEITER` meldete sich an und sah
|
||||
* „Dafuer fehlt dir die Berechtigung“ — das Standardziel ist `/`, und die
|
||||
* Uebersicht verlangt `lesen`. Genau die leere Seite, die wie ein Fehler
|
||||
* aussieht und die #035 vermeiden will.
|
||||
*
|
||||
* Eine reine Funktion und nicht ein `if` in der Seite: dieselbe Antwort braucht
|
||||
* die Uebersicht (zum Umleiten), `/kein-zugriff` (fuer seinen Knopf — der auf
|
||||
* `/` zeigte und damit im Kreis) und spaeter jede weitere Rolle mit eigenem
|
||||
* Einstieg.
|
||||
*/
|
||||
export function startseite(rolle: Rolle): string {
|
||||
if (darf(rolle, "lesen")) return "/";
|
||||
if (darf(rolle, "selbstauskunft")) return "/meine-geraete";
|
||||
/*
|
||||
* Kein Recht fuer irgendeinen Einstieg. Gibt es heute nicht — jede Rolle hat
|
||||
* entweder `lesen` oder `selbstauskunft` —, und `/` ist hier die ehrlichere
|
||||
* Antwort als ein Wurf: dort erklaert die Seite, was los ist.
|
||||
*/
|
||||
return "/";
|
||||
return staerkste;
|
||||
}
|
||||
|
||||
// Beschriftung und Rang liegen in src/lib, damit Client-Komponenten sie ohne
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { Benutzer } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { SITZUNG_COOKIE, sitzungLesen, type Sitzung } from "@/lib/sitzung";
|
||||
import { darf, type Recht } from "@/server/auth/rollen";
|
||||
import type { Rolle } from "@/generated/prisma/enums";
|
||||
|
||||
/**
|
||||
* Wer ist angemeldet und was darf er (#007).
|
||||
@@ -56,39 +55,6 @@ export type Angemeldet = {
|
||||
readonly sitzung: Sitzung;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gilt diese Sitzung **jetzt noch**, und darf sie `recht`?
|
||||
*
|
||||
* Fuer langlebige Verbindungen. `angemeldet()` liest das Cookie und laeuft
|
||||
* deshalb nur innerhalb einer Anfrage; ein Ereignisstrom lebt Minuten bis
|
||||
* Stunden **nach** dem Ende der Anfrage weiter, und in seinen Rueckrufen gibt es
|
||||
* keinen `cookies()`-Kontext mehr. Die Sitzung selbst ist zu dem Zeitpunkt
|
||||
* laengst geprueft — nachzusehen ist, was sich seither in der Datenbank
|
||||
* geaendert haben kann: `aktiv`, die Rolle und `sitzungenUngueltigAb`.
|
||||
*
|
||||
* **Warum das noetig ist:** Ohne diese Pruefung bekam eine offen gelassene
|
||||
* Lasche weiter live Daten, nachdem ein Administrator das Konto deaktiviert und
|
||||
* die Sitzungen beendet hatte — bis jemand die Lasche schloss. `docs/architektur.md`
|
||||
* verspricht „ein deaktivierter Benutzer wird auch mit gueltigem Cookie
|
||||
* abgewiesen“, und fuer einen offenen Strom galt das nicht (Reviewbefund zu #029).
|
||||
*
|
||||
* Die Rolle wird **mit**geliefert, nicht nur geprueft: wer waehrend eines Stroms
|
||||
* herabgestuft wird, soll von da an die Sicht seiner neuen Rolle bekommen — das
|
||||
* ist dieselbe Regel wie „Rolle pro Anfrage aus der Datenbank“ (CLAUDE.md).
|
||||
*/
|
||||
export async function sitzungNochGueltig(
|
||||
sitzung: Sitzung,
|
||||
recht: Recht,
|
||||
): Promise<{ readonly rolle: Rolle } | undefined> {
|
||||
const benutzer = await prisma.benutzer.findUnique({
|
||||
where: { id: sitzung.benutzerId },
|
||||
select: { id: true, aktiv: true, rolle: true, sitzungenUngueltigAb: true },
|
||||
});
|
||||
if (!sitzungBewerten(sitzung, benutzer).gueltig || !benutzer) return undefined;
|
||||
if (!darf(benutzer.rolle, recht)) return undefined;
|
||||
return { rolle: benutzer.rolle };
|
||||
}
|
||||
|
||||
/**
|
||||
* Der angemeldete Benutzer oder `undefined`. Leitet nicht weiter – dafuer gibt es
|
||||
* `sitzungErforderlich`.
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
import type { AttributZuordnung } from "@/lib/verzeichnis";
|
||||
import { ersatzName, type RoherEintrag, type Uebersprungen } from "@/server/ldap/zuordnung";
|
||||
import { GraphFehler, graphBasis, tokenHolen, type GraphZugang } from "@/server/graph/zugang";
|
||||
|
||||
/**
|
||||
* Benutzer aus Microsoft Graph lesen (#028).
|
||||
*
|
||||
* Derselbe Vertrag wie `src/server/ldap/leser.ts`: Seite für Seite, jeder Eintrag
|
||||
* einzeln weitergegeben, und der Abgleich schreibt mit. Bei einem Mandanten mit
|
||||
* einigen Tausend Konten ist das der Unterschied zwischen einem Abgleich und
|
||||
* einem Speicherproblem.
|
||||
*
|
||||
* **Was Graph anders macht als LDAP, und was daraus folgt:**
|
||||
*
|
||||
* - Es gibt **keinen DN und keine OU**. `dn` bleibt `null`, und `{{ou}}` damit
|
||||
* leer — statt dass etwas hineingedeutet wird, das kein Befehl brauchen kann.
|
||||
* - Der Vorgesetzte kommt über `$expand` **direkt als Kennung** mit. Die
|
||||
* DN-Auflösung des LDAP-Wegs entfällt; `vorgesetzterDirekt` sagt das dem
|
||||
* Abgleich.
|
||||
* - **`$filter=accountEnabled eq true` ersetzt den LDAP-Standardfilter.** Ein
|
||||
* deaktiviertes Konto wird gar nicht geliefert, und was nicht geliefert wird,
|
||||
* setzt der Abgleich auf `aktiv = false` — dieselbe Mechanik, nur mit anderer
|
||||
* Abfrage. Serverseitig gefiltert und nicht hier beim Zuordnen: „übersprungen“
|
||||
* heisst im Abgleich „nicht zuordenbar“, und solche Eintraege werden
|
||||
* absichtlich **nicht** abgeschaltet.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Wie viele Konten je Seite.
|
||||
*
|
||||
* 999 und nicht 1000: Graph lässt bei `/users` höchstens 999 zu und antwortet
|
||||
* auf 1000 mit einem Fehler. Eine Zahl, die man einmal nachliest und dann
|
||||
* aufschreibt.
|
||||
*/
|
||||
export const SEITENGROESSE = 999;
|
||||
|
||||
/** Wie viele Seiten höchstens, als Reißleine gegen eine Schleife. */
|
||||
const SEITEN_HOECHSTENS = 1000;
|
||||
|
||||
export type GraphLeseBilanz = {
|
||||
readonly gesamtGelesen: number;
|
||||
readonly uebersprungen: readonly Uebersprungen[];
|
||||
/** Wie viele Seiten geholt wurden — der Seitenabruf ist sonst unsichtbar. */
|
||||
readonly seiten: number;
|
||||
};
|
||||
|
||||
type GraphBenutzer = Record<string, unknown> & {
|
||||
manager?: { id?: unknown } | null;
|
||||
};
|
||||
|
||||
type GraphSeite = {
|
||||
value?: unknown;
|
||||
"@odata.nextLink"?: unknown;
|
||||
error?: { message?: unknown; code?: unknown };
|
||||
};
|
||||
|
||||
/** Ein einzelner Wert als Text, oder `null`. */
|
||||
function textWert(nutzer: GraphBenutzer, feld: string): string | null {
|
||||
const wert = nutzer[feld];
|
||||
if (typeof wert === "string") return wert.trim() || null;
|
||||
if (typeof wert === "number" || typeof wert === "boolean") return String(wert);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Welche Felder angefragt werden – genau die zugeordneten, nichts sonst.
|
||||
*
|
||||
* Dieselbe Begründung wie bei LDAP: ohne `$select` liefert Graph ein breites
|
||||
* Standardprofil. Gespeichert wird davon nichts, aber gelesen eben doch — es
|
||||
* stünde im Speicher, in einer Fehlerausgabe, in einem Netzwerkmitschnitt.
|
||||
*
|
||||
* `manager` fliegt dabei heraus: der ist keine Eigenschaft, sondern eine
|
||||
* Beziehung und kommt über `$expand`. Ihn in `$select` zu schreiben beantwortet
|
||||
* Graph mit einem Fehler, und der ist schwer zu lesen.
|
||||
*/
|
||||
export function angefragteFelder(zuordnung: AttributZuordnung): string[] {
|
||||
const felder = new Set<string>();
|
||||
for (const [name, wert] of Object.entries(zuordnung)) {
|
||||
if (name === "vorgesetzter") continue;
|
||||
if (wert) felder.add(wert);
|
||||
}
|
||||
// Immer dabei: ohne `id` gibt es keine Wiedererkennung, ohne `accountEnabled`
|
||||
// keine Unterscheidung zwischen gesperrt und gelöscht.
|
||||
felder.add("id");
|
||||
felder.add("accountEnabled");
|
||||
return [...felder];
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Filter, der deaktivierte Konten gar nicht erst liefert.
|
||||
*
|
||||
* **Das Gegenstück zum LDAP-Standardfilter** (`!(userAccountControl…=2)`), und
|
||||
* aus demselben Grund: was nicht im Ergebnis steht, setzt der Abgleich auf
|
||||
* `aktiv = false`. Ein gesperrtes Konto in Entra ID soll genauso behandelt
|
||||
* werden wie ein gesperrtes im AD.
|
||||
*
|
||||
* **Warum serverseitig und nicht hier beim Zuordnen:** „übersprungen“ bedeutet
|
||||
* im Abgleich „konnte nicht zugeordnet werden“, und solche Einträge werden
|
||||
* absichtlich **nicht** deaktiviert — ein Konto ohne GUID soll nicht die Person
|
||||
* abschalten. Ein deaktiviertes Konto als übersprungen zu melden hätte es
|
||||
* deshalb aktiv gelassen. Gefunden vom Test „setzt aktiv = false“.
|
||||
*/
|
||||
export const GRAPH_FILTER = "accountEnabled eq true";
|
||||
|
||||
/**
|
||||
* Die naechste Seitenadresse — **nur, wenn sie auf denselben Host zeigt**.
|
||||
*
|
||||
* Jede Seite wird mit `Authorization: Bearer <token>` geholt, und das Token gilt
|
||||
* mandantenweit (`User.Read.All`). Der Antwort von Graph ueber TLS zu trauen ist
|
||||
* vertretbar, aber `GRAPH_API_URL` ist absichtlich einstellbar — fuer nationale
|
||||
* Clouds und fuer den Test-Doppelgaenger. Ein Tippfehler darin oder ein
|
||||
* aufbrechender Firmenproxy dazwischen wuerde das Token sonst an eine fremde
|
||||
* Adresse schicken (Reviewbefund zu #029).
|
||||
*
|
||||
* Die Pruefung kostet eine Zeile und schliesst den Fall. Ein `nextLink`, der
|
||||
* woandershin zeigt, beendet den Abruf still: er ist kein Fehler des
|
||||
* Verzeichnisses, und ein Abbruch mitten im Abgleich waere schlimmer als eine
|
||||
* unvollstaendige Seite — der naechste Lauf holt sie.
|
||||
*/
|
||||
export function naechsteSeite(weiter: unknown, basis = graphBasis()): string | undefined {
|
||||
if (typeof weiter !== "string" || !weiter.trim()) return undefined;
|
||||
try {
|
||||
if (new URL(weiter).origin !== new URL(basis).origin) {
|
||||
console.error(`Graph: @odata.nextLink zeigt auf einen anderen Host als GRAPH_API_URL — Abruf beendet.`);
|
||||
return undefined;
|
||||
}
|
||||
} catch {
|
||||
// Keine brauchbare Adresse: dasselbe Ergebnis, nur ohne Erklaerung.
|
||||
return undefined;
|
||||
}
|
||||
return weiter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die erste Adresse der Abfrage. Danach folgt der Abruf `@odata.nextLink`.
|
||||
*
|
||||
* Die Parameter werden **von Hand** zusammengesetzt und nicht mit
|
||||
* `URLSearchParams`: das kodiert `$` zu `%24` und die Klammern in
|
||||
* `manager($select=id)` zu `%28`/`%29`. Graph nimmt das teils an und antwortet
|
||||
* teils mit „Invalid expand clause“ — und eine Adresse, die in einem Mitschnitt
|
||||
* anders aussieht als in der Dokumentation, kostet bei der Fehlersuche eine
|
||||
* Stunde. Die Werte selbst sind feste Feldnamen aus `ATTRIBUT_MUSTER`
|
||||
* (Buchstaben, Ziffern, Bindestrich), also ist hier nichts zu maskieren.
|
||||
*/
|
||||
export function ersteAdresse(zuordnung: AttributZuordnung, seitengroesse = SEITENGROESSE): string {
|
||||
const teile = [
|
||||
`$select=${angefragteFelder(zuordnung).join(",")}`,
|
||||
`$top=${seitengroesse}`,
|
||||
`$filter=${encodeURIComponent(GRAPH_FILTER)}`,
|
||||
"$expand=manager($select=id)",
|
||||
];
|
||||
return `${graphBasis()}/v1.0/users?${teile.join("&")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Graph-Benutzer wird ein `RoherEintrag`, oder er wird übersprungen.
|
||||
*
|
||||
* Übersprungen wird genau ein Fall, wie bei LDAP: ohne stabile Kennung gibt es
|
||||
* nichts, woran ein zweiter Abgleich den Eintrag wiedererkennen könnte.
|
||||
*
|
||||
* Deaktivierte Konten kommen hier **nicht** an — dafür sorgt `GRAPH_FILTER` in
|
||||
* der Abfrage. Kommt doch eines, ist das ein Fehler in der Abfrage, und die
|
||||
* Funktion wirft; die Begründung steht unten an der Stelle.
|
||||
*/
|
||||
export function benutzerZuordnen(
|
||||
nutzer: GraphBenutzer,
|
||||
zuordnung: AttributZuordnung,
|
||||
): { readonly eintrag: RoherEintrag } | { readonly uebersprungen: Uebersprungen } {
|
||||
const externeId = textWert(nutzer, zuordnung.externeId);
|
||||
/*
|
||||
* Als Kennzeichen in der Übersprungen-Liste dient der UPN und nicht ein DN —
|
||||
* den gibt es hier nicht. Die Liste soll sagen, **wer** übersprungen wurde;
|
||||
* dafür ist der Anmeldename die brauchbarste Angabe.
|
||||
*/
|
||||
const kennzeichen = textWert(nutzer, zuordnung.upn) ?? externeId ?? "(ohne Kennung)";
|
||||
|
||||
if (!externeId) {
|
||||
return {
|
||||
uebersprungen: {
|
||||
dn: kennzeichen,
|
||||
grund: `Ohne ${zuordnung.externeId} gibt es keine dauerhafte Kennung.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* **Kein Fall für die Übersprungen-Liste.** Deaktivierte Konten filtert die
|
||||
* Abfrage serverseitig heraus (`GRAPH_FILTER`); kommt hier trotzdem eines an,
|
||||
* ist der Filter nicht angekommen. Dann ist das ein Fehler in der Abfrage und
|
||||
* keine Eigenschaft des Kontos — und ein Eintrag in der Übersprungen-Liste
|
||||
* hätte zur Folge, dass die Person **aktiv** bleibt, weil der Abgleich
|
||||
* übersprungene Einträge absichtlich nicht abschaltet.
|
||||
*/
|
||||
if (nutzer["accountEnabled"] === false) {
|
||||
throw new GraphFehler(
|
||||
`Graph hat ein deaktiviertes Konto geliefert (${kennzeichen}), obwohl der Filter ` +
|
||||
`„${GRAPH_FILTER}“ gesetzt war. Das ist ein Fehler in der Abfrage, nicht am Konto.`,
|
||||
);
|
||||
}
|
||||
|
||||
const vorname = textWert(nutzer, zuordnung.vorname);
|
||||
const nachname = textWert(nutzer, zuordnung.nachname);
|
||||
const vorgesetzterId = typeof nutzer.manager?.id === "string" ? nutzer.manager.id.trim() : "";
|
||||
|
||||
return {
|
||||
eintrag: {
|
||||
externeId,
|
||||
vorname: vorname ?? "",
|
||||
nachname: nachname ?? "",
|
||||
anzeigename: textWert(nutzer, zuordnung.anzeigename) ?? ersatzName(vorname, nachname, kennzeichen),
|
||||
mail: textWert(nutzer, zuordnung.mail),
|
||||
upn: textWert(nutzer, zuordnung.upn),
|
||||
sam: textWert(nutzer, zuordnung.sam),
|
||||
// Kein DN in Entra ID – siehe Kopfkommentar.
|
||||
dn: null,
|
||||
abteilung: textWert(nutzer, zuordnung.abteilung),
|
||||
titel: textWert(nutzer, zuordnung.titel),
|
||||
vorgesetzterDn: null,
|
||||
vorgesetzterDirekt: vorgesetzterId || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Läuft über alle Seiten und gibt jeden Eintrag einzeln weiter.
|
||||
*
|
||||
* `grenze` zählt die **zugeordneten** Einträge, wie bei LDAP: „die ersten zehn
|
||||
* Treffer zeigen“ meint zehn Zeilen in der Tabelle. Ist die Grenze erreicht,
|
||||
* wird keine weitere Seite geholt.
|
||||
*/
|
||||
export async function graphEintraegeStroemen(
|
||||
zugang: GraphZugang,
|
||||
zuordnung: AttributZuordnung,
|
||||
verarbeiten: (eintrag: RoherEintrag) => Promise<void> | void,
|
||||
grenze?: number,
|
||||
seitengroesse = SEITENGROESSE,
|
||||
): Promise<GraphLeseBilanz> {
|
||||
const token = await tokenHolen(zugang);
|
||||
let adresse: string | undefined = ersteAdresse(zuordnung, seitengroesse);
|
||||
let gesamtGelesen = 0;
|
||||
let zugeordnet = 0;
|
||||
let seiten = 0;
|
||||
const uebersprungen: Uebersprungen[] = [];
|
||||
|
||||
while (adresse) {
|
||||
seiten += 1;
|
||||
if (seiten > SEITEN_HOECHSTENS) {
|
||||
/*
|
||||
* Reißleine. Ein `@odata.nextLink`, der auf sich selbst zeigt, wäre sonst
|
||||
* eine Schleife, die bis zum Speicherende läuft — und zwar still, weil
|
||||
* jeder einzelne Abruf gelingt.
|
||||
*/
|
||||
throw new GraphFehler(
|
||||
`Der Seitenabruf hat ${SEITEN_HOECHSTENS} Seiten überschritten. Das ist kein Verzeichnis ` +
|
||||
"dieser Größe, sondern ein Fehler bei der Weiterleitung.",
|
||||
);
|
||||
}
|
||||
|
||||
let antwort: Response;
|
||||
try {
|
||||
antwort = await fetch(adresse, {
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
} catch (fehler) {
|
||||
throw new GraphFehler(
|
||||
`Graph war nicht erreichbar: ${fehler instanceof Error ? fehler.message : "unbekannter Fehler"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const seite = (await antwort.json().catch(() => ({}))) as GraphSeite;
|
||||
|
||||
if (!antwort.ok) {
|
||||
const meldung = typeof seite.error?.message === "string" ? seite.error.message : antwort.statusText;
|
||||
const code = typeof seite.error?.code === "string" ? ` (${seite.error.code})` : "";
|
||||
/*
|
||||
* 403 ist hier der häufigste Fehler und hat genau eine Ursache: die
|
||||
* Administratoreinwilligung für `User.Read.All` fehlt. Das steht in der
|
||||
* Meldung, weil es sonst niemand errät.
|
||||
*/
|
||||
const hinweis =
|
||||
antwort.status === 403
|
||||
? " Fehlt die Administratoreinwilligung für User.Read.All in der App-Registrierung?"
|
||||
: "";
|
||||
throw new GraphFehler(`Graph antwortete mit ${antwort.status}${code}: ${meldung}.${hinweis}`);
|
||||
}
|
||||
|
||||
const nutzer = Array.isArray(seite.value) ? (seite.value as GraphBenutzer[]) : [];
|
||||
for (const einer of nutzer) {
|
||||
gesamtGelesen += 1;
|
||||
const ergebnis = benutzerZuordnen(einer, zuordnung);
|
||||
if ("uebersprungen" in ergebnis) {
|
||||
uebersprungen.push(ergebnis.uebersprungen);
|
||||
continue;
|
||||
}
|
||||
await verarbeiten(ergebnis.eintrag);
|
||||
zugeordnet += 1;
|
||||
if (grenze !== undefined && zugeordnet >= grenze) {
|
||||
return { gesamtGelesen, uebersprungen, seiten };
|
||||
}
|
||||
}
|
||||
|
||||
const weiter = seite["@odata.nextLink"];
|
||||
adresse = naechsteSeite(weiter);
|
||||
}
|
||||
|
||||
return { gesamtGelesen, uebersprungen, seiten };
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
/**
|
||||
* Zugang zu Microsoft Graph (#028).
|
||||
*
|
||||
* Gelesen wird mit dem **Client-Credentials-Flow**: die Anwendung meldet sich als
|
||||
* sie selbst an, nicht im Namen eines Menschen. Das ist für einen Abgleich der
|
||||
* richtige Weg — er läuft nachts, wenn niemand angemeldet ist.
|
||||
*
|
||||
* **Die Endpunkte sind einstellbar**, aus zwei Gründen. Erstens gibt es Graph
|
||||
* nicht nur unter `graph.microsoft.com`: eine Behörde liegt in einer nationalen
|
||||
* Cloud mit eigener Adresse. Zweitens prüft `tests/graph.test.ts` den ganzen
|
||||
* Weg gegen einen HTTP-Doppelgänger — gegen einen echten Mandanten ist dieser
|
||||
* Code **nicht** gelaufen, und das steht so auch im Issue.
|
||||
*/
|
||||
|
||||
import { entschluesseln } from "@/lib/geheimnisse";
|
||||
|
||||
export class GraphFehler extends Error {
|
||||
constructor(meldung: string) {
|
||||
super(meldung);
|
||||
this.name = "GraphFehler";
|
||||
}
|
||||
}
|
||||
|
||||
export type GraphZugang = {
|
||||
readonly tenantId: string;
|
||||
readonly clientId: string;
|
||||
readonly clientSecret: string;
|
||||
};
|
||||
|
||||
/** `https://login.microsoftonline.com` – umstellbar für nationale Clouds und Tests. */
|
||||
export function loginBasis(): string {
|
||||
return (process.env["GRAPH_LOGIN_URL"]?.trim() || "https://login.microsoftonline.com").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/** `https://graph.microsoft.com` – dito. */
|
||||
export function graphBasis(): string {
|
||||
return (process.env["GRAPH_API_URL"]?.trim() || "https://graph.microsoft.com").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Zugang aus einer Quellenzeile.
|
||||
*
|
||||
* Wirft, wenn Felder fehlen: bei `art = GRAPH` hält die Datenbank sie fest
|
||||
* (CHECK `Verzeichnisquelle_felder_je_art`), aber ein Aufruf mit einer
|
||||
* LDAP-Zeile wäre ein Programmierfehler, und der soll nicht als
|
||||
* `undefined`-Zugang weiterlaufen.
|
||||
*/
|
||||
export function graphZugangAus(quelle: {
|
||||
readonly art: string;
|
||||
readonly tenantId: string | null;
|
||||
readonly clientId: string | null;
|
||||
readonly clientSecretVerschluesselt: string | null;
|
||||
}): GraphZugang {
|
||||
if (!quelle.tenantId || !quelle.clientId || !quelle.clientSecretVerschluesselt) {
|
||||
throw new GraphFehler(
|
||||
"Dieser Quelle fehlen die Graph-Zugangsdaten (Mandant, Anwendungs-ID oder Geheimnis).",
|
||||
);
|
||||
}
|
||||
return {
|
||||
tenantId: quelle.tenantId,
|
||||
clientId: quelle.clientId,
|
||||
clientSecret: entschluesseln(quelle.clientSecretVerschluesselt),
|
||||
};
|
||||
}
|
||||
|
||||
type Zwischenspeicher = { readonly token: string; readonly gueltigBis: number };
|
||||
|
||||
/*
|
||||
* Ein Token je Mandant und Anwendung, an `globalThis` wie der Prisma-Client.
|
||||
*
|
||||
* **Warum überhaupt zwischenspeichern:** ein Token gilt eine Stunde, und ein
|
||||
* Abgleich über 5000 Konten holt rund fünf Seiten — ohne Zwischenspeicher wäre
|
||||
* das fünfmal dieselbe Anmeldung. Microsoft begrenzt die Zahl der
|
||||
* Token-Anfragen, und ein Abgleich, der dagegen läuft, scheitert mitten im
|
||||
* Schreiben.
|
||||
*
|
||||
* **Warum an `globalThis`:** Hot Reload lädt das Modul neu, und ein Token je
|
||||
* Speichervorgang wäre dasselbe Problem im Kleinen.
|
||||
*/
|
||||
const global = globalThis as typeof globalThis & {
|
||||
laufzettelGraphToken?: Map<string, Zwischenspeicher>;
|
||||
};
|
||||
|
||||
function speicher(): Map<string, Zwischenspeicher> {
|
||||
global.laufzettelGraphToken ??= new Map();
|
||||
return global.laufzettelGraphToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sicherheitsabstand zum Ablauf.
|
||||
*
|
||||
* Ein Token, das in zehn Sekunden abläuft, ist für eine Seite, die zwei Sekunden
|
||||
* braucht, formal noch gültig — und in der Mitte der nächsten nicht mehr.
|
||||
* Sechzig Sekunden Abstand kosten nichts und ersparen den Fehler, der am
|
||||
* schwersten zu lesen ist: ein Abgleich, der nach der dritten Seite mit 401
|
||||
* abbricht.
|
||||
*/
|
||||
const ABSTAND_MS = 60_000;
|
||||
|
||||
function schluessel(zugang: GraphZugang): string {
|
||||
return `${zugang.tenantId}/${zugang.clientId}`;
|
||||
}
|
||||
|
||||
/** Nur für Tests: den Zwischenspeicher leeren. */
|
||||
export function tokenSpeicherLeeren(): void {
|
||||
speicher().clear();
|
||||
}
|
||||
|
||||
type TokenAntwort = { access_token?: unknown; expires_in?: unknown; error_description?: unknown };
|
||||
|
||||
/**
|
||||
* Ein Zugriffstoken holen – aus dem Zwischenspeicher, sonst neu.
|
||||
*
|
||||
* Der Fehlerfall ist hier wichtiger als der Normalfall: Entra ID antwortet bei
|
||||
* einem falschen Geheimnis mit einer **sehr** ausführlichen Meldung, die den
|
||||
* Mandanten, die Anwendungs-ID und eine Ablaufverfolgungskennung enthält. Die
|
||||
* gehört ins Protokoll des Prozesses, nicht in eine Antwort und nicht an die
|
||||
* Quelle — `letzterAbgleichFehler` ist in der Oberfläche sichtbar.
|
||||
*/
|
||||
export async function tokenHolen(zugang: GraphZugang, jetzt = Date.now()): Promise<string> {
|
||||
const vorhanden = speicher().get(schluessel(zugang));
|
||||
if (vorhanden && vorhanden.gueltigBis - ABSTAND_MS > jetzt) return vorhanden.token;
|
||||
|
||||
const adresse = `${loginBasis()}/${encodeURIComponent(zugang.tenantId)}/oauth2/v2.0/token`;
|
||||
const rumpf = new URLSearchParams({
|
||||
grant_type: "client_credentials",
|
||||
client_id: zugang.clientId,
|
||||
client_secret: zugang.clientSecret,
|
||||
scope: `${graphBasis()}/.default`,
|
||||
});
|
||||
|
||||
let antwort: Response;
|
||||
try {
|
||||
antwort = await fetch(adresse, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: rumpf,
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
} catch (fehler) {
|
||||
throw new GraphFehler(
|
||||
`Der Anmeldedienst war nicht erreichbar: ${fehler instanceof Error ? fehler.message : "unbekannter Fehler"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const daten = (await antwort.json().catch(() => ({}))) as TokenAntwort;
|
||||
|
||||
if (!antwort.ok) {
|
||||
/*
|
||||
* Die ausführliche Meldung ins Protokoll des Prozesses, eine kurze nach
|
||||
* draußen. Dieselbe Trennung wie beim LDAP-Bind-Fehler (#016) und beim
|
||||
* Healthcheck (#039): eine Meldung, die Mandant und Anwendungs-ID nennt,
|
||||
* gehört nicht in eine Oberfläche.
|
||||
*/
|
||||
console.error(
|
||||
"Graph-Anmeldung fehlgeschlagen:",
|
||||
typeof daten.error_description === "string" ? daten.error_description : antwort.statusText,
|
||||
);
|
||||
throw new GraphFehler(
|
||||
`Die Anmeldung am Mandanten ist fehlgeschlagen (${antwort.status}). Prüfe Mandant, ` +
|
||||
"Anwendungs-ID und Geheimnis; Einzelheiten stehen im Protokoll des Dienstes.",
|
||||
);
|
||||
}
|
||||
|
||||
const token = typeof daten.access_token === "string" ? daten.access_token : undefined;
|
||||
if (!token) {
|
||||
throw new GraphFehler("Der Anmeldedienst hat kein Zugriffstoken geliefert.");
|
||||
}
|
||||
|
||||
const sekunden = typeof daten.expires_in === "number" ? daten.expires_in : 3600;
|
||||
speicher().set(schluessel(zugang), { token, gueltigBis: jetzt + sekunden * 1000 });
|
||||
return token;
|
||||
}
|
||||
@@ -45,15 +45,7 @@ export type GemappterEintrag = {
|
||||
mail: string | null;
|
||||
upn: string | null;
|
||||
sam: string | null;
|
||||
/**
|
||||
* Der DN im Verzeichnis — **`null` bei einer Graph-Quelle** (#028).
|
||||
*
|
||||
* Entra ID hat keinen DN und keine OU. Ihn dort zu erfinden hiesse, `{{ou}}`
|
||||
* mit etwas zu fuellen, das kein PowerShell-Befehl brauchen kann. `null` ist
|
||||
* die ehrliche Antwort: `{{ou}}` bleibt dann leer und faellt auf die Ziel-OU
|
||||
* der Abteilung zurueck, wenn eine gepflegt ist.
|
||||
*/
|
||||
dn: string | null;
|
||||
dn: string;
|
||||
abteilung: string | null;
|
||||
titel: string | null;
|
||||
vorgesetzterExterneId: string | null;
|
||||
@@ -70,14 +62,6 @@ export type GemappterEintrag = {
|
||||
*/
|
||||
export type RoherEintrag = Omit<GemappterEintrag, "vorgesetzterExterneId"> & {
|
||||
vorgesetzterDn: string | null;
|
||||
/**
|
||||
* Die Kennung des Vorgesetzten, falls sie schon beim Lesen feststeht (#028).
|
||||
*
|
||||
* Graph liefert sie ueber `$expand=manager($select=id)` direkt mit — dort gibt
|
||||
* es keinen DN, der erst aufgeloest werden muesste. Ist dieses Feld gesetzt,
|
||||
* uebergeht `vorgesetzteAufloesen` die DN-Tabelle.
|
||||
*/
|
||||
vorgesetzterDirekt?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -165,9 +149,6 @@ export function eintragZuordnen(
|
||||
zuordnung: AttributZuordnung,
|
||||
): { eintrag: RoherEintrag } | { uebersprungen: Uebersprungen } {
|
||||
const dn = eintrag.dn.trim();
|
||||
// Bei LDAP steht immer ein DN; die Nullability von `dn` kommt von der
|
||||
// Graph-Quelle (#028) und betrifft diese Funktion nicht.
|
||||
|
||||
let externeId: string | undefined;
|
||||
try {
|
||||
externeId = externeIdLesen(eintrag, zuordnung.externeId);
|
||||
@@ -209,11 +190,11 @@ export function eintragZuordnen(
|
||||
* wird er aus Nachname und Vorname gebaut; bleibt auch das leer, tritt der erste
|
||||
* Teil des DN ein.
|
||||
*/
|
||||
export function ersatzName(vorname: string | null, nachname: string | null, kennzeichen: string): string {
|
||||
function ersatzName(vorname: string | null, nachname: string | null, dn: string): string {
|
||||
const gebaut = [nachname, vorname].filter((teil) => teil).join(", ");
|
||||
if (gebaut) return gebaut;
|
||||
const ersterTeil = kennzeichen.split(",")[0]?.split("=")[1]?.trim();
|
||||
return ersterTeil ?? kennzeichen;
|
||||
const ersterTeil = dn.split(",")[0]?.split("=")[1]?.trim();
|
||||
return ersterTeil ?? dn;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -244,16 +225,10 @@ export function vorgesetzteAufloesen(
|
||||
): GemappterEintrag[] {
|
||||
const nachDn = new Map(zusaetzlicheKennungen ?? []);
|
||||
for (const eintrag of eintraege) {
|
||||
if (eintrag.dn) nachDn.set(dnSchluessel(eintrag.dn), eintrag.externeId);
|
||||
nachDn.set(dnSchluessel(eintrag.dn), eintrag.externeId);
|
||||
}
|
||||
return eintraege.map(({ vorgesetzterDn, vorgesetzterDirekt, ...rest }) => ({
|
||||
return eintraege.map(({ vorgesetzterDn, ...rest }) => ({
|
||||
...rest,
|
||||
/*
|
||||
* **Die direkte Kennung gewinnt** (#028). Eine Graph-Quelle kennt den
|
||||
* Vorgesetzten schon beim Lesen; eine Aufloesung ueber DNs waere dort nicht
|
||||
* nur unnoetig, sondern unmoeglich — es gibt keine.
|
||||
*/
|
||||
vorgesetzterExterneId:
|
||||
vorgesetzterDirekt ?? (vorgesetzterDn ? (nachDn.get(dnSchluessel(vorgesetzterDn)) ?? null) : null),
|
||||
vorgesetzterExterneId: vorgesetzterDn ? (nachDn.get(dnSchluessel(vorgesetzterDn)) ?? null) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
|
||||
|
||||
/**
|
||||
* Lesemodell der Anhänge (#037).
|
||||
*
|
||||
* **Warum das hier liegt und nicht beim Dienst.** `anhaengeDesLaufs` stand in
|
||||
* `src/server/services/anhaenge.ts` und nahm nur eine Lauf-Kennung — ohne Rollen-
|
||||
* und ohne Mandantensicht. Beide Aufrufer prüften den Lauf vorher, es war also
|
||||
* kein Schaden; es stand aber gegen die Regel aus `CLAUDE.md` und
|
||||
* `docs/architektur.md` §2: Lesemodelle liegen in `queries/*` und **wenden ihre
|
||||
* Sicht selbst an, damit kein Aufrufer sie vergessen kann**.
|
||||
*
|
||||
* Zwei Aufrufer in zwei Schichten gab es schon (die Abarbeitungsansicht und die
|
||||
* Druckansicht). Der dritte ist der, der es vergisst — und er fällt nicht auf,
|
||||
* weil die bestehenden korrekt sind. Gefunden bei der Durchsicht von Phase 2.
|
||||
*/
|
||||
|
||||
export type AnhangZeile = {
|
||||
readonly id: string;
|
||||
readonly schrittergebnisId: string;
|
||||
readonly dateiname: string;
|
||||
readonly medientyp: string;
|
||||
readonly groesse: number;
|
||||
readonly erstelltAm: Date;
|
||||
readonly erstelltVon: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Die Anhänge eines Laufs, je Schrittergebnis. **Nur Metadaten** — der Inhalt
|
||||
* geht über `GET /api/anhaenge/[id]`, und der prüft sein Recht selbst.
|
||||
*
|
||||
* Gibt eine leere Liste zurück, wenn der Lauf dem Benutzer nicht sichtbar ist.
|
||||
* Leer und nicht geworfen, weil dieselbe Antwort für „keine Anhänge“ und „nicht
|
||||
* sichtbar“ richtig ist: ob es den Lauf gibt, ist für jemanden, der ihn nicht
|
||||
* sehen darf, keine Auskunft.
|
||||
*/
|
||||
export async function anhaengeDesLaufs(
|
||||
laufId: string,
|
||||
benutzer: HandelnderBenutzer,
|
||||
): Promise<readonly AnhangZeile[]> {
|
||||
/*
|
||||
* `undefined` heißt „alle Kunden“ — im Modus INTERN, für ADMIN und REDAKTEUR,
|
||||
* und ohne eingerichtete Kundengruppen. Die Einschränkung sitzt deshalb in
|
||||
* einem `...(sichtbar ? … : {})` und nicht in einem `in: sichtbar ?? []`: das
|
||||
* zweite ergäbe für jeden Administrator eine leere Liste.
|
||||
*/
|
||||
const sichtbar = await sichtbareKundenIds(benutzer);
|
||||
|
||||
const zeilen = await prisma.anhang.findMany({
|
||||
where: {
|
||||
laufId,
|
||||
...(sichtbar ? { kundeId: { in: [...sichtbar] } } : {}),
|
||||
},
|
||||
orderBy: { erstelltAm: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
schrittergebnisId: true,
|
||||
dateiname: true,
|
||||
medientyp: true,
|
||||
groesse: true,
|
||||
erstelltAm: true,
|
||||
erstelltVon: { select: { name: true, email: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return zeilen.map((zeile) => ({
|
||||
id: zeile.id,
|
||||
schrittergebnisId: zeile.schrittergebnisId,
|
||||
dateiname: zeile.dateiname,
|
||||
medientyp: zeile.medientyp,
|
||||
groesse: zeile.groesse,
|
||||
erstelltAm: zeile.erstelltAm,
|
||||
// Der Name, sonst die Mailadresse: ein Systemlauf hat keinen von beidem, und
|
||||
// `null` heißt dort „von der Anwendung“ (#029 schreibt so, der Zeitplan auch).
|
||||
erstelltVon: zeile.erstelltVon?.name ?? zeile.erstelltVon?.email ?? null,
|
||||
}));
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { nurGeltende } from "@/lib/geltende-abschnitte";
|
||||
import { anhaengeDesLaufs } from "@/server/queries/anhaenge";
|
||||
import { anhaengeDesLaufs } from "@/server/services/anhaenge";
|
||||
import { platzhalterErsetzen } from "@/lib/platzhalter-werte";
|
||||
import { kundenEbeneSichtbar, sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
|
||||
import { kommentareDesLaufs, type KommentarZeile } from "@/server/queries/laeufe";
|
||||
@@ -226,7 +226,7 @@ export async function laufZumDrucken(
|
||||
const alleKommentare = await kommentareDesLaufs(laufId, benutzer);
|
||||
// Nur Metadaten: der Ausdruck verweist auf die Adresse, er traegt die Datei
|
||||
// nicht in sich (#032).
|
||||
const alleAnhaenge = await anhaengeDesLaufs(laufId, benutzer);
|
||||
const alleAnhaenge = await anhaengeDesLaufs(laufId);
|
||||
const ergebnisse = new Map(lauf.ergebnisse.map((ergebnis) => [ergebnis.vorlagenSchrittId, ergebnis]));
|
||||
|
||||
let nummer = 0;
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
import { LaufStatus, type GeraeteTyp } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Was ein Mitarbeiter über sich selbst sehen darf (#035).
|
||||
*
|
||||
* **Die wichtigste Entscheidung dieses Meilensteins steht hier im Code und nicht
|
||||
* in der Seite:** eine Selbstauskunft ist **keine Akteneinsicht**. Zurück kommen
|
||||
* Titel, Status, Zeitraum und das Gerät eines Laufs — und sonst nichts.
|
||||
*
|
||||
* Nicht dabei, und zwar ausdrücklich:
|
||||
*
|
||||
* - **Schritte.** In ihnen stehen Befehle mit Platzhaltern für Zugangsdaten.
|
||||
* - **Notizen.** „Gerät kam beschädigt zurück, Nutzer unkooperativ“ ist eine
|
||||
* Arbeitsnotiz über einen Menschen, nicht eine Auskunft an ihn.
|
||||
* - **Kommentare.** Dasselbe, nur länger.
|
||||
* - **Anhänge.** Ein Foto vom Typenschild ist harmlos, eines vom Schreibtisch
|
||||
* nicht — und welches es ist, weiß hier niemand.
|
||||
*
|
||||
* Der Typ `MeinLauf` trägt deshalb nichts davon, und das `select` unten holt es
|
||||
* gar nicht erst. Wer es ändern wollte, müsste beides anfassen und stünde vor
|
||||
* diesem Kommentar.
|
||||
*
|
||||
* **Jede Abfrage schränkt selbst ein** (CLAUDE.md): die `mitarbeiterId` kommt
|
||||
* als Parameter, und ohne sie gibt es keine Abfrage. Eine Seite, die filtert,
|
||||
* wäre eine Seite, die es vergessen kann.
|
||||
*/
|
||||
|
||||
export type MeinLauf = {
|
||||
readonly id: string;
|
||||
readonly titel: string;
|
||||
readonly status: LaufStatus;
|
||||
readonly vorlagenName: string;
|
||||
readonly begonnenAm: Date | null;
|
||||
readonly abgeschlossenAm: Date | null;
|
||||
readonly erstelltAm: Date;
|
||||
};
|
||||
|
||||
export type MeinGeraet = {
|
||||
readonly id: string;
|
||||
readonly bezeichnung: string;
|
||||
readonly typ: GeraeteTyp;
|
||||
readonly tagCode: string;
|
||||
readonly seriennummer: string | null;
|
||||
readonly archiviert: boolean;
|
||||
readonly laeufe: readonly MeinLauf[];
|
||||
};
|
||||
|
||||
/** Nur die Felder, die eine Selbstauskunft tragen darf. */
|
||||
const LAUF_AUSWAHL = {
|
||||
id: true,
|
||||
titel: true,
|
||||
status: true,
|
||||
gestartetAm: true,
|
||||
abgeschlossenAm: true,
|
||||
erstelltAm: true,
|
||||
vorlagenVersion: { select: { vorlage: { select: { name: true } } } },
|
||||
} as const;
|
||||
|
||||
type RoherLauf = {
|
||||
readonly id: string;
|
||||
readonly titel: string;
|
||||
readonly status: LaufStatus;
|
||||
readonly gestartetAm: Date | null;
|
||||
readonly abgeschlossenAm: Date | null;
|
||||
readonly erstelltAm: Date;
|
||||
readonly vorlagenVersion: { readonly vorlage: { readonly name: string } };
|
||||
};
|
||||
|
||||
function laufBauen(zeile: RoherLauf): MeinLauf {
|
||||
return {
|
||||
id: zeile.id,
|
||||
titel: zeile.titel,
|
||||
status: zeile.status,
|
||||
vorlagenName: zeile.vorlagenVersion.vorlage.name,
|
||||
begonnenAm: zeile.gestartetAm,
|
||||
abgeschlossenAm: zeile.abgeschlossenAm,
|
||||
erstelltAm: zeile.erstelltAm,
|
||||
};
|
||||
}
|
||||
|
||||
/** Der Anzeigename eines Geraets, wie ihn auch die Geraeteliste bildet. */
|
||||
function geraetName(geraet: {
|
||||
readonly hostname: string | null;
|
||||
readonly bezeichnung: string | null;
|
||||
readonly tagCode: string;
|
||||
}): string {
|
||||
return geraet.hostname ?? geraet.bezeichnung ?? geraet.tagCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Meine Geräte, je Gerät die Läufe chronologisch.
|
||||
*
|
||||
* Archivierte Geräte bleiben dabei: „was ist mit meinem alten Notebook
|
||||
* passiert?“ ist genau die Frage, um die es geht, und das alte Notebook ist
|
||||
* archiviert.
|
||||
*/
|
||||
export async function meineGeraete(mitarbeiterId: string): Promise<readonly MeinGeraet[]> {
|
||||
const zeilen = await prisma.geraet.findMany({
|
||||
where: { mitarbeiterId },
|
||||
orderBy: [{ archiviert: "asc" }, { erstelltAm: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
bezeichnung: true,
|
||||
tagCode: true,
|
||||
typ: true,
|
||||
seriennummer: true,
|
||||
archiviert: true,
|
||||
laeufe: { select: LAUF_AUSWAHL, orderBy: { erstelltAm: "desc" } },
|
||||
},
|
||||
});
|
||||
|
||||
return zeilen.map((zeile) => ({
|
||||
id: zeile.id,
|
||||
bezeichnung: geraetName(zeile),
|
||||
typ: zeile.typ,
|
||||
tagCode: zeile.tagCode,
|
||||
seriennummer: zeile.seriennummer,
|
||||
archiviert: zeile.archiviert,
|
||||
laeufe: zeile.laeufe.map(laufBauen),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Läufe, die an **mir** hängen und nicht an einem Gerät.
|
||||
*
|
||||
* Das Onboarding ist der Fall: es läuft auf die Person, nicht auf ein Notebook.
|
||||
* Läufe mit Gerät stehen schon unter dem Gerät — hier noch einmal wären sie
|
||||
* doppelt.
|
||||
*/
|
||||
export async function meineLaeufeOhneGeraet(mitarbeiterId: string): Promise<readonly MeinLauf[]> {
|
||||
const zeilen = await prisma.lauf.findMany({
|
||||
where: { mitarbeiterId, geraetId: null },
|
||||
orderBy: { erstelltAm: "desc" },
|
||||
select: LAUF_AUSWAHL,
|
||||
});
|
||||
return zeilen.map(laufBauen);
|
||||
}
|
||||
|
||||
export type MeineDaten = {
|
||||
readonly anzeigename: string;
|
||||
readonly abteilung: string | null;
|
||||
readonly titel: string | null;
|
||||
readonly mail: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Die eigenen Stammdaten, wie das Verzeichnis sie führt.
|
||||
*
|
||||
* Dabei, weil die häufigste Rückfrage nach einem Abgleich „steht meine Abteilung
|
||||
* richtig drin?“ ist — und weil jemand sehen soll, auf welche Person dieses
|
||||
* Konto zeigt. `undefined`, wenn die Zeile verschwunden ist; die Seite macht
|
||||
* daraus einen Hinweis und keine leere Fläche.
|
||||
*/
|
||||
export async function meineDaten(mitarbeiterId: string): Promise<MeineDaten | undefined> {
|
||||
const zeile = await prisma.mitarbeiter.findUnique({
|
||||
where: { id: mitarbeiterId },
|
||||
select: { anzeigename: true, abteilung: true, titel: true, mail: true },
|
||||
});
|
||||
return zeile ?? undefined;
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
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") };
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,9 @@ import { prisma } from "@/lib/db";
|
||||
import { satzEnde } from "@/lib/satzzeichen";
|
||||
import { darf } from "@/server/auth/rollen";
|
||||
import { betriebsmodus, eigeneOrganisation, type HandelnderBenutzer } from "@/server/mandant";
|
||||
import { eintraegeDerQuelleStroemen } from "@/server/verzeichnis/quellenleser";
|
||||
import { eintraegeStroemen } from "@/server/ldap/leser";
|
||||
import { fehlerMeldung } from "@/server/ldap/verbindung";
|
||||
import { dnSchluessel, type RoherEintrag } from "@/server/ldap/zuordnung";
|
||||
import { dnSchluessel, type RoherEintrag, zuordnungLesen } from "@/server/ldap/zuordnung";
|
||||
import { VerzeichnisFehler, quelleZeileHolen, zugangAus } from "@/server/services/verzeichnis";
|
||||
import { mitSperre } from "@/server/services/sperre";
|
||||
import { abgleichMeldungEinreihen } from "@/server/services/benachrichtigungen";
|
||||
@@ -105,19 +105,11 @@ export async function faelligeQuellen(
|
||||
* `psql` und eine Migration schon.
|
||||
*/
|
||||
const eigene = await eigeneOrganisation();
|
||||
/*
|
||||
* **Eine SCIM-Quelle wird nie abgeglichen** (#029). Bei ihr holt Laufzettel
|
||||
* nicht, sie bekommt geschickt — und der Zeitplan haette jede Minute versucht
|
||||
* zu lesen, waere an der Weiche gescheitert und haette „kann noch nicht
|
||||
* abgeglichen werden“ an die Quelle geschrieben. Dauerhaft ein Fehler, der
|
||||
* keiner ist, und `letzterAbgleichFehler` ist in der Oberflaeche sichtbar.
|
||||
*/
|
||||
return prisma.$queryRaw<{ id: string; name: string }[]>`
|
||||
SELECT "id", "name"
|
||||
FROM "Verzeichnisquelle"
|
||||
WHERE "aktiv" = true
|
||||
AND "kundeId" = ${eigene.id}
|
||||
AND "art" <> 'SCIM'
|
||||
AND ("letzterAbgleichAm" IS NULL
|
||||
OR "letzterAbgleichAm" + make_interval(mins => "abgleichIntervallMinuten") <= ${jetzt})
|
||||
ORDER BY "letzterAbgleichAm" ASC NULLS FIRST, "name" ASC
|
||||
@@ -155,16 +147,7 @@ async function vonHandAngelegteFinden(kundeId: string, eintrag: RoherEintrag): P
|
||||
/** Was wir zwischen den beiden Durchgaengen ueber eine Person behalten muessen. */
|
||||
type OffenerVorgesetzter = {
|
||||
readonly externeId: string;
|
||||
/** Bei LDAP: der DN, der im zweiten Durchgang aufgeloest wird. */
|
||||
readonly vorgesetzterDn: string | null;
|
||||
/**
|
||||
* Bei Graph: die Kennung steht schon fest (#028).
|
||||
*
|
||||
* Beides als zwei Felder und nicht als eines mit zwei Bedeutungen: ein DN und
|
||||
* eine GUID sehen verschieden aus, aber ein Feld, das mal das eine und mal
|
||||
* das andere traegt, verwechselt irgendwann jemand.
|
||||
*/
|
||||
readonly vorgesetzterDirekt: string | null;
|
||||
readonly vorgesetzterDn: string;
|
||||
readonly vorherigeKennung: string | null;
|
||||
readonly neuAngelegt: boolean;
|
||||
};
|
||||
@@ -224,21 +207,21 @@ async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Pr
|
||||
let bindPasswort: string | undefined;
|
||||
|
||||
try {
|
||||
/*
|
||||
* Das Bind-Passwort nur bei einer LDAP-Quelle — es dient unten dem
|
||||
* Filtern der Fehlermeldung. Eine Graph-Quelle hat keines; ihr Geheimnis
|
||||
* erscheint nirgends in einer Meldung, weil `tokenHolen` die ausfuehrliche
|
||||
* Antwort von Entra ID selbst ins Prozessprotokoll schreibt (#028).
|
||||
*/
|
||||
bindPasswort = quelle.art === "LDAP" ? zugangAus(quelle).bindPasswort : undefined;
|
||||
const zugang = zugangAus(quelle);
|
||||
bindPasswort = zugang.bindPasswort;
|
||||
const kennungNachDn = new Map<string, string>();
|
||||
const offene: OffenerVorgesetzter[] = [];
|
||||
|
||||
const bilanz = await eintraegeDerQuelleStroemen(quelle, async (eintrag) => {
|
||||
const bilanz = await eintraegeStroemen(
|
||||
zugang,
|
||||
{
|
||||
basisDn: quelle.basisDn,
|
||||
filter: quelle.filter,
|
||||
zuordnung: zuordnungLesen(quelle.attributZuordnung),
|
||||
},
|
||||
async (eintrag) => {
|
||||
verarbeitet += 1;
|
||||
// Nur LDAP liefert einen DN; bei Graph bleibt die Tabelle leer, und der
|
||||
// Vorgesetzte kommt ueber `vorgesetzterDirekt`.
|
||||
if (eintrag.dn) kennungNachDn.set(dnSchluessel(eintrag.dn), eintrag.externeId);
|
||||
kennungNachDn.set(dnSchluessel(eintrag.dn), eintrag.externeId);
|
||||
let vorhanden = await prisma.mitarbeiter.findFirst({
|
||||
where: { quelleId, externeId: eintrag.externeId },
|
||||
});
|
||||
@@ -290,11 +273,10 @@ async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Pr
|
||||
},
|
||||
});
|
||||
angelegt += 1;
|
||||
if (eintrag.vorgesetzterDn || eintrag.vorgesetzterDirekt) {
|
||||
if (eintrag.vorgesetzterDn) {
|
||||
offene.push({
|
||||
externeId: eintrag.externeId,
|
||||
vorgesetzterDn: eintrag.vorgesetzterDn,
|
||||
vorgesetzterDirekt: eintrag.vorgesetzterDirekt ?? null,
|
||||
vorherigeKennung: null,
|
||||
neuAngelegt: true,
|
||||
});
|
||||
@@ -304,9 +286,7 @@ async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Pr
|
||||
|
||||
// Steht im Verzeichnis kein Vorgesetzter mehr, wird ein alter Wert
|
||||
// geleert. Steht einer, entscheidet der zweite Durchgang.
|
||||
// Kein Vorgesetzter heisst: keiner auf **beiden** Wegen.
|
||||
const vorgesetztenLeeren =
|
||||
eintrag.vorgesetzterDn === null && (eintrag.vorgesetzterDirekt ?? null) === null;
|
||||
const vorgesetztenLeeren = eintrag.vorgesetzterDn === null;
|
||||
if (
|
||||
// Eine Uebernahme zaehlt in ihrer eigenen Spalte und nicht zusaetzlich
|
||||
// als Aktualisierung; sonst gingen die Zahlen nicht mehr auf.
|
||||
@@ -358,13 +338,13 @@ async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Pr
|
||||
if (!vorgesetztenLeeren) {
|
||||
offene.push({
|
||||
externeId: eintrag.externeId,
|
||||
vorgesetzterDn: eintrag.vorgesetzterDn,
|
||||
vorgesetzterDirekt: eintrag.vorgesetzterDirekt ?? null,
|
||||
vorgesetzterDn: eintrag.vorgesetzterDn as string,
|
||||
vorherigeKennung: vorhanden.vorgesetzterExterneId,
|
||||
neuAngelegt: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
gelesen = bilanz.gesamtGelesen;
|
||||
|
||||
// --- Zweiter Durchgang: Vorgesetzte --------------------------------------
|
||||
@@ -374,14 +354,7 @@ async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Pr
|
||||
// jeder Leseweg schoepft (#020). Vorher gab es nur das erste, und eine von
|
||||
// Hand angelegte Person konnte keinen Vorgesetzten haben.
|
||||
for (const offen of offene) {
|
||||
/*
|
||||
* **Die direkte Kennung gewinnt** (#028). Bei Graph steht sie schon fest;
|
||||
* die DN-Tabelle ist dort leer, und ein Nachschlagen darin ergaebe
|
||||
* `null` — also keinen Vorgesetzten, obwohl einer bekannt ist.
|
||||
*/
|
||||
const kennung =
|
||||
offen.vorgesetzterDirekt ??
|
||||
(offen.vorgesetzterDn ? (kennungNachDn.get(dnSchluessel(offen.vorgesetzterDn)) ?? null) : null);
|
||||
const kennung = kennungNachDn.get(dnSchluessel(offen.vorgesetzterDn)) ?? null;
|
||||
if (kennung === offen.vorherigeKennung) continue;
|
||||
const vorgesetzter =
|
||||
kennung === null
|
||||
|
||||
@@ -213,6 +213,42 @@ export async function anhangEntfernen(anhangId: string, benutzer: HandelnderBenu
|
||||
await rm(dateiPfad(anhang), { force: true });
|
||||
}
|
||||
|
||||
/** Die Anhaenge eines Laufs, je Schrittergebnis. Nur Metadaten. */
|
||||
export async function anhaengeDesLaufs(laufId: string): Promise<
|
||||
readonly {
|
||||
readonly id: string;
|
||||
readonly schrittergebnisId: string;
|
||||
readonly dateiname: string;
|
||||
readonly medientyp: string;
|
||||
readonly groesse: number;
|
||||
readonly erstelltAm: Date;
|
||||
readonly erstelltVon: string | null;
|
||||
}[]
|
||||
> {
|
||||
const zeilen = await prisma.anhang.findMany({
|
||||
where: { laufId },
|
||||
orderBy: { erstelltAm: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
schrittergebnisId: true,
|
||||
dateiname: true,
|
||||
medientyp: true,
|
||||
groesse: true,
|
||||
erstelltAm: true,
|
||||
erstelltVon: { select: { name: true, email: true } },
|
||||
},
|
||||
});
|
||||
return zeilen.map((zeile) => ({
|
||||
id: zeile.id,
|
||||
schrittergebnisId: zeile.schrittergebnisId,
|
||||
dateiname: zeile.dateiname,
|
||||
medientyp: zeile.medientyp,
|
||||
groesse: zeile.groesse,
|
||||
erstelltAm: zeile.erstelltAm,
|
||||
erstelltVon: zeile.erstelltVon?.name ?? zeile.erstelltVon?.email ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Nur fuer Tests und Werkzeuge: der Pfad einer Zeile. */
|
||||
export function anhangPfad(anhang: {
|
||||
readonly id: string;
|
||||
|
||||
@@ -39,15 +39,10 @@ export class MeldungsFehler extends Error {
|
||||
/**
|
||||
* Wie lange zwischen zwei Versuchen gewartet wird, in Minuten.
|
||||
*
|
||||
* **Vier Wartezeiten ergeben fuenf Versuche**, nicht vier: gewartet wird erst
|
||||
* *nach* einem Fehlschlag, der fuenfte Versuch findet also keine Wartezeit mehr
|
||||
* vor und wird aufgegeben. Der Kommentar hier behauptete „nach dem vierten“,
|
||||
* waehrend `naechsterVersuch` weiter unten das Richtige sagt — und das ist der
|
||||
* Kommentar, den man zuerst liest (Reviewbefund zu #029).
|
||||
*
|
||||
* Wachsend, weil die haeufigste Ursache ein Mailserver ist, der gerade neu
|
||||
* startet — eine Minute spaeter geht es meist wieder, und nach zwei Stunden ist
|
||||
* es ein Fall fuer einen Menschen.
|
||||
* Die Laenge der Liste ist zugleich die Zahl der Versuche: nach dem vierten
|
||||
* Fehlschlag wird aufgegeben. Wachsend, weil die haeufigste Ursache ein
|
||||
* Mailserver ist, der gerade neu startet — eine Minute spaeter geht es meist
|
||||
* wieder, und nach zwei Stunden ist es ein Fall fuer einen Menschen.
|
||||
*/
|
||||
export const WARTEZEITEN_MINUTEN = [1, 5, 30, 120] as const;
|
||||
|
||||
@@ -337,26 +332,8 @@ function appUrl(): string {
|
||||
return (process.env["APP_URL"]?.trim() || "").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Pfade, auf die eine Meldung zeigen kann — **an einer Stelle**.
|
||||
*
|
||||
* Sie stehen hier als Konstanten, weil ein Pfad in einer Mail nicht nachgesehen
|
||||
* wird: er wird geklickt. `/verwaltung/verzeichnis` stand hier und war ein 404 —
|
||||
* `verwaltung` ist der Name des **Rechts**, die Seite liegt unter `/admin/`
|
||||
* (Reviewbefund zu #029). Aufgefallen ist das niemandem, weil der Test nur
|
||||
* prueft, dass der **uebergebene** Link im Rumpf steht.
|
||||
*
|
||||
* `tests/benachrichtigungen.test.ts` prueft jeden dieser Pfade jetzt gegen die
|
||||
* Routen unter `src/app/` — ein Pfad, den es nicht gibt, faellt beim Testlauf
|
||||
* auf und nicht im Postfach eines Administrators.
|
||||
*/
|
||||
export const MELDUNGS_PFADE = {
|
||||
lauf: "/laeufe",
|
||||
verzeichnis: "/admin/verzeichnis",
|
||||
} as const;
|
||||
|
||||
function laufLink(laufId: string): string {
|
||||
return `${appUrl()}${MELDUNGS_PFADE.lauf}/${laufId}`;
|
||||
return `${appUrl()}/laeufe/${laufId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -447,16 +424,6 @@ async function einreihen(
|
||||
* `skipDuplicates`, weil der partielle eindeutige Index fuer „ueberfaellig“
|
||||
* genau hier zuschlaegt: der Zeitplan sieht jede Minute nach, und ohne das
|
||||
* waere jeder zweite Durchgang ein Fehler statt einer stillen Nichtaenderung.
|
||||
*
|
||||
* **Hier haelt nur die Datenbank, und das ist Absicht.** Eine zweite Pruefung
|
||||
* im Dienst („gibt es die schon?“) waere ein Wettlauf: zwei Instanzen hinter
|
||||
* einem Lastverteiler fragen beide und bekommen beide „nein“. Die Migration
|
||||
* `20261005110000_benachrichtigungen` sagt im Kommentar das Gegenteil — sie
|
||||
* verspricht eine Dienstpruefung „mit besserer Meldung“, die es nicht gibt und
|
||||
* nicht geben soll (Reviewbefund zu #029). Eine angewandte Migration wird
|
||||
* nicht nachtraeglich umgeschrieben, so wie eine veroeffentlichte
|
||||
* `VorlagenVersion` nicht; die Richtigstellung steht deshalb hier, wo sie
|
||||
* gelesen wird, und in `tests/datenbankregeln.test.ts`.
|
||||
*/
|
||||
const ergebnis = await prisma.zustellung.createMany({ data: zeilen, skipDuplicates: true });
|
||||
return ergebnis.count;
|
||||
@@ -531,7 +498,7 @@ export async function abgleichMeldungEinreihen(quelleId: string, grund: string):
|
||||
const webhook = await webhookZugang();
|
||||
if (admins.length === 0 && !webhook) return 0;
|
||||
|
||||
const link = `${appUrl()}${MELDUNGS_PFADE.verzeichnis}`;
|
||||
const link = `${appUrl()}/verwaltung/verzeichnis`;
|
||||
const { betreff, rumpf } = abgleichMeldung(quelle.name, grund, link);
|
||||
return einreihen(
|
||||
Meldeanlass.ABGLEICH_FEHLGESCHLAGEN,
|
||||
|
||||
@@ -566,14 +566,7 @@ export async function laufAnlegen(eingabe: NeuerLauf, benutzer: HandelnderBenutz
|
||||
vorlage: version.vorlagenName,
|
||||
vorlagenVersionId: version.id,
|
||||
status: LaufStatus.OFFEN,
|
||||
/*
|
||||
* Die Schritte **dieses Laufs**, nicht die der Version. Seit #031
|
||||
* fallen abteilungsfremde Abschnitte heraus; `version.schrittIds.length`
|
||||
* behauptete deshalb mehr Schritte, als der Lauf hat (Reviewbefund zu
|
||||
* #029). Das Protokoll ist seit #023 ansehbar — eine Zahl darin, die
|
||||
* nicht zur Liste darunter passt, kostet jemanden eine Suche.
|
||||
*/
|
||||
anzahlSchritte: anzulegen.length,
|
||||
anzahlSchritte: version.schrittIds.length,
|
||||
zugewiesenAnId,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,478 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,28 +1,17 @@
|
||||
import type { Prisma, Verzeichnisquelle } from "@/generated/prisma/client";
|
||||
import { QuellenArt, Betriebsmodus } from "@/generated/prisma/enums";
|
||||
import { Betriebsmodus } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { verschluesseln } from "@/lib/geheimnisse";
|
||||
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
|
||||
import { darf } from "@/server/auth/rollen";
|
||||
import { betriebsmodus, eigeneOrganisation, kundeErmitteln, type HandelnderBenutzer } from "@/server/mandant";
|
||||
import { VerzeichnisFehler } from "@/server/ldap/fehler";
|
||||
// Die Zugangs-Helfer liegen seit #028 in einem eigenen Modul – sonst waere die
|
||||
// Weiche in `quellenleser.ts` ein Import-Zyklus. Hier weitergereicht, damit
|
||||
// bestehende Aufrufer ihre Anlaufstelle behalten.
|
||||
import { ldapVorgabeAus, zugangAus, zuordnungsArt } from "@/server/verzeichnis/zugang";
|
||||
import {
|
||||
ZUORDNUNGS_VORGABE,
|
||||
INTERVALL_HOECHSTENS,
|
||||
INTERVALL_MINDESTENS,
|
||||
intervallMeldung,
|
||||
zuordnungPruefen,
|
||||
} from "@/lib/verzeichnis";
|
||||
import { eintraegeDerQuelleStroemen } from "@/server/verzeichnis/quellenleser";
|
||||
import { urlPruefen } from "@/server/ldap/verbindung";
|
||||
import { INTERVALL_HOECHSTENS, INTERVALL_MINDESTENS, intervallMeldung } from "@/lib/verzeichnis";
|
||||
import { eintraegeLesen } from "@/server/ldap/leser";
|
||||
import { type LdapZugang, urlPruefen } from "@/server/ldap/verbindung";
|
||||
import {
|
||||
AD_ZUORDNUNG,
|
||||
type AttributZuordnung,
|
||||
type GemappterEintrag,
|
||||
type RoherEintrag,
|
||||
STANDARD_FILTER,
|
||||
type Uebersprungen,
|
||||
vorgesetzteAufloesen,
|
||||
@@ -59,28 +48,16 @@ export { AD_ZUORDNUNG, STANDARD_FILTER };
|
||||
* Aufrufer sie von hier kennt.
|
||||
*/
|
||||
export { VerzeichnisFehler };
|
||||
export { ldapVorgabeAus, zugangAus, zuordnungsArt };
|
||||
|
||||
/** Ohne Passwort – diese Form geht an die Oberflaeche. */
|
||||
export type QuellenSicht = {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly aktiv: boolean;
|
||||
/** Woher gelesen wird (#028). Bis dahin war jede Quelle LDAP. */
|
||||
readonly art: QuellenArt;
|
||||
/** Nur bei `art = LDAP` belegt. */
|
||||
readonly url: string | null;
|
||||
readonly bindDn: string | null;
|
||||
readonly basisDn: string | null;
|
||||
readonly filter: string | null;
|
||||
/** Nur bei `art = GRAPH` belegt – das Geheimnis **nie**, nur ob eines da ist. */
|
||||
readonly tenantId: string | null;
|
||||
readonly clientId: string | null;
|
||||
readonly clientSecretVorhanden: boolean;
|
||||
/** Nur bei `art = SCIM`. Das Token **nie**, nur ob eines gilt und seit wann. */
|
||||
readonly scimTokenVorhanden: boolean;
|
||||
readonly scimTokenGesetztAm: Date | null;
|
||||
readonly letzteScimZustellungAm: Date | null;
|
||||
readonly url: string;
|
||||
readonly bindDn: string;
|
||||
readonly basisDn: string;
|
||||
readonly filter: string;
|
||||
readonly attributZuordnung: AttributZuordnung;
|
||||
readonly caZertifikatVorhanden: boolean;
|
||||
readonly abgleichIntervallMinuten: number;
|
||||
@@ -91,18 +68,11 @@ export type QuellenSicht = {
|
||||
|
||||
export type NeueQuelle = {
|
||||
name: string;
|
||||
/** Ohne Angabe `LDAP` – so verhalten sich Aufrufer von vor #028 unveraendert. */
|
||||
art?: QuellenArt;
|
||||
/** Pflicht bei `art = LDAP`, bei Graph nicht erlaubt. */
|
||||
url?: string;
|
||||
bindDn?: string;
|
||||
bindPasswort?: string;
|
||||
basisDn?: string;
|
||||
url: string;
|
||||
bindDn: string;
|
||||
bindPasswort: string;
|
||||
basisDn: string;
|
||||
filter?: string;
|
||||
/** Pflicht bei `art = GRAPH`. */
|
||||
tenantId?: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
attributZuordnung?: Partial<AttributZuordnung>;
|
||||
caZertifikatPem?: string | null;
|
||||
abgleichIntervallMinuten?: number;
|
||||
@@ -180,28 +150,11 @@ function alsSicht(zeile: Verzeichnisquelle): QuellenSicht {
|
||||
id: zeile.id,
|
||||
name: zeile.name,
|
||||
aktiv: zeile.aktiv,
|
||||
art: zeile.art,
|
||||
url: zeile.url,
|
||||
bindDn: zeile.bindDn,
|
||||
basisDn: zeile.basisDn,
|
||||
filter: zeile.filter,
|
||||
tenantId: zeile.tenantId,
|
||||
clientId: zeile.clientId,
|
||||
/*
|
||||
* Wie beim Bind-Passwort: nur **ob** eines hinterlegt ist. Ein Feld, das es
|
||||
* nicht in eine Antwort schafft, kann auch nicht versehentlich hinausgehen.
|
||||
*/
|
||||
clientSecretVorhanden: Boolean(zeile.clientSecretVerschluesselt?.trim()),
|
||||
/*
|
||||
* Dasselbe fuer das SCIM-Token (#029), und hier waere es noch teurer: es ist
|
||||
* der einzige Mandantenbeweis, den ein Push mitbringt. Es steht als Hash in
|
||||
* der Datenbank und geht aus dem Dienst genau einmal heraus — in der
|
||||
* Antwort von `scimTokenErzeugen`, nie in einer Sicht.
|
||||
*/
|
||||
scimTokenVorhanden: Boolean(zeile.scimTokenHash?.trim()),
|
||||
scimTokenGesetztAm: zeile.scimTokenGesetztAm,
|
||||
letzteScimZustellungAm: zeile.letzteScimZustellungAm,
|
||||
attributZuordnung: zuordnungLesen(zeile.attributZuordnung, zuordnungsArt(zeile.art)),
|
||||
attributZuordnung: zuordnungLesen(zeile.attributZuordnung),
|
||||
// Der Inhalt bleibt hier: die Oberflaeche muss nur wissen, ob eines
|
||||
// hinterlegt ist, und braucht dafuer keinen Zertifikatstext.
|
||||
caZertifikatVorhanden: Boolean(zeile.caZertifikatPem?.trim()),
|
||||
@@ -215,89 +168,6 @@ function alsSicht(zeile: Verzeichnisquelle): QuellenSicht {
|
||||
/** Auch fuer die Lesemodelle: die eine Stelle, die aus einer Zeile eine Sicht macht. */
|
||||
export { alsSicht as quellenSichtBauen };
|
||||
|
||||
/**
|
||||
* Die artabhaengigen Felder einer neuen Quelle, geprueft.
|
||||
*
|
||||
* Eine eigene Funktion, weil beide Richtungen gelten muessen: was eine Art
|
||||
* **braucht**, und was sie **nicht haben darf**. Ohne den zweiten Teil liesse
|
||||
* sich eine Graph-Quelle mit `basisDn` anlegen — eine Zeile, die zwei Dinge
|
||||
* gleichzeitig behauptet, und bei der niemand mehr sagen kann, was gilt. Die
|
||||
* Datenbank weist das ohnehin ab; hier entsteht die Meldung, die weiterhilft.
|
||||
*/
|
||||
function feldernJeArt(art: QuellenArt, eingabe: NeueQuelle) {
|
||||
if (art === "LDAP") {
|
||||
const url = pflichtText(eingabe.url, "Die Adresse des Verzeichnisses");
|
||||
urlPruefen(url);
|
||||
const bindPasswort = eingabe.bindPasswort?.trim();
|
||||
if (!bindPasswort) {
|
||||
throw new VerzeichnisFehler("Das Bind-Passwort fehlt. Ein anonymer Bind wird nicht unterstützt.");
|
||||
}
|
||||
if (eingabe.tenantId?.trim() || eingabe.clientId?.trim() || eingabe.clientSecret?.trim()) {
|
||||
throw new VerzeichnisFehler(
|
||||
"Eine LDAP-Quelle hat keine Graph-Zugangsdaten. Für Entra ID gibt es die Art „Graph“.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
// Gespeichert wird die getippte Adresse samt Schema. `ldap+starttls://`
|
||||
// ist Teil der Angabe und wird erst beim Verbinden abgetrennt — sonst
|
||||
// waere nach dem Speichern nicht mehr zu erkennen, dass StartTLS
|
||||
// gewuenscht war.
|
||||
url,
|
||||
bindDn: pflichtText(eingabe.bindDn, "Der Bind-DN"),
|
||||
bindPasswortVerschluesselt: verschluesseln(bindPasswort),
|
||||
basisDn: pflichtText(eingabe.basisDn, "Der Basis-DN"),
|
||||
filter: eingabe.filter?.trim() || STANDARD_FILTER,
|
||||
};
|
||||
}
|
||||
|
||||
if (art === "GRAPH") {
|
||||
const clientSecret = eingabe.clientSecret?.trim();
|
||||
if (!clientSecret) {
|
||||
throw new VerzeichnisFehler("Das Client-Secret der App-Registrierung fehlt.");
|
||||
}
|
||||
if (eingabe.url?.trim() || eingabe.bindDn?.trim() || eingabe.basisDn?.trim()) {
|
||||
throw new VerzeichnisFehler(
|
||||
"Eine Graph-Quelle hat keine Adresse, keinen Bind-DN und keinen Basis-DN — Entra ID kennt " +
|
||||
"beides nicht. Für ein lokales AD gibt es die Art „LDAP“.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
tenantId: pflichtText(eingabe.tenantId, "Die Mandanten-ID"),
|
||||
clientId: pflichtText(eingabe.clientId, "Die Anwendungs-ID"),
|
||||
clientSecretVerschluesselt: verschluesseln(clientSecret),
|
||||
};
|
||||
}
|
||||
|
||||
if (art === "SCIM") {
|
||||
/*
|
||||
* **Eine SCIM-Quelle hat keine Zugangsdaten — sie hat ein Token, und das
|
||||
* entsteht danach.** Erst steht die Quelle, dann erzeugt der Administrator
|
||||
* das Token und traegt es beim Anbieter ein. Eine Quelle ohne Token nimmt
|
||||
* nichts an und antwortet mit 401; das ist der richtige Anfangszustand.
|
||||
*/
|
||||
if (
|
||||
eingabe.url?.trim() ||
|
||||
eingabe.bindDn?.trim() ||
|
||||
eingabe.basisDn?.trim() ||
|
||||
eingabe.tenantId?.trim() ||
|
||||
eingabe.clientId?.trim()
|
||||
) {
|
||||
throw new VerzeichnisFehler(
|
||||
"Eine SCIM-Quelle hat weder LDAP- noch Graph-Zugangsdaten: Laufzettel verbindet sich nicht, " +
|
||||
"sondern wird beliefert. Sie braucht nur einen Namen — das Token kommt danach.",
|
||||
);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/*
|
||||
* Unerreichbar, solange `QuellenArt` drei Werte hat — und genau deshalb hier:
|
||||
* eine vierte Art faellt beim Uebersetzen auf und nicht im Betrieb.
|
||||
*/
|
||||
const unbekannt: never = art;
|
||||
throw new VerzeichnisFehler(`Quellen der Art ${String(unbekannt)} lassen sich nicht anlegen.`);
|
||||
}
|
||||
|
||||
export async function quelleAnlegen(
|
||||
eingabe: NeueQuelle,
|
||||
benutzer: HandelnderBenutzer,
|
||||
@@ -307,40 +177,31 @@ export async function quelleAnlegen(
|
||||
const kundeId = await kundeErmitteln(null, benutzer);
|
||||
|
||||
const name = pflichtText(eingabe.name, "Der Name der Quelle");
|
||||
const art = eingabe.art ?? "LDAP";
|
||||
const url = pflichtText(eingabe.url, "Die Adresse des Verzeichnisses");
|
||||
urlPruefen(url);
|
||||
const bindDn = pflichtText(eingabe.bindDn, "Der Bind-DN");
|
||||
const bindPasswort = eingabe.bindPasswort?.trim();
|
||||
if (!bindPasswort) {
|
||||
throw new VerzeichnisFehler("Das Bind-Passwort fehlt. Ein anonymer Bind wird nicht unterstützt.");
|
||||
}
|
||||
const basisDn = pflichtText(eingabe.basisDn, "Der Basis-DN");
|
||||
const intervall = intervallPruefen(eingabe.abgleichIntervallMinuten);
|
||||
|
||||
/*
|
||||
* **Welche Felder gelten, haengt an der Art** (#028). Geprueft wird hier mit
|
||||
* einer brauchbaren Meldung; dieselbe Regel haelt die Datenbank fest (CHECK
|
||||
* `Verzeichnisquelle_felder_je_art`), damit sie auch gilt, wenn jemand an
|
||||
* dieser Schicht vorbei schreibt.
|
||||
*/
|
||||
const felder = feldernJeArt(art, eingabe);
|
||||
|
||||
/*
|
||||
* **Die Zuordnung wird hier geprueft, nicht nur in der Server Action.**
|
||||
* Aus diesen Namen entsteht eine LDAP-Attributliste und ein `$select` in der
|
||||
* Graph-Adresse; `src/server/graph/leser.ts` berief sich im Kommentar darauf,
|
||||
* dass sie dem Muster entsprechen — waehrend die Pruefung eine Schicht
|
||||
* darueber in `zuordnungAusFormular` stand. Ein Name mit Komma und `$filter`
|
||||
* kam deshalb durch den Dienst und landete in der Abfrage (Reviewbefund zu
|
||||
* #029). Ein Skript, der Seed und ein Import gehen nicht durch das Formular.
|
||||
*/
|
||||
const zuordnungsMangel = zuordnungPruefen(eingabe.attributZuordnung ?? {});
|
||||
if (zuordnungsMangel) throw new VerzeichnisFehler(zuordnungsMangel);
|
||||
|
||||
const zeile = await prisma.$transaction(async (tx) => {
|
||||
const angelegt = await tx.verzeichnisquelle.create({
|
||||
data: {
|
||||
kundeId,
|
||||
name,
|
||||
art,
|
||||
...felder,
|
||||
attributZuordnung: zuordnungLesen(
|
||||
{ ...ZUORDNUNGS_VORGABE[zuordnungsArt(art)], ...eingabe.attributZuordnung },
|
||||
zuordnungsArt(art),
|
||||
),
|
||||
// Gespeichert wird die getippte Adresse samt Schema. `ldap+starttls://`
|
||||
// ist Teil der Angabe und wird erst beim Verbinden abgetrennt — sonst
|
||||
// waere nach dem Speichern nicht mehr zu erkennen, dass StartTLS
|
||||
// gewuenscht war.
|
||||
url,
|
||||
bindDn,
|
||||
bindPasswortVerschluesselt: verschluesseln(bindPasswort),
|
||||
basisDn,
|
||||
filter: eingabe.filter?.trim() || STANDARD_FILTER,
|
||||
attributZuordnung: zuordnungLesen({ ...AD_ZUORDNUNG, ...eingabe.attributZuordnung }),
|
||||
caZertifikatPem: zertifikatPruefen(eingabe.caZertifikatPem),
|
||||
// Ohne Angabe bleibt der Standardwert des Schemas (60 Minuten) stehen.
|
||||
...(intervall === undefined ? {} : { abgleichIntervallMinuten: intervall }),
|
||||
@@ -354,20 +215,8 @@ export async function quelleAnlegen(
|
||||
entitaetId: angelegt.id,
|
||||
aktion: "Verzeichnisquelle angelegt",
|
||||
benutzerId: benutzer.id,
|
||||
/*
|
||||
* Kein Passwort und kein Client-Secret, auch nicht verschluesselt: das
|
||||
* Protokoll wird gelesen (#023). Der Mandant und die Anwendungs-ID sind
|
||||
* dagegen keine Geheimnisse — ohne sie liesse sich hinterher nicht
|
||||
* sagen, welche Registrierung gemeint war.
|
||||
*/
|
||||
nachher: {
|
||||
name: angelegt.name,
|
||||
art: angelegt.art,
|
||||
...(angelegt.url ? { url: angelegt.url } : {}),
|
||||
...(angelegt.basisDn ? { basisDn: angelegt.basisDn } : {}),
|
||||
...(angelegt.tenantId ? { tenantId: angelegt.tenantId } : {}),
|
||||
...(angelegt.clientId ? { clientId: angelegt.clientId } : {}),
|
||||
},
|
||||
// Kein Passwort, auch nicht verschluesselt: das Protokoll wird gelesen.
|
||||
nachher: { name: angelegt.name, url: angelegt.url, basisDn: angelegt.basisDn },
|
||||
},
|
||||
});
|
||||
return angelegt;
|
||||
@@ -387,35 +236,6 @@ export async function quelleBearbeiten(
|
||||
const daten: Prisma.VerzeichnisquelleUpdateInput = {};
|
||||
const geaendert: string[] = [];
|
||||
|
||||
/*
|
||||
* **Die Art laesst sich nicht aendern** (#028).
|
||||
*
|
||||
* Nicht aus Bequemlichkeit: an der Art haengt, was `externeId` bedeutet. Im
|
||||
* AD ist es eine `objectGUID`, in Entra ID eine andere GUID — nach einem
|
||||
* Wechsel wuerde kein einziger Mitarbeiter wiedererkannt. Der naechste
|
||||
* Abgleich legte die ganze Belegschaft neu an und setzte die alten Zeilen auf
|
||||
* `aktiv = false`: Laeufe und Geraete haengen dann an den inaktiven.
|
||||
*
|
||||
* Wer wechselt, legt eine neue Quelle an und laesst die alte stehen, bis die
|
||||
* Leute uebernommen sind — dafuer gibt es die Uebernahme von Hand angelegter
|
||||
* Personen aus #016.
|
||||
*/
|
||||
if (aenderung.art !== undefined && aenderung.art !== vorher.art) {
|
||||
throw new VerzeichnisFehler(
|
||||
`Die Art einer Quelle lässt sich nicht ändern (${vorher.art} → ${aenderung.art}). ` +
|
||||
"Daran hängt, was die dauerhafte Kennung bedeutet — nach einem Wechsel würde kein " +
|
||||
"Mitarbeiter wiedererkannt. Leg stattdessen eine neue Quelle an.",
|
||||
);
|
||||
}
|
||||
|
||||
const nurBei = (art: QuellenArt, feld: string): void => {
|
||||
if (vorher.art !== art) {
|
||||
throw new VerzeichnisFehler(
|
||||
`„${feld}“ gilt nur für eine Quelle der Art ${art}; „${vorher.name}“ ist ${vorher.art}.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (aenderung.name !== undefined) {
|
||||
const name = pflichtText(aenderung.name, "Der Name der Quelle");
|
||||
if (name !== vorher.name) {
|
||||
@@ -423,40 +243,7 @@ export async function quelleBearbeiten(
|
||||
geaendert.push("Name");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Graph-Felder ---------------------------------------------------------
|
||||
if (aenderung.tenantId !== undefined) {
|
||||
nurBei("GRAPH", "Mandanten-ID");
|
||||
const wert = pflichtText(aenderung.tenantId, "Die Mandanten-ID");
|
||||
if (wert !== vorher.tenantId) {
|
||||
daten.tenantId = wert;
|
||||
geaendert.push("Mandant");
|
||||
}
|
||||
}
|
||||
if (aenderung.clientId !== undefined) {
|
||||
nurBei("GRAPH", "Anwendungs-ID");
|
||||
const wert = pflichtText(aenderung.clientId, "Die Anwendungs-ID");
|
||||
if (wert !== vorher.clientId) {
|
||||
daten.clientId = wert;
|
||||
geaendert.push("Anwendung");
|
||||
}
|
||||
}
|
||||
if (aenderung.clientSecret !== undefined) {
|
||||
nurBei("GRAPH", "Client-Secret");
|
||||
/*
|
||||
* Leer heisst **unveraendert**, wie beim Bind-Passwort: das Formular zeigt
|
||||
* ein gesetztes Geheimnis nie an, also kommt es leer zurueck. Wuerde leer
|
||||
* als „loeschen“ gelesen, machte jedes Umbenennen die Quelle unbrauchbar.
|
||||
*/
|
||||
const wert = aenderung.clientSecret.trim();
|
||||
if (wert) {
|
||||
daten.clientSecretVerschluesselt = verschluesseln(wert);
|
||||
geaendert.push("Client-Secret");
|
||||
}
|
||||
}
|
||||
|
||||
if (aenderung.url !== undefined) {
|
||||
nurBei("LDAP", "Adresse");
|
||||
const url = pflichtText(aenderung.url, "Die Adresse des Verzeichnisses");
|
||||
urlPruefen(url);
|
||||
if (url !== vorher.url) {
|
||||
@@ -465,7 +252,6 @@ export async function quelleBearbeiten(
|
||||
}
|
||||
}
|
||||
if (aenderung.bindDn !== undefined) {
|
||||
nurBei("LDAP", "Bind-DN");
|
||||
const bindDn = pflichtText(aenderung.bindDn, "Der Bind-DN");
|
||||
if (bindDn !== vorher.bindDn) {
|
||||
daten.bindDn = bindDn;
|
||||
@@ -473,7 +259,6 @@ export async function quelleBearbeiten(
|
||||
}
|
||||
}
|
||||
if (aenderung.bindPasswort !== undefined) {
|
||||
nurBei("LDAP", "Bind-Passwort");
|
||||
// Ein leeres Feld heisst „unveraendert“. Ein Formular, das das Passwort nicht
|
||||
// anzeigen kann (und das darf es nicht), schickt sonst bei jedem Speichern
|
||||
// eine leere Zeichenkette und loeschte das Geheimnis.
|
||||
@@ -484,7 +269,6 @@ export async function quelleBearbeiten(
|
||||
}
|
||||
}
|
||||
if (aenderung.basisDn !== undefined) {
|
||||
nurBei("LDAP", "Basis-DN");
|
||||
const basisDn = pflichtText(aenderung.basisDn, "Der Basis-DN");
|
||||
if (basisDn !== vorher.basisDn) {
|
||||
daten.basisDn = basisDn;
|
||||
@@ -492,7 +276,6 @@ export async function quelleBearbeiten(
|
||||
}
|
||||
}
|
||||
if (aenderung.filter !== undefined) {
|
||||
nurBei("LDAP", "Filter");
|
||||
const filter = aenderung.filter.trim() || STANDARD_FILTER;
|
||||
if (filter !== vorher.filter) {
|
||||
daten.filter = filter;
|
||||
@@ -500,8 +283,6 @@ export async function quelleBearbeiten(
|
||||
}
|
||||
}
|
||||
if (aenderung.attributZuordnung !== undefined) {
|
||||
const mangel = zuordnungPruefen(aenderung.attributZuordnung);
|
||||
if (mangel) throw new VerzeichnisFehler(mangel);
|
||||
const zuordnung = zuordnungLesen({
|
||||
...zuordnungLesen(vorher.attributZuordnung),
|
||||
...aenderung.attributZuordnung,
|
||||
@@ -617,32 +398,15 @@ export async function verbindungTesten(
|
||||
await nurIntern();
|
||||
rechtPruefen(benutzer);
|
||||
const quelle = await quelleZeileHolen(quelleId);
|
||||
|
||||
/*
|
||||
* **Bei SCIM gibt es nichts zu testen, und das ist keine Lücke** (#029):
|
||||
* Laufzettel baut keine Verbindung auf, sondern nimmt eine an. Ob sie steht,
|
||||
* zeigt die erste Zustellung — deshalb steht in der Oberfläche „Letzte
|
||||
* Zustellung“ dort, wo bei LDAP der Verbindungstest sitzt.
|
||||
*/
|
||||
if (quelle.art === "SCIM") {
|
||||
throw new VerzeichnisFehler(
|
||||
`„${quelle.name}“ ist eine SCIM-Quelle: es gibt keine Verbindung, die sich von hier aus ` +
|
||||
"testen ließe. Trag das Token beim Anbieter ein und lass ihn eine Zuweisung schicken — " +
|
||||
"danach steht hier, wann die letzte angekommen ist.",
|
||||
);
|
||||
}
|
||||
|
||||
const wieViele = Math.min(Math.max(Math.trunc(grenze), 1), PROBE_GRENZE_HOECHSTENS);
|
||||
|
||||
/*
|
||||
* **Dieselbe Weiche wie der Abgleich** (#028). Eine eigene Verzweigung hier
|
||||
* waere eine zweite Stelle, die sich an eine neue Quellenart erinnern muss —
|
||||
* und die vergisst man.
|
||||
*/
|
||||
const eintraege: RoherEintrag[] = [];
|
||||
const { gesamtGelesen, uebersprungen } = await eintraegeDerQuelleStroemen(
|
||||
quelle,
|
||||
(eintrag) => void eintraege.push(eintrag),
|
||||
const { eintraege, gesamtGelesen, uebersprungen } = await eintraegeLesen(
|
||||
zugangAus(quelle),
|
||||
{
|
||||
basisDn: quelle.basisDn,
|
||||
filter: quelle.filter,
|
||||
zuordnung: zuordnungLesen(quelle.attributZuordnung),
|
||||
},
|
||||
wieViele,
|
||||
);
|
||||
return { treffer: vorgesetzteAufloesen(eintraege), gesamtGelesen, uebersprungen };
|
||||
@@ -669,3 +433,13 @@ export async function quelleZeileHolen(id: string): Promise<Verzeichnisquelle> {
|
||||
}
|
||||
return zeile;
|
||||
}
|
||||
|
||||
/** Entschluesselt das Bind-Passwort. Das Ergebnis bleibt in der Aufrufkette. */
|
||||
export function zugangAus(quelle: Verzeichnisquelle): LdapZugang {
|
||||
return {
|
||||
url: quelle.url,
|
||||
bindDn: quelle.bindDn,
|
||||
bindPasswort: entschluesseln(quelle.bindPasswortVerschluesselt),
|
||||
caZertifikatPem: quelle.caZertifikatPem,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,22 +42,7 @@ export async function vorlageExportieren(versionId: string): Promise<Austausch>
|
||||
variablen: { orderBy: [{ position: "asc" }, { id: "asc" }] },
|
||||
abschnitte: {
|
||||
orderBy: [{ position: "asc" }, { id: "asc" }],
|
||||
include: {
|
||||
schritte: { orderBy: [{ position: "asc" }, { id: "asc" }] },
|
||||
/*
|
||||
* Der Baustein mit. Ein Baustein-Abschnitt im **Entwurf** hat keine
|
||||
* eigenen Schritte — die entstehen erst beim Veroeffentlichen. Ohne
|
||||
* diese Zeilen exportierte ein Entwurf einen leeren Abschnitt unter
|
||||
* dem Platzhaltertitel „wird ersetzt“ (Reviewbefund zu #029).
|
||||
*/
|
||||
bausteinVersion: {
|
||||
select: {
|
||||
titel: true,
|
||||
baustein: { select: { name: true } },
|
||||
schritte: { orderBy: [{ position: "asc" }, { id: "asc" }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { schritte: { orderBy: [{ position: "asc" }, { id: "asc" }] } },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -83,37 +68,13 @@ export async function vorlageExportieren(versionId: string): Promise<Austausch>
|
||||
optionen: [...variable.optionen],
|
||||
pflicht: variable.pflicht,
|
||||
})),
|
||||
abschnitte: version.abschnitte.map((abschnitt) => {
|
||||
/*
|
||||
* **Ein Baustein-Abschnitt wird aufgeloest, nicht abgeschrieben.**
|
||||
*
|
||||
* Der Verweis selbst wandert bewusst nicht mit (#030): in der anderen
|
||||
* Installation gibt es den Baustein vielleicht nicht, und eine Vorlage,
|
||||
* die auf etwas Fehlendes zeigt, ist kein Austauschformat. Die **Inhalte**
|
||||
* muessen aber mitwandern — und bei einem Entwurf stehen sie noch nicht
|
||||
* am Abschnitt, sondern nur am Baustein.
|
||||
*
|
||||
* Vorher exportierte ein Entwurf deshalb `{"titel":"wird ersetzt",
|
||||
* "schritte":[]}`: ein Redakteur brachte eine unveroeffentlichte Vorlage
|
||||
* in eine zweite Installation, und der geteilte Abschnitt kam dort als
|
||||
* leerer Abschnitt mit dem Editor-Platzhalter als Namen an. Die Checkliste
|
||||
* war unvollstaendig, und nichts sagte es (Reviewbefund zu #029).
|
||||
*
|
||||
* Bei einer **veroeffentlichten** Version aendert das nichts: dort sind
|
||||
* die Schritte beim Veroeffentlichen schon in den Abschnitt geschrieben
|
||||
* worden, und der Titel kommt ohnehin aus dem Baustein.
|
||||
*/
|
||||
const quelle = abschnitt.bausteinVersion;
|
||||
const ausBaustein = quelle !== null && abschnitt.schritte.length === 0;
|
||||
|
||||
return {
|
||||
titel: ausBaustein ? quelle.titel : abschnitt.titel,
|
||||
abschnitte: version.abschnitte.map((abschnitt) => ({
|
||||
titel: abschnitt.titel,
|
||||
// Wandert mit: ein Abteilungsname bedeutet in einer anderen Installation
|
||||
// dasselbe.
|
||||
// dasselbe. Der Baustein-Verweis wandert bewusst **nicht** mit (#030).
|
||||
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
|
||||
schritte: (ausBaustein ? quelle.schritte : abschnitt.schritte).map(schrittAbbilden),
|
||||
};
|
||||
}),
|
||||
schritte: abschnitt.schritte.map(schrittAbbilden),
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -129,15 +90,7 @@ export async function vorlageExportieren(versionId: string): Promise<Austausch>
|
||||
* nachsichtige Schema soll. Exportiert wird deshalb der rohe Inhalt; nur was gar
|
||||
* kein Objekt ist, gilt als leer.
|
||||
*/
|
||||
function schrittAbbilden(
|
||||
/*
|
||||
* Nur die gelesenen Felder statt `VorlagenSchritt`: dieselbe Funktion bildet
|
||||
* jetzt auch einen `BausteinSchritt` ab (Baustein-Abschnitte werden beim
|
||||
* Export aufgeloest). Die beiden Modelle unterscheiden sich nur darin, woran
|
||||
* ein Schritt haengt — und das steht ohnehin in keinem Export.
|
||||
*/
|
||||
schritt: Pick<VorlagenSchritt, "titel" | "hilfetext" | "typ" | "pflicht" | "konfig" | "dokuUrl">,
|
||||
): AustauschSchritt {
|
||||
function schrittAbbilden(schritt: VorlagenSchritt): AustauschSchritt {
|
||||
const roh = schritt.konfig;
|
||||
const konfig =
|
||||
typeof roh === "object" && roh !== null && !Array.isArray(roh) ? (roh as AustauschSchritt["konfig"]) : {};
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import type { Verzeichnisquelle } from "@/generated/prisma/client";
|
||||
import { zuordnungLesen } from "@/lib/verzeichnis";
|
||||
import { graphEintraegeStroemen } from "@/server/graph/leser";
|
||||
import { graphZugangAus } from "@/server/graph/zugang";
|
||||
import { eintraegeStroemen } from "@/server/ldap/leser";
|
||||
import type { RoherEintrag, Uebersprungen } from "@/server/ldap/zuordnung";
|
||||
import { VerzeichnisFehler } from "@/server/ldap/fehler";
|
||||
import { ldapVorgabeAus, zugangAus } from "@/server/verzeichnis/zugang";
|
||||
|
||||
/**
|
||||
* Einträge lesen, egal woher (#028).
|
||||
*
|
||||
* **Das ist die ganze Weiche.** Der Abgleich in `services/abgleich.ts` ruft nur
|
||||
* noch diese Funktion; was er danach tut — anlegen, übernehmen, aktualisieren,
|
||||
* deaktivieren, **nie löschen** — ist für LDAP und Graph dasselbe.
|
||||
*
|
||||
* Genau so steht es im Issue, und es ist die wichtigste Entscheidung dieses
|
||||
* Meilensteins: zwei Abgleiche nebeneinander würden auseinanderdriften. Der eine
|
||||
* bekäme einen Reviewbefund nachgetragen, der andere nicht — und die Zusage
|
||||
* „es wird nichts gelöscht“ gälte dann für eine Art und für die andere nicht
|
||||
* mehr ganz.
|
||||
*/
|
||||
|
||||
export type QuellenBilanz = {
|
||||
readonly gesamtGelesen: number;
|
||||
readonly uebersprungen: readonly Uebersprungen[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Läuft über die Einträge einer Quelle und gibt jeden einzeln weiter.
|
||||
*
|
||||
* `grenze` zählt die zugeordneten Einträge — „die ersten zehn Treffer zeigen“.
|
||||
*/
|
||||
export async function eintraegeDerQuelleStroemen(
|
||||
quelle: Verzeichnisquelle,
|
||||
verarbeiten: (eintrag: RoherEintrag) => Promise<void> | void,
|
||||
grenze?: number,
|
||||
): Promise<QuellenBilanz> {
|
||||
if (quelle.art === "LDAP") {
|
||||
return eintraegeStroemen(zugangAus(quelle), ldapVorgabeAus(quelle), verarbeiten, grenze);
|
||||
}
|
||||
|
||||
if (quelle.art === "GRAPH") {
|
||||
const bilanz = await graphEintraegeStroemen(
|
||||
graphZugangAus(quelle),
|
||||
zuordnungLesen(quelle.attributZuordnung, "GRAPH"),
|
||||
verarbeiten,
|
||||
grenze,
|
||||
);
|
||||
// `seiten` interessiert nur den Graph-Test; der Abgleich rechnet mit zwei Zahlen.
|
||||
return { gesamtGelesen: bilanz.gesamtGelesen, uebersprungen: bilanz.uebersprungen };
|
||||
}
|
||||
|
||||
/*
|
||||
* **Eine SCIM-Quelle wird nicht gelesen, sie liefert** (#029) — es gibt hier
|
||||
* nichts abzuholen, und das bleibt so. Trotzdem eine Meldung und kein stilles
|
||||
* Nichts: ein Abgleich, der null Einträge liest und `ERFOLG` meldet, würde
|
||||
* über die Abschaltregel in `abgleichAusfuehren` die ganze Belegschaft
|
||||
* deaktivieren. Genau dieser Fall hat in #016 einen Reviewbefund gekostet.
|
||||
*
|
||||
* Der Zeitplan kommt hier nicht an: `faelligeQuellen` nimmt SCIM-Quellen
|
||||
* nicht auf. Diese Stelle fängt den Weg über „Jetzt abgleichen“.
|
||||
*/
|
||||
throw new VerzeichnisFehler(
|
||||
`„${quelle.name}“ ist eine SCIM-Quelle: Laufzettel holt dort nichts ab, sondern wird ` +
|
||||
"beliefert. Ein Abgleich gibt es für sie nicht — der Anbieter schickt Änderungen, sobald " +
|
||||
"sie dort entstehen.",
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import type { Verzeichnisquelle } from "@/generated/prisma/client";
|
||||
import type { QuellenArt } from "@/generated/prisma/enums";
|
||||
import { entschluesseln } from "@/lib/geheimnisse";
|
||||
import { type AttributZuordnung, zuordnungLesen } from "@/lib/verzeichnis";
|
||||
import { VerzeichnisFehler } from "@/server/ldap/fehler";
|
||||
import type { LdapZugang } from "@/server/ldap/verbindung";
|
||||
|
||||
/**
|
||||
* Aus einer Quellenzeile wird ein Zugang (#016, je Art seit #028).
|
||||
*
|
||||
* **Warum ein eigenes Modul und nicht weiter in `services/verzeichnis.ts`:** die
|
||||
* Weiche in `quellenleser.ts` braucht diese Funktionen, und
|
||||
* `services/verzeichnis.ts` braucht die Weiche fuer „Verbindung testen“. Beides
|
||||
* in einer Datei waere ein Import-Zyklus — der laeuft in ESM oft, aber nicht
|
||||
* zuverlaessig, und ein Modul, das beim Laden halb leer ist, ergibt Fehler, die
|
||||
* niemand liest.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Entschluesselt das Bind-Passwort. Das Ergebnis bleibt in der Aufrufkette.
|
||||
*
|
||||
* Wirft, wenn die LDAP-Felder fehlen. Seit #028 sind sie nur bei `art = LDAP`
|
||||
* belegt — die Datenbank haelt das fest (CHECK
|
||||
* `Verzeichnisquelle_felder_je_art`), aber ein Aufruf mit einer Graph-Zeile waere
|
||||
* ein Programmierfehler, und der soll nicht als Verbindung zu `null` weiterlaufen.
|
||||
*/
|
||||
export function zugangAus(quelle: Verzeichnisquelle): LdapZugang {
|
||||
if (!quelle.url || !quelle.bindDn || !quelle.bindPasswortVerschluesselt) {
|
||||
throw new VerzeichnisFehler(
|
||||
`„${quelle.name}“ ist keine LDAP-Quelle oder hat keine vollständigen Zugangsdaten.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
url: quelle.url,
|
||||
bindDn: quelle.bindDn,
|
||||
bindPasswort: entschluesseln(quelle.bindPasswortVerschluesselt),
|
||||
caZertifikatPem: quelle.caZertifikatPem,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Die LDAP-Lesevorgabe einer Quelle.
|
||||
*
|
||||
* Dieselbe Pruefung und derselbe Grund wie bei `zugangAus`: `basisDn` und
|
||||
* `filter` sind seit #028 nullable.
|
||||
*/
|
||||
export function ldapVorgabeAus(quelle: Verzeichnisquelle): {
|
||||
readonly basisDn: string;
|
||||
readonly filter: string;
|
||||
readonly zuordnung: AttributZuordnung;
|
||||
} {
|
||||
if (!quelle.basisDn || !quelle.filter) {
|
||||
throw new VerzeichnisFehler(
|
||||
`„${quelle.name}“ fehlt der Basis-DN oder der Filter — beides braucht eine LDAP-Quelle.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
basisDn: quelle.basisDn,
|
||||
filter: quelle.filter,
|
||||
zuordnung: zuordnungLesen(quelle.attributZuordnung, "LDAP"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Welche Zuordnungs-Vorgaben fuer diese Art gelten.
|
||||
*
|
||||
* `SCIM` hat keine und faellt auf die LDAP-Namen zurueck (#029): bei einem Push
|
||||
* bestimmt der Anbieter die Feldnamen, sie stehen in RFC 7643 und sind nicht
|
||||
* einstellbar. Ein Wurf an dieser Stelle machte jede Liste unbenutzbar, in der
|
||||
* eine SCIM-Zeile steht.
|
||||
*/
|
||||
export function zuordnungsArt(art: QuellenArt): "LDAP" | "GRAPH" {
|
||||
return art === "GRAPH" ? "GRAPH" : "LDAP";
|
||||
}
|
||||
@@ -170,28 +170,6 @@ describe.runIf(datenbankVerfuegbar)("Abteilungsabschnitte am Lauf", () => {
|
||||
expect(ergebnisse.map((e) => e.vorlagenSchritt.titel).sort()).toEqual(["Papiere", "Übergabe"]);
|
||||
});
|
||||
|
||||
it("schreibt im Protokoll die Schritte dieses Laufs, nicht die der Version", async () => {
|
||||
/*
|
||||
* **Die Zahl war die der Version** (Reviewbefund zu #029). Bei einer Vorlage
|
||||
* mit abteilungsgebundenen Abschnitten behauptete der Eintrag „Lauf angelegt“
|
||||
* mehr Schritte, als der Lauf hat.
|
||||
*
|
||||
* Das Protokoll ist seit #023 ansehbar: eine Zahl darin, die nicht zur Liste
|
||||
* darunter passt, schickt jemanden auf die Suche nach Schritten, die es nie
|
||||
* gab.
|
||||
*/
|
||||
const { admin, vertrieb, versionId } = await aufbau(VORLAGE);
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, abteilungId: vertrieb.id }, admin);
|
||||
|
||||
const wirklich = await testPrisma.schrittergebnis.count({ where: { laufId: lauf.id } });
|
||||
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
|
||||
where: { laufId: lauf.id, aktion: "Lauf angelegt" },
|
||||
});
|
||||
|
||||
expect(wirklich).toBe(2);
|
||||
expect(eintrag.nachher).toMatchObject({ anzahlSchritte: wirklich });
|
||||
});
|
||||
|
||||
it("ändert die Abschnitte nicht, wenn später die Abteilung des Laufs wechselt", async () => {
|
||||
/*
|
||||
* Der Grund für das Festschreiben: ein archivierter Lauf muss genau die
|
||||
|
||||
+2
-50
@@ -12,12 +12,12 @@ import {
|
||||
} from "@/lib/anhaenge";
|
||||
import {
|
||||
AnhangFehler,
|
||||
anhaengeDesLaufs,
|
||||
anhangEntfernen,
|
||||
anhangLesen,
|
||||
anhangPfad,
|
||||
anhangSpeichern,
|
||||
} from "@/server/services/anhaenge";
|
||||
import { anhaengeDesLaufs } from "@/server/queries/anhaenge";
|
||||
import { MandantFehler } from "@/server/mandant";
|
||||
import { LaufFehler } from "@/server/services/laeufe";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
@@ -393,7 +393,7 @@ describe.runIf(datenbankVerfuegbar)("Anhänge", () => {
|
||||
admin,
|
||||
);
|
||||
|
||||
const liste = await anhaengeDesLaufs(laufId, admin);
|
||||
const liste = await anhaengeDesLaufs(laufId);
|
||||
expect(liste).toHaveLength(1);
|
||||
expect(liste[0]).toMatchObject({
|
||||
schrittergebnisId: ergebnisId,
|
||||
@@ -402,54 +402,6 @@ describe.runIf(datenbankVerfuegbar)("Anhänge", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("engt die Liste selbst auf die sichtbaren Kunden ein – ohne Vorprüfung des Aufrufers", async () => {
|
||||
/*
|
||||
* **Die Zusage von #037.** Vorher nahm `anhaengeDesLaufs` nur eine
|
||||
* Lauf-Kennung und wendete keine Sicht an; dass niemand fremde Anhänge sah,
|
||||
* hing daran, dass beide Aufrufer den Lauf vorher prüften. Das war kein
|
||||
* Schaden und trotzdem die falsche Form: ein Lesemodell wendet seine Sicht
|
||||
* selbst an, **damit kein Aufrufer sie vergessen kann** (CLAUDE.md).
|
||||
*
|
||||
* Dieser Test ruft die Funktion deshalb **direkt** und ohne jede
|
||||
* Vorprüfung — so wie es der dritte Aufrufer täte, der die Prüfung vergisst.
|
||||
*/
|
||||
const vorher = process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
|
||||
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "kunde-";
|
||||
try {
|
||||
const { admin, laufId, schrittId } = await aufbau();
|
||||
await testPrisma.einstellung.update({
|
||||
where: { id: "global" },
|
||||
data: { modus: Betriebsmodus.DIENSTLEISTUNG },
|
||||
});
|
||||
await anhangSpeichern(
|
||||
{
|
||||
laufId,
|
||||
vorlagenSchrittId: schrittId,
|
||||
dateiname: "fremd.png",
|
||||
medientyp: "image/png",
|
||||
inhalt: new Uint8Array(PNG),
|
||||
},
|
||||
admin,
|
||||
);
|
||||
|
||||
const fremd = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FRD" } });
|
||||
const techniker = await testPrisma.benutzer.create({
|
||||
data: { email: "tina@firma.example", name: "Tina", rolle: "TECHNIKER" },
|
||||
});
|
||||
await testPrisma.benutzerKunde.create({
|
||||
data: { benutzerId: techniker.id, kundeId: fremd.id },
|
||||
});
|
||||
|
||||
// Der Administrator sieht den Anhang.
|
||||
expect(await anhaengeDesLaufs(laufId, admin)).toHaveLength(1);
|
||||
// Der auf einen anderen Kunden beschränkte Techniker nicht.
|
||||
expect(await anhaengeDesLaufs(laufId, { id: techniker.id, rolle: techniker.rolle })).toHaveLength(0);
|
||||
} finally {
|
||||
if (vorher === undefined) delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
|
||||
else process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = vorher;
|
||||
}
|
||||
});
|
||||
|
||||
it("hält die Mandantengrenze in der Datenbank, nicht nur im Dienst", async () => {
|
||||
const { laufId, schrittId, ergebnisId } = await aufbau();
|
||||
const fremd = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FREMD" } });
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Betriebsmodus, VersionStatus } from "@/generated/prisma/enums";
|
||||
import type { Rolle } from "@/generated/prisma/enums";
|
||||
import { RechteFehler } from "@/server/auth/sitzungspflicht";
|
||||
import { anhangSpeichern } from "@/server/services/anhaenge";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
/**
|
||||
* Die Adresse, die einen Anhang ausliefert (#032).
|
||||
*
|
||||
* **Warum eine eigene Datei, obwohl `tests/anhaenge.test.ts` schon den Dienst
|
||||
* prüft:** Das Akzeptanzkriterium von #032 lautet „ein Anhang eines fremden
|
||||
* Kunden ist **über seine Adresse** nicht abrufbar“. Geprüft war bis hierher nur
|
||||
* `anhangLesen` — der Dienst. Die ganze Rechteprüfung im Route Handler ließ sich
|
||||
* durch einen hartcodierten ADMIN ersetzen, ohne dass ein einziger Test rot
|
||||
* wurde (Reviewbefund zu #029).
|
||||
*
|
||||
* Das ist die teuerste Lücke, die eine Prüfung haben kann: dieser Handler ist
|
||||
* die **einzige** Stelle der Anwendung, die rohe Dateibytes ausliefert. Fällt
|
||||
* die Prüfung bei einem Umbau heraus, gibt jedes Foto jedem heraus, der eine
|
||||
* Kennung hat — und das Tor vor dem Commit bleibt grün.
|
||||
*
|
||||
* Gefahren wird deshalb der **Handler selbst**, wie bei den SCIM-Handlern und
|
||||
* beim Ereignisstrom. Nur die Sitzung wird gestellt; alles andere ist echt, bis
|
||||
* hinunter zur Datei auf der Platte.
|
||||
*/
|
||||
|
||||
/* Der Handler wird echt gefahren; nur die Sitzung wird gestellt. */
|
||||
vi.mock("@/server/auth/sitzungspflicht", async (original) => {
|
||||
const echt = await original<typeof import("@/server/auth/sitzungspflicht")>();
|
||||
return { ...echt, rechtErforderlich: vi.fn() };
|
||||
});
|
||||
|
||||
const { rechtErforderlich } = await import("@/server/auth/sitzungspflicht");
|
||||
const { GET } = await import("@/app/api/anhaenge/[id]/route");
|
||||
|
||||
const VORHER = process.env["ANHAENGE_PFAD"];
|
||||
let ordner: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
ordner = await mkdtemp(join(tmpdir(), "laufzettel-anhang-route-"));
|
||||
process.env["ANHAENGE_PFAD"] = ordner;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (VORHER === undefined) delete process.env["ANHAENGE_PFAD"];
|
||||
else process.env["ANHAENGE_PFAD"] = VORHER;
|
||||
await rm(ordner, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Ein winziges, gültiges PNG. */
|
||||
const PNG = Buffer.from(
|
||||
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489" +
|
||||
"0000000a49444154789c6300010000050001" +
|
||||
"0d0a2db40000000049454e44ae426082",
|
||||
"hex",
|
||||
);
|
||||
|
||||
/** Ein winziges PDF – es wird anders ausgeliefert als ein Bild. */
|
||||
const PDF = Buffer.from("%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF\n", "utf8");
|
||||
|
||||
async function aufbau() {
|
||||
const kunde = await testPrisma.kunde.create({
|
||||
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
|
||||
});
|
||||
await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } });
|
||||
const admin = await testPrisma.benutzer.create({
|
||||
data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" },
|
||||
});
|
||||
|
||||
const vorlage = await testPrisma.vorlage.create({ data: { name: "PC Neuinstallation" } });
|
||||
const version = await testPrisma.vorlagenVersion.create({
|
||||
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
|
||||
});
|
||||
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
|
||||
data: { versionId: version.id, titel: "Vorbereitung", position: 0 },
|
||||
});
|
||||
const schritt = await testPrisma.vorlagenSchritt.create({
|
||||
data: { abschnittId: abschnitt.id, titel: "Typenschild fotografieren", position: 0 },
|
||||
});
|
||||
await testPrisma.vorlagenVersion.update({
|
||||
where: { id: version.id },
|
||||
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
|
||||
});
|
||||
|
||||
const lauf = await testPrisma.lauf.create({
|
||||
data: { vorlagenVersionId: version.id, titel: "Ein Lauf", kundeId: kunde.id },
|
||||
});
|
||||
await testPrisma.schrittergebnis.create({
|
||||
data: { laufId: lauf.id, vorlagenSchrittId: schritt.id },
|
||||
});
|
||||
|
||||
return {
|
||||
kunde,
|
||||
admin: { id: admin.id, rolle: admin.rolle },
|
||||
laufId: lauf.id,
|
||||
schrittId: schritt.id,
|
||||
};
|
||||
}
|
||||
|
||||
/** Legt einen Anhang an und gibt seine Kennung zurück. */
|
||||
async function anhangAnlegen(
|
||||
laufId: string,
|
||||
schrittId: string,
|
||||
admin: { id: string; rolle: Rolle },
|
||||
abweichend: { dateiname?: string; medientyp?: string; inhalt?: Buffer } = {},
|
||||
): Promise<string> {
|
||||
const { id } = await anhangSpeichern(
|
||||
{
|
||||
laufId,
|
||||
vorlagenSchrittId: schrittId,
|
||||
dateiname: abweichend.dateiname ?? "typenschild.png",
|
||||
medientyp: abweichend.medientyp ?? "image/png",
|
||||
inhalt: new Uint8Array(abweichend.inhalt ?? PNG),
|
||||
},
|
||||
admin,
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
const kennung = (id: string) => ({ params: Promise.resolve({ id }) });
|
||||
const anfrage = () => new Request("http://127.0.0.1:3300/api/anhaenge/egal");
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Ein Anhang über seine Adresse", () => {
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
vi.mocked(rechtErforderlich).mockReset();
|
||||
});
|
||||
|
||||
it("weist ohne Anmeldung ab, bevor eine Datei gelesen wird", async () => {
|
||||
const { admin, laufId, schrittId } = await aufbau();
|
||||
const id = await anhangAnlegen(laufId, schrittId, admin);
|
||||
|
||||
vi.mocked(rechtErforderlich).mockRejectedValue(new RechteFehler("Nicht angemeldet."));
|
||||
const antwort = await GET(anfrage(), kennung(id));
|
||||
|
||||
expect(antwort.status).toBe(401);
|
||||
// Und kein Byte der Datei im Rumpf.
|
||||
expect(await antwort.text()).not.toContain("PNG");
|
||||
});
|
||||
|
||||
it("weist auch ab, wenn die Rolle das Recht lesen nicht hat", async () => {
|
||||
/*
|
||||
* `MITARBEITER` hat seit #035 kein `lesen` — die Selbstauskunft ist dessen
|
||||
* **Ersatz** und kein Zusatz. Dass ein Anhang damit nicht erreichbar ist,
|
||||
* entscheidet `rechtErforderlich`; hier wird gezeigt, dass der Handler sich
|
||||
* daran hält und nicht selbst weiterläuft.
|
||||
*/
|
||||
const { admin, laufId, schrittId } = await aufbau();
|
||||
const id = await anhangAnlegen(laufId, schrittId, admin);
|
||||
|
||||
vi.mocked(rechtErforderlich).mockRejectedValue(new RechteFehler("Dafür fehlt dir die Berechtigung."));
|
||||
expect((await GET(anfrage(), kennung(id))).status).toBe(401);
|
||||
});
|
||||
|
||||
it("liefert einem berechtigten Benutzer die Datei unverändert", async () => {
|
||||
const { admin, laufId, schrittId } = await aufbau();
|
||||
const id = await anhangAnlegen(laufId, schrittId, admin);
|
||||
|
||||
vi.mocked(rechtErforderlich).mockResolvedValue({ benutzer: admin } as never);
|
||||
const antwort = await GET(anfrage(), kennung(id));
|
||||
|
||||
expect(antwort.status).toBe(200);
|
||||
expect(antwort.headers.get("content-type")).toBe("image/png");
|
||||
expect(antwort.headers.get("content-length")).toBe(String(PNG.length));
|
||||
// Byte für Byte dasselbe – ein Bild, das unterwegs umkodiert wird, ist kein Beweismittel.
|
||||
expect(Buffer.from(await antwort.arrayBuffer()).equals(PNG)).toBe(true);
|
||||
});
|
||||
|
||||
it("zeigt ein Bild inline und lädt ein PDF herunter", async () => {
|
||||
/*
|
||||
* Ein PDF kann Skripte enthalten. Ein Anhang, den jemand aus einem fremden
|
||||
* Lauf öffnet, soll nichts ausführen — deshalb `attachment` und
|
||||
* `nosniff`.
|
||||
*/
|
||||
const { admin, laufId, schrittId } = await aufbau();
|
||||
vi.mocked(rechtErforderlich).mockResolvedValue({ benutzer: admin } as never);
|
||||
|
||||
const bild = await anhangAnlegen(laufId, schrittId, admin);
|
||||
const bildAntwort = await GET(anfrage(), kennung(bild));
|
||||
expect(bildAntwort.headers.get("content-disposition")).toBe('inline; filename="typenschild.png"');
|
||||
|
||||
const pdf = await anhangAnlegen(laufId, schrittId, admin, {
|
||||
dateiname: "abnahme.pdf",
|
||||
medientyp: "application/pdf",
|
||||
inhalt: PDF,
|
||||
});
|
||||
const pdfAntwort = await GET(anfrage(), kennung(pdf));
|
||||
expect(pdfAntwort.headers.get("content-disposition")).toBe('attachment; filename="abnahme.pdf"');
|
||||
expect(pdfAntwort.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
});
|
||||
|
||||
it("lässt keinen Zwischenspeicher die Datei weitergeben", async () => {
|
||||
// Ein Anhang gehört einem Kunden. Ein öffentlicher Cache gäbe ihn an den
|
||||
// nächsten Betrachter weiter, und hinter einem Reverse Proxy ist das real.
|
||||
const { admin, laufId, schrittId } = await aufbau();
|
||||
const id = await anhangAnlegen(laufId, schrittId, admin);
|
||||
|
||||
vi.mocked(rechtErforderlich).mockResolvedValue({ benutzer: admin } as never);
|
||||
expect((await GET(anfrage(), kennung(id))).headers.get("cache-control")).toContain("private");
|
||||
});
|
||||
|
||||
it("gibt den Anhang eines fremden Kunden nicht heraus – über seine Adresse", async () => {
|
||||
/*
|
||||
* **Das Akzeptanzkriterium von #032, zum ersten Mal an der Adresse
|
||||
* geprüft.** Der Techniker ist auf einen anderen Kunden beschränkt; die
|
||||
* Kennung des Anhangs kennt er. Ohne diese Prüfung reicht die Kennung.
|
||||
*/
|
||||
const vorher = process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
|
||||
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "kunde-";
|
||||
try {
|
||||
const { admin, laufId, schrittId } = await aufbau();
|
||||
await testPrisma.einstellung.update({
|
||||
where: { id: "global" },
|
||||
data: { modus: Betriebsmodus.DIENSTLEISTUNG },
|
||||
});
|
||||
const id = await anhangAnlegen(laufId, schrittId, admin);
|
||||
|
||||
const fremd = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FRD" } });
|
||||
const techniker = await testPrisma.benutzer.create({
|
||||
data: { email: "tina@firma.example", name: "Tina", rolle: "TECHNIKER" },
|
||||
});
|
||||
await testPrisma.benutzerKunde.create({
|
||||
data: { benutzerId: techniker.id, kundeId: fremd.id },
|
||||
});
|
||||
|
||||
vi.mocked(rechtErforderlich).mockResolvedValue({
|
||||
benutzer: { id: techniker.id, rolle: techniker.rolle },
|
||||
} as never);
|
||||
const antwort = await GET(anfrage(), kennung(id));
|
||||
|
||||
/*
|
||||
* `404` und nicht `403`: nicht vorhanden und nicht sichtbar sind dieselbe
|
||||
* Antwort. Die Unterscheidung wäre schon eine Auskunft über fremde Kunden.
|
||||
*/
|
||||
expect(antwort.status).toBe(404);
|
||||
expect(antwort.headers.get("content-type")).toContain("application/json");
|
||||
const daten = (await antwort.json()) as Record<string, unknown>;
|
||||
// Und die Meldung nennt weder Dateiname noch Kunde.
|
||||
expect(JSON.stringify(daten)).not.toContain("typenschild");
|
||||
expect(JSON.stringify(daten)).not.toContain("Eigene Organisation");
|
||||
} finally {
|
||||
if (vorher === undefined) delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
|
||||
else process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = vorher;
|
||||
}
|
||||
});
|
||||
|
||||
it("antwortet auf eine Kennung, die es nicht gibt, mit 404", async () => {
|
||||
const { admin } = await aufbau();
|
||||
vi.mocked(rechtErforderlich).mockResolvedValue({ benutzer: admin } as never);
|
||||
|
||||
const antwort = await GET(anfrage(), kennung("gibt-es-nicht"));
|
||||
expect(antwort.status).toBe(404);
|
||||
});
|
||||
|
||||
it("sagt bei fehlender Datei, dass die Zeile steht und die Datei fehlt", async () => {
|
||||
/*
|
||||
* Der Fall nach einer Sicherung, die nur die Datenbank mitgenommen hat. Die
|
||||
* Meldung muss das sagen und nicht „nicht gefunden“ behaupten — sonst sucht
|
||||
* der Betreiber an der falschen Stelle (#032, und siehe
|
||||
* `docs/betrieb/sicherung.md`).
|
||||
*/
|
||||
const { admin, laufId, schrittId } = await aufbau();
|
||||
const id = await anhangAnlegen(laufId, schrittId, admin);
|
||||
await rm(ordner, { recursive: true, force: true });
|
||||
|
||||
vi.mocked(rechtErforderlich).mockResolvedValue({ benutzer: admin } as never);
|
||||
const antwort = await GET(anfrage(), kennung(id));
|
||||
|
||||
expect(antwort.status).toBe(404);
|
||||
expect(String((await antwort.json())["fehler"])).toMatch(/fehlt im Dateisystem/u);
|
||||
});
|
||||
});
|
||||
@@ -506,60 +506,6 @@ describe.runIf(datenbankVerfuegbar)("Bausteine", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("exportiert auch einen Entwurf mit seinen Baustein-Schritten", async () => {
|
||||
/*
|
||||
* **Der Fall, der still einen leeren Abschnitt lieferte** (Reviewbefund zu
|
||||
* #029). Ein Baustein-Abschnitt im Entwurf hat keine eigenen Schritte —
|
||||
* die entstehen erst beim Veröffentlichen. Der Export schrieb deshalb
|
||||
* `{"titel":"wird ersetzt","schritte":[]}`, und der Import nahm das
|
||||
* stillschweigend an: das Schema lässt einen leeren Abschnitt zu.
|
||||
*
|
||||
* Ein Redakteur bringt eine noch unveröffentlichte Vorlage in eine zweite
|
||||
* Installation — genau der Zweck des Formats. Dort kam der geteilte
|
||||
* Abschnitt unter dem Editor-Platzhalter „wird ersetzt“ und ohne einen
|
||||
* einzigen Schritt an. Die Checkliste war unvollständig, und nichts sagte
|
||||
* es.
|
||||
*/
|
||||
const { adminId } = await grundbestand();
|
||||
const { versionId: bausteinVersionId } = await bausteinMitSchritten(
|
||||
"Datensicherung prüfen",
|
||||
"Datensicherung",
|
||||
["Sicherung läuft", "Rücksicherung geprüft"],
|
||||
adminId,
|
||||
);
|
||||
|
||||
// Wie `vorlageMitBaustein`, aber **ohne** zu veröffentlichen.
|
||||
const { vorlage } = await vorlageAnlegen({ name: "Noch Entwurf" }, adminId);
|
||||
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, adminId);
|
||||
await entwurfSpeichern(entwurf.id, {
|
||||
variablen: [],
|
||||
abschnitte: [
|
||||
{ titel: "Eigener Abschnitt", schritte: [{ titel: "Eigener Schritt" }] },
|
||||
{ titel: "wird ersetzt", bausteinVersionId, schritte: [] },
|
||||
],
|
||||
});
|
||||
|
||||
const austausch = await vorlageExportieren(entwurf.id);
|
||||
|
||||
expect(
|
||||
austausch.struktur.abschnitte.map((abschnitt) => ({
|
||||
titel: abschnitt.titel,
|
||||
schritte: abschnitt.schritte.map((schritt) => schritt.titel),
|
||||
})),
|
||||
).toEqual([
|
||||
{ titel: "Eigener Abschnitt", schritte: ["Eigener Schritt"] },
|
||||
// Der Titel aus dem Baustein, nicht der Platzhalter – und die Schritte.
|
||||
{ titel: "Datensicherung", schritte: ["Sicherung läuft", "Rücksicherung geprüft"] },
|
||||
]);
|
||||
// Und weiterhin kein Verweis: in der anderen Installation gibt es den
|
||||
// Baustein vielleicht nicht.
|
||||
expect(JSON.stringify(austausch)).not.toContain(bausteinVersionId);
|
||||
// Kein Abschnitt ohne Schritte – das ist die Zusage, die vorher brach.
|
||||
for (const abschnitt of austausch.struktur.abschnitte) {
|
||||
expect(abschnitt.schritte.length, abschnitt.titel).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("übernimmt beim Import keinen mitgeschickten Verweis", async () => {
|
||||
const { adminId } = await grundbestand();
|
||||
const { versionId: bausteinVersionId } = await bausteinMitSchritten(
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
Betriebsmodus,
|
||||
@@ -14,7 +12,6 @@ import { abgleichMeldung, laufMeldung, webhookRumpf } from "@/lib/meldungen";
|
||||
import { SIGNATUR_KOPF, signaturBauen, signaturPruefen } from "@/lib/signatur";
|
||||
import { verschluesseln } from "@/lib/geheimnisse";
|
||||
import {
|
||||
MELDUNGS_PFADE,
|
||||
WARTEZEITEN_MINUTEN,
|
||||
abgleichMeldungEinreihen,
|
||||
laufMeldungEinreihen,
|
||||
@@ -202,31 +199,6 @@ describe("Die Texte der Meldungen", () => {
|
||||
expect(abgleichMeldung("AD", "Grund", link).rumpf).toContain(link);
|
||||
});
|
||||
|
||||
it("zeigt mit jedem Pfad auf eine Seite, die es wirklich gibt", () => {
|
||||
/*
|
||||
* **Der Test, der gefehlt hat.** Oben wird geprüft, dass der *übergebene*
|
||||
* Link im Rumpf steht — das ist richtig und sagt nichts darüber, ob der
|
||||
* Pfad existiert. `/verwaltung/verzeichnis` stand im Dienst und war ein
|
||||
* 404: `verwaltung` ist der Name des Rechts, die Seite liegt unter
|
||||
* `/admin/verzeichnis` (Reviewbefund zu #029).
|
||||
*
|
||||
* Ein Pfad in einer Mail wird nicht nachgesehen, sondern geklickt — und
|
||||
* genau diese eine Meldung geht an **alle** Administratoren, im Moment
|
||||
* eines Ausfalls. Geprüft wird deshalb gegen das Dateisystem: zu jedem Pfad
|
||||
* muss unter `src/app/(app)/` eine `page.tsx` liegen.
|
||||
*/
|
||||
const wurzel = new URL("../src/app/(app)", import.meta.url).pathname;
|
||||
for (const pfad of Object.values(MELDUNGS_PFADE)) {
|
||||
const teile = pfad.replace(/^\//u, "").split("/");
|
||||
// Ein Pfad, der auf eine Kennung endet, liegt unter `[id]`.
|
||||
const kandidaten = [join(wurzel, ...teile, "page.tsx"), join(wurzel, ...teile, "[id]", "page.tsx")];
|
||||
expect(
|
||||
kandidaten.some((datei) => existsSync(datei)),
|
||||
`${pfad} zeigt auf keine Seite – gesucht: ${kandidaten.join(" oder ")}`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("nimmt keinen Schrittinhalt auf — der Typ lässt es nicht zu", () => {
|
||||
/*
|
||||
* Die wichtigste Zusage dieses Meilensteins. Geprueft wird sie hier ueber
|
||||
|
||||
+8
-49
@@ -22,26 +22,13 @@ import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
const UMGEBUNG = { ...process.env };
|
||||
|
||||
/**
|
||||
* Standard-Anspruechge **mit** einer Rollengruppe.
|
||||
*
|
||||
* Seit #035 ist „angemeldet, aber in keiner Rollengruppe“ kein stilles `LESER`
|
||||
* mehr, sondern fuehrt in die Selbstauskunft — und ohne passende Person im
|
||||
* Verzeichnis wird die Anmeldung abgewiesen. Die Tests hier pruefen
|
||||
* JIT-Provisioning, Protokoll und Kundenbindung; sie sollen nicht nebenbei an
|
||||
* dieser Weiche haengen. Wer die Weiche selbst prueft, findet das in
|
||||
* `tests/selbstauskunft.test.ts`.
|
||||
*/
|
||||
function anspruecke(ueberschreiben: Partial<OidcAnspruecke> = {}): OidcAnspruecke {
|
||||
return {
|
||||
sub: "anbieter-sub-1",
|
||||
email: "maria.huber@firma.example",
|
||||
emailBestaetigt: true,
|
||||
name: "Maria Huber",
|
||||
gruppen: ["lz-techniker"],
|
||||
// Der UPN haengt an der Selbstauskunft (#035); im AD ist er oft nicht die
|
||||
// Mailadresse, deshalb hier bewusst ein anderer Wert.
|
||||
upn: "mhuber@firma.local",
|
||||
gruppen: [],
|
||||
ziel: "/",
|
||||
...ueberschreiben,
|
||||
};
|
||||
@@ -149,23 +136,9 @@ describe.runIf(datenbankVerfuegbar)("Anmeldung und Benutzerpflege", () => {
|
||||
const befoerdert = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-admin"] }));
|
||||
expect(befoerdert.rolle).toBe("ADMIN");
|
||||
|
||||
/*
|
||||
* Und wieder zurueck. **Seit #035 ist das kein `LESER` mehr:** wem die
|
||||
* Rollengruppe entzogen wird und wer zu keiner Person im Verzeichnis
|
||||
* passt, kommt nicht mehr herein.
|
||||
*
|
||||
* Das ist strenger als vorher und Absicht. Vorher behielt ein
|
||||
* ausgeschiedener Techniker Lesezugriff auf **alle** Laeufe des Hauses,
|
||||
* solange niemand sein Konto deaktivierte — und genau das vergisst man.
|
||||
* Der Notfallzugang dagegen bleibt: `AUTH_LOCAL_ENABLED` haengt nicht an
|
||||
* den Gruppen.
|
||||
*/
|
||||
await expect(benutzerAusOidcUebernehmen(anspruecke({ gruppen: [] }))).rejects.toThrow(AnmeldeFehler);
|
||||
|
||||
// Die Rolle in der Datenbank bleibt dabei, was sie war – abgewiesen heisst
|
||||
// abgewiesen, nicht herabgestuft.
|
||||
const unveraendert = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: erst.id } });
|
||||
expect(unveraendert.rolle).toBe("ADMIN");
|
||||
// Und wieder zurueck: eine entzogene Gruppe entzieht die Rolle.
|
||||
const entzogen = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: [] }));
|
||||
expect(entzogen.rolle).toBe("LESER");
|
||||
});
|
||||
|
||||
it("protokolliert jede Rollenaenderung aus den Gruppen", async () => {
|
||||
@@ -222,22 +195,12 @@ describe.runIf(datenbankVerfuegbar)("Anmeldung und Benutzerpflege", () => {
|
||||
});
|
||||
|
||||
describe("Kundenbindung", () => {
|
||||
/*
|
||||
* **Rollengruppe und Kundengruppe sind zwei Dinge** (#035). Diese Tests gaben
|
||||
* vorher nur `lz-kunde-MUS` mit und bekamen dadurch stillschweigend `LESER`.
|
||||
* Seit #035 fuehrt „keine Rollengruppe“ in die Selbstauskunft — eine
|
||||
* Kundengruppe allein sagt eben nichts darueber, was jemand duerfen soll.
|
||||
* Wer lesen soll, gehoert in die Leser-Gruppe; dafuer gibt es
|
||||
* `OIDC_GROUP_VIEWER`.
|
||||
*/
|
||||
it("bindet den Benutzer an die Kunden aus seinen Gruppen", async () => {
|
||||
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
|
||||
const mus = await testPrisma.kunde.create({ data: { name: "Musterfirma", kuerzel: "MUS" } });
|
||||
await testPrisma.kunde.create({ data: { name: "Baeckerei Reiter", kuerzel: "REI" } });
|
||||
|
||||
const benutzer = await benutzerAusOidcUebernehmen(
|
||||
anspruecke({ gruppen: ["lz-leser", "lz-kunde-MUS"] }),
|
||||
);
|
||||
const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-kunde-MUS"] }));
|
||||
const bindungen = await testPrisma.benutzerKunde.findMany({
|
||||
where: { benutzerId: benutzer.id },
|
||||
});
|
||||
@@ -252,10 +215,8 @@ describe.runIf(datenbankVerfuegbar)("Anmeldung und Benutzerpflege", () => {
|
||||
data: { name: "Baeckerei Reiter", kuerzel: "REI" },
|
||||
});
|
||||
|
||||
const benutzer = await benutzerAusOidcUebernehmen(
|
||||
anspruecke({ gruppen: ["lz-leser", "lz-kunde-MUS"] }),
|
||||
);
|
||||
await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-leser", "lz-kunde-REI"] }));
|
||||
const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-kunde-MUS"] }));
|
||||
await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-kunde-REI"] }));
|
||||
|
||||
const bindungen = await testPrisma.benutzerKunde.findMany({
|
||||
where: { benutzerId: benutzer.id },
|
||||
@@ -266,9 +227,7 @@ describe.runIf(datenbankVerfuegbar)("Anmeldung und Benutzerpflege", () => {
|
||||
|
||||
it("bindet ohne Praefix niemanden ein", async () => {
|
||||
await testPrisma.kunde.create({ data: { name: "Musterfirma", kuerzel: "MUS" } });
|
||||
const benutzer = await benutzerAusOidcUebernehmen(
|
||||
anspruecke({ gruppen: ["lz-leser", "lz-kunde-MUS"] }),
|
||||
);
|
||||
const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-kunde-MUS"] }));
|
||||
expect(await testPrisma.benutzerKunde.count({ where: { benutzerId: benutzer.id } })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { E2E_PRAEFIX } from "./e2e/aufraeumen";
|
||||
import { parse } from "yaml";
|
||||
|
||||
/**
|
||||
@@ -84,62 +83,6 @@ describe("Betriebsstand: docker-compose.yml", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Die Ende-zu-Ende-Abläufe räumen auf", () => {
|
||||
/*
|
||||
* **Der Test, der die Lücke beim nächsten Meilenstein fängt** (#027).
|
||||
*
|
||||
* In der Entwicklungsdatenbank standen 105 Vorlagen mit Namen wie
|
||||
* `ZZ E2E Editor 1790324192596 C`. Der Editor-Ablauf räumt seit #018 selbst
|
||||
* auf — aber zwei Abläufe aus Phase 2 (`anhaenge`, `live`) legten Zeilen an
|
||||
* und hatten keinen Nachlauf. Das fiel nicht auf, weil die Suite davon nichts
|
||||
* sieht: Reste sind kein Testfehler, sie sind ein Schaden an allem, was
|
||||
* danach in dieselbe Datenbank sieht.
|
||||
*
|
||||
* Geräumt wird jetzt zentral in `tests/e2e/aufraeumen.ts`, vor **und** nach
|
||||
* dem Lauf. Damit das trägt, muss zweierlei gelten, und beides steht hier:
|
||||
* jeder Ablauf benutzt das Präfix, und der Vor- und Nachlauf sind registriert.
|
||||
*/
|
||||
|
||||
const ABLAEUFE = readdirSync("tests/e2e").filter((datei) => datei.endsWith(".spec.ts"));
|
||||
|
||||
it("findet überhaupt Abläufe – sonst prüft der Test darunter nichts", () => {
|
||||
// Ohne diese Zeile wäre eine leere Liste ein grüner Test.
|
||||
expect(ABLAEUFE.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it("benennt jede angelegte Zeile mit dem Präfix, an dem aufgeräumt wird", () => {
|
||||
/*
|
||||
* Geprüft wird die **Umkehrung**: ein Ablauf, der Daten anlegt, ohne das
|
||||
* Präfix zu benutzen, ist von keinem Aufräumen erfasst. Erkennbar ist das
|
||||
* daran, dass er ein Formularfeld „Name“ oder „Titel“ füllt.
|
||||
*/
|
||||
const ohnePraefix: string[] = [];
|
||||
for (const datei of ABLAEUFE) {
|
||||
const inhalt = readFileSync(`tests/e2e/${datei}`, "utf8");
|
||||
const legtAn = /getByLabel\((?:"|')(?:Name|Titel|Bezeichnung)/u.test(inhalt);
|
||||
if (legtAn && !inhalt.includes(E2E_PRAEFIX)) ohnePraefix.push(datei);
|
||||
}
|
||||
expect(
|
||||
ohnePraefix,
|
||||
`Diese Abläufe legen Zeilen an, ohne „${E2E_PRAEFIX}“ zu benutzen — das zentrale ` +
|
||||
"Aufräumen findet sie nicht wieder.",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("hat Vorlauf und Nachlauf registriert", () => {
|
||||
/*
|
||||
* Beides, und das ist der Punkt: der Nachlauf läuft nach einem Testfehler
|
||||
* und nach einem Zeitablauf, aber nicht nach `Strg+C`. Dann findet der
|
||||
* Vorlauf des nächsten Durchgangs die Reste — und ein Vorlauf lässt sich
|
||||
* nicht überspringen.
|
||||
*/
|
||||
const konfiguration = readFileSync("playwright.config.ts", "utf8");
|
||||
expect(konfiguration).toContain("global-setup");
|
||||
expect(konfiguration).toContain("global-teardown");
|
||||
expect(readFileSync("tests/e2e/global-setup.ts", "utf8")).toContain("e2eAufraeumenMitBericht");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Betriebsstand: Entwicklungsdatenbank", () => {
|
||||
it("bindet nur auf localhost und auf einen eigenen, umstellbaren Port", () => {
|
||||
const stand = compose("docker-compose.dev.yml") as unknown as {
|
||||
|
||||
@@ -63,12 +63,6 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
|
||||
).toEqual([
|
||||
"BausteinSchritt_unveraenderlich",
|
||||
"BausteinVersion_status_einbahn",
|
||||
/*
|
||||
* Zieht die Rolle mit, wenn eine Person geloescht wird (#035). Ohne ihn
|
||||
* scheiterte das `ON DELETE SET NULL` des Fremdschluessels am CHECK, und
|
||||
* das Loeschen eines Mitarbeiters waere unmoeglich statt folgerichtig.
|
||||
*/
|
||||
"Mitarbeiter_konto_mitziehen",
|
||||
"VorlagenAbschnitt_baustein_veroeffentlicht",
|
||||
"VorlagenAbschnitt_unveraenderlich",
|
||||
"VorlagenSchritt_unveraenderlich",
|
||||
@@ -122,13 +116,6 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
|
||||
expect(vorhanden).toEqual([
|
||||
"Anhang_groesse_positiv",
|
||||
"Anhang_pruefsumme_form",
|
||||
/*
|
||||
* Die Rolle MITARBEITER und ihre Zuordnung gehoeren zusammen, in beide
|
||||
* Richtungen (#035). Ohne das waere ein MITARBEITER ohne Person moeglich —
|
||||
* ein Recht, das sich auf niemanden bezieht, und eine Einschraenkung, die
|
||||
* je nach Formulierung „nichts“ oder „alles“ bedeutet.
|
||||
*/
|
||||
"Benutzer_mitarbeiter_nur_bei_rolle",
|
||||
"Einstellung_einzelne_zeile",
|
||||
"Einstellung_smtp_passwort_verschluesselt",
|
||||
"Einstellung_smtp_port_bereich",
|
||||
@@ -144,21 +131,6 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
|
||||
"Schrittergebnis_begruendung_noetig",
|
||||
"TagCodeVergabe_form",
|
||||
"Verzeichnisquelle_bindpasswort_verschluesselt",
|
||||
/*
|
||||
* Seit #028 hat eine Quelle eine **Art**, und welche Felder sie braucht,
|
||||
* haengt daran. Ohne diese Bedingung waere eine Graph-Quelle mit
|
||||
* `basisDn` moeglich — eine Zeile, die zwei Dinge gleichzeitig behauptet.
|
||||
*/
|
||||
"Verzeichnisquelle_clientsecret_verschluesselt",
|
||||
"Verzeichnisquelle_felder_je_art",
|
||||
/*
|
||||
* Das SCIM-Token steht nur als argon2-Hash da (#029). Ein Token, das
|
||||
* jemand versehentlich roh speichert, faellt hier auf — und zwar in der
|
||||
* Datenbank, also auch dann, wenn jemand am Dienst vorbei schreibt.
|
||||
*/
|
||||
"Verzeichnisquelle_scimtoken_gehasht",
|
||||
// Und ein Token gehoert zu einem Zeitpunkt, in beide Richtungen.
|
||||
"Verzeichnisquelle_scimtoken_mit_zeitpunkt",
|
||||
"VorlagenVersion_veroeffentlicht_mit_zeitpunkt",
|
||||
"Zustellung_bezug_passt_zum_anlass",
|
||||
"Zustellung_empfaenger_nicht_leer",
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Was die Ende-zu-Ende-Abläufe hinterlassen, wieder wegräumen (#027).
|
||||
*
|
||||
* **Alles, was ein Ablauf anlegt, beginnt mit `ZZ`.** Das Präfix sortiert
|
||||
* die Zeilen in jeder Liste ans Ende und ist zugleich der Griff, an dem sie
|
||||
* wieder herausgehen — ohne gemerkte Kennungen, denn ein Lauf, der mitten im
|
||||
* Ablauf abbricht, merkt sich nichts.
|
||||
*
|
||||
* **Warum vor *und* nach dem Lauf geräumt wird.** Ein `afterAll` je Datei und
|
||||
* ein `globalTeardown` laufen bei einem Testfehler und bei einem Zeitablauf —
|
||||
* aber nicht, wenn jemand `Strg+C` drückt oder der Prozess hart stirbt. Dann
|
||||
* bleiben die Zeilen stehen, und der nächste Lauf findet sie vor. Deshalb räumt
|
||||
* derselbe Code auch im Vorlauf: was vom letzten Mal übrig ist, geht weg, bevor
|
||||
* der erste Ablauf startet. Ein Vorlauf kann nicht übersprungen werden.
|
||||
*
|
||||
* Aufgefallen ist das Ganze, weil in der Entwicklungsdatenbank 105 Vorlagen mit
|
||||
* Namen wie `ZZ E2E Editor 1790324192596 C` standen — aus einem Fenster von
|
||||
* sechzehn Stunden, in dem noch kein Ablauf aufräumte. Der Schaden liegt nicht
|
||||
* bei den Tests, sondern bei allem, was **danach** in dieselbe Datenbank sieht:
|
||||
* die Vorlagenliste, die Screenshots, jede Abfrage von Hand. Ein Fehler, der
|
||||
* „zu viele Treffer“ heißt, kostet dann eine Stunde.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Das Präfix, das jeder Ablauf seinen Zeilen gibt.
|
||||
*
|
||||
* **`ZZ` und nicht `ZZ E2E`**, weil die Abläufe es in fünf Schreibweisen
|
||||
* benutzen: `ZZ E2E Editor …`, `ZZ-E2E-<zeit>-PC-01` (Gerätenamen dürfen keine
|
||||
* Leerzeichen haben), `ZZPruef…`, `ZZQuelle…` und `ZZ Abteilung …`. Alle fünf
|
||||
* sind sinnvoll — ein Hostname sieht anders aus als ein Vorlagenname —, und
|
||||
* alle fünf beginnen mit `ZZ`. Darauf zu greifen deckt sie ab, ohne sechzehn
|
||||
* Dateien umzubenennen.
|
||||
*
|
||||
* Zwei Buchstaben sind kurz. Das ist hier in Ordnung: geräumt wird nur beim
|
||||
* Ende-zu-Ende-Lauf und nur gegen die Entwicklungsdatenbank, und `ZZ` am
|
||||
* **Anfang** eines Namens ist in echten Daten dieses Hauses nichts, was jemand
|
||||
* tippt. Der Grund für die Wahl ist ohnehin derselbe: `ZZ` sortiert in jeder
|
||||
* Liste ans Ende.
|
||||
*/
|
||||
export const E2E_PRAEFIX = "ZZ";
|
||||
|
||||
/** Was aufgeräumt wurde — damit der Aufrufer es melden kann. */
|
||||
export type Aufgeraeumt = Readonly<Record<string, number>>;
|
||||
|
||||
/**
|
||||
* Räumt alles mit `ZZ` weg und meldet, was es war.
|
||||
*
|
||||
* Die Reihenfolge ist nicht beliebig: erst was an einem Lauf hängt, dann die
|
||||
* Läufe, dann woran ein Lauf hängen könnte. Ein Fremdschlüssel, der sich
|
||||
* beschwert, ist dabei **kein** Fehler dieser Funktion, sondern die Auskunft,
|
||||
* dass ein Ablauf etwas verknüpft hat, das er nicht aufräumen kann — dann soll
|
||||
* es auffallen und nicht stillschweigend bleiben.
|
||||
*/
|
||||
export async function e2eRestlosAufraeumen(): Promise<Aufgeraeumt> {
|
||||
const praefix = { startsWith: E2E_PRAEFIX };
|
||||
const gezaehlt: Record<string, number> = {};
|
||||
const merken = (was: string, anzahl: number): void => {
|
||||
if (anzahl > 0) gezaehlt[was] = (gezaehlt[was] ?? 0) + anzahl;
|
||||
};
|
||||
|
||||
/*
|
||||
* `Protokoll.entitaetId` ist **kein** Fremdschlüssel: die Zeilen überleben
|
||||
* ihren Gegenstand absichtlich (ein archivierter Lauf soll seine Geschichte
|
||||
* behalten). Hier heißt das, dass sie als Waisen stehen bleiben, wenn sie
|
||||
* nicht mitgenommen werden — darum werden die Kennungen vorher gesammelt.
|
||||
*/
|
||||
const laeufe = await prisma.lauf.findMany({ where: { titel: praefix }, select: { id: true } });
|
||||
const vorlagen = await prisma.vorlage.findMany({
|
||||
where: { name: praefix },
|
||||
select: { id: true, versionen: { select: { id: true } } },
|
||||
});
|
||||
const bausteine = await prisma.baustein.findMany({
|
||||
where: { name: praefix },
|
||||
select: { id: true, versionen: { select: { id: true } } },
|
||||
});
|
||||
const geraete = await prisma.geraet.findMany({
|
||||
where: { OR: [{ bezeichnung: praefix }, { hostname: praefix }] },
|
||||
select: { id: true },
|
||||
});
|
||||
const personen = await prisma.mitarbeiter.findMany({
|
||||
where: { anzeigename: praefix },
|
||||
select: { id: true },
|
||||
});
|
||||
const abteilungen = await prisma.abteilung.findMany({
|
||||
where: { name: praefix },
|
||||
select: { id: true },
|
||||
});
|
||||
const quellen = await prisma.verzeichnisquelle.findMany({
|
||||
where: { name: praefix },
|
||||
select: { id: true },
|
||||
});
|
||||
const benutzer = await prisma.benutzer.findMany({
|
||||
where: { name: praefix },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
/*
|
||||
* **Waisen werden nicht erraten.** Eine Protokollzeile, deren Gegenstand schon
|
||||
* fort ist, bleibt stehen — ihre `entitaetId` ist hier nicht mehr zu finden.
|
||||
* Sie über den Namen im Json zu greifen wäre naheliegend und falsch: derselbe
|
||||
* Name steht auch in echter Historie einer Zeile, die es weiterhin gibt (etwa
|
||||
* „Version veröffentlicht“ mit der Liste der eingesetzten Bausteine). Dass
|
||||
* eine solche Zeile ihren Gegenstand überlebt, ist genau der Grund, warum
|
||||
* `entitaetId` kein Fremdschlüssel ist.
|
||||
*/
|
||||
const laufIds = laeufe.map((zeile) => zeile.id);
|
||||
const betroffen = [
|
||||
...laufIds,
|
||||
...vorlagen.flatMap((v) => [v.id, ...v.versionen.map((version) => version.id)]),
|
||||
...bausteine.flatMap((b) => [b.id, ...b.versionen.map((version) => version.id)]),
|
||||
...geraete.map((zeile) => zeile.id),
|
||||
...personen.map((zeile) => zeile.id),
|
||||
...abteilungen.map((zeile) => zeile.id),
|
||||
...quellen.map((zeile) => zeile.id),
|
||||
...benutzer.map((zeile) => zeile.id),
|
||||
];
|
||||
|
||||
if (laufIds.length > 0) {
|
||||
merken("Anhänge", (await prisma.anhang.deleteMany({ where: { laufId: { in: laufIds } } })).count);
|
||||
merken("Kommentare", (await prisma.kommentar.deleteMany({ where: { laufId: { in: laufIds } } })).count);
|
||||
merken(
|
||||
"Schrittergebnisse",
|
||||
(await prisma.schrittergebnis.deleteMany({ where: { laufId: { in: laufIds } } })).count,
|
||||
);
|
||||
merken(
|
||||
"Zustellungen",
|
||||
(await prisma.zustellung.deleteMany({ where: { laufId: { in: laufIds } } })).count,
|
||||
);
|
||||
}
|
||||
|
||||
if (betroffen.length > 0) {
|
||||
merken(
|
||||
"Protokollzeilen",
|
||||
(
|
||||
await prisma.protokoll.deleteMany({
|
||||
where: { OR: [{ entitaetId: { in: betroffen } }, { laufId: { in: laufIds } }] },
|
||||
})
|
||||
).count,
|
||||
);
|
||||
}
|
||||
|
||||
merken("Läufe", (await prisma.lauf.deleteMany({ where: { titel: praefix } })).count);
|
||||
merken("Vorlagen", (await prisma.vorlage.deleteMany({ where: { name: praefix } })).count);
|
||||
merken("Bausteine", (await prisma.baustein.deleteMany({ where: { name: praefix } })).count);
|
||||
merken(
|
||||
"Geräte",
|
||||
(
|
||||
await prisma.geraet.deleteMany({
|
||||
where: { OR: [{ bezeichnung: praefix }, { hostname: praefix }] },
|
||||
})
|
||||
).count,
|
||||
);
|
||||
merken("Personen", (await prisma.mitarbeiter.deleteMany({ where: { anzeigename: praefix } })).count);
|
||||
merken("Abteilungen", (await prisma.abteilung.deleteMany({ where: { name: praefix } })).count);
|
||||
merken(
|
||||
"Verzeichnisquellen",
|
||||
(await prisma.verzeichnisquelle.deleteMany({ where: { name: praefix } })).count,
|
||||
);
|
||||
merken("Benutzer", (await prisma.benutzer.deleteMany({ where: { name: praefix } })).count);
|
||||
|
||||
return gezaehlt;
|
||||
}
|
||||
|
||||
/** Dasselbe mit einer Zeile Ausgabe — für Vor- und Nachlauf. */
|
||||
export async function e2eAufraeumenMitBericht(wann: "vor" | "nach"): Promise<void> {
|
||||
const gezaehlt = await e2eRestlosAufraeumen();
|
||||
const summe = Object.values(gezaehlt).reduce((a, b) => a + b, 0);
|
||||
if (summe === 0) return;
|
||||
|
||||
const liste = Object.entries(gezaehlt)
|
||||
.map(([was, anzahl]) => `${anzahl} ${was}`)
|
||||
.join(", ");
|
||||
/*
|
||||
* Der Vorlauf **sagt es laut**: Zeilen, die er findet, sind der Rückstand
|
||||
* eines abgebrochenen Durchgangs. Still wegzuräumen hieße, die Ursache zu
|
||||
* verdecken — und ein Ablauf, der jedes Mal abbricht, fiele nie auf.
|
||||
*/
|
||||
console.info(
|
||||
wann === "vor"
|
||||
? `E2E-Aufräumen (Rückstand aus einem früheren Lauf): ${liste}.`
|
||||
: `E2E-Aufräumen: ${liste}.`,
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,7 @@
|
||||
import { chromium } from "@playwright/test";
|
||||
import "dotenv/config";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ABTEILUNGEN } from "@/../prisma/seed-daten/stammdaten";
|
||||
import { passwortHashen } from "@/server/auth/benutzer";
|
||||
import {
|
||||
anmelden,
|
||||
E2E_MITARBEITER_MAIL,
|
||||
E2E_MITARBEITER_NAME,
|
||||
E2E_MITARBEITER_PASSWORT,
|
||||
SEED_GERAET,
|
||||
SEED_TAG,
|
||||
SEED_VORLAGE,
|
||||
} from "./helfer";
|
||||
import { e2eAufraeumenMitBericht } from "./aufraeumen";
|
||||
import { anmelden, SEED_GERAET, SEED_TAG, SEED_VORLAGE } from "./helfer";
|
||||
|
||||
/**
|
||||
* Was vor dem ersten Ablauf stimmen muss (#018).
|
||||
@@ -34,14 +23,6 @@ function fehlen(was: string, abhilfe: string): never {
|
||||
}
|
||||
|
||||
export default async function vorbereiten(): Promise<void> {
|
||||
/*
|
||||
* **Zuerst aufraeumen, dann pruefen** (#027). Was hier noch steht, ist der
|
||||
* Rueckstand eines abgebrochenen Durchgangs: `globalTeardown` und die
|
||||
* `afterAll`-Bloecke laufen bei einem Testfehler, aber nicht bei `Strg+C`.
|
||||
* Ein Vorlauf laesst sich nicht ueberspringen — das ist der Unterschied.
|
||||
*/
|
||||
await e2eAufraeumenMitBericht("vor");
|
||||
|
||||
if (!process.env["SEED_ADMIN_EMAIL"] || !process.env["SEED_ADMIN_PASSWORD"]) {
|
||||
fehlen(
|
||||
"SEED_ADMIN_EMAIL oder SEED_ADMIN_PASSWORD fehlt.",
|
||||
@@ -78,37 +59,6 @@ export default async function vorbereiten(): Promise<void> {
|
||||
fehlen(`Das Beispielgerät ${SEED_GERAET} (${SEED_TAG}) fehlt.`, "pnpm db:seed laufen lassen.");
|
||||
}
|
||||
|
||||
/*
|
||||
* 2c. Stehen die **Werte** des Seed-Bestands noch, nicht nur die Zeilen?
|
||||
*
|
||||
* Der Onboarding-Ablauf prueft den `New-ADUser`-Befehl **zeichengenau**, und
|
||||
* darin steckt die Ziel-OU der Abteilung. In der Entwicklungsdatenbank stand
|
||||
* an „Buchhaltung“ `DC=firma,DC=de` statt `DC=firma,DC=local` — von Hand
|
||||
* geaendert oder aus einer aelteren Seed-Fassung. Der Seed setzt bestehende
|
||||
* Abteilungen absichtlich **nicht** zurueck (gepflegte Daten sollen bleiben),
|
||||
* also blieb die Abweichung stehen.
|
||||
*
|
||||
* Der Ablauf scheiterte daran mit „expected … received …“ — einer Meldung, die
|
||||
* nach einem Fehler im Befehlsbau aussieht und keiner war. Hier faellt es
|
||||
* vorher auf, und die Meldung sagt, was zu tun ist (Reviewbefund zu #029).
|
||||
*/
|
||||
for (const vorgabe of ABTEILUNGEN) {
|
||||
const abteilung = await prisma.abteilung.findFirst({
|
||||
where: { name: vorgabe.name },
|
||||
select: { zielOu: true, standardGruppen: true },
|
||||
});
|
||||
if (!abteilung) continue;
|
||||
if (abteilung.zielOu !== vorgabe.zielOu) {
|
||||
fehlen(
|
||||
`Die Abteilung „${vorgabe.name}“ hat eine abweichende Ziel-OU: ` +
|
||||
`„${abteilung.zielOu}“ statt „${vorgabe.zielOu}“.`,
|
||||
"Der Onboarding-Ablauf prüft den erzeugten Befehl zeichengenau. Entweder die Ziel-OU " +
|
||||
"unter Admin → Abteilungen zurückstellen, oder gegen eine frische Datenbank fahren: " +
|
||||
"pnpm db:seed:reset.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* 2b. In welchem Betriebsmodus laeuft die Installation?
|
||||
*
|
||||
@@ -123,42 +73,6 @@ export default async function vorbereiten(): Promise<void> {
|
||||
});
|
||||
process.env["E2E_BETRIEBSMODUS"] = einstellung?.modus ?? "INTERN";
|
||||
|
||||
/*
|
||||
* 2d. Das Konto der Rolle `MITARBEITER` fuer `/meine-geraete` (#037).
|
||||
*
|
||||
* Es muss an einer `Mitarbeiter`-Zeile haengen — der CHECK
|
||||
* `Benutzer_mitarbeiter_nur_bei_rolle` verlangt das in **beide** Richtungen
|
||||
* (#035): ein MITARBEITER ohne Person waere ein Recht, das sich auf niemanden
|
||||
* bezieht. Genommen wird die erste Person des Seed-Bestands, denn sie hat
|
||||
* Geraete, und ohne Geraet zeigte die Seite nur ihren Leerzustand.
|
||||
*/
|
||||
const person = await prisma.mitarbeiter.findFirst({
|
||||
where: { aktiv: true, geraete: { some: {} } },
|
||||
select: { id: true, kundeId: true, anzeigename: true },
|
||||
orderBy: { anzeigename: "asc" },
|
||||
});
|
||||
if (!person) {
|
||||
fehlen(
|
||||
"Es gibt keine Person mit einem Gerät.",
|
||||
"pnpm db:seed laufen lassen — die Selbstauskunft braucht eine Person, der ein Gerät gehört.",
|
||||
);
|
||||
}
|
||||
await prisma.benutzer.upsert({
|
||||
where: { email: E2E_MITARBEITER_MAIL },
|
||||
create: {
|
||||
email: E2E_MITARBEITER_MAIL,
|
||||
name: E2E_MITARBEITER_NAME,
|
||||
rolle: "MITARBEITER",
|
||||
passwortHash: await passwortHashen(E2E_MITARBEITER_PASSWORT),
|
||||
mitarbeiterId: person.id,
|
||||
aktiv: true,
|
||||
},
|
||||
// Ein Durchgang, der vorher abgebrochen ist, laesst das Konto stehen. Die
|
||||
// Person kann dabei eine andere sein als beim letzten Mal.
|
||||
update: { rolle: "MITARBEITER", mitarbeiterId: person.id, aktiv: true },
|
||||
});
|
||||
process.env["E2E_MITARBEITER_PERSON"] = person.anzeigename;
|
||||
|
||||
/*
|
||||
* 3. Liest der Server **dieselbe** Datenbank?
|
||||
*
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { e2eAufraeumenMitBericht } from "./aufraeumen";
|
||||
|
||||
/**
|
||||
* Nach dem letzten Ablauf: wegräumen, was die Abläufe angelegt haben (#027).
|
||||
*
|
||||
* Läuft auch dann, wenn Abläufe fehlgeschlagen sind — Playwright ruft den
|
||||
* Nachlauf nach dem Lauf, nicht nach dem Erfolg. Was er **nicht** abdeckt, ist
|
||||
* ein harter Abbruch (`Strg+C`, `kill`); dafür räumt derselbe Code im Vorlauf,
|
||||
* siehe `aufraeumen.ts`.
|
||||
*/
|
||||
export default async function aufraeumen(): Promise<void> {
|
||||
try {
|
||||
await e2eAufraeumenMitBericht("nach");
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
+1
-47
@@ -20,22 +20,6 @@ export const SEED_VORLAGE = "PC Neuinstallation";
|
||||
|
||||
/** Das Beispielgerät aus dem Seed. Sein Aufkleber steht im Abnahmekriterium. */
|
||||
export const SEED_GERAET = "PC-001";
|
||||
|
||||
/**
|
||||
* Ein Konto der Rolle `MITARBEITER`, nur fuer die Ablaeufe (#037).
|
||||
*
|
||||
* **Nicht im Seed**, obwohl das kuerzer waere: ein Konto mit bekanntem Passwort
|
||||
* gehoert nicht in einen Seed, der auch produktiv laeuft. Der E2E-Vorlauf legt
|
||||
* es an, der Nachlauf nimmt es wieder mit — es traegt `ZZ` im Namen und faellt
|
||||
* damit unter `tests/e2e/aufraeumen.ts`.
|
||||
*
|
||||
* Gebraucht wird es, weil `/meine-geraete` das Recht `selbstauskunft` verlangt
|
||||
* und das **nur** `MITARBEITER` hat: ein Administrator saehe dort „kein
|
||||
* Zugriff“, und ein Ablauf haette die Verweigerungsseite gemessen.
|
||||
*/
|
||||
export const E2E_MITARBEITER_MAIL = "zz.e2e.mitarbeiter@firma.example";
|
||||
export const E2E_MITARBEITER_PASSWORT = "zz-e2e-nur-fuer-ablaeufe";
|
||||
export const E2E_MITARBEITER_NAME = "ZZ E2E Mitarbeiterkonto";
|
||||
export const SEED_TAG = "LZ-7K3F9Q";
|
||||
|
||||
export async function anmelden(seite: Page): Promise<void> {
|
||||
@@ -47,40 +31,10 @@ export async function anmelden(seite: Page): Promise<void> {
|
||||
"Administrator passen, den der Seed angelegt hat.",
|
||||
);
|
||||
}
|
||||
/*
|
||||
* Wartet auf `/`, und zwar genau so streng wie vorher. Lockerer („irgendwas
|
||||
* ausser /anmelden“) sah harmlos aus und machte zwei Ablaeufe im vollen
|
||||
* Durchgang unzuverlaessig: der naechste Schritt begann, bevor die Uebersicht
|
||||
* stand. Der Admin landet immer auf `/` — fuer ihn gibt es keinen Grund, das
|
||||
* aufzuweichen.
|
||||
*/
|
||||
await anmeldenMit(seite, email, passwort, "**/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Anmelden mit beliebigen Zugangsdaten.
|
||||
*
|
||||
* `ziel` ist das Muster, auf das gewartet wird. Ohne Angabe genuegt es, dass die
|
||||
* Anmeldeseite verlassen ist: ein `MITARBEITER` landet auf `/meine-geraete`,
|
||||
* weil `startseite(rolle)` das Ziel an der Rolle entscheidet (#035), und ein
|
||||
* Warten auf `/` liefe dort in den Zeitablauf.
|
||||
*/
|
||||
export async function anmeldenMit(
|
||||
seite: Page,
|
||||
email: string,
|
||||
passwort: string,
|
||||
ziel?: string,
|
||||
): Promise<void> {
|
||||
await seite.goto("/anmelden");
|
||||
await seite.fill('input[name="email"]', email);
|
||||
await seite.fill('input[name="passwort"]', passwort);
|
||||
await Promise.all([
|
||||
seite.waitForURL(ziel ?? ((adresse) => !adresse.pathname.startsWith("/anmelden"))),
|
||||
seite.click('button[type="submit"]'),
|
||||
]);
|
||||
// Und bis die Seite wirklich steht: ein `waitForURL` endet an der Adresse,
|
||||
// nicht am geladenen Dokument.
|
||||
await seite.waitForLoadState("load");
|
||||
await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -273,19 +273,7 @@ test.describe("Der Zustands-Endpunkt", () => {
|
||||
expect(antwort.headers()["cache-control"]).toBe("no-store");
|
||||
|
||||
const zustand: unknown = await antwort.json();
|
||||
/*
|
||||
* Die Felder **namentlich** und vollständig: der Zustand ist die einzige
|
||||
* Antwort, die alle zehn Sekunden (seit #033 bei jeder Änderung) hinausgeht,
|
||||
* und ein Feld, das sich hier einschleicht, wandert ungesehen mit. Deshalb
|
||||
* `toEqual` und nicht `toContain`.
|
||||
*
|
||||
* `anzahlAnhaenge` kam mit #033 dazu und fehlte hier — der Ablauf war seitdem
|
||||
* rot, und niemand hat es gemerkt, weil `pnpm test:e2e` nicht im Tor vor dem
|
||||
* Commit steht (Reviewbefund zu #029). Wer hier ein Feld ergänzt, trägt es
|
||||
* ein; genau das ist der Zweck dieser Liste.
|
||||
*/
|
||||
expect(Object.keys(zustand as object).sort()).toEqual([
|
||||
"anzahlAnhaenge",
|
||||
"anzahlKommentare",
|
||||
"anzahlPflichtOffen",
|
||||
"geaendertAm",
|
||||
|
||||
+1
-84
@@ -1,15 +1,7 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import "dotenv/config";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
E2E_MITARBEITER_MAIL,
|
||||
E2E_MITARBEITER_PASSWORT,
|
||||
SEED_TAG,
|
||||
SEED_VORLAGE,
|
||||
anmelden,
|
||||
anmeldenMit,
|
||||
betriebsmodus,
|
||||
} from "./helfer";
|
||||
import { SEED_TAG, SEED_VORLAGE, anmelden, betriebsmodus } from "./helfer";
|
||||
|
||||
/**
|
||||
* Am Handy scrollt nichts nach rechts (#018).
|
||||
@@ -192,22 +184,6 @@ const SEITEN: readonly Hauptseite[] = [
|
||||
*/
|
||||
{ name: "Admin: Verzeichnis", pfad: () => "/admin/verzeichnis", nurModus: "INTERN" },
|
||||
{ name: "Kunden", pfad: () => "/kunden", nurModus: "DIENSTLEISTUNG" },
|
||||
/*
|
||||
* **Die Seiten aus Phase 2.** Sie fehlten hier, obwohl jedes der Issues eine
|
||||
* Klickprobe bei 390 px von Hand berichtet — eine Probe von Hand ist kein
|
||||
* Regressionstest (Reviewbefund zu #029). Gerade die Bausteinliste und das
|
||||
* Zustellungs-Protokoll sind Tabellen, und Tabellen sind die Stellen, an denen
|
||||
* horizontales Scrollen entsteht.
|
||||
*
|
||||
* `/meine-geraete` steht **nicht** in dieser Liste, sondern in einem eigenen
|
||||
* Block darunter: die Seite verlangt `selbstauskunft`, und das hat nur
|
||||
* `MITARBEITER`. Mit der Anmeldung dieses Ablaufs (Admin) würde die
|
||||
* Verweigerungsseite gemessen — und die scrollt garantiert nicht (#037).
|
||||
*/
|
||||
{ name: "Vorlagen: Bausteine", pfad: () => "/vorlagen/bausteine" },
|
||||
{ name: "Admin: Schnellstart", pfad: () => "/admin/schnellstart" },
|
||||
{ name: "Admin: Protokoll", pfad: () => "/admin/protokoll" },
|
||||
{ name: "Admin: Zustellungen", pfad: () => "/admin/zustellungen" },
|
||||
];
|
||||
|
||||
type Messung = {
|
||||
@@ -291,65 +267,6 @@ test.describe("Am Handy bei 390 px", () => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* `/meine-geraete` braucht eine andere Anmeldung (#037).
|
||||
*
|
||||
* Eigener Block statt eines Sonderfalls in der Schleife oben: die Seitenliste
|
||||
* dort gilt für **eine** Rolle, und ein `if` darin hätte beim nächsten Eintrag
|
||||
* geerbt werden müssen. Die Messung selbst ist dieselbe Funktion.
|
||||
*/
|
||||
test.describe("Am Handy bei 390 px: die Selbstauskunft", () => {
|
||||
const PFAD = "/meine-geraete";
|
||||
|
||||
test(`scrollt „Meine Geräte“ nicht horizontal`, async ({ page }) => {
|
||||
test.slow();
|
||||
await anmeldenMit(page, E2E_MITARBEITER_MAIL, E2E_MITARBEITER_PASSWORT);
|
||||
|
||||
await page.goto(PFAD);
|
||||
/*
|
||||
* Dieselbe Vorsicht wie oben, hier noch wichtiger: hätte das Konto die Rolle
|
||||
* verloren, landete es auf `/kein-zugriff` — und der Ablauf hätte grün
|
||||
* gemeldet, ohne die Seite gesehen zu haben.
|
||||
*/
|
||||
expect(new URL(page.url()).pathname, `${PFAD} hat weitergeleitet.`).toBe(PFAD);
|
||||
await page.waitForLoadState("load");
|
||||
|
||||
// Und es ist wirklich die Selbstauskunft, nicht eine leere Hülle: der Name
|
||||
// der verknüpften Person steht darauf.
|
||||
const person = process.env["E2E_MITARBEITER_PERSON"];
|
||||
if (person) {
|
||||
await expect(page.getByText(person, { exact: false }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
const masse = await messen(page);
|
||||
expect(
|
||||
masse.scroll,
|
||||
`„Meine Geräte“ (${PFAD}) scrollt horizontal: erwartet <= ${masse.sichtbar}, ` +
|
||||
`war ${masse.scroll} — ${masse.scroll - masse.sichtbar} px zu breit.` +
|
||||
(masse.uebeltaeter.length > 0 ? `\n Zu weit rechts: ${masse.uebeltaeter.join("\n ")}` : ""),
|
||||
).toBeLessThanOrEqual(masse.sichtbar);
|
||||
});
|
||||
|
||||
test("sieht dort keinen Weg in die Verwaltung", async ({ page }) => {
|
||||
/*
|
||||
* Die Gegenprobe zur Rolle, und sie gehört hierher: die Seitenleiste nennt
|
||||
* seit #035 an jedem Eintrag ihr Recht, damit einem `MITARBEITER` keine
|
||||
* sechs Links angeboten werden, die alle auf `/kein-zugriff` führen. Das
|
||||
* ist in Vitest als Rollenmatrix geprüft — dass es im **gerenderten** DOM
|
||||
* ankommt, nur hier.
|
||||
*/
|
||||
await anmeldenMit(page, E2E_MITARBEITER_MAIL, E2E_MITARBEITER_PASSWORT);
|
||||
await page.goto("/meine-geraete");
|
||||
|
||||
for (const ziel of ["/admin", "/vorlagen", "/laeufe", "/mitarbeiter", "/geraete", "/archiv"]) {
|
||||
await expect(
|
||||
page.locator(`a[href^="${ziel}"]`),
|
||||
`Ein MITARBEITER bekommt einen Link auf ${ziel} angeboten.`,
|
||||
).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (laufId) {
|
||||
await prisma.kommentar.deleteMany({ where: { laufId } });
|
||||
|
||||
@@ -112,17 +112,7 @@ test.describe("Personen", () => {
|
||||
|
||||
await treffer.click();
|
||||
await page.waitForURL(/\/mitarbeiter\/[^/]+$/);
|
||||
/*
|
||||
* **Der ganze Satz, nicht nur der Anfang.** „kommt aus dem Verzeichnis“
|
||||
* allein traf seit #020 zwei Stellen: dieselbe Auskunft steht auch am
|
||||
* Vorgesetzten-Feld. Playwright wies das als „strict mode violation“ ab, und
|
||||
* der Ablauf war seitdem rot — unbemerkt, weil `pnpm test:e2e` nicht im Tor
|
||||
* vor dem Commit steht (Reviewbefund zu #029).
|
||||
*
|
||||
* Hier zählt die Auskunft an den **Stammdaten**: dass sie dort gepflegt
|
||||
* werden und eine Änderung hier der nächste Abgleich zurücksetzen würde.
|
||||
*/
|
||||
await expect(page.getByText(/kommt aus dem Verzeichnis\. Die Stammdaten werden dort/u)).toBeVisible();
|
||||
await expect(page.getByText(/kommt aus dem Verzeichnis/)).toBeVisible();
|
||||
await expect(page.getByLabel("Vorname")).toHaveAttribute("readonly", "");
|
||||
await expect(page.getByRole("button", { name: "Stammdaten speichern" })).toHaveCount(0);
|
||||
// Deaktivieren muss trotzdem gehen: das Offboarding wartet nicht auf den
|
||||
|
||||
@@ -132,15 +132,8 @@ test.describe("Vorlagen-Editor", () => {
|
||||
.evaluateAll((felder) => felder.map((feld) => (feld as HTMLInputElement).value));
|
||||
expect(await werte()).toEqual(["Erster", "Zweiter"]);
|
||||
|
||||
/*
|
||||
* **Erst in den Blick scrollen.** Am Handy stand der erste Anfasser nach dem
|
||||
* Anlegen zweier Abschnitte bei `y = -115`, also oberhalb des Sichtfensters;
|
||||
* der Touch-Start landete damit außerhalb und bewegte nichts. Das fiel
|
||||
* nicht auf, weil `pnpm test:e2e` nicht im Tor vor dem Commit steht
|
||||
* (Reviewbefund zu #029).
|
||||
*/
|
||||
// Der Anfasser ist mindestens 44 px hoch – sonst trifft ihn niemand.
|
||||
const anfasser = page.getByRole("button", { name: "Verschieben" }).first();
|
||||
await anfasser.scrollIntoViewIfNeeded();
|
||||
const kasten = await anfasser.boundingBox();
|
||||
expect(kasten?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||
expect(kasten?.width ?? 0).toBeGreaterThanOrEqual(44);
|
||||
@@ -149,32 +142,13 @@ test.describe("Vorlagen-Editor", () => {
|
||||
// eine Wischgeste ein Bildlauf bleibt und kein Verschieben.
|
||||
const x = kasten!.x + kasten!.width / 2;
|
||||
const y = kasten!.y + kasten!.height / 2;
|
||||
|
||||
/*
|
||||
* **Die Zugweite wird gemessen, nicht gesetzt.** Vorher waren es fest
|
||||
* 12 × 25 px = 300; der Abstand zwischen den beiden Anfassern ist aber
|
||||
* 532 px, seit mit #030 und #031 Bausteinverweis und Abteilungsfilter in
|
||||
* jeden Abschnitt gekommen sind. Der Finger kam damit nicht mehr über den
|
||||
* zweiten Abschnitt, und die Reihenfolge blieb stehen.
|
||||
*
|
||||
* Eine feste Weite in einem Sortiertest ist eine Zusage über das Layout,
|
||||
* die niemand pflegt. Gemessen wird sie bei jedem Lauf richtig — und wenn
|
||||
* der Abstand sich wieder ändert, bleibt der Test grün, statt eine
|
||||
* Pixelzahl nachzuziehen.
|
||||
*/
|
||||
const zweiterAnfasser = await page.getByRole("button", { name: "Verschieben" }).nth(1).boundingBox();
|
||||
// Ein Stück über die Mitte des zweiten hinaus, sonst entscheidet dnd-kit
|
||||
// sich fuer „bleibt“.
|
||||
const weite = (zweiterAnfasser?.y ?? y + 300) - kasten!.y + kasten!.height;
|
||||
const schritte = 20;
|
||||
|
||||
const sitzung = await page.context().newCDPSession(page);
|
||||
await sitzung.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
|
||||
await page.waitForTimeout(350);
|
||||
for (let schritt = 1; schritt <= schritte; schritt += 1) {
|
||||
for (let schritt = 1; schritt <= 12; schritt += 1) {
|
||||
await sitzung.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x, y: y + (weite * schritt) / schritte }],
|
||||
touchPoints: [{ x, y: y + schritt * 25 }],
|
||||
});
|
||||
await page.waitForTimeout(40);
|
||||
}
|
||||
|
||||
@@ -1,650 +0,0 @@
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { Betriebsmodus } from "@/generated/prisma/enums";
|
||||
import { GRAPH_ZUORDNUNG, zuordnungLesen } from "@/lib/verzeichnis";
|
||||
import { verschluesseln } from "@/lib/geheimnisse";
|
||||
import {
|
||||
angefragteFelder,
|
||||
benutzerZuordnen,
|
||||
ersteAdresse,
|
||||
graphEintraegeStroemen,
|
||||
naechsteSeite,
|
||||
} from "@/server/graph/leser";
|
||||
import { GraphFehler, graphZugangAus, tokenHolen, tokenSpeicherLeeren } from "@/server/graph/zugang";
|
||||
import { vorgesetzteAufloesen } from "@/server/ldap/zuordnung";
|
||||
import { abgleichAusfuehren } from "@/server/services/abgleich";
|
||||
import { eintraegeDerQuelleStroemen } from "@/server/verzeichnis/quellenleser";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
/**
|
||||
* Microsoft-Graph-Quelle (#028).
|
||||
*
|
||||
* **Geprueft wird gegen einen HTTP-Doppelgaenger mit aufgezeichneten Antworten,
|
||||
* nicht gegen eine abgefangene Funktion.** Dass der Seitenabruf, der
|
||||
* Token-Austausch und die Zuordnung zusammenpassen, zeigt erst eine Antwort, die
|
||||
* durch `fetch`, JSON und `@odata.nextLink` gegangen ist.
|
||||
*
|
||||
* **Was dieser Test nicht leisten kann:** gegen einen echten Entra-Mandanten ist
|
||||
* das nicht gelaufen. Die Gegenprobe steht als Pruefliste im Issue, wie bei #024
|
||||
* fuer Proxmox. Was der Doppelgaenger liefert, ist der dokumentierten Form von
|
||||
* Graph nachgebaut — stimmt die Form nicht, stimmt dieser Test und die
|
||||
* Wirklichkeit nicht.
|
||||
*/
|
||||
|
||||
const MANDANT = "11111111-2222-3333-4444-555555555555";
|
||||
const ANWENDUNG = "66666666-7777-8888-9999-000000000000";
|
||||
const GEHEIMNIS = "streng-geheimes-client-secret";
|
||||
const TOKEN = "ein-zugriffstoken";
|
||||
|
||||
/** Ein Benutzer in der Form, in der Graph ihn liefert. */
|
||||
function nutzer(nummer: number, zusatz: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
id: `guid-${nummer}`,
|
||||
givenName: `Vorname${nummer}`,
|
||||
surname: `Nachname${nummer}`,
|
||||
displayName: `Vorname${nummer} Nachname${nummer}`,
|
||||
mail: `person${nummer}@firma.example`,
|
||||
userPrincipalName: `person${nummer}@firma.example`,
|
||||
onPremisesSamAccountName: `person${nummer}`,
|
||||
department: "Buchhaltung",
|
||||
jobTitle: "Sachbearbeiter",
|
||||
accountEnabled: true,
|
||||
manager: null,
|
||||
...zusatz,
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Der Doppelgaenger */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type Aufzeichnung = {
|
||||
/** Seiten in der Reihenfolge, in der sie geliefert werden. */
|
||||
seiten: Record<string, unknown>[][];
|
||||
/** Antwortcode fuer `/users`; 200, wenn nicht gesetzt. */
|
||||
usersStatus?: number;
|
||||
usersFehler?: { code: string; message: string };
|
||||
tokenStatus?: number;
|
||||
};
|
||||
|
||||
let server: Server | undefined;
|
||||
let basis = "";
|
||||
let aufzeichnung: Aufzeichnung = { seiten: [] };
|
||||
let tokenAnfragen = 0;
|
||||
let tokenRuempfe: string[] = [];
|
||||
let usersAnfragen: string[] = [];
|
||||
let usersKopfzeilen: (string | undefined)[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
server = createServer((anfrage, antwort) => {
|
||||
const adresse = new URL(anfrage.url ?? "/", basis);
|
||||
|
||||
if (adresse.pathname.endsWith("/oauth2/v2.0/token")) {
|
||||
tokenAnfragen += 1;
|
||||
let rumpf = "";
|
||||
anfrage.on("data", (stueck) => {
|
||||
rumpf += stueck;
|
||||
});
|
||||
anfrage.on("end", () => {
|
||||
tokenRuempfe.push(rumpf);
|
||||
const status = aufzeichnung.tokenStatus ?? 200;
|
||||
antwort.writeHead(status, { "Content-Type": "application/json" });
|
||||
if (status !== 200) {
|
||||
/*
|
||||
* Entra ID antwortet sehr ausfuehrlich — mit Mandant, Anwendung und
|
||||
* einer Ablaufverfolgungskennung. Genau das soll nicht nach draussen
|
||||
* gelangen, und deshalb steht es hier im Doppelgaenger.
|
||||
*/
|
||||
antwort.end(
|
||||
JSON.stringify({
|
||||
error: "invalid_client",
|
||||
error_description: `AADSTS7000215: Invalid client secret provided. Trace ID: abc Tenant: ${MANDANT} App: ${ANWENDUNG}`,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
antwort.end(JSON.stringify({ access_token: TOKEN, expires_in: 3600 }));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (adresse.pathname === "/v1.0/users") {
|
||||
usersAnfragen.push(anfrage.url ?? "");
|
||||
usersKopfzeilen.push(anfrage.headers.authorization);
|
||||
const status = aufzeichnung.usersStatus ?? 200;
|
||||
if (status !== 200) {
|
||||
antwort.writeHead(status, { "Content-Type": "application/json" });
|
||||
antwort.end(JSON.stringify({ error: aufzeichnung.usersFehler ?? { code: "x", message: "y" } }));
|
||||
return;
|
||||
}
|
||||
// Die Seitennummer steckt in `$skiptoken`; die erste Anfrage hat keinen.
|
||||
const marke = Number.parseInt(adresse.searchParams.get("$skiptoken") ?? "0", 10);
|
||||
/*
|
||||
* **Der Doppelgaenger filtert, wie Graph es tut.** Sonst prueft der Test
|
||||
* den Filter nicht, sondern nur, dass er in der Adresse steht — und der
|
||||
* Unterschied ist genau der Fehler, den der Test „setzt aktiv = false“
|
||||
* gefunden hat.
|
||||
*/
|
||||
const filter = adresse.searchParams.get("$filter") ?? "";
|
||||
const nurAktive = filter.includes("accountEnabled eq true");
|
||||
const seite = (aufzeichnung.seiten[marke] ?? []).filter(
|
||||
(einer) => !nurAktive || einer["accountEnabled"] !== false,
|
||||
);
|
||||
const weiter =
|
||||
marke + 1 < aufzeichnung.seiten.length ? `${basis}/v1.0/users?$skiptoken=${marke + 1}` : undefined;
|
||||
antwort.writeHead(200, { "Content-Type": "application/json" });
|
||||
antwort.end(JSON.stringify({ value: seite, ...(weiter ? { "@odata.nextLink": weiter } : {}) }));
|
||||
return;
|
||||
}
|
||||
|
||||
antwort.writeHead(404).end("unbekannt");
|
||||
});
|
||||
|
||||
await new Promise<void>((fertig) => {
|
||||
server?.listen(0, "127.0.0.1", () => fertig());
|
||||
});
|
||||
const adresse = server?.address();
|
||||
const port = typeof adresse === "object" && adresse ? adresse.port : 0;
|
||||
basis = `http://127.0.0.1:${port}`;
|
||||
process.env["GRAPH_LOGIN_URL"] = basis;
|
||||
process.env["GRAPH_API_URL"] = basis;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env["GRAPH_LOGIN_URL"];
|
||||
delete process.env["GRAPH_API_URL"];
|
||||
await new Promise<void>((fertig) => {
|
||||
server?.close(() => fertig());
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
aufzeichnung = { seiten: [] };
|
||||
tokenAnfragen = 0;
|
||||
tokenRuempfe = [];
|
||||
usersAnfragen = [];
|
||||
usersKopfzeilen = [];
|
||||
tokenSpeicherLeeren();
|
||||
});
|
||||
|
||||
const ZUGANG = { tenantId: MANDANT, clientId: ANWENDUNG, clientSecret: GEHEIMNIS };
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Die reinen Teile */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("Die Abfrage, die gestellt wird", () => {
|
||||
it("fragt genau die zugeordneten Felder an, plus id und accountEnabled", () => {
|
||||
const felder = angefragteFelder(GRAPH_ZUORDNUNG);
|
||||
for (const erwartet of [
|
||||
"givenName",
|
||||
"surname",
|
||||
"displayName",
|
||||
"mail",
|
||||
"userPrincipalName",
|
||||
"onPremisesSamAccountName",
|
||||
"department",
|
||||
"jobTitle",
|
||||
"id",
|
||||
"accountEnabled",
|
||||
]) {
|
||||
expect(felder, `${erwartet} fehlt`).toContain(erwartet);
|
||||
}
|
||||
});
|
||||
|
||||
it("schreibt manager nicht in $select", () => {
|
||||
/*
|
||||
* `manager` ist eine **Beziehung**, keine Eigenschaft. In `$select` antwortet
|
||||
* Graph mit einem Fehler, der nach einem Tippfehler aussieht und keiner ist.
|
||||
*/
|
||||
expect(angefragteFelder(GRAPH_ZUORDNUNG)).not.toContain("manager");
|
||||
});
|
||||
|
||||
it("baut $expand mit unkodierten Klammern", () => {
|
||||
const adresse = ersteAdresse(GRAPH_ZUORDNUNG);
|
||||
// Prozentkodierte Klammern beantwortet Graph mit „Invalid expand clause“.
|
||||
expect(adresse).toContain("$expand=manager($select=id)");
|
||||
expect(adresse).not.toContain("%28");
|
||||
expect(adresse).toContain("$top=999");
|
||||
expect(adresse).toContain("$select=");
|
||||
// Kein %24: die Adresse soll so aussehen wie in der Dokumentation.
|
||||
expect(adresse).not.toContain("%24");
|
||||
});
|
||||
|
||||
it("nimmt eine abweichende Zuordnung mit", () => {
|
||||
const eigen = { ...GRAPH_ZUORDNUNG, abteilung: "companyName" };
|
||||
expect(angefragteFelder(eigen)).toContain("companyName");
|
||||
expect(angefragteFelder(eigen)).not.toContain("department");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Die Zuordnung eines Graph-Benutzers", () => {
|
||||
it("füllt die Felder und lässt den DN leer", () => {
|
||||
const ergebnis = benutzerZuordnen(nutzer(1), GRAPH_ZUORDNUNG);
|
||||
expect("eintrag" in ergebnis).toBe(true);
|
||||
if (!("eintrag" in ergebnis)) return;
|
||||
expect(ergebnis.eintrag).toMatchObject({
|
||||
externeId: "guid-1",
|
||||
vorname: "Vorname1",
|
||||
nachname: "Nachname1",
|
||||
upn: "person1@firma.example",
|
||||
sam: "person1",
|
||||
abteilung: "Buchhaltung",
|
||||
titel: "Sachbearbeiter",
|
||||
// Kein DN in Entra ID – und deshalb auch kein {{ou}}.
|
||||
dn: null,
|
||||
vorgesetzterDn: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("nimmt den Vorgesetzten direkt aus dem expand", () => {
|
||||
const ergebnis = benutzerZuordnen(nutzer(2, { manager: { id: "guid-1" } }), GRAPH_ZUORDNUNG);
|
||||
if (!("eintrag" in ergebnis)) throw new Error("übersprungen");
|
||||
expect(ergebnis.eintrag.vorgesetzterDirekt).toBe("guid-1");
|
||||
expect(ergebnis.eintrag.vorgesetzterDn).toBeNull();
|
||||
});
|
||||
|
||||
it("überspringt einen Benutzer ohne id", () => {
|
||||
const ergebnis = benutzerZuordnen(nutzer(3, { id: null }), GRAPH_ZUORDNUNG);
|
||||
expect("uebersprungen" in ergebnis).toBe(true);
|
||||
if (!("uebersprungen" in ergebnis)) return;
|
||||
// Als Kennzeichen der UPN: „wer“ wurde übersprungen, nicht „welcher DN“.
|
||||
expect(ergebnis.uebersprungen.dn).toBe("person3@firma.example");
|
||||
expect(ergebnis.uebersprungen.grund).toContain("id");
|
||||
});
|
||||
|
||||
it("wirft bei einem deaktivierten Konto, statt es zu überspringen", () => {
|
||||
/*
|
||||
* **Der Unterschied kostet eine Person ihren Zugang.** „Übersprungen“ heißt
|
||||
* im Abgleich „nicht zuordenbar“, und solche Einträge werden absichtlich
|
||||
* nicht abgeschaltet (#016). Ein deaktiviertes Konto als übersprungen zu
|
||||
* melden hätte es also **aktiv** gelassen — genau umgekehrt.
|
||||
*
|
||||
* Gefiltert wird deshalb serverseitig. Kommt hier doch eines an, ist der
|
||||
* Filter nicht angekommen, und das ist ein Fehler in der Abfrage.
|
||||
*/
|
||||
expect(() => benutzerZuordnen(nutzer(4, { accountEnabled: false }), GRAPH_ZUORDNUNG)).toThrow(/Filter/);
|
||||
});
|
||||
|
||||
it("nennt den Filter in der Abfrage", () => {
|
||||
expect(ersteAdresse(GRAPH_ZUORDNUNG)).toContain("accountEnabled%20eq%20true");
|
||||
});
|
||||
|
||||
it("baut einen Anzeigenamen, wenn keiner da ist", () => {
|
||||
const ergebnis = benutzerZuordnen(
|
||||
nutzer(5, { displayName: null, givenName: "Ada", surname: "Lovelace" }),
|
||||
GRAPH_ZUORDNUNG,
|
||||
);
|
||||
if (!("eintrag" in ergebnis)) throw new Error("übersprungen");
|
||||
expect(ergebnis.eintrag.anzeigename).toBe("Lovelace, Ada");
|
||||
});
|
||||
|
||||
it("lässt den Anmeldenamen leer, wenn der Mandant kein AD synchronisiert", () => {
|
||||
// Eine reine Cloud-Umgebung hat kein `onPremisesSamAccountName`. `{{sam}}`
|
||||
// bleibt dann leer, statt dass etwas hineingedeutet wird.
|
||||
const ergebnis = benutzerZuordnen(nutzer(6, { onPremisesSamAccountName: null }), GRAPH_ZUORDNUNG);
|
||||
if (!("eintrag" in ergebnis)) throw new Error("übersprungen");
|
||||
expect(ergebnis.eintrag.sam).toBeNull();
|
||||
});
|
||||
|
||||
it("gibt dem Vorgesetzten den Vorrang vor der DN-Auflösung", () => {
|
||||
const eintraege = [{ ...nutzer(1) }, { ...nutzer(2, { manager: { id: "guid-1" } }) }].map((n) => {
|
||||
const e = benutzerZuordnen(n, GRAPH_ZUORDNUNG);
|
||||
if (!("eintrag" in e)) throw new Error("übersprungen");
|
||||
return e.eintrag;
|
||||
});
|
||||
|
||||
const aufgeloest = vorgesetzteAufloesen(eintraege);
|
||||
expect(aufgeloest[1]?.vorgesetzterExterneId).toBe("guid-1");
|
||||
expect(aufgeloest[0]?.vorgesetzterExterneId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Die Zuordnungs-Vorgaben", () => {
|
||||
it("nimmt für eine Graph-Quelle die Graph-Namen", () => {
|
||||
expect(zuordnungLesen({}, "GRAPH")).toEqual(GRAPH_ZUORDNUNG);
|
||||
// Und für LDAP weiter die AD-Namen – der Standardwert hält jeden Aufrufer
|
||||
// von vor #028 bei seinem Verhalten.
|
||||
expect(zuordnungLesen({}).nachname).toBe("sn");
|
||||
expect(zuordnungLesen({}, "GRAPH").nachname).toBe("surname");
|
||||
});
|
||||
|
||||
it("lässt eine gespeicherte Abweichung gewinnen", () => {
|
||||
expect(zuordnungLesen({ titel: "eigenesFeld" }, "GRAPH").titel).toBe("eigenesFeld");
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Gegen den Doppelgaenger */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("Der Token-Austausch", () => {
|
||||
it("holt ein Token und benutzt es wieder", async () => {
|
||||
const erst = await tokenHolen(ZUGANG);
|
||||
expect(erst).toBe(TOKEN);
|
||||
expect(tokenAnfragen).toBe(1);
|
||||
|
||||
// Zweiter Aufruf aus dem Zwischenspeicher: Microsoft begrenzt die Zahl der
|
||||
// Token-Anfragen, und ein Abgleich mit fünf Seiten wäre sonst fünf Anmeldungen.
|
||||
await tokenHolen(ZUGANG);
|
||||
expect(tokenAnfragen).toBe(1);
|
||||
});
|
||||
|
||||
it("holt kurz vor Ablauf ein neues", async () => {
|
||||
const jetzt = Date.now();
|
||||
await tokenHolen(ZUGANG, jetzt);
|
||||
expect(tokenAnfragen).toBe(1);
|
||||
|
||||
/*
|
||||
* 3600 s Gültigkeit, 60 s Sicherheitsabstand — die Grenze liegt also bei
|
||||
* +3540 s. Die Zahlen hier standen beim ersten Lauf daneben, und der Test
|
||||
* war rot: ein Beispiel dafür, warum die Grenze eine eigene Prüfung braucht
|
||||
* und nicht „so ungefähr eine Stunde“ heißt.
|
||||
*/
|
||||
await tokenHolen(ZUGANG, jetzt + 3_530_000);
|
||||
expect(tokenAnfragen).toBe(1);
|
||||
await tokenHolen(ZUGANG, jetzt + 3_545_000);
|
||||
expect(tokenAnfragen).toBe(2);
|
||||
});
|
||||
|
||||
it("schickt den Client-Credentials-Flow mit dem Scope .default", async () => {
|
||||
await tokenHolen(ZUGANG);
|
||||
const rumpf = new URLSearchParams(tokenRuempfe[0] ?? "");
|
||||
expect(rumpf.get("grant_type")).toBe("client_credentials");
|
||||
expect(rumpf.get("client_id")).toBe(ANWENDUNG);
|
||||
expect(rumpf.get("client_secret")).toBe(GEHEIMNIS);
|
||||
/*
|
||||
* `.default` und nicht einzelne Berechtigungen: beim
|
||||
* Client-Credentials-Flow gelten die, die in der App-Registrierung
|
||||
* eingewilligt wurden. Eine Liste hier waere eine zweite Wahrheit.
|
||||
*/
|
||||
expect(rumpf.get("scope")).toMatch(/\/\.default$/u);
|
||||
});
|
||||
|
||||
it("schickt das Token als Bearer an die Abfrage", async () => {
|
||||
aufzeichnung.seiten = [[nutzer(1)]];
|
||||
await graphEintraegeStroemen(ZUGANG, GRAPH_ZUORDNUNG, () => undefined);
|
||||
expect(usersKopfzeilen[0]).toBe(`Bearer ${TOKEN}`);
|
||||
});
|
||||
|
||||
it("nennt das Geheimnis nicht in der Fehlermeldung", async () => {
|
||||
aufzeichnung.tokenStatus = 401;
|
||||
try {
|
||||
await tokenHolen(ZUGANG);
|
||||
throw new Error("hätte werfen müssen");
|
||||
} catch (fehler) {
|
||||
expect(fehler).toBeInstanceOf(GraphFehler);
|
||||
const meldung = (fehler as Error).message;
|
||||
expect(meldung).not.toContain(GEHEIMNIS);
|
||||
// Und auch nicht die ausführliche Antwort von Entra ID – die geht ins
|
||||
// Protokoll des Prozesses, nicht in eine Oberfläche.
|
||||
expect(meldung).not.toContain("AADSTS7000215");
|
||||
expect(meldung).not.toContain(MANDANT);
|
||||
expect(meldung).toContain("401");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Der Seitenabruf", () => {
|
||||
it("folgt einem nextLink nur auf denselben Host", () => {
|
||||
/*
|
||||
* **Jede Seite wird mit dem Zugangstoken geholt**, und das gilt mandantenweit
|
||||
* (`User.Read.All`). Der Antwort von Graph über TLS zu trauen ist
|
||||
* vertretbar — aber `GRAPH_API_URL` ist absichtlich einstellbar, für
|
||||
* nationale Clouds und für den Doppelgänger in diesem Test. Ein Tippfehler
|
||||
* darin oder ein aufbrechender Firmenproxy dazwischen schickte das Token
|
||||
* sonst an eine fremde Adresse (Reviewbefund zu #029).
|
||||
*/
|
||||
const basis = "https://graph.microsoft.com";
|
||||
expect(naechsteSeite(`${basis}/v1.0/users?$skiptoken=abc`, basis)).toBe(
|
||||
`${basis}/v1.0/users?$skiptoken=abc`,
|
||||
);
|
||||
|
||||
// Anderer Host: Abruf endet, statt das Token mitzuschicken.
|
||||
expect(naechsteSeite("https://boese.example/v1.0/users", basis)).toBeUndefined();
|
||||
// Auch ein anderes Schema oder ein anderer Port ist ein anderer Ursprung.
|
||||
expect(naechsteSeite("http://graph.microsoft.com/v1.0/users", basis)).toBeUndefined();
|
||||
expect(naechsteSeite("https://graph.microsoft.com:8443/v1.0/users", basis)).toBeUndefined();
|
||||
|
||||
// Und das Übliche: nichts, leer, kein gültiges URL.
|
||||
expect(naechsteSeite(undefined, basis)).toBeUndefined();
|
||||
expect(naechsteSeite("", basis)).toBeUndefined();
|
||||
expect(naechsteSeite("kein-url", basis)).toBeUndefined();
|
||||
expect(naechsteSeite(42, basis)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("folgt @odata.nextLink über drei Seiten", async () => {
|
||||
aufzeichnung.seiten = [[nutzer(1), nutzer(2)], [nutzer(3), nutzer(4)], [nutzer(5)]];
|
||||
|
||||
const gesehen: string[] = [];
|
||||
const bilanz = await graphEintraegeStroemen(
|
||||
ZUGANG,
|
||||
GRAPH_ZUORDNUNG,
|
||||
(eintrag) => void gesehen.push(eintrag.externeId),
|
||||
);
|
||||
|
||||
expect(bilanz.seiten).toBe(3);
|
||||
expect(bilanz.gesamtGelesen).toBe(5);
|
||||
expect(gesehen).toEqual(["guid-1", "guid-2", "guid-3", "guid-4", "guid-5"]);
|
||||
// Drei Abrufe, und nur der erste trägt die Abfrageparameter.
|
||||
expect(usersAnfragen).toHaveLength(3);
|
||||
expect(usersAnfragen[0]).toContain("$select=");
|
||||
expect(usersAnfragen[1]).toContain("$skiptoken=1");
|
||||
});
|
||||
|
||||
it("hört bei der Grenze auf und holt keine weitere Seite", async () => {
|
||||
aufzeichnung.seiten = [[nutzer(1), nutzer(2)], [nutzer(3)], [nutzer(4)]];
|
||||
|
||||
const bilanz = await graphEintraegeStroemen(ZUGANG, GRAPH_ZUORDNUNG, () => undefined, 2);
|
||||
|
||||
expect(bilanz.seiten).toBe(1);
|
||||
expect(usersAnfragen).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("zählt übersprungene mit und liefert sie als Liste", async () => {
|
||||
// Das deaktivierte Konto liefert der Server gar nicht; übersprungen wird
|
||||
// nur das ohne Kennung.
|
||||
aufzeichnung.seiten = [[nutzer(1), nutzer(2, { accountEnabled: false }), nutzer(3, { id: null })]];
|
||||
|
||||
const gesehen: string[] = [];
|
||||
const bilanz = await graphEintraegeStroemen(
|
||||
ZUGANG,
|
||||
GRAPH_ZUORDNUNG,
|
||||
(eintrag) => void gesehen.push(eintrag.externeId),
|
||||
);
|
||||
|
||||
expect(bilanz.gesamtGelesen).toBe(2);
|
||||
expect(gesehen).toEqual(["guid-1"]);
|
||||
expect(bilanz.uebersprungen).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("nennt bei 403 die fehlende Einwilligung", async () => {
|
||||
aufzeichnung.usersStatus = 403;
|
||||
aufzeichnung.usersFehler = { code: "Authorization_RequestDenied", message: "Insufficient privileges" };
|
||||
|
||||
await expect(graphEintraegeStroemen(ZUGANG, GRAPH_ZUORDNUNG, () => undefined)).rejects.toThrow(
|
||||
/User\.Read\.All/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Der Abgleich – derselbe Dienst wie bei LDAP */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const ADMIN = { id: "", rolle: "ADMIN" } as const;
|
||||
|
||||
async function graphQuelleAnlegen() {
|
||||
const eigene = await testPrisma.kunde.create({
|
||||
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
|
||||
});
|
||||
await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } });
|
||||
const quelle = await testPrisma.verzeichnisquelle.create({
|
||||
data: {
|
||||
kundeId: eigene.id,
|
||||
name: "Entra ID",
|
||||
art: "GRAPH",
|
||||
aktiv: true,
|
||||
tenantId: MANDANT,
|
||||
clientId: ANWENDUNG,
|
||||
clientSecretVerschluesselt: verschluesseln(GEHEIMNIS),
|
||||
attributZuordnung: {},
|
||||
},
|
||||
});
|
||||
return { eigene, quelle };
|
||||
}
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Der Abgleich einer Graph-Quelle", () => {
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
tokenSpeicherLeeren();
|
||||
});
|
||||
|
||||
it("legt beim ersten Lauf an und aktualisiert beim zweiten ohne Dubletten", async () => {
|
||||
const { quelle } = await graphQuelleAnlegen();
|
||||
aufzeichnung.seiten = [[nutzer(1), nutzer(2, { manager: { id: "guid-1" } })]];
|
||||
|
||||
const erst = await abgleichAusfuehren(quelle.id, ADMIN);
|
||||
expect(erst.status).toBe("ERFOLG");
|
||||
expect(erst.angelegt).toBe(2);
|
||||
expect(await testPrisma.mitarbeiter.count()).toBe(2);
|
||||
|
||||
// Der Vorgesetzte kommt über `$expand` und nicht über eine DN-Auflösung.
|
||||
const zwei = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { externeId: "guid-2" } });
|
||||
const eins = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { externeId: "guid-1" } });
|
||||
expect(zwei.vorgesetzterExterneId).toBe("guid-1");
|
||||
expect(zwei.vorgesetzterId).toBe(eins.id);
|
||||
|
||||
// Und kein DN, also auch kein erfundenes {{ou}}.
|
||||
expect(eins.dn).toBeNull();
|
||||
|
||||
// Zweiter Lauf: eine Änderung, keine Dublette.
|
||||
aufzeichnung.seiten = [[nutzer(1, { department: "Vertrieb" }), nutzer(2, { manager: { id: "guid-1" } })]];
|
||||
const zweit = await abgleichAusfuehren(quelle.id, ADMIN);
|
||||
expect(zweit.angelegt).toBe(0);
|
||||
expect(zweit.aktualisiert).toBe(1);
|
||||
expect(await testPrisma.mitarbeiter.count()).toBe(2);
|
||||
expect(
|
||||
(await testPrisma.mitarbeiter.findFirstOrThrow({ where: { externeId: "guid-1" } })).abteilung,
|
||||
).toBe("Vertrieb");
|
||||
});
|
||||
|
||||
it("setzt aktiv = false, wenn accountEnabled auf false steht", async () => {
|
||||
const { quelle } = await graphQuelleAnlegen();
|
||||
aufzeichnung.seiten = [[nutzer(1), nutzer(2)]];
|
||||
await abgleichAusfuehren(quelle.id, ADMIN);
|
||||
|
||||
// Konto in Entra ID gesperrt: es wird übersprungen, also nicht mehr
|
||||
// wiedergesehen — und was nicht wiedergesehen wird, ist inaktiv.
|
||||
aufzeichnung.seiten = [[nutzer(1), nutzer(2, { accountEnabled: false })]];
|
||||
const ergebnis = await abgleichAusfuehren(quelle.id, ADMIN);
|
||||
|
||||
expect(ergebnis.deaktiviert).toBe(1);
|
||||
const zwei = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { externeId: "guid-2" } });
|
||||
expect(zwei.aktiv).toBe(false);
|
||||
// Gelöscht wird nichts – die Zeile bleibt mit allem daran.
|
||||
expect(await testPrisma.mitarbeiter.count()).toBe(2);
|
||||
});
|
||||
|
||||
it("gibt das Client-Secret in keiner Antwort und in keinem Protokoll heraus", async () => {
|
||||
const { quelle } = await graphQuelleAnlegen();
|
||||
aufzeichnung.seiten = [[nutzer(1)]];
|
||||
await abgleichAusfuehren(quelle.id, ADMIN);
|
||||
|
||||
const protokoll = await testPrisma.protokoll.findMany({ where: { entitaetId: quelle.id } });
|
||||
const zeile = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
|
||||
const alles = JSON.stringify({ protokoll, letzterFehler: zeile.letzterAbgleichFehler });
|
||||
|
||||
expect(alles).not.toContain(GEHEIMNIS);
|
||||
expect(alles).not.toContain("clientSecret");
|
||||
// In der Datenbank steht es verschlüsselt.
|
||||
expect(zeile.clientSecretVerschluesselt).toMatch(/^v[0-9]+\./u);
|
||||
expect(zeile.clientSecretVerschluesselt).not.toContain(GEHEIMNIS);
|
||||
});
|
||||
|
||||
it("schreibt den Fehler an die Quelle, ohne das Geheimnis zu nennen", async () => {
|
||||
const { quelle } = await graphQuelleAnlegen();
|
||||
aufzeichnung.tokenStatus = 401;
|
||||
|
||||
const ergebnis = await abgleichAusfuehren(quelle.id, ADMIN);
|
||||
expect(ergebnis.status).not.toBe("ERFOLG");
|
||||
|
||||
const zeile = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
|
||||
expect(zeile.letzterAbgleichFehler).toBeTruthy();
|
||||
expect(zeile.letzterAbgleichFehler).not.toContain(GEHEIMNIS);
|
||||
expect(zeile.letzterAbgleichFehler).not.toContain("AADSTS7000215");
|
||||
});
|
||||
|
||||
it("weist eine SCIM-Quelle ab, statt stillschweigend nichts zu tun", async () => {
|
||||
/*
|
||||
* Dieselbe Weiche, dritter Fall. Ein Abgleich, der null Einträge liest und
|
||||
* ERFOLG meldet, hätte über die Abschaltregel die ganze Belegschaft
|
||||
* deaktiviert — genau der Fall, der in #016 einen Reviewbefund gekostet hat.
|
||||
*
|
||||
* Seit #029 ist SCIM umgesetzt, und die Antwort bleibt dieselbe: dort wird
|
||||
* nicht geholt, sondern geliefert. Die Meldung sagt das und nennt keine
|
||||
* Issue-Nummer mehr — sie steht auch in der Oberfläche.
|
||||
*/
|
||||
const { eigene } = await graphQuelleAnlegen();
|
||||
const scim = await testPrisma.verzeichnisquelle.create({
|
||||
data: { kundeId: eigene.id, name: "SCIM später", art: "SCIM", aktiv: true, attributZuordnung: {} },
|
||||
});
|
||||
|
||||
const ergebnis = await abgleichAusfuehren(scim.id, ADMIN);
|
||||
expect(ergebnis.status).not.toBe("ERFOLG");
|
||||
expect(ergebnis.fehler).toMatch(/beliefert/);
|
||||
expect(ergebnis.fehler).toContain("SCIM später");
|
||||
});
|
||||
|
||||
it("liest eine LDAP-Quelle weiter über den LDAP-Weg", async () => {
|
||||
// Die Weiche darf den bestehenden Weg nicht umlenken: eine LDAP-Quelle ohne
|
||||
// erreichbaren Server scheitert an der **Verbindung** und nicht daran, dass
|
||||
// Graph gefragt würde.
|
||||
const { eigene } = await graphQuelleAnlegen();
|
||||
const ldap = await testPrisma.verzeichnisquelle.create({
|
||||
data: {
|
||||
kundeId: eigene.id,
|
||||
name: "AD",
|
||||
art: "LDAP",
|
||||
aktiv: true,
|
||||
url: "ldap://127.0.0.1:1",
|
||||
bindDn: "cn=admin,dc=firma,dc=test",
|
||||
bindPasswortVerschluesselt: verschluesseln("egal"),
|
||||
basisDn: "dc=firma,dc=test",
|
||||
filter: "(objectClass=user)",
|
||||
attributZuordnung: {},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
eintraegeDerQuelleStroemen(
|
||||
await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: ldap.id } }),
|
||||
() => undefined,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
// Keine Graph-Anfrage dabei.
|
||||
expect(usersAnfragen).toHaveLength(0);
|
||||
expect(tokenAnfragen).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Der Zugang aus einer Quellenzeile", () => {
|
||||
it("entschlüsselt das Geheimnis", () => {
|
||||
const zugang = graphZugangAus({
|
||||
art: "GRAPH",
|
||||
tenantId: MANDANT,
|
||||
clientId: ANWENDUNG,
|
||||
clientSecretVerschluesselt: verschluesseln(GEHEIMNIS),
|
||||
});
|
||||
expect(zugang.clientSecret).toBe(GEHEIMNIS);
|
||||
});
|
||||
|
||||
it("wirft bei einer Zeile ohne Graph-Felder", () => {
|
||||
expect(() =>
|
||||
graphZugangAus({ art: "LDAP", tenantId: null, clientId: null, clientSecretVerschluesselt: null }),
|
||||
).toThrow(GraphFehler);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Betriebsmodus, LaufStatus, VersionStatus } from "@/generated/prisma/enums";
|
||||
import type { Rolle } from "@/generated/prisma/enums";
|
||||
import { HERZSCHLAG, KANAL, herzschlagMs, maxStroeme, sseEreignis, willStrom } from "@/lib/sse";
|
||||
import {
|
||||
STROM_GESCHLOSSEN,
|
||||
@@ -8,7 +7,6 @@ import {
|
||||
sollZurueckfallen,
|
||||
} from "@/components/laeufe/lauf-aktualisierung";
|
||||
import { RechteFehler } from "@/server/auth/sitzungspflicht";
|
||||
import type { Sitzung } from "@/lib/sitzung";
|
||||
import { aufLaufHoeren, ereignisseSchliessen, stroemeOffen } from "@/server/lauf-ereignisse";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
@@ -307,29 +305,10 @@ describe.runIf(datenbankVerfuegbar)("Der Route Handler", () => {
|
||||
delete process.env["SSE_MAX_STROEME"];
|
||||
});
|
||||
|
||||
/**
|
||||
* Die Sitzung wird mit ihrer **echten Form** gestellt, nicht als `{}`.
|
||||
*
|
||||
* Vorher stand hier ein leeres Objekt, und genau daran ist ein Befund
|
||||
* vorbeigekommen: der Strom prueft die Sitzung seit #029 beim Herzschlag nach,
|
||||
* und mit einer Attrappe ohne `benutzerId` liesse sich das nicht zeigen. Eine
|
||||
* Attrappe, die nicht die Form der Wirklichkeit hat, prueft etwas anderes als
|
||||
* die Wirklichkeit — dieselbe Lehre wie beim Graph-Doppelgaenger in #028.
|
||||
*/
|
||||
function sitzungFuer(benutzerId: string, ausgestelltAm = Math.floor(Date.now() / 1000)): Sitzung {
|
||||
return {
|
||||
benutzerId,
|
||||
email: "wer@firma.example",
|
||||
name: "Wer",
|
||||
herkunft: "passwort",
|
||||
ausgestelltAm,
|
||||
};
|
||||
}
|
||||
|
||||
function angemeldetAls(benutzer: { id: string; rolle: Rolle }): void {
|
||||
function angemeldetAls(benutzer: { id: string; rolle: "ADMIN" }): void {
|
||||
vi.mocked(rechtErforderlich).mockResolvedValue({
|
||||
benutzer: { id: benutzer.id, rolle: benutzer.rolle },
|
||||
sitzung: sitzungFuer(benutzer.id),
|
||||
sitzung: {},
|
||||
} as unknown as Awaited<ReturnType<typeof rechtErforderlich>>);
|
||||
}
|
||||
|
||||
@@ -386,7 +365,7 @@ describe.runIf(datenbankVerfuegbar)("Der Route Handler", () => {
|
||||
});
|
||||
vi.mocked(rechtErforderlich).mockResolvedValue({
|
||||
benutzer: { id: leser.id, rolle: "LESER" },
|
||||
sitzung: sitzungFuer(leser.id),
|
||||
sitzung: {},
|
||||
} as unknown as Awaited<ReturnType<typeof rechtErforderlich>>);
|
||||
|
||||
const antwort = await GET(anfrage("text/event-stream"), {
|
||||
@@ -479,78 +458,6 @@ describe.runIf(datenbankVerfuegbar)("Der Route Handler", () => {
|
||||
expect(gelesen.split(HERZSCHLAG).length - 1).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("beendet den Strom, wenn das Konto deaktiviert wird", async () => {
|
||||
/*
|
||||
* **Ein offener Strom hat das Deaktivieren überlebt** (Reviewbefund zu
|
||||
* #029). Das Recht wurde genau einmal geprüft, beim Aufbau, und das Paar aus
|
||||
* Kennung und Rolle für die Lebensdauer des Stroms festgehalten. Ein
|
||||
* Administrator deaktivierte ein Konto — und eine offen gelassene Lasche auf
|
||||
* dem Schreibtisch des Betroffenen bekam weiter live Status, Schrittzustände
|
||||
* und die Zahl der Kommentare, bis jemand sie schloss.
|
||||
*
|
||||
* `docs/architektur.md` verspricht „ein deaktivierter Benutzer wird auch mit
|
||||
* gültigem Cookie abgewiesen“. Für einen offenen Strom galt das nicht.
|
||||
*/
|
||||
process.env["SSE_HERZSCHLAG_MS"] = "40";
|
||||
const { laufId, admin } = await aufbau();
|
||||
angemeldetAls(admin as { id: string; rolle: "ADMIN" });
|
||||
|
||||
const antwort = await GET(anfrage("text/event-stream"), {
|
||||
params: Promise.resolve({ id: laufId }),
|
||||
});
|
||||
|
||||
await testPrisma.benutzer.update({ where: { id: admin.id }, data: { aktiv: false } });
|
||||
|
||||
const gelesen = await stromLesen(antwort, (text) => text.includes("nicht-angemeldet"));
|
||||
expect(gelesen).toContain("nicht-angemeldet");
|
||||
// Und der Strom ist auch wirklich freigegeben, nicht nur stillgelegt.
|
||||
await warteAuf(() => stroemeOffen() === 0, "Der Strom nach dem Deaktivieren");
|
||||
});
|
||||
|
||||
it("beendet den Strom, wenn die Sitzungen widerrufen werden", async () => {
|
||||
/*
|
||||
* Derselbe Weg, anderer Anlass: ein Administrator beendet die Sitzungen
|
||||
* eines Benutzers (`sitzungenUngueltigAb`), ohne das Konto zu deaktivieren —
|
||||
* etwa nach einem verlorenen Notebook. Dann muss der Strom genauso enden.
|
||||
*/
|
||||
process.env["SSE_HERZSCHLAG_MS"] = "40";
|
||||
const { laufId, admin } = await aufbau();
|
||||
angemeldetAls(admin as { id: string; rolle: "ADMIN" });
|
||||
|
||||
const antwort = await GET(anfrage("text/event-stream"), {
|
||||
params: Promise.resolve({ id: laufId }),
|
||||
});
|
||||
|
||||
await testPrisma.benutzer.update({
|
||||
where: { id: admin.id },
|
||||
// Eine Minute in der Zukunft: die Sitzung dieses Tests ist „von vorher“.
|
||||
data: { sitzungenUngueltigAb: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
|
||||
expect(await stromLesen(antwort, (text) => text.includes("nicht-angemeldet"))).toContain(
|
||||
"nicht-angemeldet",
|
||||
);
|
||||
});
|
||||
|
||||
it("hält den Strom, solange das Konto gilt", async () => {
|
||||
/*
|
||||
* Die Gegenrichtung, und sie ist nötig: eine Nachprüfung, die **immer**
|
||||
* beendet, sähe im Test oben genauso aus. Hier bleibt der Strom über
|
||||
* mehrere Herzschläge offen, ohne dass sich am Konto etwas ändert.
|
||||
*/
|
||||
process.env["SSE_HERZSCHLAG_MS"] = "40";
|
||||
const { laufId, admin } = await aufbau();
|
||||
angemeldetAls(admin as { id: string; rolle: "ADMIN" });
|
||||
|
||||
const antwort = await GET(anfrage("text/event-stream"), {
|
||||
params: Promise.resolve({ id: laufId }),
|
||||
});
|
||||
const gelesen = await stromLesen(antwort, (text) => text.split(HERZSCHLAG).length - 1 >= 3);
|
||||
|
||||
expect(gelesen).not.toContain("nicht-angemeldet");
|
||||
expect(gelesen.split(HERZSCHLAG).length - 1).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("fällt bei erreichter Obergrenze auf eine gewöhnliche Antwort zurück", async () => {
|
||||
process.env["SSE_MAX_STROEME"] = "1";
|
||||
const { laufId, admin } = await aufbau();
|
||||
|
||||
+15
-48
@@ -11,20 +11,13 @@ const ALLE_RECHTE = Object.keys(RECHTE) as Recht[];
|
||||
* Test, der die Tabelle nur gegen sich selbst haelt, wuerde jede Aenderung mitmachen.
|
||||
*/
|
||||
const MATRIX: Record<Recht, Record<Rolle, boolean>> = {
|
||||
lesen: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: true, MITARBEITER: false },
|
||||
selbstauskunft: { ADMIN: false, REDAKTEUR: false, TECHNIKER: false, LESER: false, MITARBEITER: true },
|
||||
laeufeBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: false, MITARBEITER: false },
|
||||
geraeteBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: false, MITARBEITER: false },
|
||||
mitarbeiterBearbeiten: {
|
||||
ADMIN: true,
|
||||
REDAKTEUR: true,
|
||||
TECHNIKER: true,
|
||||
LESER: false,
|
||||
MITARBEITER: false,
|
||||
},
|
||||
vorlagenBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: false, LESER: false, MITARBEITER: false },
|
||||
archivVerwalten: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false, MITARBEITER: false },
|
||||
verwaltung: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false, MITARBEITER: false },
|
||||
lesen: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: true },
|
||||
laeufeBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: false },
|
||||
geraeteBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: false },
|
||||
mitarbeiterBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: false },
|
||||
vorlagenBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: false, LESER: false },
|
||||
archivVerwalten: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false },
|
||||
verwaltung: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false },
|
||||
};
|
||||
|
||||
describe("Rechtematrix", () => {
|
||||
@@ -33,12 +26,10 @@ describe("Rechtematrix", () => {
|
||||
);
|
||||
|
||||
it("prueft jede Rolle gegen jedes Recht", () => {
|
||||
// Haelt fest, dass die Tabelle oben wirklich vollstaendig ist: fuenf Rollen,
|
||||
// acht Rechte. Die Zahlen stehen hier ausgeschrieben, damit eine neue Rolle
|
||||
// oder ein neues Recht **hier** auffaellt und nicht erst im Betrieb (#035).
|
||||
expect(ALLE_ROLLEN).toHaveLength(5);
|
||||
expect(ALLE_RECHTE).toHaveLength(8);
|
||||
expect(faelle).toHaveLength(40);
|
||||
// Haelt fest, dass die Tabelle unten wirklich vollstaendig ist: vier Rollen, sieben Rechte.
|
||||
expect(ALLE_ROLLEN).toHaveLength(4);
|
||||
expect(ALLE_RECHTE).toHaveLength(7);
|
||||
expect(faelle).toHaveLength(28);
|
||||
});
|
||||
|
||||
it.each(faelle)("%s: %s -> %s", (recht, rolle, erwartet) => {
|
||||
@@ -71,27 +62,10 @@ describe("Rollen im Einzelnen", () => {
|
||||
expect(darf("REDAKTEUR", "archivVerwalten")).toBe(false);
|
||||
});
|
||||
|
||||
it("gibt ADMIN alles – ausser der Selbstauskunft", () => {
|
||||
for (const recht of ALLE_RECHTE.filter((r) => r !== "selbstauskunft")) {
|
||||
it("gibt ADMIN alles", () => {
|
||||
for (const recht of ALLE_RECHTE) {
|
||||
expect(darf("ADMIN", recht), `ADMIN darf ${recht}`).toBe(true);
|
||||
}
|
||||
/*
|
||||
* Die eine Ausnahme, und sie ist Absicht (#035): `selbstauskunft` ist kein
|
||||
* zusaetzliches Recht, sondern ein **Ersatz** fuer `lesen`. Ein ADMIN sieht
|
||||
* ueber `lesen` ohnehin alles; ihm die Selbstauskunft zu geben waere ein
|
||||
* zweiter Weg zu denselben Daten — und damit eine zweite Stelle, an der
|
||||
* jemand die Einschraenkung auf die eigene Person vergessen kann.
|
||||
*/
|
||||
expect(darf("ADMIN", "selbstauskunft")).toBe(false);
|
||||
});
|
||||
|
||||
it("laesst MITARBEITER genau eines und sonst nichts", () => {
|
||||
expect(darf("MITARBEITER", "selbstauskunft")).toBe(true);
|
||||
for (const recht of ALLE_RECHTE.filter((r) => r !== "selbstauskunft")) {
|
||||
expect(darf("MITARBEITER", recht), `MITARBEITER darf ${recht} nicht`).toBe(false);
|
||||
}
|
||||
// Vor allem nicht `lesen`: das waere die Akteneinsicht, die es nicht sein soll.
|
||||
expect(darf("MITARBEITER", "lesen")).toBe(false);
|
||||
});
|
||||
|
||||
it("behaelt das Zurueckholen aus dem Archiv beim ADMIN", () => {
|
||||
@@ -129,21 +103,14 @@ describe("Aufbau der Tabelle", () => {
|
||||
expect(Object.keys(ROLLEN_RANG).sort()).toStrictEqual([...ALLE_ROLLEN].sort());
|
||||
});
|
||||
|
||||
it("gibt jeder Rolle einen eigenen Rang, ADMIN oben und MITARBEITER unten", () => {
|
||||
it("gibt jeder Rolle einen eigenen Rang, ADMIN oben und LESER unten", () => {
|
||||
const raenge = ALLE_ROLLEN.map((rolle) => ROLLEN_RANG[rolle]);
|
||||
expect(new Set(raenge).size).toBe(ALLE_ROLLEN.length);
|
||||
expect(Math.max(...raenge)).toBe(ROLLEN_RANG.ADMIN);
|
||||
expect(Math.min(...raenge)).toBe(ROLLEN_RANG.MITARBEITER);
|
||||
expect(Math.min(...raenge)).toBe(ROLLEN_RANG.LESER);
|
||||
expect(ROLLEN_RANG.ADMIN).toBeGreaterThan(ROLLEN_RANG.REDAKTEUR);
|
||||
expect(ROLLEN_RANG.REDAKTEUR).toBeGreaterThan(ROLLEN_RANG.TECHNIKER);
|
||||
expect(ROLLEN_RANG.TECHNIKER).toBeGreaterThan(ROLLEN_RANG.LESER);
|
||||
/*
|
||||
* **Unter LESER.** Der Rang entscheidet in `staerksteRolle`, wer gewinnt,
|
||||
* wenn jemand in mehreren abgebildeten Gruppen steht. Stuende MITARBEITER
|
||||
* darueber, verdraengte eine Selbstauskunft-Gruppe die echte Rolle — und ein
|
||||
* Techniker verlöre seine Rechte, weil er auch Mitarbeiter des Hauses ist.
|
||||
*/
|
||||
expect(ROLLEN_RANG.LESER).toBeGreaterThan(ROLLEN_RANG.MITARBEITER);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
-1111
File diff suppressed because it is too large
Load Diff
+2
-5
@@ -526,14 +526,11 @@ describe.runIf(datenbankVerfuegbar)("Seed gegen die Datenbank", () => {
|
||||
|
||||
const quelle = await testPrisma.verzeichnisquelle.findFirstOrThrow();
|
||||
expect(quelle.aktiv).toBe(false);
|
||||
// Seit #028 sind die LDAP-Felder nullable (nur bei `art = LDAP` belegt) — die
|
||||
// Beispielquelle ist eine LDAP-Quelle, also stehen sie.
|
||||
expect(quelle.art).toBe("LDAP");
|
||||
expect(quelle.url?.startsWith("ldaps://")).toBe(true);
|
||||
expect(quelle.url.startsWith("ldaps://")).toBe(true);
|
||||
// Das Versionspraefix prueft auch die Datenbank; hier zaehlt, dass sich das
|
||||
// Feld ueberhaupt entschluesseln laesst und nichts Lesbares enthaelt.
|
||||
expect(quelle.bindPasswortVerschluesselt).toMatch(/^v[0-9]+\./u);
|
||||
expect(entschluesseln(quelle.bindPasswortVerschluesselt ?? "").length).toBeGreaterThan(16);
|
||||
expect(entschluesseln(quelle.bindPasswortVerschluesselt).length).toBeGreaterThan(16);
|
||||
});
|
||||
|
||||
it("erzeugt im Modus Dienstleistung die beiden Demo-Kunden mit MUS-PC-001", async () => {
|
||||
|
||||
@@ -1,569 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { Betriebsmodus, LaufStatus, VersionStatus } from "@/generated/prisma/enums";
|
||||
import { AnmeldeFehler, benutzerAusOidcUebernehmen } from "@/server/auth/benutzer";
|
||||
import type { OidcAnspruecke } from "@/server/auth/oidc";
|
||||
import { darf, startseite } from "@/server/auth/rollen";
|
||||
import { navigationFuer } from "@/lib/navigation";
|
||||
import { meineDaten, meineGeraete, meineLaeufeOhneGeraet } from "@/server/queries/selbstauskunft";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
/**
|
||||
* Mitarbeiter-Selbstauskunft (#035).
|
||||
*
|
||||
* Die wichtigste Zusage dieses Meilensteins ist eine **Verneinung**: eine
|
||||
* Selbstauskunft ist keine Akteneinsicht. Geprueft wird sie hier nicht an der
|
||||
* Seite, sondern an der **Antwort** des Lesemodells — eine Seite laesst sich
|
||||
* umgehen, ein Lesemodell nicht.
|
||||
*/
|
||||
|
||||
const UMGEBUNG = { ...process.env };
|
||||
|
||||
function anspruecke(ueberschreiben: Partial<OidcAnspruecke> = {}): OidcAnspruecke {
|
||||
return {
|
||||
sub: "anbieter-sub-mitarbeiter",
|
||||
email: "maria.huber@firma.example",
|
||||
emailBestaetigt: true,
|
||||
name: "Maria Huber",
|
||||
gruppen: [],
|
||||
upn: "mhuber@firma.local",
|
||||
ziel: "/",
|
||||
...ueberschreiben,
|
||||
};
|
||||
}
|
||||
|
||||
async function aufbau() {
|
||||
const kunde = await testPrisma.kunde.create({
|
||||
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
|
||||
});
|
||||
await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } });
|
||||
|
||||
const maria = await testPrisma.mitarbeiter.create({
|
||||
data: {
|
||||
kundeId: kunde.id,
|
||||
vorname: "Maria",
|
||||
nachname: "Huber",
|
||||
anzeigename: "Maria Huber",
|
||||
upn: "mhuber@firma.local",
|
||||
mail: "maria.huber@firma.example",
|
||||
abteilung: "Buchhaltung",
|
||||
titel: "Sachbearbeiterin",
|
||||
},
|
||||
});
|
||||
const kollege = await testPrisma.mitarbeiter.create({
|
||||
data: {
|
||||
kundeId: kunde.id,
|
||||
vorname: "Ali",
|
||||
nachname: "Demir",
|
||||
anzeigename: "Ali Demir",
|
||||
upn: "ademir@firma.local",
|
||||
},
|
||||
});
|
||||
|
||||
const meinNotebook = await testPrisma.geraet.create({
|
||||
data: { kundeId: kunde.id, mitarbeiterId: maria.id, tagCode: "LZ-MEANBC", hostname: "NB-042" },
|
||||
});
|
||||
const fremdesNotebook = await testPrisma.geraet.create({
|
||||
data: { kundeId: kunde.id, mitarbeiterId: kollege.id, tagCode: "LZ-FREMDA", hostname: "NB-099" },
|
||||
});
|
||||
|
||||
const vorlage = await testPrisma.vorlage.create({ data: { name: "PC Neuinstallation" } });
|
||||
const version = await testPrisma.vorlagenVersion.create({
|
||||
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
|
||||
});
|
||||
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
|
||||
data: { versionId: version.id, titel: "Konto", position: 0 },
|
||||
});
|
||||
const schritt = await testPrisma.vorlagenSchritt.create({
|
||||
data: {
|
||||
abschnittId: abschnitt.id,
|
||||
titel: "Konto anlegen",
|
||||
typ: "BEFEHL",
|
||||
position: 0,
|
||||
konfig: {
|
||||
shell: "powershell",
|
||||
befehl: 'New-ADUser -AccountPassword "{{generiertes_passwort}}"',
|
||||
},
|
||||
},
|
||||
});
|
||||
await testPrisma.vorlagenVersion.update({
|
||||
where: { id: version.id },
|
||||
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
|
||||
});
|
||||
|
||||
const admin = await testPrisma.benutzer.create({
|
||||
data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" },
|
||||
});
|
||||
|
||||
/* Ein Lauf an **meinem** Geraet, mit Notiz, Kommentar und Anhang daran. */
|
||||
const meinLauf = await testPrisma.lauf.create({
|
||||
data: {
|
||||
vorlagenVersionId: version.id,
|
||||
kundeId: kunde.id,
|
||||
titel: "PC Neuinstallation – NB-042",
|
||||
geraetId: meinNotebook.id,
|
||||
mitarbeiterId: maria.id,
|
||||
status: LaufStatus.ABGESCHLOSSEN,
|
||||
gestartetAm: new Date("2026-09-01T08:00:00Z"),
|
||||
abgeschlossenAm: new Date("2026-09-02T15:00:00Z"),
|
||||
},
|
||||
});
|
||||
const ergebnis = await testPrisma.schrittergebnis.create({
|
||||
data: {
|
||||
laufId: meinLauf.id,
|
||||
vorlagenSchrittId: schritt.id,
|
||||
zustand: "ERLEDIGT",
|
||||
notiz: "Nutzerin war unkooperativ, Gerät kam beschädigt zurück",
|
||||
wert: "irgendein Wert",
|
||||
aktualisiertVonId: admin.id,
|
||||
},
|
||||
});
|
||||
await testPrisma.kommentar.create({
|
||||
data: { laufId: meinLauf.id, benutzerId: admin.id, rumpf: "Rückfrage ans Team wegen der Lizenz" },
|
||||
});
|
||||
await testPrisma.anhang.create({
|
||||
data: {
|
||||
schrittergebnisId: ergebnis.id,
|
||||
laufId: meinLauf.id,
|
||||
kundeId: kunde.id,
|
||||
dateiname: "schreibtisch.png",
|
||||
medientyp: "image/png",
|
||||
groesse: 10,
|
||||
pruefsumme: "d".repeat(64),
|
||||
},
|
||||
});
|
||||
|
||||
/* Ein Lauf am Geraet des Kollegen – darf nie auftauchen. */
|
||||
const fremderLauf = await testPrisma.lauf.create({
|
||||
data: {
|
||||
vorlagenVersionId: version.id,
|
||||
kundeId: kunde.id,
|
||||
titel: "PC Neuinstallation – NB-099",
|
||||
geraetId: fremdesNotebook.id,
|
||||
mitarbeiterId: kollege.id,
|
||||
status: LaufStatus.IN_ARBEIT,
|
||||
gestartetAm: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
/* Ein Lauf auf meine Person, ohne Geraet: das Onboarding. */
|
||||
const persoenlich = await testPrisma.lauf.create({
|
||||
data: {
|
||||
vorlagenVersionId: version.id,
|
||||
kundeId: kunde.id,
|
||||
titel: "Onboarding Maria Huber",
|
||||
mitarbeiterId: maria.id,
|
||||
status: LaufStatus.ABGESCHLOSSEN,
|
||||
gestartetAm: new Date("2026-08-01T08:00:00Z"),
|
||||
abgeschlossenAm: new Date("2026-08-01T12:00:00Z"),
|
||||
},
|
||||
});
|
||||
|
||||
return { kunde, maria, kollege, meinNotebook, fremdesNotebook, meinLauf, fremderLauf, persoenlich };
|
||||
}
|
||||
|
||||
describe("Die Rolle MITARBEITER", () => {
|
||||
it("darf genau die Selbstauskunft und sonst nichts", () => {
|
||||
expect(darf("MITARBEITER", "selbstauskunft")).toBe(true);
|
||||
expect(darf("MITARBEITER", "lesen")).toBe(false);
|
||||
expect(darf("MITARBEITER", "laeufeBearbeiten")).toBe(false);
|
||||
expect(darf("MITARBEITER", "geraeteBearbeiten")).toBe(false);
|
||||
expect(darf("MITARBEITER", "mitarbeiterBearbeiten")).toBe(false);
|
||||
expect(darf("MITARBEITER", "vorlagenBearbeiten")).toBe(false);
|
||||
expect(darf("MITARBEITER", "archivVerwalten")).toBe(false);
|
||||
expect(darf("MITARBEITER", "verwaltung")).toBe(false);
|
||||
});
|
||||
|
||||
it("landet nach der Anmeldung auf der eigenen Seite und nicht auf „kein Zugriff“", () => {
|
||||
/*
|
||||
* Beim Durchklicken gefunden: das Standardziel nach der Anmeldung ist `/`,
|
||||
* und die Uebersicht verlangt `lesen`. Ein MITARBEITER sah deshalb als
|
||||
* Erstes „Dafuer fehlt dir die Berechtigung“ — genau die leere Seite, die
|
||||
* dieses Issue vermeiden will. Dieselbe Funktion richtet auch den Knopf auf
|
||||
* `/kein-zugriff`, der vorher im Kreis fuehrte.
|
||||
*/
|
||||
expect(startseite("MITARBEITER")).toBe("/meine-geraete");
|
||||
for (const rolle of ["ADMIN", "REDAKTEUR", "TECHNIKER", "LESER"] as const) {
|
||||
expect(startseite(rolle), `${rolle} faengt auf der Uebersicht an`).toBe("/");
|
||||
}
|
||||
});
|
||||
|
||||
it("sieht in der Navigation nur die eine Seite", () => {
|
||||
/*
|
||||
* „Alles andere ist weg, nicht grau“ — ein Eintrag, der auf
|
||||
* `/kein-zugriff` fuehrt, ist schlimmer als keiner.
|
||||
*/
|
||||
const eintraege = navigationFuer("INTERN", (recht) => darf("MITARBEITER", recht));
|
||||
expect(eintraege.map((e) => e.pfad)).toEqual(["/meine-geraete"]);
|
||||
});
|
||||
|
||||
it("nimmt den anderen Rollen die Selbstauskunft nicht weg – sie brauchen sie nicht", () => {
|
||||
for (const rolle of ["ADMIN", "REDAKTEUR", "TECHNIKER", "LESER"] as const) {
|
||||
const pfade = navigationFuer("INTERN", (recht) => darf(rolle, recht)).map((e) => e.pfad);
|
||||
expect(pfade, `${rolle} sieht die Selbstauskunft nicht`).not.toContain("/meine-geraete");
|
||||
expect(pfade.length, `${rolle} sieht die gewoehnliche Navigation`).toBeGreaterThan(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Was ein Mitarbeiter sieht", () => {
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
});
|
||||
|
||||
it("sieht die Läufe seiner Geräte und keine anderen", async () => {
|
||||
const { maria, meinLauf, fremderLauf } = await aufbau();
|
||||
|
||||
const geraete = await meineGeraete(maria.id);
|
||||
expect(geraete).toHaveLength(1);
|
||||
expect(geraete[0]?.bezeichnung).toBe("NB-042");
|
||||
|
||||
const laufIds = geraete.flatMap((g) => g.laeufe.map((l) => l.id));
|
||||
expect(laufIds).toEqual([meinLauf.id]);
|
||||
expect(laufIds).not.toContain(fremderLauf.id);
|
||||
});
|
||||
|
||||
it("sieht die Läufe zu seiner Person ohne Gerät", async () => {
|
||||
const { maria, persoenlich, meinLauf } = await aufbau();
|
||||
const ohne = await meineLaeufeOhneGeraet(maria.id);
|
||||
expect(ohne.map((l) => l.id)).toEqual([persoenlich.id]);
|
||||
// Der Lauf am Geraet steht schon unter dem Geraet – hier waere er doppelt.
|
||||
expect(ohne.map((l) => l.id)).not.toContain(meinLauf.id);
|
||||
});
|
||||
|
||||
it("sieht keine Schritte, Notizen, Kommentare oder Anhänge – geprüft über die Antwort", async () => {
|
||||
/*
|
||||
* Das Kernkriterium. Geprueft wird die **Antwort** und nicht die Seite: im
|
||||
* Aufbau haengen an dem Lauf eine Notiz („Nutzerin war unkooperativ“), ein
|
||||
* Kommentar, ein Anhang und ein Befehlsschritt mit
|
||||
* `{{generiertes_passwort}}`. Nichts davon darf durchkommen.
|
||||
*/
|
||||
const { maria } = await aufbau();
|
||||
const antwort = JSON.stringify([
|
||||
await meineGeraete(maria.id),
|
||||
await meineLaeufeOhneGeraet(maria.id),
|
||||
await meineDaten(maria.id),
|
||||
]);
|
||||
|
||||
expect(antwort).not.toContain("unkooperativ");
|
||||
expect(antwort).not.toContain("Rückfrage ans Team");
|
||||
expect(antwort).not.toContain("schreibtisch.png");
|
||||
expect(antwort).not.toContain("New-ADUser");
|
||||
expect(antwort).not.toContain("{{");
|
||||
expect(antwort).not.toContain("irgendein Wert");
|
||||
expect(antwort).not.toContain("ERLEDIGT");
|
||||
|
||||
// Und was durchkommen **soll**, kommt durch.
|
||||
expect(antwort).toContain("PC Neuinstallation – NB-042");
|
||||
expect(antwort).toContain("ABGESCHLOSSEN");
|
||||
});
|
||||
|
||||
it("holt die verbotenen Felder nicht einmal aus der Datenbank", () => {
|
||||
/*
|
||||
* **Gefunden durch eine Gegenprobe, die gruen blieb.** Der Test unten prueft
|
||||
* die Antwort, und die ist sicher: `laufBauen` setzt sie aus den erlaubten
|
||||
* Feldern zusammen, also kommt auch bei einem zu weiten `select` nichts
|
||||
* durch. Genau deshalb blieb er gruen, als im `select` versuchsweise
|
||||
* `ergebnisse: { notiz, wert }` stand — waehrend der Kopfkommentar der Datei
|
||||
* behauptet, das `select` hole es „gar nicht erst".
|
||||
*
|
||||
* Diese Behauptung braucht ihre eigene Deckung. Sie ist nicht bloss Zierde:
|
||||
* ein `select`, das Notizen mitliest, zieht sie durch Protokolle und
|
||||
* Fehlerberichte, und der naechste, der `laufBauen` erweitert, hat sie schon
|
||||
* zur Hand.
|
||||
*/
|
||||
const quelle = readFileSync(join(process.cwd(), "src/server/queries/selbstauskunft.ts"), "utf8");
|
||||
for (const verboten of ["notiz", "wert", "ergebnisse", "kommentare", "anhaenge", "konfig"]) {
|
||||
expect(quelle, `das Lesemodell darf "${verboten}" nicht abfragen`).not.toContain(`${verboten}: true`);
|
||||
expect(quelle, `das Lesemodell darf "${verboten}" nicht einbinden`).not.toContain(`${verboten}: {`);
|
||||
}
|
||||
});
|
||||
|
||||
it("gibt je Lauf genau die erlaubten Felder heraus", async () => {
|
||||
// Namentlich aufgezaehlt: ein zusaetzliches Feld im Lesemodell faellt hier
|
||||
// auf und nicht erst, wenn es auf der Seite steht.
|
||||
const { maria } = await aufbau();
|
||||
const geraete = await meineGeraete(maria.id);
|
||||
const lauf = geraete[0]?.laeufe[0];
|
||||
expect(lauf).toBeDefined();
|
||||
expect(Object.keys(lauf ?? {}).sort()).toEqual([
|
||||
"abgeschlossenAm",
|
||||
"begonnenAm",
|
||||
"erstelltAm",
|
||||
"id",
|
||||
"status",
|
||||
"titel",
|
||||
"vorlagenName",
|
||||
]);
|
||||
});
|
||||
|
||||
it("zeigt archivierte Geräte weiter – „mein altes Notebook“ ist die Frage", async () => {
|
||||
const { maria, meinNotebook } = await aufbau();
|
||||
await testPrisma.geraet.update({ where: { id: meinNotebook.id }, data: { archiviert: true } });
|
||||
|
||||
const geraete = await meineGeraete(maria.id);
|
||||
expect(geraete).toHaveLength(1);
|
||||
expect(geraete[0]?.archiviert).toBe(true);
|
||||
});
|
||||
|
||||
it("liefert die eigenen Stammdaten, aber keine fremden", async () => {
|
||||
const { maria, kollege } = await aufbau();
|
||||
const daten = await meineDaten(maria.id);
|
||||
expect(daten).toMatchObject({ anzeigename: "Maria Huber", abteilung: "Buchhaltung" });
|
||||
|
||||
// Die Funktion nimmt eine Kennung – mit der des Kollegen bekaeme man dessen
|
||||
// Daten. Genau deshalb kommt sie aus der Sitzung und nie aus einer Eingabe.
|
||||
const fremd = await meineDaten(kollege.id);
|
||||
expect(fremd?.anzeigename).toBe("Ali Demir");
|
||||
});
|
||||
|
||||
it("gibt für jemanden ohne Geräte eine leere Liste und keinen Fehler", async () => {
|
||||
const { kollege, fremdesNotebook } = await aufbau();
|
||||
await testPrisma.lauf.deleteMany({ where: { geraetId: fremdesNotebook.id } });
|
||||
await testPrisma.geraet.delete({ where: { id: fremdesNotebook.id } });
|
||||
|
||||
expect(await meineGeraete(kollege.id)).toEqual([]);
|
||||
expect(await meineLaeufeOhneGeraet(kollege.id)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Zuordnung bei der Anmeldung", () => {
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
process.env = { ...UMGEBUNG };
|
||||
// Zuordnung aktiv – sonst greift die Selbstauskunft absichtlich gar nicht.
|
||||
process.env["OIDC_GROUP_ADMIN"] = "lz-admin";
|
||||
process.env["OIDC_GROUP_TECHNICIAN"] = "lz-technik";
|
||||
});
|
||||
|
||||
it("macht aus einer Anmeldung ohne Rollengruppe einen MITARBEITER", async () => {
|
||||
const { maria } = await aufbau();
|
||||
|
||||
const benutzer = await benutzerAusOidcUebernehmen(anspruecke());
|
||||
|
||||
expect(benutzer.rolle).toBe("MITARBEITER");
|
||||
expect(benutzer.mitarbeiterId).toBe(maria.id);
|
||||
});
|
||||
|
||||
it("ordnet unabhängig von Groß- und Kleinschreibung zu", async () => {
|
||||
const { maria } = await aufbau();
|
||||
const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ upn: "MHuber@Firma.Local" }));
|
||||
expect(benutzer.mitarbeiterId).toBe(maria.id);
|
||||
});
|
||||
|
||||
it("lässt eine echte Rollengruppe vorgehen und ordnet dann keine Person zu", async () => {
|
||||
await aufbau();
|
||||
const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-technik"] }));
|
||||
expect(benutzer.rolle).toBe("TECHNIKER");
|
||||
// Wer eine echte Rolle hat, sieht ueber `lesen` ohnehin alles – eine
|
||||
// Zuordnung daneben waere ein zweiter Weg zu denselben Daten.
|
||||
expect(benutzer.mitarbeiterId).toBeNull();
|
||||
});
|
||||
|
||||
it("weist ab, wenn es zu dem UPN keine Person gibt", async () => {
|
||||
await aufbau();
|
||||
await expect(benutzerAusOidcUebernehmen(anspruecke({ upn: "niemand@firma.local" }))).rejects.toThrow(
|
||||
AnmeldeFehler,
|
||||
);
|
||||
|
||||
// Und kein halb angelegtes Konto hinterlassen.
|
||||
expect(await testPrisma.benutzer.count({ where: { email: "maria.huber@firma.example" } })).toBe(0);
|
||||
});
|
||||
|
||||
it("nennt den Grund, damit die Anmeldeseite etwas Hilfreiches sagen kann", async () => {
|
||||
await aufbau();
|
||||
try {
|
||||
await benutzerAusOidcUebernehmen(anspruecke({ upn: "niemand@firma.local" }));
|
||||
throw new Error("haette werfen muessen");
|
||||
} catch (fehler) {
|
||||
expect(fehler).toBeInstanceOf(AnmeldeFehler);
|
||||
expect((fehler as AnmeldeFehler).grund).toBe("keine-person");
|
||||
}
|
||||
});
|
||||
|
||||
it("weist einen mehrdeutigen UPN ab und protokolliert ihn", async () => {
|
||||
const { kunde } = await aufbau();
|
||||
// Zwei Personen mit demselben UPN – etwa zwei Mandanten derselben Firma.
|
||||
const zweiter = await testPrisma.kunde.create({ data: { name: "Zweite GmbH", kuerzel: "ZWEI" } });
|
||||
await testPrisma.mitarbeiter.create({
|
||||
data: {
|
||||
kundeId: zweiter.id,
|
||||
vorname: "Maria",
|
||||
nachname: "Huber",
|
||||
anzeigename: "Maria Huber (zweite)",
|
||||
upn: "mhuber@firma.local",
|
||||
},
|
||||
});
|
||||
expect(kunde.id).toBeTruthy();
|
||||
|
||||
try {
|
||||
await benutzerAusOidcUebernehmen(anspruecke());
|
||||
throw new Error("haette werfen muessen");
|
||||
} catch (fehler) {
|
||||
expect((fehler as AnmeldeFehler).grund).toBe("upn-mehrdeutig");
|
||||
}
|
||||
|
||||
const protokoll = await testPrisma.protokoll.findMany({
|
||||
where: { aktion: { contains: "mehrdeutig" } },
|
||||
});
|
||||
expect(protokoll).toHaveLength(1);
|
||||
// Ohne Urheber: das Konto gibt es an der Stelle vielleicht noch nicht.
|
||||
expect(protokoll[0]?.benutzerId).toBeNull();
|
||||
expect(JSON.stringify(protokoll[0]?.nachher)).toContain("mhuber@firma.local");
|
||||
expect(protokoll[0]?.nachher).toMatchObject({ treffer: 2 });
|
||||
});
|
||||
|
||||
it("nennt im Protokoll die wirkliche Zahl der Treffer, nicht zwei", async () => {
|
||||
/*
|
||||
* **Die Zahl war geraten.** `personZuUpn` hatte `take: 2` — fuer die
|
||||
* Entscheidung „mehrdeutig“ genuegt das, aber dieselbe Zahl ging in den
|
||||
* Protokolleintrag und sagte dort immer „2“, auch bei vier Treffern
|
||||
* (Reviewbefund zu #029).
|
||||
*
|
||||
* Wer das Protokoll liest, liest es, **um den Fall aufzuloesen** — er muss
|
||||
* wissen, wie viele Zeilen er zu bereinigen hat. Eine Zahl, die nicht die
|
||||
* Zahl ist, schickt ihn nach der zweiten Korrektur zurueck.
|
||||
*/
|
||||
await aufbau();
|
||||
for (const kuerzel of ["ZWEI", "DREI", "VIER"]) {
|
||||
const weiterer = await testPrisma.kunde.create({
|
||||
data: { name: `${kuerzel} GmbH`, kuerzel },
|
||||
});
|
||||
await testPrisma.mitarbeiter.create({
|
||||
data: {
|
||||
kundeId: weiterer.id,
|
||||
vorname: "Maria",
|
||||
nachname: "Huber",
|
||||
anzeigename: `Maria Huber (${kuerzel})`,
|
||||
upn: "mhuber@firma.local",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await expect(benutzerAusOidcUebernehmen(anspruecke())).rejects.toThrow();
|
||||
|
||||
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
|
||||
where: { aktion: { contains: "mehrdeutig" } },
|
||||
});
|
||||
expect(eintrag.nachher).toMatchObject({ treffer: 4 });
|
||||
});
|
||||
|
||||
it("ordnet keine deaktivierte Person zu", async () => {
|
||||
const { maria } = await aufbau();
|
||||
await testPrisma.mitarbeiter.update({ where: { id: maria.id }, data: { aktiv: false } });
|
||||
|
||||
await expect(benutzerAusOidcUebernehmen(anspruecke())).rejects.toThrow(AnmeldeFehler);
|
||||
});
|
||||
|
||||
it("greift gar nicht, solange keine Gruppen-Zuordnung eingerichtet ist", async () => {
|
||||
/*
|
||||
* Die Wache gegen ein Update, das Leute aussperrt. Ohne abgebildete Gruppen
|
||||
* kann die Anwendung nicht unterscheiden, ob jemand Techniker oder
|
||||
* Mitarbeiter ist — dann bleibt alles wie vor #035.
|
||||
*/
|
||||
delete process.env["OIDC_GROUP_ADMIN"];
|
||||
delete process.env["OIDC_GROUP_TECHNICIAN"];
|
||||
await aufbau();
|
||||
|
||||
const benutzer = await benutzerAusOidcUebernehmen(anspruecke());
|
||||
expect(benutzer.rolle).toBe("LESER");
|
||||
expect(benutzer.mitarbeiterId).toBeNull();
|
||||
});
|
||||
|
||||
it("stuft bei der nächsten Anmeldung um, wenn jemand eine Rollengruppe bekommt", async () => {
|
||||
const { maria } = await aufbau();
|
||||
const erst = await benutzerAusOidcUebernehmen(anspruecke());
|
||||
expect(erst.rolle).toBe("MITARBEITER");
|
||||
expect(erst.mitarbeiterId).toBe(maria.id);
|
||||
|
||||
const dann = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-admin"] }));
|
||||
expect(dann.rolle).toBe("ADMIN");
|
||||
// Die Zuordnung muss **mit** verschwinden, sonst verletzt sie den CHECK.
|
||||
expect(dann.mitarbeiterId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Was die Datenbank selbst festhält", () => {
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
});
|
||||
|
||||
it("lässt die Rolle MITARBEITER nicht ohne Zuordnung zu", async () => {
|
||||
await expect(
|
||||
testPrisma.benutzer.create({
|
||||
data: { email: "ohne@firma.example", name: "Ohne Person", rolle: "MITARBEITER" },
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("lässt eine Zuordnung nicht an einer anderen Rolle zu", async () => {
|
||||
const { maria } = await aufbau();
|
||||
await expect(
|
||||
testPrisma.benutzer.create({
|
||||
data: {
|
||||
email: "admin2@firma.example",
|
||||
name: "Admin mit Person",
|
||||
rolle: "ADMIN",
|
||||
mitarbeiterId: maria.id,
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("gibt eine Person an höchstens ein Konto", async () => {
|
||||
const { maria } = await aufbau();
|
||||
await testPrisma.benutzer.create({
|
||||
data: {
|
||||
email: "maria@firma.example",
|
||||
name: "Maria Huber",
|
||||
rolle: "MITARBEITER",
|
||||
mitarbeiterId: maria.id,
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
testPrisma.benutzer.create({
|
||||
data: {
|
||||
email: "maria.zweitkonto@firma.example",
|
||||
name: "Maria Huber (zweites Konto)",
|
||||
rolle: "MITARBEITER",
|
||||
mitarbeiterId: maria.id,
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("zieht die Rolle mit, wenn die Person gelöscht wird", async () => {
|
||||
/*
|
||||
* `ON DELETE SET NULL` allein wuerde am CHECK scheitern: ein MITARBEITER
|
||||
* ohne Zuordnung ist verboten. Der Trigger setzt deshalb beides zugleich —
|
||||
* sonst waere das Loeschen eines Mitarbeiters unmoeglich statt folgerichtig.
|
||||
*/
|
||||
const { kunde } = await aufbau();
|
||||
const person = await testPrisma.mitarbeiter.create({
|
||||
data: {
|
||||
kundeId: kunde.id,
|
||||
vorname: "Weg",
|
||||
nachname: "Damit",
|
||||
anzeigename: "Weg Damit",
|
||||
upn: "wdamit@firma.local",
|
||||
},
|
||||
});
|
||||
const konto = await testPrisma.benutzer.create({
|
||||
data: {
|
||||
email: "weg@firma.example",
|
||||
name: "Weg Damit",
|
||||
rolle: "MITARBEITER",
|
||||
mitarbeiterId: person.id,
|
||||
},
|
||||
});
|
||||
|
||||
await testPrisma.mitarbeiter.delete({ where: { id: person.id } });
|
||||
|
||||
const nachher = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: konto.id } });
|
||||
expect(nachher.rolle).toBe("LESER");
|
||||
expect(nachher.mitarbeiterId).toBeNull();
|
||||
// Das Konto bleibt – nur ohne Person.
|
||||
expect(nachher.aktiv).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -436,57 +436,6 @@ describe.runIf(datenbankVerfuegbar)("Verzeichnisquellen", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("weist einen Attributnamen ab, der keiner ist – auch am Formular vorbei", async () => {
|
||||
/*
|
||||
* **Die Regel sass eine Schicht ueber dem Code, der sich auf sie berief**
|
||||
* (Reviewbefund zu #029). `src/server/graph/leser.ts` schrieb im Kommentar
|
||||
* „die Werte selbst sind feste Feldnamen aus ATTRIBUT_MUSTER, also ist hier
|
||||
* nichts zu maskieren“ — gepruefet wurde das aber nur in
|
||||
* `zuordnungAusFormular`, also in der Server Action.
|
||||
*
|
||||
* Nachgewiesen wurde, dass genau dieser Name durch den Dienst kam und einen
|
||||
* **zweiten `$filter`** in die Graph-Adresse schrieb. Hereinkommen konnte er
|
||||
* nur mit `verwaltung` oder per Skript, Seed und `psql` — die
|
||||
* Sicherheitswirkung ist also klein. Der Befund ist der Ort der Regel:
|
||||
* CLAUDE.md verlangt sie in der Datenbank, und sie stand nicht einmal im
|
||||
* Dienst.
|
||||
*/
|
||||
const { admin } = await grundbestand();
|
||||
const boese = { abteilung: "department,id&$filter=boese eq 1" };
|
||||
|
||||
await expect(quelleAnlegen(quellenEingabe({ attributZuordnung: boese }), admin)).rejects.toThrow(
|
||||
/kein Attributname/u,
|
||||
);
|
||||
|
||||
// Und beim Bearbeiten genauso.
|
||||
const quelle = await quelleAnlegen(quellenEingabe(), admin);
|
||||
await expect(quelleBearbeiten(quelle.id, { attributZuordnung: boese }, admin)).rejects.toThrow(
|
||||
/kein Attributname/u,
|
||||
);
|
||||
|
||||
// Die gespeicherte Zeile ist unberührt.
|
||||
expect((await quelleHolen(quelle.id, admin))?.attributZuordnung.abteilung).toBe(AD_ZUORDNUNG.abteilung);
|
||||
});
|
||||
|
||||
it("verwirft beim Lesen einen unbrauchbaren Namen, statt die Liste zu sprengen", async () => {
|
||||
/*
|
||||
* Die Gegenrichtung: eine Zeile, die vor dieser Prüfung angelegt wurde oder
|
||||
* von Hand in die Datenbank kam. Hier zu werfen wäre falsch — eine einzige
|
||||
* kaputte Zeile machte die ganze Quellenliste unbenutzbar. Sie bekommt
|
||||
* deshalb die Vorgabe.
|
||||
*/
|
||||
const { admin } = await grundbestand();
|
||||
const quelle = await quelleAnlegen(quellenEingabe(), admin);
|
||||
await testPrisma.verzeichnisquelle.update({
|
||||
where: { id: quelle.id },
|
||||
data: { attributZuordnung: { abteilung: "department,id&$filter=boese eq 1" } },
|
||||
});
|
||||
|
||||
const gelesen = await quelleHolen(quelle.id, admin);
|
||||
expect(gelesen?.attributZuordnung.abteilung).toBe(AD_ZUORDNUNG.abteilung);
|
||||
expect(JSON.stringify(gelesen)).not.toContain("$filter");
|
||||
});
|
||||
|
||||
it("weist ein Intervall ausserhalb der Grenzen ab", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
await expect(quelleAnlegen(quellenEingabe({ abgleichIntervallMinuten: 1 }), admin)).rejects.toThrow(
|
||||
@@ -587,12 +536,12 @@ describe.runIf(datenbankVerfuegbar)("Verzeichnisquellen", () => {
|
||||
// Umbenennen kaputt.
|
||||
await quelleBearbeiten(quelle.id, { bindPasswort: "", name: "Neu" }, admin);
|
||||
const nachher = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
|
||||
expect(entschluesseln(nachher.bindPasswortVerschluesselt ?? "")).toBe(LDAP_BIND_PASSWORT);
|
||||
expect(entschluesseln(nachher.bindPasswortVerschluesselt)).toBe(LDAP_BIND_PASSWORT);
|
||||
expect(nachher.bindPasswortVerschluesselt).toBe(vorher.bindPasswortVerschluesselt);
|
||||
|
||||
await quelleBearbeiten(quelle.id, { bindPasswort: "ein-neues-geheimnis" }, admin);
|
||||
const dritte = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
|
||||
expect(entschluesseln(dritte.bindPasswortVerschluesselt ?? "")).toBe("ein-neues-geheimnis");
|
||||
expect(entschluesseln(dritte.bindPasswortVerschluesselt)).toBe("ein-neues-geheimnis");
|
||||
});
|
||||
|
||||
it("gibt das Bind-Passwort in keiner Antwort und in keinem Protokoll heraus", async () => {
|
||||
@@ -613,7 +562,7 @@ describe.runIf(datenbankVerfuegbar)("Verzeichnisquellen", () => {
|
||||
const zeile = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
|
||||
expect(zeile.bindPasswortVerschluesselt).not.toContain(LDAP_BIND_PASSWORT);
|
||||
expect(zeile.bindPasswortVerschluesselt).toMatch(/^v1\./u);
|
||||
expect(entschluesseln(zeile.bindPasswortVerschluesselt ?? "")).toBe(LDAP_BIND_PASSWORT);
|
||||
expect(entschluesseln(zeile.bindPasswortVerschluesselt)).toBe(LDAP_BIND_PASSWORT);
|
||||
});
|
||||
|
||||
it("loescht eine Quelle ohne Mitarbeiter und verweigert es sonst", async () => {
|
||||
|
||||
+5
-166
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SignJWT } from "jose";
|
||||
import { sicheresZiel } from "@/server/auth/anmeldung";
|
||||
import { OEFFENTLICH, istOeffentlich } from "@/proxy";
|
||||
import { istOeffentlich } from "@/proxy";
|
||||
import { kurzzeitTokenErzeugen, kurzzeitTokenLesen } from "@/lib/kurzzeit-token";
|
||||
import { sitzungErzeugen, sitzungLesen } from "@/lib/sitzung";
|
||||
|
||||
@@ -67,55 +67,20 @@ describe("Ruecksprungziel gegen Steuerzeichen", () => {
|
||||
});
|
||||
|
||||
describe("Allowlist des Proxys", () => {
|
||||
/**
|
||||
* Jeder oeffentliche Pfad, einzeln aufgezaehlt — **und zwar absichtlich von
|
||||
* Hand.** Diese Liste ist die Behauptung „genau das soll ohne Anmeldung
|
||||
* gehen“; aus `OEFFENTLICH` abgeleitet wuerde sie nichts mehr behaupten.
|
||||
* Dass sie vollstaendig ist, prueft der Test darunter.
|
||||
*/
|
||||
const ERWARTET_OEFFENTLICH = [
|
||||
"/anmelden",
|
||||
"/api/auth",
|
||||
"/api/gesundheit",
|
||||
// Der SCIM-Endpunkt (#029): ein Identitaetsanbieter hat kein Cookie, er
|
||||
// weist sich mit einem Bearer-Token aus, und jeder Handler darunter prueft
|
||||
// es selbst. Ohne diesen Eintrag leitet der Proxy jede Zustellung auf
|
||||
// /anmelden um, und das Provisioning hoert still auf zu arbeiten.
|
||||
"/api/scim",
|
||||
"/manifest.webmanifest",
|
||||
"/icon.svg",
|
||||
] as const;
|
||||
|
||||
it("laesst genau die Pfade durch, die ohne Anmeldung gehen muessen", () => {
|
||||
for (const pfad of [
|
||||
...ERWARTET_OEFFENTLICH,
|
||||
// Und die Unterpfade, auf die es im Betrieb ankommt.
|
||||
"/anmelden",
|
||||
"/api/auth/oidc/start",
|
||||
"/api/auth/oidc/rueckruf",
|
||||
"/api/auth/abmelden",
|
||||
"/api/scim/v2/Users",
|
||||
"/api/scim/v2/ServiceProviderConfig",
|
||||
"/api/gesundheit",
|
||||
"/manifest.webmanifest",
|
||||
"/icon.svg",
|
||||
]) {
|
||||
expect(istOeffentlich(pfad), pfad).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("hat keinen oeffentlichen Pfad, den dieser Test nicht kennt", () => {
|
||||
/*
|
||||
* **Der Test, der gefehlt hat.** `/api/scim` kam mit #029 in die Allowlist
|
||||
* und stand in keiner Pruefung — die Zeile liess sich entfernen, ohne dass
|
||||
* ein einziger Test rot wurde (Reviewbefund). Dabei ist der Eintrag beides:
|
||||
* noetig, damit das Provisioning ueberhaupt ankommt, und eine
|
||||
* Sicherheitsentscheidung, weil darunter jeder Handler sein Token selbst
|
||||
* pruefen **muss**.
|
||||
*
|
||||
* Deshalb hier der Vergleich in die andere Richtung: wer `OEFFENTLICH`
|
||||
* erweitert, muss es oben aufschreiben. Eine Liste, die nur in einer
|
||||
* Richtung geprueft wird, laeuft dem Code hinterher.
|
||||
*/
|
||||
expect([...OEFFENTLICH].sort()).toEqual([...ERWARTET_OEFFENTLICH].sort());
|
||||
});
|
||||
|
||||
it("laesst sich nicht durch einen angehaengten Namen austricksen", () => {
|
||||
// Ein reiner startsWith-Vergleich haette /anmeldenX durchgelassen.
|
||||
for (const pfad of [
|
||||
@@ -211,132 +176,6 @@ describe("Rechtepruefung an den Einstiegspunkten", () => {
|
||||
expect(befunde).toEqual([]);
|
||||
});
|
||||
|
||||
it("laesst keine Server Action auf dem Recht der Selbstauskunft stehen", () => {
|
||||
/*
|
||||
* Die Zusage „ein MITARBEITER kann nichts schreiben“ (#035) steht nicht in
|
||||
* einer einzelnen Pruefung, sondern in der **Abwesenheit** einer Aktion mit
|
||||
* diesem Recht. Das ist strukturell: `selbstauskunft` ist ein reines
|
||||
* Leserecht, und sobald eine Server Action es verlangte, haette diese Rolle
|
||||
* einen Schreibweg — ohne dass irgendein anderer Test rot wuerde.
|
||||
*
|
||||
* Deshalb hier und nicht in `tests/rollen.test.ts`: dort geht es um die
|
||||
* Tabelle, hier um das, was mit ihr gebaut wird.
|
||||
*/
|
||||
const befunde: string[] = [];
|
||||
for (const name of readdirSync(verzeichnis).filter((n) => n.endsWith(".ts"))) {
|
||||
const quelle = readFileSync(join(verzeichnis, name), "utf8");
|
||||
if (quelle.includes('rechtErforderlich("selbstauskunft")')) {
|
||||
befunde.push(`${name} verlangt "selbstauskunft" – das ist ein reines Leserecht`);
|
||||
}
|
||||
}
|
||||
expect(befunde).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Alle `page.tsx` unter `(app)`, rekursiv.
|
||||
*
|
||||
* Von Hand aufzuzaehlen waere dasselbe Problem wie bei der Proxy-Allowlist:
|
||||
* eine Liste, die dem Code hinterherlaeuft. Eine neue Seite soll **ohne**
|
||||
* Zutun in diesem Test landen.
|
||||
*/
|
||||
function seitenDateien(ordner: string): string[] {
|
||||
const gefunden: string[] = [];
|
||||
for (const eintrag of readdirSync(ordner, { withFileTypes: true })) {
|
||||
const pfad = join(ordner, eintrag.name);
|
||||
if (eintrag.isDirectory()) gefunden.push(...seitenDateien(pfad));
|
||||
else if (eintrag.name === "page.tsx") gefunden.push(pfad);
|
||||
}
|
||||
return gefunden;
|
||||
}
|
||||
|
||||
it("laesst jede Seite ihr Recht selbst pruefen", () => {
|
||||
/*
|
||||
* **Die Huelle ist das zweite Netz, nicht das erste** (CLAUDE.md). Next
|
||||
* rendert Layouts bei einer Navigation innerhalb desselben Layouts nicht
|
||||
* neu — eine Seite, die sich auf `src/app/(app)/layout.tsx` verlaesst, ist
|
||||
* nach dem ersten Seitenwechsel ungeschuetzt.
|
||||
*
|
||||
* Dass das heute an allen 27 Seiten stimmt, hat der Review von Phase 2 **von
|
||||
* Hand** nachgesehen (Reviewbefund zu #029). Eine Pruefung von Hand ist kein
|
||||
* Regressionstest: sie gilt fuer den Tag, an dem sie lief. Dieser Test gilt
|
||||
* fuer jede kuenftige Seite — und genau dort liegt sein Wert, nicht im
|
||||
* heutigen gruenen Ergebnis.
|
||||
*/
|
||||
const befunde: string[] = [];
|
||||
const seiten = seitenDateien("src/app/(app)");
|
||||
|
||||
for (const datei of seiten) {
|
||||
const quelle = readFileSync(datei, "utf8");
|
||||
const prueft = quelle.includes("sitzungErforderlich(") || quelle.includes("rechtErforderlich(");
|
||||
if (!prueft) befunde.push(`${datei} prueft weder Sitzung noch Recht`);
|
||||
}
|
||||
|
||||
expect(befunde).toEqual([]);
|
||||
// Ohne diese Zeile waere eine leere Liste ein gruener Test.
|
||||
expect(seiten.length).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
it("laesst jeden Route Handler sein Recht selbst pruefen", () => {
|
||||
/*
|
||||
* **Die Huelle deckt Route Handler gar nicht ab**, und der Proxy prueft das
|
||||
* Cookie absichtlich nur auf Vorhandensein (CLAUDE.md). Alles unter
|
||||
* `src/app/api/`, das mehr tut als Auth und Healthcheck, prueft deshalb
|
||||
* selbst.
|
||||
*
|
||||
* Die Ausnahmen stehen **namentlich** darunter, wie bei den oeffentlichen
|
||||
* Aktionen: ein neuer Handler ohne Pruefung soll jemanden zwingen, ihn hier
|
||||
* einzutragen — und damit zu begruenden.
|
||||
*
|
||||
* Dass das noetig ist, hat der Review gezeigt: beim Anhang-Handler liess
|
||||
* sich die ganze Pruefung entfernen, ohne dass ein Test rot wurde. Jetzt
|
||||
* faehrt `tests/anhang-route.test.ts` ihn echt; dieser Test hier faengt den
|
||||
* **naechsten**.
|
||||
*/
|
||||
const OHNE_PRUEFUNG = new Map([
|
||||
[
|
||||
"src/app/api/gesundheit/route.ts",
|
||||
"Healthcheck fuer Docker und den Reverse Proxy – muss ohne Anmeldung antworten",
|
||||
],
|
||||
["src/app/api/auth/abmelden/route.ts", "Abmelden kann keine gueltige Sitzung verlangen"],
|
||||
["src/app/api/auth/oidc/start/route.ts", "Beginn des Anmeldevorgangs"],
|
||||
["src/app/api/auth/oidc/rueckruf/route.ts", "Rueckruf des Anbieters, vor der Sitzung"],
|
||||
]);
|
||||
|
||||
function handlerDateien(ordner: string): string[] {
|
||||
const gefunden: string[] = [];
|
||||
for (const eintrag of readdirSync(ordner, { withFileTypes: true })) {
|
||||
const pfad = join(ordner, eintrag.name);
|
||||
if (eintrag.isDirectory()) gefunden.push(...handlerDateien(pfad));
|
||||
else if (eintrag.name === "route.ts") gefunden.push(pfad);
|
||||
}
|
||||
return gefunden;
|
||||
}
|
||||
|
||||
const befunde: string[] = [];
|
||||
const handler = handlerDateien("src/app/api");
|
||||
|
||||
for (const datei of handler) {
|
||||
const quelle = readFileSync(datei, "utf8");
|
||||
const prueft =
|
||||
quelle.includes("rechtErforderlich(") ||
|
||||
quelle.includes("sitzungErforderlich(") ||
|
||||
// Der SCIM-Weg: das Bearer-Token wird zur Quelle und damit zum Kunden.
|
||||
quelle.includes("quelleAusAnfrage(");
|
||||
if (!prueft && !OHNE_PRUEFUNG.has(datei)) {
|
||||
befunde.push(`${datei} prueft nichts und steht nicht in der Ausnahmeliste`);
|
||||
}
|
||||
}
|
||||
|
||||
expect(befunde).toEqual([]);
|
||||
expect(handler.length).toBeGreaterThan(8);
|
||||
|
||||
// Und die Gegenrichtung: eine Ausnahme fuer einen Handler, den es nicht mehr
|
||||
// gibt, verdeckt beim naechsten Mal einen echten Befund.
|
||||
for (const datei of OHNE_PRUEFUNG.keys()) {
|
||||
expect(handler, `${datei} steht in der Ausnahmeliste, existiert aber nicht`).toContain(datei);
|
||||
}
|
||||
});
|
||||
|
||||
it("erkennt eine Aktion ohne jede Pruefung", () => {
|
||||
// Der Test oben ist nachsichtiger geworden; dieser hier zeigt, dass er
|
||||
// trotzdem noch anschlaegt. Geprueft wird die Regel, nicht die Datei.
|
||||
|
||||
Reference in New Issue
Block a user