2 Commits
Author SHA1 Message Date
Florian WildeandClaude Opus 5 4aa79c93ff feat(laeufe): Live-Aktualisierung per Ereignisstrom statt Polling (#53)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Die Abarbeitungsansicht fragte alle zehn Sekunden nach dem Zustand. Jetzt
melden vier Datenbank-Trigger `NOTIFY lauf_geaendert, '<laufId>'`, eine
LISTEN-Verbindung je Prozess hoert zu, und der Route Handler schiebt
weiter. Gemessen: der Haken des Kollegen ist nach 485 ms beim Zuschauer
(am Handy 487 ms), vorher bis zu zehn Sekunden. Zwanzig offene Laschen
kosten nichts mehr statt zwanzig Abfragen je zehn Sekunden.

Das Ereignis kommt aus der Datenbank und nicht aus dem Dienst, aus
demselben Grund, aus dem auch die Regeln dort stehen: ein Trigger sieht
auch den Seed, eine Migration und die Reparatur per psql. Und NOTIFY
erreicht jede Instanz der Anwendung, ohne einen zweiten Dienst.

Welche Tabellen melden, haengt an dem, was `laufZustand` liefert — nicht
daran, was das Issue vorgeschlagen hat. Mit Triggern nur auf
Schrittergebnis und Lauf waere der Strom ein **Rueckschritt** gewesen:
das Polling verglich den ganzen Stand und sah deshalb auch Kommentare,
ein Strom sieht nur, was gemeldet wird. Der Kommentar des Kollegen waere
nie angekommen, und die Seite haette ausgesehen wie immer. Es melden
jetzt vier Tabellen, und `laufZustand` traegt `anzahlAnhaenge` — ein
Ereignis ohne Entsprechung im Zustand loest nichts aus.

Das Polling bleibt als Rueckfall an **derselben** Adresse, unterschieden
nur am Accept-Kopf: eine Rechtepruefung, ein Lesemodell, eine Nutzlast.
Ein zweiter Endpunkt waere eine zweite Stelle, an der jemand
rechtErforderlich vergessen kann. Bei erreichter Obergrenze antwortet
der Handler gewoehnlich mit JSON; EventSource meldet darauf einen Fehler
und versucht es nicht wieder, also faellt der Client von sich aus
zurueck — ohne Sonderfall.

Nebenbei zwei Fehler in docs/betrieb/reverse-proxy.md behoben, beide aus
#52: `client_max_body_size 8m` bei `ANHANG_MAX_MB=10` haette ein
10-MB-Foto mit 413 abgewiesen, bevor die eigene Meldung zum Zuge kommt.

Closes #53

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 09:36:10 +02:00
Florian WildeandClaude Opus 5 3cbd5026f8 feat(anhaenge): Fotos und Dateien an einem Schritt (#52)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Ein Schritt nimmt jetzt Bilder (JPEG, PNG, WebP, HEIC) und PDF auf, am
Handy direkt aus der Kamera. Die Dateien liegen im Dateisystem unter
ANHAENGE_PFAD, nach Kunde und Lauf gegliedert; in der Datenbank stehen
nur Name, Typ, Groesse und Pruefsumme. Der Pfad entsteht aus der
Kennung, nie aus dem Dateinamen vom Browser.

Jeder Abruf prueft Recht und Mandantengrenze — auch der des Bildes
selbst: der Route Handler ruft rechtErforderlich() selbst, weil die
Huelle unter (app) Route Handler nicht abdeckt. Hochgeladen wird ueber
eine Server Action statt ueber einen zweiten Endpunkt; der
Archiv-Schreibschutz haengt an derselben einen Stelle wie alles andere
am Lauf. Kein SVG: ein SVG ist ein Dokument, das Skripte ausfuehrt.

In der Datenbank halten zwei CHECK-Bedingungen und zwei
zusammengesetzte Fremdschluessel fest, was auch gilt, wenn jemand an
der Service-Schicht vorbei schreibt — ein Anhang kann strukturell nicht
an einem Schritt eines anderen Laufs oder bei einem fremden Kunden
haengen.

docs/betrieb/sicherung.md ist neu geschrieben: dort stand "Kein
Upload-Verzeichnis, keine erzeugten Dateien", und eine Sicherung nach
der alten Anleitung haette ab hier still Daten verloren. Sicherung und
Ruecksicherung sind einmal wirklich gefahren, mit Pruefsummenvergleich.

Gefunden in der Klickprobe am gebauten Stand und nur dort: zwei Felder
"datei" in einem Formular, und FormData.get nahm das leere. Wer "Datei"
waehlte, bekam "Keine Datei gewaehlt", waehrend "Foto" ging. Die Regel
liegt jetzt als reine Funktion in src/lib, und tests/e2e/anhaenge.spec.ts
laedt bewusst ueber den zweiten Knopf hoch.

Closes #52

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 23:05:00 +02:00
116 changed files with 777 additions and 13900 deletions
-127
View File
@@ -7,19 +7,8 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht] ## [Unveröffentlicht]
## [0.2.0] – 2026-10-11
### Geändert ### 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 - **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, 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 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 ### 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 - **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 `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 wäre mit „413 Request Entity Too Large“ an der Wand gelandet, und die freundliche Meldung der
@@ -121,65 +53,6 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Hinzugefügt ### Hinzugefügt
- **SCIM 2.0: Personen können jetzt auch geschickt werden, statt geholt zu werden.** Bisher
verbindet sich Laufzettel nach Zeitplan mit einem Verzeichnis und liest. Das setzt zwei Dinge
voraus, die nicht überall gegeben sind: Lesezugriff auf das Verzeichnis, und ein Netzweg dorthin.
Mit der neuen Quellenart `SCIM` ruft stattdessen der Identitätsanbieter Laufzettel an — authentik,
Entra ID, Okta — und zwar in dem Moment, in dem sich bei ihm etwas ändert, nicht erst beim
nächsten Intervall.
Umgesetzt ist, was die Anbieter wirklich schicken: `Users` mit dem Filter `userName eq "…"`,
`POST`, `GET`, `PUT`, `PATCH`, `DELETE`, dazu `ServiceProviderConfig`, `Schemas` und
`ResourceTypes` — ohne die drei weigern sich manche Anbieter, überhaupt anzufangen. Übernommen
werden Anmeldename, Name, Mailadresse, Titel, Abteilung, der Vorgesetzte und ob das Konto aktiv
ist; keine Gruppen, keine Rollen, keine Passwörter.
**`DELETE` deaktiviert und löscht nicht.** Der Anbieter bekommt `204` und hält die Person damit
für entfernt — aus seiner Sicht richtig. In Laufzettel bleibt sie inaktiv stehen, weil Läufe und
Geräte an ihr hängen. Das steht in der Anleitung, weil es sonst wie ein Fehler wirkt.
**Eine SCIM-Quelle wird nicht abgeglichen**, und das ist keine Kosmetik: ein Abgleich, der dort
nichts liest und „Erfolg“ meldet, hätte über die Abschaltregel die ganze Belegschaft deaktiviert.
Verbindungstest und „Jetzt abgleichen“ gibt es für sie deshalb nicht; an ihrer Stelle steht die
letzte Zustellung.
Das **Token** wird unter Admin → Verzeichnis erzeugt, **einmal** angezeigt und nur als
argon2-Hash gespeichert — es gibt keinen zweiten Weg, es zu lesen. Es ist der einzige
Mandantenbeweis, den eine Zustellung mitbringt: ein Token sieht nur die Personen seiner eigenen
Quelle. Einrichtung in authentik und Entra ID steht in
[Kapitel 06](docs/anleitung/06-verzeichnis.md#scim-der-anbieter-schickt), der Betrieb in
[`docs/betrieb/scim.md`](docs/betrieb/scim.md) (#029)
- **Entra ID als Verzeichnisquelle, über Microsoft Graph.** Ein Haus ohne lokales Active Directory
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
Person abschaltbar, und **niemand bekommt Post über die eigene Tat**. Mail über SMTP (Zugangsdaten
verschlüsselt wie das LDAP-Bind-Passwort, mit „Testmail schicken“), dazu optional ein Webhook mit
HMAC-SHA256-Signatur im Kopf `X-Laufzettel-Signatur`.
Zugestellt wird aus einer **Warteschlange in der Datenbank**, nicht im Anfrage-Pfad: ein hängender
Mailserver darf kein Abhaken blockieren. Fehlschläge werden nach 1, 5, 30 und 120 Minuten
wiederholt und bleiben danach als **aufgegeben** sichtbar — unter Verwaltung → Zustellungen, denn
eine stille Warteschlange ist schlimmer als keine.
**In keiner Nachricht steht ein Schrittinhalt**, und damit auch kein erzeugtes Passwort: geprüft
über alle Rumpftexte, die wirklich verschickt wurden (#034)
- **Fotos und Dateien an einem Schritt.** „Seriennummer fotografiert“, „Typenschild“, „Zustand bei - **Fotos und Dateien an einem Schritt.** „Seriennummer fotografiert“, „Typenschild“, „Zustand bei
Übergabe“ — manches lässt sich nicht abhaken, es muss gezeigt werden. Ein Schritt nimmt jetzt Übergabe“ — manches lässt sich nicht abhaken, es muss gezeigt werden. Ein Schritt nimmt jetzt
Bilder (JPEG, PNG, WebP, HEIC) und PDF auf, am Handy direkt aus der Kamera. Die Dateien liegen im Bilder (JPEG, PNG, WebP, HEIC) und PDF auf, am Handy direkt aus der Kamera. Die Dateien liegen im
+11 -83
View File
@@ -38,18 +38,14 @@ Der Auftrag nennt englische Modelle. Verbindlich gilt:
| DeviceTemplateDefault | `GeraetetypVorlage` | Comment | `Kommentar` | | DeviceTemplateDefault | `GeraetetypVorlage` | Comment | `Kommentar` |
| — (ergänzt) | `AbgleichSperre` | AuditLog | `Protokoll` | | — (ergänzt) | `AbgleichSperre` | AuditLog | `Protokoll` |
| — (ergänzt) | `BenutzerKunde` | — (ergänzt) | `TagCodeVergabe` | | — (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}` · `LaufStatus {OFFEN IN_ARBEIT ABGESCHLOSSEN ARCHIVIERT}` ·
`SchrittZustand {OFFEN ERLEDIGT NICHT_ZUTREFFEND FEHLGESCHLAGEN}` · `SchrittZustand {OFFEN ERLEDIGT NICHT_ZUTREFFEND FEHLGESCHLAGEN}` ·
`SchrittTyp {HAKEN TEXT ZAHL AUSWAHL DATUM BEFEHL}` (später `AKTION`) · `SchrittTyp {HAKEN TEXT ZAHL AUSWAHL DATUM BEFEHL}` (später `AKTION`) ·
`VariablenTyp {TEXT ZAHL AUSWAHL DATUM}` · `VariablenTyp {TEXT ZAHL AUSWAHL DATUM}` ·
`GeraeteTyp {PC NOTEBOOK SERVER FIREWALL SWITCH DRUCKER RAUM SONSTIGES}` · `GeraeteTyp {PC NOTEBOOK SERVER FIREWALL SWITCH DRUCKER RAUM SONSTIGES}` ·
`VersionStatus {ENTWURF VEROEFFENTLICHT}` · `Herkunft {ABGLEICH MANUELL}` · `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}`.
`SEED_MODE` akzeptiert `DIENSTLEISTUNG` und `SERVICE` als Alias. `SEED_MODE` akzeptiert `DIENSTLEISTUNG` und `SERVICE` als Alias.
@@ -80,19 +76,12 @@ Dreizeiler: Recht prüfen, Zod-Schema anwenden, Service rufen.
- **Mandant:** Jede kundenbezogene Zeile trägt `kundeId`. `kundeErmitteln()` liefert im Modus - **Mandant:** Jede kundenbezogene Zeile trägt `kundeId`. `kundeErmitteln()` liefert im Modus
`INTERN` immer die eigene Organisation und **verwirft** eine mitgeschickte `kundeId`. `INTERN` immer die eigene Organisation und **verwirft** eine mitgeschickte `kundeId`.
- **Rechte:** nur serverseitig, aus der `RECHTE`-Tabelle über `darf()` / `rechtErforderlich()`. - **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. 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, **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. 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 **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 und Healthcheck, ruft selbst `rechtErforderlich()`. Der Proxy prüft das Cookie absichtlich nur
auf Vorhandensein, nie inhaltlich. 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 - **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` — `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 `ROLLEN_TEXT` etwa in `src/lib/rollen-text.ts`, während `RECHTE` und `darf()` serverseitig
@@ -109,11 +98,9 @@ Dreizeiler: Recht prüfen, Zod-Schema anwenden, Service rufen.
wird. `SchrittTyp` bekommt später `AKTION`, `konfig` und `wert` sind Json, und wird. `SchrittTyp` bekommt später `AKTION`, `konfig` und `wert` sind Json, und
`Lauf.erstelltVonId` wie `Schrittergebnis.aktualisiertVonId` dürfen leer sein — ein Systemlauf `Lauf.erstelltVonId` wie `Schrittergebnis.aktualisiertVonId` dürfen leer sein — ein Systemlauf
ist darstellbar. ist darstellbar.
- **Regeln stehen in der Datenbank, nicht nur im Service.** CHECK-Bedingungen, partielle - **Regeln stehen in der Datenbank, nicht nur im Service.** Zwölf CHECK-Bedingungen, zwei
Eindeutigkeitsindizes, zusammengesetzte Fremdschlüssel und Trigger halten fest, was auch partielle Eindeutigkeitsindizes und vier zusammengesetzte Fremdschlüssel halten fest, was auch
dann gelten muss, wenn jemand an der Service-Schicht vorbei schreibt. dann gelten muss, wenn jemand an der Service-Schicht vorbei schreibt. Die Mandantengrenze ist
`tests/datenbankregeln.test.ts` zählt sie **namentlich** auf — dort steht die jeweils aktuelle
Liste, und genau deshalb steht hier keine Zahl mehr: sie lief dreimal der Wirklichkeit nach. Die Mandantengrenze ist
einer davon: ein Lauf kann strukturell kein Gerät, keine Person und keine Abteilung eines einer davon: ein Lauf kann strukturell kein Gerät, keine Person und keine Abteilung eines
fremden Kunden wählen. Der Service prüft dasselbe noch einmal, nur mit besserer Meldung. fremden Kunden wählen. Der Service prüft dasselbe noch einmal, nur mit besserer Meldung.
@@ -135,12 +122,6 @@ Zwei Obergrenzen, die nicht angehoben werden dürfen:
Build-Skripte von Abhängigkeiten sind in `pnpm-workspace.yaml` unter `allowBuilds` freigegeben Build-Skripte von Abhängigkeiten sind in `pnpm-workspace.yaml` unter `allowBuilds` freigegeben
(pnpm 12 liest das `pnpm`-Feld in `package.json` nicht mehr). (pnpm 12 liest das `pnpm`-Feld in `package.json` nicht mehr).
**`minimumReleaseAgeExclude` bleibt leer.** pnpm 12 nimmt Pakete erst nach einer Wartezeit an — ein
Schutz gegen ein kompromittiertes Release, das Stunden später zurückgezogen wird. Trägt `pnpm add`
dort von sich aus eine Ausnahme ein, ist das kein Formfehler, sondern die Meldung „diese Version ist
frisch von heute“. Dann gehört **eine abgelagerte Version** gepinnt, nicht die Ausnahme committet.
`nodemailer` steht deshalb exakt auf `10.0.10` und nicht auf der neuesten Fassung (#034).
## Next 16 ## Next 16
`middleware.ts` heißt **`proxy.ts`** und exportiert `proxy()`, Node-Laufzeit. `cookies()`, `middleware.ts` heißt **`proxy.ts`** und exportiert `proxy()`, Node-Laufzeit. `cookies()`,
@@ -193,71 +174,18 @@ 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. speichern, neu laden, und nachsehen, ob es wirklich in der Datenbank steht.
`pnpm docs:screenshots` verlangt deshalb ebenfalls den gebauten Stand. `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 ## Vor jedem Commit
```bash ```bash
docker compose -f docker-compose.test.yml up -d --wait ldap mailpit docker compose -f docker-compose.test.yml up -d --wait ldap
pnpm lint && pnpm typecheck && pnpm format:pruefen && pnpm test && pnpm pruefe:doku pnpm lint && pnpm typecheck && pnpm format:pruefen && pnpm test && pnpm pruefe:doku
``` ```
**Die beiden Container gehören in die erste Zeile.** Ohne sie überspringt die Suite Tests, sagt das **Der LDAP-Container gehört in die erste Zeile.** Ohne ihn überspringt die Suite **22 Tests** — den
auf stderr — und sieht trotzdem grün aus. Wer nur diesem Abschnitt folgte, hätte das Betroffene nie ganzen Verzeichnis-Abgleich (nachgezählt: 81 statt 59 in `tests/abgleich.test.ts` und
geprüft. In CI bricht sie stattdessen ab, absichtlich. `tests/verzeichnis.test.ts`). Sie sagt das auf stderr, aber der Lauf sieht grün aus, und wer nur
diesem Abschnitt folgte, hat den Abgleich nie geprüft. In CI bricht sie stattdessen ab, absichtlich.
| Container | Fehlt er, fehlen | Betroffen | Aufgefallen bei der Abnahme zu #019.
|---|---|---|
| `ldap` | **22 Tests** (nachgezählt: 81 statt 59) | der ganze Verzeichnis-Abgleich, `tests/abgleich.test.ts` und `tests/verzeichnis.test.ts` |
| `mailpit` | **3 Tests** (nachgezählt: 36 statt 33) | der echte SMTP-Versand in `tests/benachrichtigungen.test.ts` — die vier Anlässe, die Passwort-Prüfung über alle Rumpftexte und „nichts zweimal“ |
`ldap` fiel bei der Abnahme zu #019 auf, `mailpit` kam mit #034 dazu. Beide Zahlen sind **gemessen**,
nicht geschätzt — in #040 hätte beinahe eine geratene in dieser Datei gestanden.
`format:pruefen` gehört dazu, seit der Review in #010 darauf gestoßen ist: `lint-staged` formatiert `format:pruefen` gehört dazu, seit der Review in #010 darauf gestoßen ist: `lint-staged` formatiert
beim Commit automatisch nach, und das Ergebnis wäre ein Commit mit Änderungen, die niemand gesehen beim Commit automatisch nach, und das Ergebnis wäre ein Commit mit Änderungen, die niemand gesehen
+10 -44
View File
@@ -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 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. 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 > **Stand:** in Entwicklung auf `v0.1.0` (MVP). Der Fortschritt steht in
> Fortschritt steht in [`docs/issues/README.md`](docs/issues/README.md), Entscheidungen im > [`docs/issues/README.md`](docs/issues/README.md), Entscheidungen im
> [Entwicklungstagebuch](docs/devlog.md). > [Entwicklungstagebuch](docs/devlog.md).
## Zwei Betriebsmodi ## Zwei Betriebsmodi
| Modus | Für wen | Kunden-Ebene | Verzeichnis | | 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 | | `DIENSTLEISTUNG` | IT-Dienstleister mit mehreren Kunden | sichtbar | manuell oder per CSV je Kunde |
Der Modus liegt in der Datenbank und ist vom Admin ohne Neustart umschaltbar. Jede kundenbezogene Der Modus liegt in der Datenbank und ist vom Admin ohne Neustart umschaltbar. Jede kundenbezogene
@@ -45,13 +45,12 @@ openssl rand -base64 48
### Qualitätsschranken ### Qualitätsschranken
```bash ```bash
docker compose -f docker-compose.test.yml up -d --wait ldap mailpit # sonst fehlen 25 Tests docker compose -f docker-compose.test.yml up -d --wait ldap # sonst fehlen 22 Tests
pnpm lint && pnpm typecheck && pnpm format:pruefen && pnpm test && pnpm pruefe:doku pnpm lint && pnpm typecheck && pnpm format:pruefen && pnpm test && pnpm pruefe:doku
``` ```
Die beiden Container sind keine Zierde: ohne `ldap` überspringt die Suite den ganzen Der LDAP-Container ist keine Zierde: ohne ihn überspringt die Suite den ganzen Verzeichnis-Abgleich
Verzeichnis-Abgleich (22 Tests), ohne `mailpit` den echten Mailversand (3 Tests) — sie sagt das auf — sie sagt das auf stderr, aber der Lauf sieht grün aus. In CI bricht sie stattdessen ab.
stderr, aber der Lauf sieht grün aus. In CI bricht sie stattdessen ab.
Ein Pre-Commit-Hook erzwingt Lint und Format, ein Commit-Msg-Hook Conventional Commits. Ein Pre-Commit-Hook erzwingt Lint und Format, ein Commit-Msg-Hook Conventional Commits.
Ende-zu-Ende-Tests: `pnpm test:e2e` (verlangt einen gebauten, laufenden Stand — siehe Ende-zu-Ende-Tests: `pnpm test:e2e` (verlangt einen gebauten, laufenden Stand — siehe
@@ -158,48 +157,15 @@ zugeordnet. Unbekannte Benutzer werden beim ersten Login angelegt.
| `REDAKTEUR` | Vorlagen anlegen, bearbeiten, veröffentlichen; alles was `TECHNIKER` darf | | `REDAKTEUR` | Vorlagen anlegen, bearbeiten, veröffentlichen; alles was `TECHNIKER` darf |
| `TECHNIKER` | Läufe starten, abarbeiten, abschließen, archivieren; Geräte pflegen | | `TECHNIKER` | Läufe starten, abarbeiten, abschließen, archivieren; Geräte pflegen |
| `LESER` | nur lesen | | `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) Die Anbindung an authentik ist in [`docs/betrieb/authentik.md`](docs/betrieb/authentik.md)
Schritt für Schritt beschrieben. Schritt für Schritt beschrieben.
## Verzeichnis (nur Modus `INTERN`) ## Verzeichnis-Abgleich (nur Modus `INTERN`)
Unter **Admin → Verzeichnis** wird eine Quelle angelegt. Es gibt drei Arten, und sie unterscheiden Unter **Admin → Verzeichnis** wird eine LDAP-Quelle angelegt: Adresse (`ldaps://` empfohlen,
sich in der **Richtung**: `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.
| Art | Für wen | Richtung |
|---|---|---|
| `LDAP` | lokales Active Directory oder anderes LDAP-Verzeichnis | Laufzettel holt nach Zeitplan |
| `GRAPH` | Entra ID über Microsoft Graph, für Häuser ohne lokales AD | Laufzettel holt nach Zeitplan |
| `SCIM` | authentik, Entra ID, Okta — wenn Laufzettel nicht ins Verzeichnis sehen soll oder es nicht erreicht | der Anbieter schickt, sofort |
**Die Art ist unveränderlich.** An ihr hängt, was die dauerhafte Kennung einer Person bedeutet;
nach einem Wechsel würde niemand wiedererkannt. Alle drei verhalten sich danach gleich: anlegen,
übernehmen, aktualisieren, deaktivieren, **nie löschen**.
Für **LDAP** gehören ins Formular: Adresse (`ldaps://` empfohlen, `ldap+starttls://` und `ldap://`
gehen auch), ein Dienstkonto mit **Leserecht**, der Basis-DN und optional ein eigener Filter. Ein
Dienstkonto genügt — Laufzettel schreibt nie ins Verzeichnis. Für **Graph** eine App-Registrierung
mit der Anwendungsberechtigung `User.Read.All`, siehe `docs/betrieb/entra-id.md`.
Für **SCIM** gibt es keine Zugangsdaten: Quelle anlegen, Token erzeugen (es erscheint **einmal**,
gespeichert wird nur ein argon2-Hash), Token und die Adresse `…/api/scim/v2` beim Anbieter
eintragen. Verbindungstest, Zeitplan und Abgleich-Log gibt es dort nicht — an ihrer Stelle steht
die letzte Zustellung. Ein `DELETE` des Anbieters **deaktiviert** die Person und löscht nichts.
Einzelheiten in `docs/betrieb/scim.md`.
- **„Verbindung testen“** liest die ersten Einträge und zeigt sie **mit den gemappten Feldern**, - **„Verbindung testen“** liest die ersten Einträge und zeigt sie **mit den gemappten Feldern**,
ohne etwas zu speichern. Erst daran sieht man, ob die Zuordnung stimmt. ohne etwas zu speichern. Erst daran sieht man, ob die Zuordnung stimmt.
-24
View File
@@ -17,30 +17,6 @@ services:
timeout: 5s timeout: 5s
retries: 10 retries: 10
# Der Mailfaenger fuer die Benachrichtigungen (#034). Mailpit nimmt SMTP auf
# 1025 an und zeigt die Nachrichten ueber eine HTTP-Schnittstelle auf 8025 —
# darueber liest der Test, was wirklich ankam.
#
# **Ein echter SMTP-Dialog, keine Attrappe.** Eine abgefangene
# `sendMail`-Funktion haette in #034 nichts bewiesen: dass Betreff und
# Empfaenger stimmen, zeigt erst eine Nachricht, die durch Verbindungsaufbau,
# EHLO und DATA gegangen ist. Ohne Anmeldung und ohne TLS, weil der Test den
# Dialog prueft und nicht die Verschluesselung.
mailpit:
image: axllent/mailpit:latest
environment:
MP_SMTP_AUTH_ACCEPT_ANY: "true"
MP_SMTP_AUTH_ALLOW_INSECURE: "true"
ports:
- "127.0.0.1:1025:1025"
- "127.0.0.1:8025:8025"
healthcheck:
# Mailpit bringt kein curl mit; `/livez` ueber den eigenen Client.
test: ["CMD", "/mailpit", "readyz"]
interval: 5s
timeout: 5s
retries: 20
# Das Verzeichnis fuer den Abgleich (#016). OpenLDAP mit einem AD-aehnlichen # Das Verzeichnis fuer den Abgleich (#016). OpenLDAP mit einem AD-aehnlichen
# Schema: sAMAccountName, userPrincipalName, binaeres objectGUID, # Schema: sAMAccountName, userPrincipalName, binaeres objectGUID,
# userAccountControl, objectCategory und department bringt OpenLDAP nicht mit, # userAccountControl, objectCategory und department bringt OpenLDAP nicht mit,
-143
View File
@@ -16,141 +16,6 @@ Zwei Dinge vorweg:
Wer im Verzeichnis verschwindet oder dort deaktiviert wird, wird hier **inaktiv**. Gelöscht wird Wer im Verzeichnis verschwindet oder dort deaktiviert wird, wird hier **inaktiv**. Gelöscht wird
nie, sonst verlöre die Historie ihre Namen. nie, sonst verlöre die Historie ihre Namen.
## 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 ## Eine Quelle anlegen
![Die Seite Verzeichnis mit Quelle, Status und Formular](bilder/verzeichnis-desktop.png) ![Die Seite Verzeichnis mit Quelle, Status und Formular](bilder/verzeichnis-desktop.png)
@@ -225,10 +90,6 @@ Namensänderung der Quelle ihre Anmeldung.
![Das Ergebnis eines Verbindungstests: „Die Verbindung steht. 4 Einträge gelesen, 1 übersprungen“ und darunter die ersten drei Einträge mit UPN, Mail, DN, sam und Abteilung](bilder/verbindungstest-desktop.png) ![Das Ergebnis eines Verbindungstests: „Die Verbindung steht. 4 Einträge gelesen, 1 übersprungen“ und darunter die ersten drei Einträge mit UPN, Mail, DN, sam und Abteilung](bilder/verbindungstest-desktop.png)
Die folgenden vier Abschnitte gelten für **LDAP und Graph**. Bei einer SCIM-Quelle gibt es weder
Verbindungstest noch Zeitplan noch Abgleich-Log — dort steht an ihrer Stelle die letzte Zustellung,
siehe [oben](#scim-der-anbieter-schickt).
**Verbindung testen** liest die ersten Einträge und zeigt sie so, wie sie ankämen — **gespeichert **Verbindung testen** liest die ersten Einträge und zeigt sie so, wie sie ankämen — **gespeichert
wird nichts**. Mach das immer vor dem ersten Abgleich. Klappt es nicht, steht der Grund in Rot wird nichts**. Mach das immer vor dem ersten Abgleich. Klappt es nicht, steht der Grund in Rot
darunter; die häufigsten stehen in [Kapitel 08](08-fehlerbehebung.md). darunter; die häufigsten stehen in [Kapitel 08](08-fehlerbehebung.md).
@@ -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 Eine Quelle ist keine Voraussetzung. Personen lassen sich von Hand anlegen oder aus einer CSV-Datei
einlesen — ein Verzeichnis nimmt nur die Arbeit ab. Siehe einlesen — ein Verzeichnis nimmt nur die Arbeit ab. Siehe
[Kapitel 05](05-mitarbeiter-geraete.md). [Kapitel 05](05-mitarbeiter-geraete.md).
Von Hand angelegte Personen gehen dabei nicht verloren, wenn später eine Quelle dazukommt: taucht
jemand mit demselben Anmeldenamen aus dem Verzeichnis oder per SCIM auf, **übernimmt** Laufzettel
die vorhandene Zeile statt eine zweite anzulegen. Ihre Läufe und Geräte bleiben an ihr hängen.
-49
View File
@@ -169,55 +169,6 @@ nicht archivieren.
Eine Vorlage lässt sich **einem** Kunden zuordnen („nur Musterfirma GmbH“) oder für alle gelten Eine Vorlage lässt sich **einem** Kunden zuordnen („nur Musterfirma GmbH“) oder für alle gelten
lassen. Eine kundenspezifische Vorlage startet keinen Lauf bei einem anderen Kunden. lassen. Eine kundenspezifische Vorlage startet keinen Lauf bei einem anderen Kunden.
## Benachrichtigungen
Ein zugewiesener Lauf fiel früher nur auf, wenn jemand die Übersicht öffnete. Ein überfälliger
überhaupt nicht. Laufzettel meldet deshalb vier Dinge — und nur diese vier:
| Anlass | Wer bekommt es |
|---|---|
| Ein Lauf wird jemandem **zugewiesen** | der Zugewiesene |
| Ein Lauf ist **überfällig** | der Zugewiesene, einmal je Lauf |
| Ein Lauf wird **abgeschlossen** | der Zugewiesene und wer ihn angelegt hat |
| Ein **Verzeichnisabgleich schlägt fehl** | alle Administratoren |
**Über die eigene Tat bekommt niemand Post.** Wer selbst zuweist, abschließt oder abhakt, erfährt es
nicht per Mail — das ist der häufigste Grund, Benachrichtigungen ganz abzuschalten.
**In der Nachricht stehen Titel, Status, Gerät, Person, Frist und ein Link.** Keine Schrittinhalte,
und damit auch kein erzeugtes Passwort. Das Passwort entsteht bei jeder Anzeige neu; eine Mail
dagegen bleibt für immer in einem Postfach.
### Einrichten
Unter **Verwaltung → Einstellungen**, Abschnitt **Benachrichtigungen**: Host, Port, Benutzer,
Passwort und Absender des Mailservers. Dann **Speichern**, dann **Testmail schicken** — die geht
sofort hinaus, nicht über die Warteschlange, und ein Fehler erscheint im Klartext.
Daneben steht ein **Webhook**: dieselben vier Anlässe als JSON an eine Adresse, mit Signatur. Das ist
für Ticketsysteme und Chat-Räume gedacht; die Einzelheiten stehen in
[`docs/betrieb/benachrichtigungen.md`](../betrieb/benachrichtigungen.md).
**Das Passwortfeld ist leer, obwohl ein Passwort gesetzt ist.** Leer heißt „unverändert lassen“ —
sonst löschte jedes Speichern der übrigen Felder das Passwort mit.
### Abschalten
Unter **Verwaltung → Benutzer** schaltet der **Mail**-Knopf an einer Person die Benachrichtigungen
ab. Das geht auch für das eigene Konto. Wer abgeschaltet hat, bekommt nichts und löst auch keinen
Fehler aus.
### Zustellungen
Unter **Verwaltung → Zustellungen** steht, was hinausgegangen ist und was nicht. Schlägt eine
Zustellung fehl, versucht Laufzettel es nach 1, 5, 30 und 120 Minuten erneut; nach dem fünften
Fehlschlag bleibt sie als
**aufgegeben** stehen.
**Da hinzusehen lohnt, wenn jemand sagt, er bekomme keine Mails.** Fast immer ist das SMTP-Passwort
abgelaufen — und ohne diese Seite merkt es wochenlang niemand, weil die Läufe weiter funktionieren.
Ganz oben steht ein Hinweis, sobald etwas aufgegeben wurde.
## Protokoll ## Protokoll
Laufzettel schreibt ein Protokoll über das, was nachvollziehbar bleiben muss: Rollenänderungen, Laufzettel schreibt ein Protokoll über das, was nachvollziehbar bleiben muss: Rollenänderungen,
-69
View File
@@ -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.
-8
View File
@@ -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 - [06 Verzeichnis anbinden](06-verzeichnis.md) — Active Directory als Quelle der Personen
- [07 Verwaltung](07-admin.md) — Benutzer, Rollen, Einstellungen, Betriebsmodus, Kunden - [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 ## Zwei Betriebsmodi
Laufzettel läuft in einem von zwei Modi. Der Modus entscheidet, ob es eine Kundenebene gibt, und Laufzettel läuft in einem von zwei Modi. Der Modus entscheidet, ob es eine Kundenebene gibt, und
-107
View File
@@ -153,17 +153,6 @@ Zwei Folgen, beide verbindlich:
klickt den Editor durch: anlegen, tippen, sortieren mit dem Finger bei 390 px, speichern, neu 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. 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 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 `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 `process.env.NODE_ENV`, und `tests/sicherheitsrichtlinie.test.ts` prüft sie am Proxy selbst — eine
@@ -243,74 +232,6 @@ versucht es nicht wieder, und der Client fällt von sich aus auf Polling zurück
Sonderfall und keinen eigenen Fehlercode. Sonderfall und keinen eigenen Fehlercode.
Die Hülle unter `src/app/(app)/layout.tsx` deckt `src/app/api/` nicht ab. Die Hülle unter `src/app/(app)/layout.tsx` deckt `src/app/api/` nicht ab.
### Warum die Benachrichtigungen in einer Warteschlange liegen
Vier Anlässe gehen seit #034 hinaus: zugewiesen, überfällig, abgeschlossen, Abgleich fehlgeschlagen.
Drei Entscheidungen prägen das.
**Eingereiht wird im Anfrage-Pfad, gesendet wird es nicht.** Ein hängender Mailserver darf kein
Abhaken blockieren. Der Dienst schreibt eine Zeile in `Zustellung` — eine Einfügung in dieselbe
Datenbank, die der Vorgang ohnehin anfasst —, und ein Zeitplan im selben Prozess schickt sie. Das
Einreihen passiert **nach** der Transaktion und mit abgefangenem Fehler: ein Abhaken darf nicht
daran scheitern, dass eine Benachrichtigung nicht eingereiht werden konnte.
**Betreff und Rumpf stehen fertig in der Zeile.** Das kostet Platz und ist es wert: eine Meldung
sagt, was *damals* war. Baute der Zeitplan den Text erst beim Senden, stünde in einer Mail von heute
der Stand von jetzt — und in einer aufgegebenen Zustellung von nächster Woche etwas völlig anderes.
**In keiner Meldung steht ein Schrittinhalt.** Darüber wacht `src/lib/meldungen.ts`: die Funktionen
dort nehmen **nur** Kopfdaten, der Typ lässt nichts anderes zu. Eine Mail mit dem Lauf-Inhalt wäre
genau die Lücke, die dieses Projekt an jeder anderen Stelle geschlossen hat — das erzeugte Passwort
wird nirgends gespeichert, aber eine Mail liegt für immer in einem Postfach, oft auf einem Server,
der nicht im Haus steht. `tests/benachrichtigungen.test.ts` prüft es zusätzlich über **alle**
Rumpftexte, die wirklich verschickt wurden, so wie #012 es für die Druckansicht tat.
Zwei Dinge, die ohne Datenbank nicht gehalten hätten. Die **Einmaligkeit der
Überfälligkeitsmeldung** liegt in einem partiellen eindeutigen Index: der Zeitplan sieht jede Minute
nach, und ohne ihn bekäme der Zuständige für einen Lauf, der über das Wochenende steht, 2880 Mails —
jede davon für sich korrekt, kein Test rot. Und die **Sperre** der Warteschlange liegt in derselben
Tabelle wie die des Abgleichs: hinter einem Lastverteiler laufen zwei Instanzen, und ein `let laeuft
= false` im Modul wüsste nichts von der zweiten.
**Das Polling-Prinzip gilt auch hier nicht umgekehrt:** ein Kommentar und ein angehängtes Foto lösen
*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 ## 5e. Der Aufkleber ist die Schnittstelle
Jedes Objekt traegt einen QR-Aufkleber mit einem `tagCode`, der **nie geaendert Jedes Objekt traegt einen QR-Aufkleber mit einem `tagCode`, der **nie geaendert
@@ -389,34 +310,6 @@ was darauf steht, ist aus der Hand gegeben.
## 5g. Der Verzeichnis-Abgleich: vier Schichten und eine Notbremse ## 5g. Der Verzeichnis-Abgleich: vier Schichten und eine Notbremse
Eine Verzeichnisquelle hat seit #028 eine **Art** — `LDAP`, `GRAPH` oder `SCIM` —,
und die erste Frage ist nicht, wie gelesen wird, sondern **in welche Richtung**:
| Art | Richtung | Wo der Code liegt |
|---|---|---|
| `LDAP` | Laufzettel holt | `src/server/ldap/*` |
| `GRAPH` | Laufzettel holt | `src/server/graph/*` |
| `SCIM` | der Anbieter schickt | `src/app/api/scim/*`, `src/server/scim/*`, `src/server/services/scim.ts` |
Die ersten zwei laufen durch **denselben** Abgleich; die eine Stelle, die die Art
kennt, ist `eintraegeDerQuelleStroemen` in
`src/server/verzeichnis/quellenleser.ts`. Eine zweite Verzweigung waere eine
zweite Stelle, die sich an eine neue Quellenart erinnern muss, und die vergisst
man.
**Bei `SCIM` gibt es keinen Abgleich**, und das ist an zwei Stellen gehalten:
`faelligeQuellen` nimmt solche Quellen nicht auf, und die Weiche wirft. Der Grund
ist nicht Ordnungsliebe — ein Abgleich, der dort nichts liest und `ERFOLG` meldet,
haette ueber die Abschaltregel die ganze Belegschaft deaktiviert. Dasselbe Muster
wie der Reviewbefund aus #016, nur mit einer anderen Ursache.
Was beim Schreiben danach gilt, gilt fuer alle drei gleich: anlegen, uebernehmen,
aktualisieren, deaktivieren, **nie loeschen** — auch ein `DELETE` eines
SCIM-Anbieters setzt nur `aktiv = false`. Das **Token** einer SCIM-Quelle ist der
einzige Mandantenbeweis, den eine Zustellung mitbringt; es steht als argon2-Hash
in der Datenbank, wird einmal angezeigt und kommt in keiner Sicht und keinem
Protokolleintrag vor.
Das LDAP-Modul liegt unter `src/server/ldap/` und ist absichtlich in vier Dateien Das LDAP-Modul liegt unter `src/server/ldap/` und ist absichtlich in vier Dateien
geteilt, von denen nur eine das Netz anfasst: geteilt, von denen nur eine das Netz anfasst:
-6
View File
@@ -56,12 +56,6 @@ Sitzungen ungültig *und* die verschlüsselt gespeicherten LDAP-Bind-Passwörter
- **[Anmeldung über authentik](authentik.md)** — OIDC-Provider, Gruppen auf Rollen, Notfallzugang - **[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 - **[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 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 ## Wo sonst noch etwas steht
+3 -46
View File
@@ -58,16 +58,9 @@ Lege in authentik vier Gruppen an und trage die Mitglieder ein:
| `lz-redakteur` | `REDAKTEUR` | Vorlagen anlegen, bearbeiten, veröffentlichen | | `lz-redakteur` | `REDAKTEUR` | Vorlagen anlegen, bearbeiten, veröffentlichen |
| `lz-techniker` | `TECHNIKER` | Läufe starten, abarbeiten, abschließen, archivieren; Geräte pflegen | | `lz-techniker` | `TECHNIKER` | Läufe starten, abarbeiten, abschließen, archivieren; Geräte pflegen |
| `lz-leser` | `LESER` | nur lesen | | `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. 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 ## 5. Laufzettel konfigurieren
In der `.env`: In der `.env`:
@@ -100,47 +93,11 @@ docker compose up -d
- Ein unbekannter Benutzer wird beim ersten Login **angelegt** (JIT-Provisioning) - 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 - Die Rolle wird bei **jedem** Login aus `groups` neu gesetzt — eine Änderung in authentik greift
ab der nächsten Anmeldung ab der nächsten Anmeldung
- Ist der Benutzer in keiner der vier Gruppen, versucht Laufzettel, ihn über seinen **UPN** einer - Ist der Benutzer in keiner der vier Gruppen, bekommt er `LESER`
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
- Wer vor dem Login eine bestimmte Seite aufgerufen hat (z. B. `/t/LZ-7K3F9Q` nach dem Scannen - Wer vor dem Login eine bestimmte Seite aufgerufen hat (z. B. `/t/LZ-7K3F9Q` nach dem Scannen
eines QR-Codes), landet danach genau dort eines QR-Codes), landet danach genau dort
## 7. Selbstauskunft für Mitarbeiter ## 7. Notfallzugang
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
Fällt authentik aus, kommt niemand mehr in Laufzettel. Für diesen Fall gibt es den lokalen Login: 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` Empfehlung: ein einziges lokales Admin-Konto, Passwort im Passwortmanager, `AUTH_LOCAL_ENABLED`
im Normalbetrieb auf `false` und nur im Notfall umgestellt. im Normalbetrieb auf `false` und nur im Notfall umgestellt.
## 9. Fehlersuche ## 8. Fehlersuche
| Symptom | Ursache | | Symptom | Ursache |
|---|---| |---|---|
-170
View File
@@ -1,170 +0,0 @@
# Benachrichtigungen einrichten
Laufzettel meldet vier Dinge, und nur diese vier:
| Anlass | Wer bekommt es |
|---|---|
| Ein Lauf wird jemandem **zugewiesen** | der Zugewiesene |
| Ein Lauf ist **überfällig** | der Zugewiesene, **einmal** je Lauf |
| Ein Lauf wird **abgeschlossen** | der Zugewiesene und wer ihn angelegt hat |
| Ein **Verzeichnisabgleich schlägt fehl** | alle aktiven Administratoren |
**Niemand bekommt Post über das, was er selbst gerade getan hat.** Das ist der häufigste Grund,
Benachrichtigungen ganz abzuschalten, und deshalb fällt der Handelnde immer aus der Empfängerliste.
> **In keiner Nachricht steht ein Schrittinhalt.** Titel, Status, Gerät, Person, Frist und ein Link —
> mehr nicht. Kein Wert, keine Notiz, kein Befehl, und damit auch kein `{{generiertes_passwort}}`.
> Das Passwort entsteht bei jeder Anzeige neu und wird nirgends gespeichert; eine Mail dagegen liegt
> für immer in einem Postfach, oft auf einem Server, der nicht im Haus steht.
## Was gebraucht wird
- **`APP_URL`**, damit der Link in der Mail irgendwohin führt. Fehlt sie, gehen die Meldungen
trotzdem hinaus — die Einstellungsseite sagt es dann deutlich.
- **Ein SMTP-Server** für Mail, **oder** eine Webhook-Adresse, **oder** beides. Ohne eines von beiden
wird nichts eingereiht; es entstehen also keine Zeilen, die nie ankommen.
## Mail über SMTP
Unter **Admin → Einstellungen → Benachrichtigungen**:
| Feld | Was hineingehört |
|---|---|
| Host | `mail.firma.de` — der Mailserver des Hauses |
| Port | `587` für StartTLS, `465` für TLS von Anfang an, `25` nur im eigenen Netz |
| Benutzer | leer lassen, wenn der Server dem Rechner ohne Anmeldung traut |
| Passwort | wird verschlüsselt gespeichert und **nie wieder angezeigt** |
| Absender | `laufzettel@firma.de` — muss der Server akzeptieren |
| TLS verlangen | an, außer der Server kann es wirklich nicht |
Dann **Speichern**, dann **Testmail schicken**. Die Testmail geht **direkt** hinaus und nicht über
die Warteschlange: ein Fehler erscheint sofort und im Klartext, statt eine Minute später in einer
Liste.
**Das Passwortfeld ist leer, obwohl ein Passwort gesetzt ist** — das ist Absicht. Leer heißt
„unverändert lassen“. Sonst löschte jedes Speichern der übrigen Felder das Passwort mit.
### Wenn die Testmail nicht ankommt
| Meldung | Ursache |
|---|---|
| `greeting never received` | `secure` passt nicht zum Port. Auf 587 gehört StartTLS, nicht TLS von Anfang an — Laufzettel entscheidet das selbst am Port, aber ein Server, der auf 465 StartTLS erwartet, ist falsch konfiguriert. |
| `535 Authentication failed` | Benutzer oder Passwort stimmen nicht. Manche Server wollen die vollständige Mailadresse als Benutzernamen. |
| `550 relay not permitted` | Der Server nimmt von dieser Maschine keine Mail für fremde Domains an. Entweder den Rechner freischalten oder sich anmelden. |
| `self signed certificate` | Der Mailserver hat ein eigenes Zertifikat. Entweder ein richtiges einrichten oder TLS für diesen Server abschalten — im eigenen Netz ist das zweite vertretbar. |
| `ECONNREFUSED` | Port falsch, oder eine Firewall dazwischen. |
## Webhook
Dieselben vier Anlässe als `POST` mit JSON-Rumpf:
```json
{
"anlass": "LAUF_ABGESCHLOSSEN",
"betreff": "Laufzettel abgeschlossen: „PC Neuinstallation – PC-001“",
"link": "https://laufzettel.firma.de/laeufe/cmu…",
"laufId": "cmu…",
"verzeichnisquelleId": null,
"zeitpunkt": "2026-10-05T08:00:00.000Z"
}
```
`anlass` steht vorn, damit sich ohne Auspacken filtern lässt. Auch hier: **keine Schrittinhalte.**
Als erfolgreich gilt nur eine Antwort mit `2xx`. Alles andere ist ein Fehlschlag und wird wiederholt.
### Die Signatur nachrechnen
Ist ein Geheimnis gesetzt, trägt jede Anfrage den Kopf:
```
X-Laufzettel-Signatur: t=1762334400,v1=9f86d08188...
```
Signiert wird `<t>.<rumpf>` mit HMAC-SHA256. **Der Zeitstempel ist Teil der signierten Nachricht**,
nicht Beiwerk — stünde er nur daneben, könnte ihn jeder ändern, ohne die Signatur zu brechen, und
eine abgefangene Nachricht ließe sich in einem Jahr noch einspielen.
So prüft die Gegenseite (Node, aber in jeder Sprache dasselbe):
```js
import { createHmac, timingSafeEqual } from "node:crypto";
function pruefen(geheimnis, rumpf, kopf) {
const teile = Object.fromEntries(kopf.split(",").map((s) => s.split("=")));
const alter = Math.abs(Math.floor(Date.now() / 1000) - Number(teile.t));
if (alter > 300) return false; // älter als fünf Minuten: ablehnen
const erwartet = createHmac("sha256", geheimnis).update(`${teile.t}.${rumpf}`).digest("hex");
// Zeitkonstant vergleichen, nicht mit ===
return timingSafeEqual(Buffer.from(erwartet), Buffer.from(teile.v1));
}
```
Drei Dinge, die dabei schiefgehen:
1. **Den Rumpf vorher geparst und wieder serialisiert.** Dann stimmt die Signatur nicht mehr —
`JSON.stringify` setzt Leerzeichen und Reihenfolge anders. Signiert wird der **rohe** Rumpf, also
vor jeder Verarbeitung lesen.
2. **Mit `===` verglichen.** Das bricht beim ersten Unterschied ab; aus der Dauer ließe sich die
richtige Signatur Zeichen für Zeichen erraten. `timingSafeEqual` oder das Äquivalent benutzen.
3. **Nur „zu alt“ geprüft.** Eine Nachricht mit Zeitstempel im Jahr 2040 wäre dann für immer gültig.
Der Abstand gehört in **beide** Richtungen geprüft.
Ohne Geheimnis fehlt die Kopfzeile ganz, statt dass eine leere Signatur Sicherheit vortäuscht.
## Die Warteschlange
**Eingereiht wird sofort, gesendet nicht.** Ein hängender Mailserver darf kein Abhaken blockieren:
der Dienst schreibt eine Zeile, und ein Zeitplan im selben Prozess schickt sie — jede Minute, kein
zweiter Dienst, kein Systemd-Timer.
Ein Fehlschlag wird wiederholt, mit wachsendem Abstand: nach **1, 5, 30 und 120 Minuten**. Beim
fünften Fehlschlag gibt Laufzettel auf und lässt die Zeile als **aufgegeben** stehen.
> **Eine stille Warteschlange ist schlimmer als keine.** Unter **Admin → Zustellungen** steht, was
> hinausgegangen ist und was nicht — mit Grund. Sieh dort hin, wenn jemand sagt, er bekomme keine
> Mails: fast immer ist das SMTP-Passwort abgelaufen, und ohne diese Seite merkt es wochenlang
> niemand, weil jeder Lauf weiter funktioniert.
Betreff und Rumpf stehen **fertig** in der Zeile. Eine Meldung sagt, was damals war; würde der Text
erst beim Senden gebaut, stünde in einer Mail von heute der Stand von jetzt.
### Mehrere Instanzen
Läuft Laufzettel hinter einem Lastverteiler mehrfach, nimmt genau eine Instanz je Durchgang die
Warteschlange — über eine Sperre in der Datenbank, dieselbe Mechanik wie beim Verzeichnisabgleich.
Ohne sie bekäme jeder Empfänger jede Mail doppelt. Dafür ist nichts einzurichten.
## Je Person abschalten
Unter **Admin → Benutzer** schaltet der **Mail**-Knopf die Benachrichtigungen einer Person ab. Das
gilt auch für das eigene Konto — sich selbst zu deaktivieren wäre ein Eigentor, seine eigene Post
abzustellen ist der häufigste Wunsch überhaupt.
Wer abgeschaltet hat, bekommt keine Zeile und keinen Fehler: es wird gar nichts eingereiht. Ein
deaktiviertes Konto ebenso.
## Was nicht gemeldet wird
- **Ein Kommentar oder ein angehängtes Foto.** Das sieht die Live-Aktualisierung in der
Abarbeitungsansicht sofort; eine Mail dafür wäre Lärm.
- **Ein einzelner abgehakter Schritt.** Dasselbe.
- **Ein archivierter Lauf.** Archivieren ist eine Aufräumarbeit, keine Nachricht.
Mehr Anlässe wären ein Benachrichtigungsbaukasten, und der ist eine Phase für sich. Diese vier decken
ab, was im Betrieb wirklich fehlt.
## Prüfen, dass es läuft
```bash
# Steht etwas in der Warteschlange, das nicht hinausgeht?
docker compose exec -T postgres psql -U laufzettel laufzettel -tAc \
"SELECT zustand, count(*) FROM \"Zustellung\" GROUP BY zustand"
```
Nativ dasselbe über `sudo -u postgres psql -d laufzettel -tAc "…"`.
Steht dort etwas unter `AUFGEGEBEN`, schau unter **Admin → Zustellungen** nach dem Grund. Alles unter
`OFFEN` oder `FEHLGESCHLAGEN` regelt der Zeitplan von selbst — es sei denn, es steht dort seit
Stunden, dann läuft der Prozess nicht oder `ZEITPLAN_AKTIV` ist auf `false`.
-126
View File
@@ -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
-159
View File
@@ -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“.
+5 -22
View File
@@ -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 | | **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` | | **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 > **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 > **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`: Und die Anhänge dazu — gewöhnliche Dateien, also `tar`:
```bash ```bash
# Der Pfad steht in ANHAENGE_PFAD; `deploy/install.sh` setzt ihn auf # Der Pfad steht in ANHAENGE_PFAD; ohne Angabe liegt er unter /opt/laufzettel/daten/anhaenge.
# /var/lib/laufzettel/anhaenge. **Kein Rückfallwert** – siehe den Kasten darunter. ANHAENGE=$(sudo sh -c '. /etc/laufzettel/laufzettel.env; echo "${ANHAENGE_PFAD:-/opt/laufzettel/daten/anhaenge}"')
ANHAENGE=$(sudo sh -c '. /etc/laufzettel/laufzettel.env; echo "${ANHAENGE_PFAD:?ANHAENGE_PFAD fehlt in /etc/laufzettel/laufzettel.env}"')
sudo tar czf /var/backups/laufzettel-anhaenge-$(date +%F).tar.gz -C "$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: Und das Geheimnis, einmalig und in den Passwortmanager:
```bash ```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 # 4. Die Anhänge zurück – **vorher leeren**, sonst bleiben Dateien liegen, auf
# die keine Zeile mehr zeigt # die keine Zeile mehr zeigt
# Ohne ANHAENGE_PFAD bricht das hier ab – und zwar **vor** dem rm -rf. Ein ANHAENGE=$(sudo sh -c '. /etc/laufzettel/laufzettel.env; echo "${ANHAENGE_PFAD:-/opt/laufzettel/daten/anhaenge}"')
# 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}"')
sudo rm -rf "$ANHAENGE" sudo rm -rf "$ANHAENGE"
sudo install -d -o laufzettel -g laufzettel -m 0750 "$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" 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 # shellcheck source=/dev/null
. /etc/laufzettel/laufzettel.env . /etc/laufzettel/laufzettel.env
# Kein Rueckfallwert: ein geratener Pfad ergibt eine leere Sicherung, und die anhaenge="${ANHAENGE_PFAD:-/opt/laufzettel/daten/anhaenge}"
# faellt erst beim Zuruckspielen auf.
anhaenge="${ANHAENGE_PFAD:?ANHAENGE_PFAD fehlt in /etc/laufzettel/laufzettel.env}"
if [ -d "$anhaenge" ]; then if [ -d "$anhaenge" ]; then
tar czf "/var/backups/laufzettel-anhaenge-$tag.tar.gz" -C "$anhaenge" . tar czf "/var/backups/laufzettel-anhaenge-$tag.tar.gz" -C "$anhaenge" .
fi fi
+2 -527
View File
@@ -32,20 +32,12 @@ als Unterpunkte mit Aufgabe und Dauer.
| #031 | M18 – Abteilungsspezifische Zusatzabschnitte | 1 h 35 min | | #031 | M18 – Abteilungsspezifische Zusatzabschnitte | 1 h 35 min |
| #032 | M19 – Anhänge und Fotos an Schritten | 3 h 05 min | | #032 | M19 – Anhänge und Fotos an Schritten | 3 h 05 min |
| #033 | M20 – Live-Aktualisierung per SSE statt Polling | 1 h 40 min | | #033 | M20 – Live-Aktualisierung per SSE statt Polling | 1 h 40 min |
| #034 | M21 – Benachrichtigungen per Mail und Webhook | 3 h 30 min | | | **Summe** | **50 h 05 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** |
| Meilenstein | Tatsächlich | Status | | Meilenstein | Tatsächlich | Status |
|---|---|---| |---|---|---|
| v0.1.0 | 35 h 20 min | veröffentlicht | | v0.1.0 | 35 h 20 min | veröffentlicht |
| v0.2.0 | 38 h 35 min | in Arbeit | | v0.2.0 | 14 h 45 min | in Arbeit |
--- ---
@@ -1247,520 +1239,3 @@ und nicht nach jeder einzelnen — das ist der Grund, warum er alles auf einmal
jetzt stehen. Danach **M22 (#035)** Selbstauskunft. jetzt stehen. Danach **M22 (#035)** Selbstauskunft.
- **M15 (#028)** Graph-Quelle und **M16 (#029)** SCIM sind die beiden großen verbliebenen. - **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. - Weiter offen und nicht von hier aus machbar: **#024** Proxmox-Einzeiler auf einem echten PVE-Host.
---
## 2026-10-05 · Sitzung 19 · 10:45–14:15
**Issue:** #034 (M21)
**Version:** 0.2.0 (unveröffentlicht)
### Erledigt
- **Vier Anlässe gehen hinaus**, per Mail über SMTP und optional per Webhook mit
HMAC-SHA256-Signatur. Je Person abschaltbar, und niemand bekommt Post über die eigene Tat.
- **Eine Warteschlange in der Datenbank**, abgearbeitet von einem eigenen Zeitplan im selben
Prozess. Fehlschläge nach 1, 5, 30 und 120 Minuten wiederholt, danach sichtbar aufgegeben.
- **Verwaltung → Zustellungen** zeigt, was hinausging und was nicht, mit Grund.
- 1302 Vitest-Tests (vorher 1266), davon 36 neue — **gegen einen echten SMTP-Fänger** (mailpit),
nicht gegen eine abgefangene Funktion.
- Im gebauten Stand gefahren: SMTP eingetragen, Testmail angekommen, ein überfälliger Lauf angelegt,
und der Zeitplan hat die Meldung **von selbst** verschickt.
### Entscheidungen
- **Eingereiht im Anfrage-Pfad, gesendet vom Zeitplan.** Ein hängender Mailserver darf kein Abhaken
blockieren. Das Einreihen passiert nach der Transaktion und mit abgefangenem Fehler.
- **Betreff und Rumpf stehen fertig in der Zeile.** Eine Meldung sagt, was *damals* war; würde der
Text erst beim Senden gebaut, stünde in einer Mail von heute der Stand von jetzt.
- **Die Texte liegen in `src/lib/meldungen.ts` und nehmen nur Kopfdaten.** Der Typ lässt keinen
Schrittinhalt zu — das ist die Absicht, und es ist die einzige Stelle, die man dafür ändern müsste.
- **Die Sperre wurde aus dem Abgleich herausgezogen** nach `services/sperre.ts`. Zwei Fassungen
derselben Mechanik wären die schlechteste aller Lösungen.
- **Die Mandantengrenze gilt auch für Empfänger.** Ohne diese Prüfung wäre die Benachrichtigung der
eine Weg nach draußen, den `sichtbareKundenIds` nicht abdeckt.
### Probleme
- **pnpm wollte eine Sicherheitsregel umgehen, und ich hätte es fast committet.** `pnpm add
nodemailer` trug von sich aus `minimumReleaseAgeExclude: nodemailer@10.0.15` in
`pnpm-workspace.yaml` ein — die Version war **vom selben Tag**. Das ist kein Formfehler, sondern
genau die Warnung, vor der die Regel schützt. Gepinnt ist jetzt `10.0.10` vom 14. September, die
Ausnahme ist weg, und die Konvention steht in `CLAUDE.md`. Aufgefallen beim Durchsehen des Diffs,
nicht durch einen Test — eine Zeile in einer Datei, die man leicht übersieht.
- **Drei Tests waren stillschweigend übersprungen.** `it.runIf(mailpitDa)` wird beim **Einlesen** der
Datei ausgewertet; mein Flag stand in `beforeAll` und kam zu spät. Der erste Lauf meldete
„3 skipped“, war grün — und hatte den echten SMTP-Dialog nie geprüft. Genau das Muster, das
`ldap` in #019 schon einmal gekostet hat. `mailpit` steht jetzt im Tor vor dem Commit, und
`pruefe:doku` erzwingt es; gegengeprobt.
- **Die Wartezeiten waren um eins verschoben.** Nach dem ersten Fehlschlag wartete meine erste
Fassung fünf Minuten statt einer. Der Test hat es sofort gezeigt — und die Umbenennung des
Parameters auf `versucheGesamt` macht die Semantik jetzt lesbar.
- **Zwei Knöpfe „Speichern“ auf einer Seite**, beim Durchklicken gefunden. Der neue heißt jetzt
„Benachrichtigungen speichern“.
- **Drei eigene Testfehler**, keine Produktfehler: die `Verzeichnisquelle` hat `url`, nicht
`host`/`port`; `Lauf` mit Status `ABGESCHLOSSEN` braucht `abgeschlossenAm` (die Datenbank verlangt
es); und ein fester Testzeitpunkt lag **vor** der echten Einreihung, sodass der Durchgang die
Zeile gar nicht erst anfasste.
### Nächster Schritt
- **M22 (#035)** Mitarbeiter-Selbstauskunft — dort kommt auch die Profilseite, auf der jeder seine
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`.
+1 -11
View File
@@ -2,7 +2,7 @@
**Status:** offen **Status:** offen
**Im Tracker:** [#38](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/38) **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 **Labels:** phase-2, infra
**Geschätzt:** 2 h **Tatsächlich:** – **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 `status ok` liefert
- [ ] `pct exec <ctid> -- ss -ltn` zeigt keinen Port 5432 - [ ] `pct exec <ctid> -- ss -ltn` zeigt keinen Port 5432
- [ ] Zweiter Aufruf: Daten, `APP_SECRET` und Datenbankpasswort unverändert - [ ] 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.
+8 -63
View File
@@ -1,10 +1,10 @@
# #027 Die Editor-Abläufe lassen ihre Vorlagen stehen # #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) **Im Tracker:** [#46](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/46)
**Meilenstein:** v0.2.0 **Meilenstein:** v0.2.0
**Labels:** phase-2, test **Labels:** phase-2, test
**Geschätzt:** 1 h **Tatsächlich:** 2 h 10 min **Geschätzt:** 1 h **Tatsächlich:** –
## Ziel ## Ziel
@@ -22,69 +22,14 @@ Vorlagenliste in der Oberfläche, Screenshots, und jede Abfrage von Hand. Bei ei
## Aufgaben ## 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) ü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 - [ ] 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 - [ ] 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, - [ ] Prüfen, ob ein Nachlauf das überhaupt leisten kann — ein Ablauf, der mitten im Lauf scheitert,
darf den Nachlauf nicht überspringen darf den Nachlauf nicht überspringen
## Akzeptanzkriterien ## Akzeptanzkriterien
- [x] Nach `pnpm test:e2e` steht in `Vorlage` keine Zeile mit `ZZ E2E` - [ ] Nach `pnpm test:e2e` steht in `Vorlage` keine Zeile mit `ZZ E2E`
- [x] Ein abgebrochener Lauf lässt ebenfalls nichts stehen - [ ] 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).
+19 -74
View File
@@ -1,10 +1,10 @@
# #028 M15 – Quellenarten und Microsoft-Graph-Quelle # #028 M15 – Quellenarten und Microsoft-Graph-Quelle
**Status:** erledigt **Status:** offen
**Im Tracker:** [#48](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/48) **Im Tracker:** [#48](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/48)
**Meilenstein:** v0.2.0 **Meilenstein:** v0.2.0
**Labels:** phase-2, backend, frontend **Labels:** phase-2, backend, frontend
**Geschätzt:** 10 h **Tatsächlich:** 4 h 10 min **Geschätzt:** 10 h **Tatsächlich:** –
## Ziel ## Ziel
@@ -17,94 +17,39 @@ Dieser Meilenstein legt dazu die Weiche, die auch [#029](029-scim-endpunkt.md) b
## Zu entscheiden ## 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, `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 prüft der Service, nicht die Datenbank. Die Alternative wäre eine Tabelle je Art mit
gemeinsamer Oberklasse — mehr Struktur, als drei Arten rechtfertigen. 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 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. in der Anleitung zu sagen. Wer OU-abhängige Befehle fährt, braucht ein lokales AD.
## Aufgaben ## Aufgaben
- [x] `QuellenArt` als Enum, Migration mit `LDAP` für alle bestehenden Zeilen - [ ] `QuellenArt` als Enum, Migration mit `LDAP` für alle bestehenden Zeilen
- [x] Graph-Zugang: `tenantId`, `clientId`, `clientSecret` (verschlüsselt wie das Bind-Passwort, nie - [ ] 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 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)` 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`) `userPrincipalName`, `onPremisesSamAccountName`, `department`, `jobTitle`, `accountEnabled`)
- [x] `externeId` ist die Entra-`id` (GUID). Sie ist stabil und übersteht eine Umbenennung - [ ] `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 - [ ] „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 - [ ] Der Abgleich läuft durch **denselben** Dienst wie LDAP — nur das Holen der Einträge
unterscheidet sich. Sonst driften die Regeln auseinander unterscheidet sich. Sonst driften die Regeln auseinander
- [x] Admin → Verzeichnis zeigt die Art je Quelle und bietet beim Anlegen die Wahl - [ ] 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 - [ ] Anleitung Kapitel 06 und `docs/betrieb/`: was in Entra ID einzurichten ist
(App-Registrierung, `User.Read.All`, `Directory.Read.All`, Administratoreinwilligung) (App-Registrierung, `User.Read.All`, `Directory.Read.All`, Administratoreinwilligung)
## Akzeptanzkriterien ## 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 Abgleich legt an, zweiter aktualisiert ohne Dubletten, ein `accountEnabled: false` setzt
`aktiv = false` `aktiv = false`
- [x] Test: der Seitenabruf folgt `@odata.nextLink` über mindestens drei Seiten - [ ] Test: der Seitenabruf folgt `@odata.nextLink` über mindestens drei Seiten
- [x] Test: `clientSecret` erscheint in keiner Antwort und in keinem Protokolleintrag - [ ] Test: `clientSecret` erscheint in keiner Antwort und in keinem Protokolleintrag
- [x] Test: eine Graph-Quelle lässt `{{ou}}` leer, statt einen Wert zu erfinden - [ ] 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 - [ ] 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 - [ ] **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) 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).
+19 -115
View File
@@ -1,10 +1,10 @@
# #029 M16 – SCIM-Endpunkt als Alternative zum Abgleich # #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) **Im Tracker:** [#49](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/49)
**Meilenstein:** v0.2.0 **Meilenstein:** v0.2.0
**Labels:** phase-2, backend **Labels:** phase-2, backend
**Geschätzt:** 12 h **Tatsächlich:** 4 h 05 min **Geschätzt:** 12 h **Tatsächlich:** –
## Ziel ## Ziel
@@ -16,137 +16,41 @@ Setzt [#028](028-graph-quelle.md) voraus: die Quellenart `SCIM` braucht die Weic
## Zu entscheiden ## 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 lokalen Passwort (argon2). Es wird **einmal** angezeigt und ist danach nicht mehr lesbar. Das
Token identifiziert die Quelle und damit den Kunden; es ist der einzige Mandantenbeweis, den Token identifiziert die Quelle und damit den Kunden; es ist der einzige Mandantenbeweis, den
ein Push mitbringt. ein Push mitbringt.
- [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 des ganzen Projekts, und ein Identitätsanbieter, der löschen will, bekommt `204` — aber die
Zeile bleibt, weil Läufe an ihr hängen. Das muss in der Anleitung stehen, sonst wirkt es wie Zeile bleibt, weil Läufe an ihr hängen. Das muss in der Anleitung stehen, sonst wirkt es wie
ein Fehler. ein Fehler.
## Aufgaben ## Aufgaben
- [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`, `Users/{id}` (GET, PUT, PATCH, DELETE), dazu `ServiceProviderConfig`, `Schemas`,
`ResourceTypes` — ohne die drei weigern sich manche Anbieter, überhaupt anzufangen `ResourceTypes` — ohne die drei weigern sich manche Anbieter, überhaupt anzufangen
- [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) 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` `emails[primary]` → `mail`, `active` → `aktiv`, `urn:ietf:params:scim:schemas:extension:enterprise:2.0:User`
für `department` und `manager` für `department` und `manager`
- [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` `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 nicht der ganze Standard
- [x] Idempotenz: zweimal dasselbe `POST` ergibt `409`, nicht eine zweite Person - [ ] Idempotenz: zweimal dasselbe `POST` ergibt `409`, nicht eine zweite Person
- [x] Protokolleintrag je Vorgang, mit der Quelle als Urheber (Systemlauf, kein Benutzer) - [ ] Protokolleintrag je Vorgang, mit der Quelle als Urheber (Systemlauf, kein Benutzer)
- [x] Admin → Verzeichnis: Token erzeugen, einmal anzeigen, widerrufen; letzte Zustellung sichtbar - [ ] Admin → Verzeichnis: Token erzeugen, einmal anzeigen, widerrufen; letzte Zustellung sichtbar
- [x] Anleitung Kapitel 06: Einrichtung in authentik und in Entra ID - [ ] Anleitung Kapitel 06: Einrichtung in authentik und in Entra ID
## Akzeptanzkriterien ## 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 anlegen, aktualisieren, deaktivieren, wieder aktivieren
- [x] Test: ohne Token `401`, mit fremdem Token kein Zugriff auf die Personen einer anderen Quelle - [ ] 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 - [ ] Test: `DELETE` setzt `aktiv = false` und löscht nichts
- [x] Test: zweimal dasselbe `POST` ergibt `409` und keine zweite Zeile - [ ] 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 - [ ] 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: eine Quelle der Art `SCIM` wird vom Zeitplan **nicht** abgeglichen — sie bekommt
geschickt geschickt
## Beim Umsetzen dazugekommen
- **Zwei Gegenproben blieben grün, und beide deckten etwas auf.** Von acht Gegenproben (Regel
brechen, Test muss rot werden) schlugen sechs sofort an. Die zwei stillen waren die wertvollen:
- **`PATCH remove` wirkte gar nicht** — ein echter Fehler, kein Testloch. `personAendern` hatte
einen Schalter `nurGesetzte`, der bei `PATCH` jedes `null` als „nicht mitgeschickt“ las und den
alten Wert stehen ließ. Nimmt jemand beim Anbieter die Abteilung aus dem Profil, stand sie in
Laufzettel weiter — sichtbar, gepflegt aussehend, falsch. Nötig war der Schalter nie:
`patchAnwenden` setzt auf dem vorhandenen Stand auf und liefert deshalb immer einen
**vollständigen** Zielzustand. Der Unterschied zwischen `PUT` und `PATCH` liegt vor der
Funktion, nicht in ihr. Schalter entfernt, Test ergänzt, Gegenprobe schlägt jetzt an.
- **Entra ID schickt `active` als Zeichenkette `"True"`/`"False"`.** Das war richtig umgesetzt,
aber nur halb geprüft: der Deaktivierungs-Test lief gegen `"False"`, und mit einem reinen
`value === true` kommt dort **zufällig** das Richtige heraus. Nur das Aktivieren wäre kaputt
gewesen — ein Konto, das wieder freigeschaltet wird und in Laufzettel für immer inaktiv bleibt.
Der Fehler wäre also nur in einer Richtung sichtbar gewesen. Jetzt steht `ENTRA_PATCH_AN` mit
`"True"` als eigene aufgezeichnete Form daneben.
- **`manager` war im Issue genannt und wäre fast untergegangen.** Die Zuordnung nennt die
Enterprise-Erweiterung für `department` **und** `manager`; umgesetzt war zunächst nur die
Abteilung. Dazugekommen sind: `vorgesetzterAus` liest alle drei Formen, die die Anbieter schicken
(`{value}`, `{$ref}` und die nackte Zeichenkette — letztere nicht standardkonform, aber authentik
schickt sie), die Auflösung über die Laufzettel-Kennung, und `manager` im `Schemas`-Endpunkt. Das
Letzte ist das Entscheidende: ein Anbieter liest dieses Schema und schickt nur, was darin steht.
Fehlte es, bliebe der Vorgesetzte dauerhaft leer — **ohne Fehlermeldung auf einer der beiden
Seiten**.
- **`manager` wäre ein Weg über die Quellengrenze gewesen.** Eine Kennung aus einer fremden Quelle
zeigt auf jemanden, den diese hier nicht sehen darf, und über die Vorgesetzten-Anzeige stünde
dessen Name in der Oberfläche. Die Auflösung sucht deshalb nur in der eigenen Quelle; eine
Gegenprobe ohne diese Einschränkung ist rot. Dasselbe für den Fall „sein eigener Vorgesetzter“ —
die Datenbank weist das ohnehin ab, aber dann wäre die ganze Zuweisung daran gescheitert.
- **Ein unbekannter Vorgesetzter lässt die Zuweisung nicht scheitern.** Der Anbieter schickt die
Leute in beliebiger Reihenfolge. Wer an einer noch unbekannten Kennung scheiterte, ließe die halbe
Belegschaft draußen; nachgezogen wird beim nächsten `PATCH`.
- **Ein unverstandener Filter ergibt `400` und nicht „alle Personen“.** Das ist die wichtigste
Einzelentscheidung dieses Meilensteins. Ihn zu ignorieren hieße, alle zu liefern — und der
Anbieter schlösse daraus, die gesuchte Person gäbe es schon, und legte sie **nie** an. Unterstützt
wird genau `userName eq "…"`, die Frage, die jeder Anbieter vor dem Anlegen stellt.
- **Ein Fehler im Nachbarcode aus #028**: in `verbindungTestenAktion` stand zweimal dieselbe
Bedingung (`ergebnis.treffer.length === 0`), der zweite Zweig war damit unerreichbar. Genau der
Zweig war in #028 als Reviewbefund eingebaut worden — „gelesen, aber nichts zuordenbar“ sollte
anders klingen als „der Filter findet niemanden“, weil die Ursachen verschiedene sind und den
Administrator an verschiedene Stellen schicken. Wirkungslos, seit er geschrieben wurde. Behoben.
- **„Noch nie abgeglichen“ ist an einer SCIM-Quelle eine Falschaussage**, und „kein Zeitplan“ liest
sich wie ein Mangel. Beides ersetzt: die Pille sagt „nimmt Zustellungen an“, und an der Stelle, an
der bei LDAP der Verbindungstest sitzt, steht die letzte Zustellung. Verbindungstest und „Jetzt
abgleichen“ gibt es dort nicht — ein Knopf, der nur eine Erklärung auswirft, ist kein Knopf. Wer
sie trotzdem über den Dienst ruft, bekommt eine Erklärung statt „unbekannter Fehler“.
- **Der Zeitplan lässt SCIM-Quellen aus, und das ist keine Kosmetik.** Ein Abgleich, der an einer
SCIM-Quelle nichts liest und `ERFOLG` meldet, hätte über die Abschaltregel die **ganze
Belegschaft deaktiviert** — derselbe Fall, der in #016 einen Reviewbefund gekostet hat. Zwei
Stellen halten das: `faelligeQuellen` nimmt sie nicht auf, und die Weiche im Quellenleser wirft.
- **Das Token in keiner Sicht** — geprüft nicht an einem Feld, sondern an der **ganzen**
serialisierten Sicht aus Liste und Detail. Ein später ergänztes Feld fällt damit auch auf. Dazu:
nicht im Protokolleintrag, nicht im HTML der Seite nach dem Neuladen (in der Klickprobe am
gebauten Stand nachgesehen).
- **Dieselbe `401` für „kein Token“ und „falsches Token“.** Die Unterscheidung wäre eine Auskunft
darüber, ob ein geratenes Token die richtige Form hat. Und vor der Hash-Prüfung steht eine
Vorab-Prüfung auf das Präfix: ohne sie würde ein Token mit falschem Präfix gegen jeden Hash
geprüft, eines mit richtigem nur bis zum Treffer — ein Zeitorakel.
- **Die Beschreibungs-Endpunkte verlangen ebenfalls ein Token**, und ihre **Verneinungen** sind der
wichtigere Teil: `bulk`, `sort`, `filter` und `etag` stehen ausdrücklich auf `false`. Ein
Anbieter, der `bulk` für unterstützt hält, schickt Anfragen, die niemand beantwortet, und meldet
das als Fehler der Gegenseite.
- **Die Klickprobe am gebauten Stand fand keinen Bedienfehler** — zwanzig Prüfungen von „Art SCIM
wählbar“ über „Token angezeigt“, eine echte Zustellung mit genau diesem Token, „Person steht in
der Liste“, `DELETE` deaktiviert, Widerruf sperrt, bis „390 px ohne horizontales Scrollen“. Die
Probe hat ihre Daten danach selbst wieder entfernt; das ist die Lehre aus #027.
- **Ein Test aus #028 war überholt**, nicht falsch: er prüfte, dass die Meldung der Weiche „#029“
enthält. Jetzt ist #029 da, und die Antwort bleibt dieselbe — nur ohne Issue-Nummer, denn die
Meldung steht auch in der Oberfläche.
## Was offen bleibt
- **Gegen echte Mandanten ist das nicht gelaufen.** Die Anfrageformen in `tests/scim.test.ts`
stammen aus der Dokumentation von authentik und Entra ID, nicht aus einem Mitschnitt — dieselbe
Grenze wie bei #028, und sie ist dort im Kopfkommentar der Testdatei benannt. Stimmt eine Form
nicht, stimmen Test und Wirklichkeit nicht. Die Prüflisten für den ersten echten Lauf stehen in
[`docs/betrieb/scim.md`](../betrieb/scim.md) unter „Prüfen von Hand“.
- **Keine Paginierung über 200 Einträge** bei `GET /Users` ohne Filter. Die Anbieter fragen
gefiltert; ein voller Abzug ist keine Betriebsart dieses Endpunkts.
- **Kein `/Groups`.** Rollen entstehen beim Anmelden aus den OIDC-Gruppen, nicht aus dem
Provisioning. `ResourceTypes` nennt deshalb nur `User`.
+21 -70
View File
@@ -1,10 +1,10 @@
# #034 M21 – Benachrichtigungen per Mail und Webhook # #034 M21 – Benachrichtigungen per Mail und Webhook
**Status:** erledigt **Status:** offen
**Im Tracker:** [#54](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/54) **Im Tracker:** [#54](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/54)
**Meilenstein:** v0.2.0 **Meilenstein:** v0.2.0
**Labels:** phase-2, backend, frontend, infra **Labels:** phase-2, backend, frontend, infra
**Geschätzt:** 10 h **Tatsächlich:** 3 h 30 min **Geschätzt:** 10 h **Tatsächlich:** –
## Ziel ## Ziel
@@ -14,93 +14,44 @@ Ein zugewiesener Lauf fällt heute nur auf, wenn jemand die Übersicht öffnet.
## Zu entscheiden ## Zu entscheiden
- [x] **Nur diese vier Ereignisse, je Empfänger abschaltbar** — Empfehlung. Ein allgemeiner - [ ] **Nur diese vier Ereignisse, je Empfänger abschaltbar** — Empfehlung. Ein allgemeiner
Benachrichtigungsbaukasten ist eine Phase für sich; vier Ereignisse decken ab, was im Betrieb Benachrichtigungsbaukasten ist eine Phase für sich; vier Ereignisse decken ab, was im Betrieb
wirklich fehlt. wirklich fehlt.
- [x] **Mail über SMTP, keine Dienstanbindung.** Eine selbst gehostete Anwendung soll an den - [ ] **Mail über SMTP, keine Dienstanbindung.** Eine selbst gehostete Anwendung soll an den
Mailserver des Hauses können, und das ist SMTP. Zugangsdaten verschlüsselt wie das Mailserver des Hauses können, und das ist SMTP. Zugangsdaten verschlüsselt wie das
LDAP-Bind-Passwort. LDAP-Bind-Passwort.
- [x] **Zustellung in einer Warteschlange in der Datenbank**, nicht im Anfrage-Pfad. Ein hängender - [ ] **Zustellung in einer Warteschlange in der Datenbank**, nicht im Anfrage-Pfad. Ein hängender
Mailserver darf kein Abhaken blockieren. Dieselbe Sperre wie beim Abgleich Mailserver darf kein Abhaken blockieren. Dieselbe Sperre wie beim Abgleich
(`AbgleichSperre`) verhindert, dass zwei Instanzen dasselbe zweimal schicken. (`AbgleichSperre`) verhindert, dass zwei Instanzen dasselbe zweimal schicken.
## Aufgaben ## Aufgaben
- [x] Modell `Zustellung`: Ereignis, Empfänger, Weg (MAIL | WEBHOOK), Versuche, letzter Fehler, - [ ] Modell `Zustellung`: Ereignis, Empfänger, Weg (MAIL | WEBHOOK), Versuche, letzter Fehler,
Zustand; mit Index auf das, was der Zeitplan abholt Zustand; mit Index auf das, was der Zeitplan abholt
- [x] SMTP-Einstellungen unter Admin → Einstellungen: Host, Port, TLS, Benutzer, Passwort - [ ] SMTP-Einstellungen unter Admin → Einstellungen: Host, Port, TLS, Benutzer, Passwort
(verschlüsselt, nie in einer Antwort), Absender, „Testmail schicken“ (verschlüsselt, nie in einer Antwort), Absender, „Testmail schicken“
- [x] Webhook: Ziel-URL, Geheimnis, **HMAC-SHA256 über den Rumpf** im Kopf `X-Laufzettel-Signatur`, - [ ] Webhook: Ziel-URL, Geheimnis, **HMAC-SHA256 über den Rumpf** im Kopf `X-Laufzettel-Signatur`,
dazu ein Zeitstempel gegen Wiedereinspielen dazu ein Zeitstempel gegen Wiedereinspielen
- [x] Wiederholung mit wachsendem Abstand (1, 5, 30, 120 Minuten), dann aufgeben und sichtbar - [ ] Wiederholung mit wachsendem Abstand (1, 5, 30, 120 Minuten), dann aufgeben und sichtbar
machen. Eine stille Warteschlange ist schlimmer als keine machen. Eine stille Warteschlange ist schlimmer als keine
- [x] Überfälligkeit erkennt der Zeitplan, der schon läuft (`node-cron` im Prozess) — kein zweiter - [ ] Überfälligkeit erkennt der Zeitplan, der schon läuft (`node-cron` im Prozess) — kein zweiter
Dienst Dienst
- [x] Je Benutzer abschaltbar; ein Benutzer ohne Mailadresse bekommt keine Mail und keinen Fehler - [ ] Je Benutzer abschaltbar; ein Benutzer ohne Mailadresse bekommt keine Mail und keinen Fehler
- [x] Admin: die letzten Zustellungen mit Zustand und Grund - [ ] Admin: die letzten Zustellungen mit Zustand und Grund
- [x] **`{{generiertes_passwort}}` kommt in keine Nachricht.** Eine Mail mit dem Lauf-Inhalt wäre - [ ] **`{{generiertes_passwort}}` kommt in keine Nachricht.** Eine Mail mit dem Lauf-Inhalt wäre
genau die Lücke, die das Projekt an jeder anderen Stelle geschlossen hat. Die Nachricht genau die Lücke, die das Projekt an jeder anderen Stelle geschlossen hat. Die Nachricht
enthält Titel, Status und einen Link — **keine Schrittinhalte** enthält Titel, Status und einen Link — **keine Schrittinhalte**
- [x] `docs/betrieb/`: SMTP einrichten, Webhook prüfen, Signatur nachrechnen - [ ] `docs/betrieb/`: SMTP einrichten, Webhook prüfen, Signatur nachrechnen
- [x] Anleitung Kapitel 07 - [ ] Anleitung Kapitel 07
## Akzeptanzkriterien ## Akzeptanzkriterien
- [x] Test gegen einen echten SMTP-Fänger im Container (mailpit): zugewiesen, überfällig, - [ ] Test gegen einen echten SMTP-Fänger im Container (mailpit): zugewiesen, überfällig,
abgeschlossen, Abgleich fehlgeschlagen — vier Mails, richtige Empfänger abgeschlossen, Abgleich fehlgeschlagen — vier Mails, richtige Empfänger
- [x] Test gegen einen lokalen Empfänger: die Signatur lässt sich mit dem Geheimnis nachrechnen, mit - [ ] Test gegen einen lokalen Empfänger: die Signatur lässt sich mit dem Geheimnis nachrechnen, mit
einem falschen nicht einem falschen nicht
- [x] Test: eine fehlgeschlagene Zustellung wird wiederholt und ist danach sichtbar - [ ] Test: eine fehlgeschlagene Zustellung wird wiederholt und ist danach sichtbar
- [x] Test: zwei gleichzeitige Durchgänge schicken nichts zweimal - [ ] Test: zwei gleichzeitige Durchgänge schicken nichts zweimal
- [x] Test: in **keiner** Nachricht steht ein erzeugtes Passwort — über alle Rumpftexte geprüft, wie - [ ] Test: in **keiner** Nachricht steht ein erzeugtes Passwort — über alle Rumpftexte geprüft, wie
in #012 in #012
- [x] Test: ein Benutzer mit abgeschalteter Benachrichtigung bekommt nichts - [ ] Test: ein Benutzer mit abgeschalteter Benachrichtigung bekommt nichts
## Beim Umsetzen dazugekommen
- **Die Sperre wurde aus dem Abgleich herausgezogen.** `mitAbgleichSperre` lag in
`services/abgleich.ts`; die Warteschlange braucht dieselbe Mechanik, und zwei Fassungen davon
wären die schlechteste aller Lösungen. Sie heißt jetzt `mitSperre` in `services/sperre.ts`. Die
Tabelle behält den Namen `AbgleichSperre` — ein Umbenennen wäre eine Migration, die nichts
verbessert, und der Schlüssel sagt ohnehin, wer sperrt.
- **Niemand bekommt Post über die eigene Tat.** Steht nicht im Issue, ist aber der häufigste Grund,
Benachrichtigungen ganz abzuschalten. Der Handelnde fällt immer aus der Empfängerliste.
- **Die Mandantengrenze gilt auch für Empfänger.** Eine Meldung trägt Lauftitel und Gerätenamen;
ohne diese Prüfung wäre die Benachrichtigung der eine Weg nach draußen, den `sichtbareKundenIds`
nicht abdeckt. Ein Benutzer, der den Kunden des Laufs nicht sehen darf, bekommt keine Mail — mit
eigenem Test.
- **Die Einmaligkeit der Überfälligkeitsmeldung liegt in der Datenbank**, als partieller eindeutiger
Index. Der Zeitplan sieht jede Minute nach; ohne ihn bekäme der Zuständige für einen Lauf, der
über das Wochenende steht, **2880 Mails** — und jede einzelne davon wäre für sich korrekt, kein
Test rot. Gegenprobe gefahren: ohne den Index werden zwei Tests rot.
- **Fünf Versuche, nicht vier.** Die vier Wartezeiten aus dem Issue (1, 5, 30, 120 Minuten) liegen
*zwischen* den Versuchen — der fünfte Fehlschlag gibt auf. Meine erste Fassung indizierte um eins
verschoben und wartete nach dem ersten Fehlschlag fünf Minuten; der Test hat es sofort gezeigt.
- **Ein eigener Zeitplan, nicht derselbe wie der Abgleich.** Der Mailversand darf nicht warten, weil
gerade ein großes Verzeichnis gelesen wird. Zwei Takte sind billig.
- **`mailpit` gehört ins Tor vor dem Commit.** Ohne den Container überspringt die Suite **3 Tests**
(gemessen: 36 statt 33) — genau die, die den echten SMTP-Dialog prüfen. `CLAUDE.md`, das README
und `scripts/pruefe-doku.mjs` nennen ihn jetzt, und die Prüfung ist gegengeprobt: ohne ihn im
Block wird `pnpm pruefe:doku` rot.
- **`it.runIf(mailpitDa)` wird beim Einlesen der Datei ausgewertet.** Das Flag stand in `beforeAll`
und kam zu spät: der erste Lauf meldete „3 skipped“, war grün und hatte den Mailversand nie
geprüft. Jetzt wird auf Modulebene geprüft, wie bei `datenbankVerfuegbar`.
- **pnpm wollte eine Mindestalter-Sperre umgehen.** `pnpm add nodemailer` trug von sich aus
`minimumReleaseAgeExclude: nodemailer@10.0.15` ein — die Version war **vom selben Tag**. Das ist
kein Formfehler, sondern genau die Warnung, vor der die Regel schützt: ein frisches Release eines
Mailpakets ungeprüft einzubauen. Gepinnt ist jetzt `10.0.10` vom 14. September, die Ausnahme ist
weg, und die Konvention steht in `CLAUDE.md`.
- **Zwei Knöpfe „Speichern“ auf einer Seite.** Beim Durchklicken aufgefallen: die Einstellungsseite
hatte zwei gleich benannte. Der neue heißt jetzt „Benachrichtigungen speichern“ — für einen
Menschen genauso verwirrend wie für einen Test.
- **Die Testmail geht direkt hinaus, nicht über die Warteschlange.** Ein Knopf, der „eingereiht“
meldet und den Fehler eine Minute später woanders zeigt, prüft nichts.
- **Der Mail-Schalter gilt auch für das eigene Konto**, anders als „aktiv“: sich selbst zu
deaktivieren wäre ein Eigentor, seine eigene Post abzustellen der häufigste Wunsch überhaupt. Eine
**Profilseite**, auf der das jeder selbst könnte, fehlt noch — sie kommt mit der Selbstauskunft
(#035). Bis dahin stellt ein Administrator es um, und so steht es in der Anleitung.
- **Im gebauten Stand wirklich gefahren:** SMTP eingetragen, Testmail bei mailpit angekommen, ein
überfälliger Lauf angelegt — und der Zeitplan hat die Meldung **von selbst** verschickt, ohne
Zutun. Dazu geprüft: das Passwort steht nicht im Quelltext der Seite, Speichern ohne Passwortfeld
behält es, die Zustellungsliste zeigt keinen Rumpf, und 390 px ohne waagerechtes Scrollen auf allen
drei Seiten.
+19 -72
View File
@@ -1,10 +1,10 @@
# #035 M22 – Mitarbeiter-Selbstauskunft # #035 M22 – Mitarbeiter-Selbstauskunft
**Status:** erledigt **Status:** offen
**Im Tracker:** [#55](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/55) **Im Tracker:** [#55](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/55)
**Meilenstein:** v0.2.0 **Meilenstein:** v0.2.0
**Labels:** phase-2, backend, frontend **Labels:** phase-2, backend, frontend
**Geschätzt:** 8 h **Tatsächlich:** 2 h 50 min **Geschätzt:** 8 h **Tatsächlich:** –
## Ziel ## Ziel
@@ -20,95 +20,42 @@ Anmeldung zu einer `Mitarbeiter`-Zeile zu kommen.
## Zu entscheiden ## 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 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 Rollengruppe angehört, wird über `upn` einer `Mitarbeiter`-Zeile zugeordnet und bekommt genau
ein Recht: `selbstauskunft`. `Benutzer.mitarbeiterId` hält die Zuordnung fest. ein Recht: `selbstauskunft`. `Benutzer.mitarbeiterId` hält die Zuordnung fest.
Die Alternative — ein Link mit Token je Person — braucht keine Anmeldung, aber jeder 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. 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 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 Arbeitsnotizen über Menschen und Zugangsdaten-Platzhalter; eine Selbstauskunft ist keine
Akteneinsicht. Das ist die wichtigste Entscheidung dieses Meilensteins und gehört in die Akteneinsicht. Das ist die wichtigste Entscheidung dieses Meilensteins und gehört in die
Anleitung, nicht nur in den Code. 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. bekommt `/kein-zugriff` mit Begründung — nicht eine leere Seite, die wie ein Fehler aussieht.
## Aufgaben ## 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 **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 ausgeschrieben und nicht berechnet, genau damit das auffällt
- [x] `Benutzer.mitarbeiterId` mit Mandantengrenze als zusammengesetzter Fremdschlüssel - [ ] `Benutzer.mitarbeiterId` mit Mandantengrenze als zusammengesetzter Fremdschlüssel
- [x] Zuordnung beim Login über `upn`, unabhängig von Groß- und Kleinschreibung; mehrdeutig ist ein - [ ] Zuordnung beim Login über `upn`, unabhängig von Groß- und Kleinschreibung; mehrdeutig ist ein
Fehler und kein Zufallstreffer Fehler und kein Zufallstreffer
- [x] Eigene Seite `/meine-geraete`: meine Geräte, je Gerät die Läufe chronologisch, Titel und Status - [ ] 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 - [ ] **Jede Abfrage schränkt auf die eigene Person ein** — nicht die Seite. Ein Lesemodell trägt
seine Sicht selbst (CLAUDE.md) seine Sicht selbst (CLAUDE.md)
- [x] Die Navigation zeigt einem `MITARBEITER` nur diese Seite; alles andere ist weg, nicht grau - [ ] 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 - [ ] Anleitung: ein eigenes kurzes Kapitel, an Mitarbeiter gerichtet und nicht an Techniker
## Akzeptanzkriterien ## Akzeptanzkriterien
- [x] Test: ein `MITARBEITER` sieht die Läufe seiner Geräte und keinen anderen - [ ] 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: er sieht **keine** Schritte, Notizen, Kommentare oder Anhänge — geprüft über die Antwort,
nicht über die Seite 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) erkennt eine ungeprüfte Aktion ohnehin)
- [x] Test: ohne passende `Mitarbeiter`-Zeile führt die Anmeldung auf `/kein-zugriff` mit Begründung - [ ] Test: ohne passende `Mitarbeiter`-Zeile führt die Anmeldung auf `/kein-zugriff` mit Begründung
- [x] Test: ein mehrdeutiger UPN wird abgewiesen und protokolliert - [ ] Test: ein mehrdeutiger UPN wird abgewiesen und protokolliert
- [x] Test: die Rollenmatrix ist vollständig — jede der fünf Rollen gegen jedes Recht - [ ] Test: die Rollenmatrix ist vollständig — jede der fünf Rollen gegen jedes Recht
- [x] Am Handy kein horizontales Scrollen - [ ] 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.
-136
View File
@@ -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.
-87
View File
@@ -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.
-92
View File
@@ -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.
+7 -10
View File
@@ -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 | | #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 | | #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 | | #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 | | #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 | | #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 | | #027 | [Die Editor-Abläufe lassen ihre Vorlagen stehen](027-e2e-reste-vorlagen.md) | offen | v0.2.0 | 1 h | – |
| #028 | [M15 – Quellenarten und Microsoft-Graph-Quelle](028-graph-quelle.md) | erledigt | v0.2.0 | 10 h | 4 h 10 min | | #028 | [M15 – Quellenarten und Microsoft-Graph-Quelle](028-graph-quelle.md) | offen | v0.2.0 | 10 h | – |
| #029 | [M16 – SCIM-Endpunkt als Alternative zum Abgleich](029-scim-endpunkt.md) | erledigt | v0.2.0 | 12 h | 4 h 05 min | | #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 | | #030 | [M17 – Wiederverwendbare Bausteine](030-bausteine.md) | erledigt | v0.2.0 | 12 h | 2 h 25 min |
| #031 | [M18 – Abteilungsspezifische Zusatzabschnitte](031-abteilungsabschnitte.md) | erledigt | v0.2.0 | 8 h | 1 h 35 min | | #031 | [M18 – Abteilungsspezifische Zusatzabschnitte](031-abteilungsabschnitte.md) | erledigt | v0.2.0 | 8 h | 1 h 35 min |
| #032 | [M19 – Anhänge und Fotos an Schritten](032-anhaenge.md) | erledigt | v0.2.0 | 10 h | 3 h 05 min | | #032 | [M19 – Anhänge und Fotos an Schritten](032-anhaenge.md) | erledigt | v0.2.0 | 10 h | 3 h 05 min |
| #033 | [M20 – Live-Aktualisierung per SSE statt Polling](033-sse-live.md) | erledigt | v0.2.0 | 6 h | 1 h 40 min | | #033 | [M20 – Live-Aktualisierung per SSE statt Polling](033-sse-live.md) | erledigt | v0.2.0 | 6 h | 1 h 40 min |
| #034 | [M21 – Benachrichtigungen per Mail und Webhook](034-benachrichtigungen.md) | erledigt | v0.2.0 | 10 h | 3 h 30 min | | #034 | [M21 – Benachrichtigungen per Mail und Webhook](034-benachrichtigungen.md) | offen | v0.2.0 | 10 h | – |
| #035 | [M22 – Mitarbeiter-Selbstauskunft](035-selbstauskunft.md) | erledigt | v0.2.0 | 8 h | 2 h 50 min | | #035 | [M22 – Mitarbeiter-Selbstauskunft](035-selbstauskunft.md) | offen | v0.2.0 | 8 h | – |
| #036 | [Reviewbefunde aus der Durchsicht von Phase 2](036-reviewbefunde-phase-2.md) | erledigt | v0.2.0 | 4 h | 3 h 40 min | | | **Summe** | | | **229 h** | **50 h 05 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** |
+1 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "laufzettel", "name": "laufzettel",
"version": "0.2.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Checklisten fuer wiederkehrende IT-Arbeiten: versionierte Vorlagen, Laeufe, Archiv, QR-Tags", "description": "Checklisten fuer wiederkehrende IT-Arbeiten: versionierte Vorlagen, Laeufe, Archiv, QR-Tags",
@@ -54,7 +54,6 @@
"lucide-react": "1.47.0", "lucide-react": "1.47.0",
"next": "16.3.6", "next": "16.3.6",
"node-cron": "^4.6.0", "node-cron": "^4.6.0",
"nodemailer": "10.0.10",
"openid-client": "^6.8.8", "openid-client": "^6.8.8",
"pg": "^8.23.0", "pg": "^8.23.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
@@ -73,7 +72,6 @@
"@playwright/test": "1.63.0", "@playwright/test": "1.63.0",
"@tailwindcss/postcss": "^4.3.3", "@tailwindcss/postcss": "^4.3.3",
"@types/node": "^24.13.6", "@types/node": "^24.13.6",
"@types/nodemailer": "^8.0.2",
"@types/pg": "^8.23.1", "@types/pg": "^8.23.1",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19.3.0", "@types/react": "^19.3.0",
-6
View File
@@ -11,12 +11,6 @@ export default defineConfig({
* erst mitten im Lauf auf — als Zeitablauf oder als „0 Treffer“ (#018). * erst mitten im Lauf auf — als Zeitablauf oder als „0 Treffer“ (#018).
*/ */
globalSetup: "./tests/e2e/global-setup.ts", 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. // Die Ablaeufe aendern gemeinsame Daten, deshalb nacheinander.
fullyParallel: false, fullyParallel: false,
workers: 1, workers: 1,
-19
View File
@@ -220,9 +220,6 @@ importers:
node-cron: node-cron:
specifier: ^4.6.0 specifier: ^4.6.0
version: 4.6.0 version: 4.6.0
nodemailer:
specifier: 10.0.10
version: 10.0.10
openid-client: openid-client:
specifier: ^6.8.8 specifier: ^6.8.8
version: 6.8.8 version: 6.8.8
@@ -272,9 +269,6 @@ importers:
'@types/node': '@types/node':
specifier: ^24.13.6 specifier: ^24.13.6
version: 24.13.6 version: 24.13.6
'@types/nodemailer':
specifier: ^8.0.2
version: 8.0.2
'@types/pg': '@types/pg':
specifier: ^8.23.1 specifier: ^8.23.1
version: 8.23.1 version: 8.23.1
@@ -2327,9 +2321,6 @@ packages:
'@types/node@24.13.6': '@types/node@24.13.6':
resolution: {integrity: sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==} resolution: {integrity: sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==}
'@types/nodemailer@8.0.2':
resolution: {integrity: sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA==}
'@types/pg@8.23.1': '@types/pg@8.23.1':
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==} resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
@@ -4122,10 +4113,6 @@ packages:
resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==} resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==}
engines: {node: '>=18'} engines: {node: '>=18'}
nodemailer@10.0.10:
resolution: {integrity: sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A==}
engines: {node: '>=20.0.0'}
oauth4webapi@3.8.8: oauth4webapi@3.8.8:
resolution: {integrity: sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==} resolution: {integrity: sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==}
@@ -6893,10 +6880,6 @@ snapshots:
dependencies: dependencies:
undici-types: 7.18.2 undici-types: 7.18.2
'@types/nodemailer@8.0.2':
dependencies:
'@types/node': 24.13.6
'@types/pg@8.23.1': '@types/pg@8.23.1':
dependencies: dependencies:
'@types/node': 24.13.6 '@types/node': 24.13.6
@@ -9028,8 +9011,6 @@ snapshots:
node-releases@2.0.57: {} node-releases@2.0.57: {}
nodemailer@10.0.10: {}
oauth4webapi@3.8.8: {} oauth4webapi@3.8.8: {}
object-assign@4.1.1: {} object-assign@4.1.1: {}
@@ -1,143 +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.
-- ---------------------------------------------------------------------------
-- Benachrichtigungen per Mail und Webhook (#034).
CREATE TYPE "Zustellweg" AS ENUM ('MAIL', 'WEBHOOK');
CREATE TYPE "Zustellzustand" AS ENUM ('OFFEN', 'GESENDET', 'FEHLGESCHLAGEN', 'AUFGEGEBEN');
CREATE TYPE "Meldeanlass" AS ENUM (
'LAUF_ZUGEWIESEN',
'LAUF_UEBERFAELLIG',
'LAUF_ABGESCHLOSSEN',
'ABGLEICH_FEHLGESCHLAGEN'
);
-- Je Empfaenger abschaltbar.
ALTER TABLE "Benutzer" ADD COLUMN "meldungenAktiv" BOOLEAN NOT NULL DEFAULT true;
-- SMTP und Webhook gehoeren zur einen Einstellungszeile.
ALTER TABLE "Einstellung"
ADD COLUMN "smtpHost" TEXT,
ADD COLUMN "smtpPort" INTEGER,
ADD COLUMN "smtpTls" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "smtpBenutzer" TEXT,
ADD COLUMN "smtpPasswortVerschluesselt" TEXT,
ADD COLUMN "smtpAbsender" TEXT,
ADD COLUMN "webhookUrl" TEXT,
ADD COLUMN "webhookGeheimnisVerschluesselt" TEXT;
CREATE TABLE "Zustellung" (
"id" TEXT NOT NULL,
"anlass" "Meldeanlass" NOT NULL,
"weg" "Zustellweg" NOT NULL,
"empfaenger" TEXT NOT NULL,
"betreff" TEXT NOT NULL,
"rumpf" TEXT NOT NULL,
"zustand" "Zustellzustand" NOT NULL DEFAULT 'OFFEN',
"versuche" INTEGER NOT NULL DEFAULT 0,
"naechsterVersuchAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"letzterFehler" TEXT,
"gesendetAm" TIMESTAMP(3),
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"benutzerId" TEXT,
"laufId" TEXT,
"verzeichnisquelleId" TEXT,
CONSTRAINT "Zustellung_pkey" PRIMARY KEY ("id")
);
-- Der Index, auf dem der Zeitplan arbeitet: er fragt jede Minute nach offenen
-- und faelligen Zeilen. Ohne ihn waere das ein vollstaendiger Durchlauf der
-- Tabelle, die mit der Zeit nur waechst.
CREATE INDEX "Zustellung_zustand_naechsterVersuchAm_idx"
ON "Zustellung"("zustand", "naechsterVersuchAm");
CREATE INDEX "Zustellung_erstelltAm_idx" ON "Zustellung"("erstelltAm");
CREATE INDEX "Zustellung_laufId_idx" ON "Zustellung"("laufId");
CREATE INDEX "Zustellung_benutzerId_idx" ON "Zustellung"("benutzerId");
ALTER TABLE "Zustellung"
ADD CONSTRAINT "Zustellung_benutzerId_fkey" FOREIGN KEY ("benutzerId")
REFERENCES "Benutzer"("id") ON DELETE SET NULL ON UPDATE CASCADE,
ADD CONSTRAINT "Zustellung_laufId_fkey" FOREIGN KEY ("laufId")
REFERENCES "Lauf"("id") ON DELETE CASCADE ON UPDATE CASCADE,
ADD CONSTRAINT "Zustellung_verzeichnisquelleId_fkey" FOREIGN KEY ("verzeichnisquelleId")
REFERENCES "Verzeichnisquelle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- ---------------------------------------------------------------------------
-- Regeln, die auch dann gelten, wenn jemand an der Service-Schicht vorbei
-- schreibt.
-- ---------------------------------------------------------------------------
-- Ein Empfaenger ohne Inhalt ist eine Zeile, die nie ankommen kann.
ALTER TABLE "Zustellung"
ADD CONSTRAINT "Zustellung_empfaenger_nicht_leer"
CHECK (length(btrim("empfaenger")) > 0);
ALTER TABLE "Zustellung"
ADD CONSTRAINT "Zustellung_versuche_nicht_negativ" CHECK ("versuche" >= 0);
-- „Gesendet“ und „wann“ gehoeren zusammen, in beide Richtungen. Dieselbe Form
-- wie bei `Lauf_abgeschlossen_mit_zeitpunkt`: ein Zustand ohne Zeitpunkt macht
-- jede spaetere Auswertung zur Schaetzung.
ALTER TABLE "Zustellung"
ADD CONSTRAINT "Zustellung_gesendet_mit_zeitpunkt"
CHECK (("zustand" = 'GESENDET') = ("gesendetAm" IS NOT NULL));
-- Eine Mail geht an eine Person, ein Webhook nicht. Ohne das koennte eine
-- Webhook-Zeile an einem Benutzer haengen und in dessen Mailverlauf auftauchen.
ALTER TABLE "Zustellung"
ADD CONSTRAINT "Zustellung_webhook_ohne_benutzer"
CHECK ("weg" <> 'WEBHOOK' OR "benutzerId" IS NULL);
-- Jeder Anlass haengt an dem, worum es geht: die drei Lauf-Anlaesse an einem
-- Lauf, der Abgleich an einer Quelle. Eine Meldung ohne Bezug waere ein Satz
-- ohne Gegenstand — und der Link in der Mail zeigte ins Leere.
ALTER TABLE "Zustellung"
ADD CONSTRAINT "Zustellung_bezug_passt_zum_anlass"
CHECK (
("anlass" = 'ABGLEICH_FEHLGESCHLAGEN' AND "verzeichnisquelleId" IS NOT NULL AND "laufId" IS NULL)
OR ("anlass" <> 'ABGLEICH_FEHLGESCHLAGEN' AND "laufId" IS NOT NULL AND "verzeichnisquelleId" IS NULL)
);
-- Verschluesselt, nicht im Klartext – dieselbe Pruefung wie beim
-- LDAP-Bind-Passwort. `v<n>.` ist das Praefix aus `src/lib/geheimnisse.ts`.
ALTER TABLE "Einstellung"
ADD CONSTRAINT "Einstellung_smtp_passwort_verschluesselt"
CHECK ("smtpPasswortVerschluesselt" ~ '^v[0-9]+\.');
ALTER TABLE "Einstellung"
ADD CONSTRAINT "Einstellung_webhook_geheimnis_verschluesselt"
CHECK ("webhookGeheimnisVerschluesselt" ~ '^v[0-9]+\.');
ALTER TABLE "Einstellung"
ADD CONSTRAINT "Einstellung_smtp_port_bereich"
CHECK ("smtpPort" IS NULL OR ("smtpPort" BETWEEN 1 AND 65535));
-- ---------------------------------------------------------------------------
-- Und die Regel, die der Zeitplan braucht.
-- ---------------------------------------------------------------------------
/*
* **Ueberfaellig wird je Lauf und Empfaenger genau einmal gemeldet.**
*
* Der Zeitplan sieht jede Minute nach. Ohne diesen Index bekaeme der
* Zustaendige fuer einen Lauf, der ueber das Wochenende ueberfaellig steht,
* 2880 Mails — und zwar ohne dass irgendein Test rot waere, denn jede einzelne
* davon ist korrekt. Der Dienst prueft dasselbe noch einmal, nur mit besserer
* Meldung.
*
* Partiell, weil es nur fuer diesen einen Anlass gilt: „abgeschlossen“ darf nach
* einem Zurueckholen und erneuten Abschliessen ein zweites Mal hinausgehen, und
* „zugewiesen“ bei jeder neuen Zuweisung.
*/
CREATE UNIQUE INDEX "Zustellung_ueberfaellig_einmal"
ON "Zustellung"("laufId", "empfaenger")
WHERE "anlass" = 'LAUF_UEBERFAELLIG';
@@ -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 -159
View File
@@ -31,12 +31,6 @@ enum Rolle {
REDAKTEUR REDAKTEUR
TECHNIKER TECHNIKER
LESER 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 { enum Betriebsmodus {
@@ -116,99 +110,10 @@ model Einstellung {
/// entfernte Zuordnung beim naechsten Seed-Lauf zurueck: eine fehlende Zeile /// entfernte Zuordnung beim naechsten Seed-Lauf zurueck: eine fehlende Zeile
/// sagt nicht, ob sie noch nie da war oder bewusst entfernt wurde. /// sagt nicht, ob sie noch nie da war oder bewusst entfernt wurde.
schnellstartGepflegt Boolean @default(false) schnellstartGepflegt Boolean @default(false)
/// SMTP fuer die Benachrichtigungen (#034). Leerer Host heisst: keine Mail.
/// Kein Dienstanbieter, sondern SMTP — eine selbst gehostete Anwendung soll an
/// den Mailserver des Hauses koennen.
smtpHost String?
smtpPort Int?
/// `true` = StartTLS oder implizites TLS (Port 465). Klartext nur, wenn jemand
/// das ausdruecklich abschaltet.
smtpTls Boolean @default(true)
smtpBenutzer String?
/// AES-256-GCM aus APP_SECRET, wie `Verzeichnisquelle.bindPasswortVerschluesselt`.
/// Erreicht **nie** eine Antwort.
smtpPasswortVerschluesselt String?
smtpAbsender String?
/// Ein Webhook-Ziel fuer dieselben vier Anlaesse. Leere URL heisst: keiner.
webhookUrl String?
/// Geheimnis fuer die HMAC-SHA256-Signatur, verschluesselt wie oben.
webhookGeheimnisVerschluesselt String?
erstelltAm DateTime @default(now()) erstelltAm DateTime @default(now())
geaendertAm DateTime @default(now()) @updatedAt geaendertAm DateTime @default(now()) @updatedAt
} }
/// Wie eine Meldung hinausgeht (#034).
enum Zustellweg {
MAIL
WEBHOOK
}
/// Wo eine Zustellung steht (#034).
enum Zustellzustand {
OFFEN
GESENDET
/// Fehlgeschlagen, wird aber noch einmal versucht.
FEHLGESCHLAGEN
/// Endgueltig aufgegeben. Bleibt sichtbar — eine stille Warteschlange ist
/// schlimmer als keine.
AUFGEGEBEN
}
/// Was gemeldet wird. Bewusst genau vier Anlaesse (#034).
enum Meldeanlass {
LAUF_ZUGEWIESEN
LAUF_UEBERFAELLIG
LAUF_ABGESCHLOSSEN
ABGLEICH_FEHLGESCHLAGEN
}
/// Eine einzelne Meldung in der Warteschlange (#034).
///
/// **In der Datenbank und nicht im Anfrage-Pfad.** Ein haengender Mailserver
/// darf kein Abhaken blockieren: der Dienst reiht ein, der Zeitplan schickt.
///
/// Betreff und Rumpf stehen fertig in der Zeile. Das kostet Platz und ist es
/// wert: eine Meldung sagt, was **damals** war. Baute der Zeitplan den Text erst
/// beim Senden, stuende in der Mail der Stand von jetzt — und in einer
/// aufgegebenen Zustellung von naechster Woche etwas voellig anderes.
model Zustellung {
id String @id @default(cuid())
anlass Meldeanlass
weg Zustellweg
/// Mailadresse oder Webhook-URL. Als Text, damit die Zeile auch dann noch
/// sagt, wohin sie ging, wenn das Konto spaeter deaktiviert wurde.
empfaenger String
betreff String
rumpf String
zustand Zustellzustand @default(OFFEN)
versuche Int @default(0)
/// Vor diesem Zeitpunkt holt der Zeitplan die Zeile nicht ab. Traegt das
/// wachsende Warten zwischen zwei Versuchen.
naechsterVersuchAm DateTime @default(now())
letzterFehler String?
gesendetAm DateTime?
erstelltAm DateTime @default(now())
geaendertAm DateTime @default(now()) @updatedAt
/// Wer gemeint ist. Leer bei einem Webhook — der gehoert keiner Person.
benutzerId String?
laufId String?
verzeichnisquelleId String?
benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: SetNull)
lauf Lauf? @relation(fields: [laufId], references: [id], onDelete: Cascade)
verzeichnisquelle Verzeichnisquelle? @relation(fields: [verzeichnisquelleId], references: [id], onDelete: Cascade)
/// Der Index, auf dem der Zeitplan arbeitet.
@@index([zustand, naechsterVersuchAm])
@@index([erstelltAm])
@@index([laufId])
@@index([benutzerId])
}
model Kunde { model Kunde {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
@@ -256,19 +161,6 @@ model Benutzer {
/// So laesst sich ein Konto sperren, ohne auf den Ablauf des Cookies zu warten. /// So laesst sich ein Konto sperren, ohne auf den Ablauf des Cookies zu warten.
sitzungenUngueltigAb DateTime? sitzungenUngueltigAb DateTime?
letzteAnmeldungAm DateTime? letzteAnmeldungAm DateTime?
/// Bekommt diese Person Benachrichtigungen (#034)? Je Empfaenger abschaltbar.
/// 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()) erstelltAm DateTime @default(now())
geaendertAm DateTime @default(now()) @updatedAt geaendertAm DateTime @default(now()) @updatedAt
@@ -282,8 +174,6 @@ model Benutzer {
schrittergebnisse Schrittergebnis[] schrittergebnisse Schrittergebnis[]
veroeffentlichteVersionen VorlagenVersion[] @relation("VersionVeroeffentlichtVon") veroeffentlichteVersionen VorlagenVersion[] @relation("VersionVeroeffentlichtVon")
anhaenge Anhang[] anhaenge Anhang[]
zustellungen Zustellung[]
mitarbeiter Mitarbeiter? @relation(fields: [mitarbeiterId], references: [id], onDelete: SetNull)
@@index([aktiv]) @@index([aktiv])
@@index([rolle]) @@index([rolle])
@@ -314,56 +204,18 @@ model BenutzerKunde {
/// genau eine (das Firmen-AD); mehrere sind erlaubt, etwa eine zweite Domaene. /// genau eine (das Firmen-AD); mehrere sind erlaubt, etwa eine zweite Domaene.
/// Nur fuer die eigene Organisation – im Modus DIENSTLEISTUNG gibt es keinen /// Nur fuer die eigene Organisation – im Modus DIENSTLEISTUNG gibt es keinen
/// Abgleich zu Kunden-ADs. /// 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 { model Verzeichnisquelle {
id String @id @default(cuid()) id String @id @default(cuid())
kundeId String kundeId String
name String name String
aktiv Boolean @default(false) aktiv Boolean @default(false)
/// Bestehende Zeilen sind LDAP – das war vor #028 die einzige Art. url String
art QuellenArt @default(LDAP) bindDn String
/// 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?
/// AES-256-GCM aus APP_SECRET. Darf keine API-Antwort erreichen; dafuer sorgt /// AES-256-GCM aus APP_SECRET. Darf keine API-Antwort erreichen; dafuer sorgt
/// der Serializer in der Service-Schicht, abgedeckt durch einen eigenen Test. /// der Serializer in der Service-Schicht, abgedeckt durch einen eigenen Test.
bindPasswortVerschluesselt String? bindPasswortVerschluesselt String
basisDn String? basisDn String
filter 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?
/// Zuordnung der LDAP-Attribute auf unsere Felder, mit AD-Vorgaben. /// Zuordnung der LDAP-Attribute auf unsere Felder, mit AD-Vorgaben.
attributZuordnung Json attributZuordnung Json
/// Eigenes CA-Zertifikat, falls ldaps gegen eine interne CA laeuft. /// Eigenes CA-Zertifikat, falls ldaps gegen eine interne CA laeuft.
@@ -379,7 +231,6 @@ model Verzeichnisquelle {
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict) kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
mitarbeiter Mitarbeiter[] mitarbeiter Mitarbeiter[]
zustellungen Zustellung[]
@@index([kundeId]) @@index([kundeId])
@@index([aktiv]) @@index([aktiv])
@@ -430,9 +281,6 @@ model Mitarbeiter {
untergebene Mitarbeiter[] @relation("Vorgesetzter") untergebene Mitarbeiter[] @relation("Vorgesetzter")
geraete Geraet[] geraete Geraet[]
laeufe Lauf[] 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 /// Postgres behandelt NULL als verschieden, deshalb stoeren mehrere manuell
/// angelegte Mitarbeiter ohne Quelle und externe Id hier nicht. /// angelegte Mitarbeiter ohne Quelle und externe Id hier nicht.
@@ -792,8 +640,6 @@ model Lauf {
anhaenge Anhang[] anhaenge Anhang[]
/// Schluesselkandidat fuer den zusammengesetzten Fremdschluessel von Anhang. /// Schluesselkandidat fuer den zusammengesetzten Fremdschluessel von Anhang.
zustellungen Zustellung[]
@@unique([id, kundeId]) @@unique([id, kundeId])
@@index([kundeId, status]) @@index([kundeId, status])
@@index([geraetId, status]) @@index([geraetId, status])
+3 -15
View File
@@ -175,27 +175,15 @@ function feld(text, name) {
); );
} }
for (const [datei, block] of bloecke) { for (const [datei, block] of bloecke) {
/* if (!/docker compose -f docker-compose\.test\.yml up .*ldap/.test(block)) {
* Beide Container namentlich. Ohne sie ueberspringt die Suite Tests und
* sieht gruen aus — genau die Art Luecke, die dieses Skript finden soll.
*/
for (const [dienst, was] of [
["ldap", "den Verzeichnis-Abgleich"],
["mailpit", "den echten Mailversand"],
]) {
const gefunden = new RegExp(`docker compose -f docker-compose\\.test\\.yml up [^\n]*${dienst}`).test(
block,
);
if (!gefunden) {
melde( melde(
`${datei}: der Schranken-Block nennt den Container "${dienst}" nicht. ` + `${datei}: der Schranken-Block nennt den LDAP-Container nicht. ` +
`Ohne ihn ueberspringt die Suite ${was}, und der Lauf sieht gruen aus.`, `Ohne ihn ueberspringt die Suite den Verzeichnis-Abgleich, und der Lauf sieht gruen aus.`,
); );
} }
} }
} }
} }
}
// ---- Ergebnis -------------------------------------------------------------- // ---- Ergebnis --------------------------------------------------------------
if (befunde.length > 0) { if (befunde.length > 0) {
-32
View File
@@ -1,8 +1,6 @@
import { Rolle } from "@/generated/prisma/enums"; import { Rolle } from "@/generated/prisma/enums";
import { Bell, BellOff } from "lucide-react";
import { BenutzerAnlegen } from "@/components/benutzer-anlegen"; import { BenutzerAnlegen } from "@/components/benutzer-anlegen";
import { aktivSetzenAktion, rolleSetzenAktion } from "@/server/actions/benutzer"; import { aktivSetzenAktion, rolleSetzenAktion } from "@/server/actions/benutzer";
import { meldungenSchalterAktion } from "@/server/actions/benachrichtigungen";
import { lokalerLoginAktiv } from "@/server/auth/benutzer"; import { lokalerLoginAktiv } from "@/server/auth/benutzer";
import { ROLLEN_TEXT } from "@/lib/rollen-text"; import { ROLLEN_TEXT } from "@/lib/rollen-text";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
@@ -61,36 +59,6 @@ function BenutzerKarte({ zeile, selbst }: { zeile: BenutzerZeile; selbst: boolea
</button> </button>
</form> </form>
{/*
* Benachrichtigungen auch **fuer sich selbst** umschaltbar, anders als
* „aktiv“: sich selbst zu deaktivieren waere ein Eigentor, seine
* eigene Post abzustellen ist der haeufigste Wunsch ueberhaupt. Eine
* Profilseite, auf der das jeder selbst koennte, kommt mit der
* Selbstauskunft (#035).
*/}
<form action={meldungenSchalterAktion}>
<input type="hidden" name="benutzerId" value={zeile.id} />
<input type="hidden" name="aktiv" value={zeile.meldungenAktiv ? "aus" : "an"} />
<button
type="submit"
aria-label={`Benachrichtigungen für ${zeile.name} ${
zeile.meldungenAktiv ? "abschalten" : "einschalten"
}`}
className={`flex min-h-11 items-center gap-1.5 rounded-pille px-3 text-sm md:min-h-9 ${
zeile.meldungenAktiv
? "bg-flaeche-hoch text-nebentext hover:text-text"
: "bg-flaeche-hoch text-nebentext line-through hover:text-text"
}`}
>
{zeile.meldungenAktiv ? (
<Bell className="size-4" strokeWidth={1.75} aria-hidden="true" />
) : (
<BellOff className="size-4" strokeWidth={1.75} aria-hidden="true" />
)}
Mail
</button>
</form>
{selbst ? ( {selbst ? (
<span className="rounded-pille bg-akzent-flaeche px-3 py-2 text-xs text-akzent">das bist du</span> <span className="rounded-pille bg-akzent-flaeche px-3 py-2 text-xs text-akzent">das bist du</span>
) : ( ) : (
-1
View File
@@ -15,7 +15,6 @@ const REITER = [
{ pfad: "/admin/abteilungen", beschriftung: "Abteilungen" }, { pfad: "/admin/abteilungen", beschriftung: "Abteilungen" },
{ pfad: "/admin/schnellstart", beschriftung: "Schnellstart" }, { pfad: "/admin/schnellstart", beschriftung: "Schnellstart" },
{ pfad: "/admin/protokoll", beschriftung: "Protokoll" }, { pfad: "/admin/protokoll", beschriftung: "Protokoll" },
{ pfad: "/admin/zustellungen", beschriftung: "Zustellungen" },
/* /*
* Das Verzeichnis gibt es nur im Modus INTERN: dort hat das Haus **ein** AD, * Das Verzeichnis gibt es nur im Modus INTERN: dort hat das Haus **ein** AD,
* aus dem seine Leute kommen. Im Modus DIENSTLEISTUNG waere es ein LDAP zu * aus dem seine Leute kommen. Im Modus DIENSTLEISTUNG waere es ein LDAP zu
+2 -21
View File
@@ -3,8 +3,6 @@ import { EinstellungsFormular } from "@/components/einstellungen-formular";
import { ModusWechsel } from "@/components/modus-wechsel"; import { ModusWechsel } from "@/components/modus-wechsel";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { einstellungenHolen, modusWechselPruefen } from "@/server/services/einstellungen"; import { einstellungenHolen, modusWechselPruefen } from "@/server/services/einstellungen";
import { meldungseinstellungenHolen } from "@/server/services/benachrichtigungen";
import { BenachrichtigungenFormular } from "@/components/benachrichtigungen-formular";
const MODUS_NAME: Record<Betriebsmodus, string> = { const MODUS_NAME: Record<Betriebsmodus, string> = {
INTERN: "Intern", INTERN: "Intern",
@@ -19,8 +17,8 @@ const MODUS_ERKLAERUNG: Record<Betriebsmodus, string> = {
}; };
export default async function EinstellungenSeite() { export default async function EinstellungenSeite() {
const { benutzer } = await sitzungErforderlich("verwaltung", "/admin"); await sitzungErforderlich("verwaltung", "/admin");
const [einstellungen, meldungen] = await Promise.all([einstellungenHolen(), meldungseinstellungenHolen()]); const einstellungen = await einstellungenHolen();
const gegenteil = const gegenteil =
einstellungen.modus === Betriebsmodus.INTERN ? Betriebsmodus.DIENSTLEISTUNG : Betriebsmodus.INTERN; einstellungen.modus === Betriebsmodus.INTERN ? Betriebsmodus.DIENSTLEISTUNG : Betriebsmodus.INTERN;
@@ -70,23 +68,6 @@ export default async function EinstellungenSeite() {
/> />
</section> </section>
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
<BenachrichtigungenFormular
werte={{
smtpHost: meldungen.smtp.host ?? "",
smtpPort: meldungen.smtp.port === null ? "" : String(meldungen.smtp.port),
smtpTls: meldungen.smtp.tls,
smtpBenutzer: meldungen.smtp.benutzer ?? "",
smtpAbsender: meldungen.smtp.absender ?? "",
smtpPasswortGesetzt: meldungen.smtp.passwortGesetzt,
webhookUrl: meldungen.webhook.url ?? "",
webhookGeheimnisGesetzt: meldungen.webhook.geheimnisGesetzt,
appUrlFehlt: meldungen.appUrl === null,
eigeneMail: benutzer.email,
}}
/>
</section>
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5"> <section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Adresse für die QR-Codes</h2> <h2 className="text-lg font-semibold">Adresse für die QR-Codes</h2>
<p className="mt-1 mb-3 max-w-prose text-sm text-nebentext"> <p className="mt-1 mb-3 max-w-prose text-sm text-nebentext">
+9 -95
View File
@@ -1,13 +1,9 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation"; 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 { Betriebsmodus } from "@/generated/prisma/enums";
import { JetztAbgleichen } from "@/components/verzeichnis/jetzt-abgleichen"; import { JetztAbgleichen } from "@/components/verzeichnis/jetzt-abgleichen";
import { QuelleFormular } from "@/components/verzeichnis/quelle-formular"; import { QuelleFormular } from "@/components/verzeichnis/quelle-formular";
import { ScimToken } from "@/components/verzeichnis/scim-token";
import { VerbindungTesten } from "@/components/verzeichnis/verbindung-testen"; import { VerbindungTesten } from "@/components/verzeichnis/verbindung-testen";
import { basisAdresse } from "@/lib/adressen";
import { QUELLEN_ART_TEXT } from "@/lib/verzeichnis";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { betriebsmodus } from "@/server/mandant"; import { betriebsmodus } from "@/server/mandant";
import { abgleichLog, type AbgleichLogZeile, quellenListe } from "@/server/queries/verzeichnis"; import { abgleichLog, type AbgleichLogZeile, quellenListe } from "@/server/queries/verzeichnis";
@@ -34,13 +30,6 @@ const ZEITPUNKT = new Intl.DateTimeFormat("de-DE", {
}); });
function Status({ quelle }: { quelle: QuellenSicht }) { function Status({ quelle }: { quelle: QuellenSicht }) {
/*
* **Bei SCIM sagt „noch nie abgeglichen“ etwas Falsches** (#029): dort wird
* nie abgeglichen, und das ist kein Rückstand. Was an diese Stelle gehört,
* steht im Token-Abschnitt — „letzte Zustellung“.
*/
if (quelle.art === "SCIM") return null;
if (quelle.letzterAbgleichAm === null) { if (quelle.letzterAbgleichAm === null) {
return ( return (
<p className="mt-2 text-sm text-nebentext"> <p className="mt-2 text-sm text-nebentext">
@@ -134,26 +123,11 @@ export default async function VerzeichnisSeite() {
// daran, und ein hart gesetzter „ADMIN“ waere eine Hintertuer. // daran, und ein hart gesetzter „ADMIN“ waere eine Hintertuer.
const handelnder = { id: benutzer.id, rolle: benutzer.rolle }; const handelnder = { id: benutzer.id, rolle: benutzer.rolle };
const quellen = await quellenListe(handelnder); const quellen = await quellenListe(handelnder);
/*
* Die Adresse, die beim Anbieter eingetragen wird. Über `basisAdresse`, weil
* sie jemand **behält** — ein Unterpfad aus `APP_URL` darf dabei nicht
* herausfallen, sonst trägt der Administrator eine Adresse ein, die ins Leere
* zeigt (derselbe Grund wie bei den Etiketten, #014).
*/
const kopfzeilen = await headers();
const scimAdresse = new URL(
"api/scim/v2",
basisAdresse(`http://${kopfzeilen.get("host") ?? "127.0.0.1:3000"}`),
).toString();
// Je Quelle ein Log. Die Abfragen laufen parallel; es sind so viele wie Quellen, // Je Quelle ein Log. Die Abfragen laufen parallel; es sind so viele wie Quellen,
// und das sind im Modus INTERN eine oder zwei. // und das sind im Modus INTERN eine oder zwei.
const logs = new Map( const logs = new Map(
await Promise.all( await Promise.all(
quellen quellen.map(async (quelle) => [quelle.id, await abgleichLog(quelle.id, handelnder)] as const),
// 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),
), ),
); );
@@ -161,10 +135,9 @@ export default async function VerzeichnisSeite() {
<> <>
<h1 className="text-2xl font-bold">Verzeichnis</h1> <h1 className="text-2xl font-bold">Verzeichnis</h1>
<p className="mt-2 max-w-prose text-nebentext"> <p className="mt-2 max-w-prose text-nebentext">
Die Personen der eigenen Firma kommen aus dem Verzeichnis, damit beim Start eines Laufs nichts Die Personen der eigenen Firma kommen aus dem Active Directory, damit beim Start eines Laufs nichts
abgetippt werden muss. Laufzettel liest sie entweder selbst — aus einem Active Directory oder aus abgetippt werden muss. Wer dort verschwindet oder deaktiviert wird, wird hier <strong>inaktiv</strong>{" "}
Entra ID — oder lässt sich per SCIM beliefern. Wer dort verschwindet oder deaktiviert wird, wird hier{" "} — gelöscht wird nie, sonst verlöre die Historie ihre Namen.
<strong>inaktiv</strong> — gelöscht wird nie, sonst verlöre die Historie ihre Namen.
</p> </p>
{quellen.length === 0 ? ( {quellen.length === 0 ? (
@@ -179,23 +152,7 @@ export default async function VerzeichnisSeite() {
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Server className="size-5 shrink-0 text-akzent" strokeWidth={2} aria-hidden="true" /> <Server className="size-5 shrink-0 text-akzent" strokeWidth={2} aria-hidden="true" />
<h2 className="text-lg font-semibold break-words">{quelle.name}</h2> <h2 className="text-lg font-semibold break-words">{quelle.name}</h2>
{/* {quelle.aktiv ? (
* Bei SCIM gibt es keinen Zeitplan, und „kein Zeitplan“ wäre
* trotzdem die falsche Auskunft — es liest sich wie ein Mangel.
* Dort zählt, ob Zustellungen angenommen werden.
*/}
{quelle.art === "SCIM" ? (
<span
className={
quelle.aktiv
? "flex items-center gap-1 rounded-pille bg-akzent-flaeche px-2.5 py-1 text-xs text-akzent"
: "rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext"
}
>
<Inbox className="size-3" strokeWidth={2} aria-hidden="true" />
{quelle.aktiv ? "nimmt Zustellungen an" : "nimmt nichts an"}
</span>
) : quelle.aktiv ? (
<span className="flex items-center gap-1 rounded-pille bg-akzent-flaeche px-2.5 py-1 text-xs text-akzent"> <span className="flex items-center gap-1 rounded-pille bg-akzent-flaeche px-2.5 py-1 text-xs text-akzent">
<Clock className="size-3" strokeWidth={2} aria-hidden="true" /> <Clock className="size-3" strokeWidth={2} aria-hidden="true" />
alle {quelle.abgleichIntervallMinuten} min alle {quelle.abgleichIntervallMinuten} min
@@ -207,65 +164,23 @@ export default async function VerzeichnisSeite() {
)} )}
</div> </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"> <p className="mono mt-2 text-xs break-all text-nebentext">
{quelle.art === "GRAPH" ? ( {quelle.url} · {quelle.basisDn}
<>
{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}
</>
)}
</p> </p>
<Status quelle={quelle} /> <Status quelle={quelle} />
<div className="mt-4 flex flex-col gap-4"> <div className="mt-4 flex flex-col gap-4">
{/*
* **Zwei Arten, zwei Arbeitsweisen.** „Verbindung testen“ und
* „Jetzt abgleichen“ setzen voraus, dass Laufzettel holt; bei
* einem Push gibt es beides nicht, und ein Knopf, der nur eine
* Erklärung auswirft, ist kein Knopf.
*/}
{quelle.art === "SCIM" ? (
<ScimToken
quelleId={quelle.id}
vorhanden={quelle.scimTokenVorhanden}
gesetztAm={quelle.scimTokenGesetztAm}
letzteZustellungAm={quelle.letzteScimZustellungAm}
/>
) : (
<>
<VerbindungTesten quelleId={quelle.id} /> <VerbindungTesten quelleId={quelle.id} />
<JetztAbgleichen quelleId={quelle.id} /> <JetztAbgleichen quelleId={quelle.id} />
</>
)}
</div> </div>
{quelle.art === "SCIM" ? null : (
<details className="mt-4 border-t border-linie pt-4"> <details className="mt-4 border-t border-linie pt-4">
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10"> <summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Abgleich-Log Abgleich-Log
</summary> </summary>
<Log zeilen={logs.get(quelle.id) ?? []} /> <Log zeilen={logs.get(quelle.id) ?? []} />
</details> </details>
)}
<details className="mt-4 border-t border-linie pt-4"> <details className="mt-4 border-t border-linie pt-4">
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10"> <summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
@@ -283,9 +198,8 @@ export default async function VerzeichnisSeite() {
<section className="mt-8 rounded-karte border border-linie bg-flaeche p-5"> <section className="mt-8 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Quelle anlegen</h2> <h2 className="text-lg font-semibold">Quelle anlegen</h2>
<p className="mt-1 mb-5 max-w-prose text-sm text-nebentext"> <p className="mt-1 mb-5 max-w-prose text-sm text-nebentext">
Zum Lesen genügt ein Dienstkonto mit Leserechten; bei SCIM braucht es gar keine Zugangsdaten, nur Ein Dienstkonto mit Leserechten genügt. Laufzettel schreibt nie ins Verzeichnis und speichert nur
ein Token für den Anbieter. Laufzettel schreibt nie ins Verzeichnis und speichert nur die die zugeordneten Felder — keine Passwörter, keine Gruppenmitgliedschaften.
zugeordneten Felder — keine Passwörter, keine Gruppenmitgliedschaften.
</p> </p>
<QuelleFormular /> <QuelleFormular />
</section> </section>
-195
View File
@@ -1,195 +0,0 @@
import Link from "next/link";
import { AlertTriangle, Check, Clock, RotateCcw } from "lucide-react";
import { Zustellweg, Zustellzustand, type Meldeanlass } from "@/generated/prisma/enums";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { zustellungenHolen, zustellungenZaehlen } from "@/server/queries/zustellungen";
/**
* Admin → Zustellungen (#034).
*
* **Warum es diese Seite gibt:** eine stille Warteschlange ist schlimmer als
* keine. Ohne sie merkt niemand, dass seit drei Wochen keine Mail mehr
* hinausgeht, weil das SMTP-Passwort abgelaufen ist — jeder Lauf funktioniert
* weiter, nur erfaehrt niemand davon.
*
* Gezeigt wird der **Betreff**, nicht der Rumpf: fuer „laeuft der Versand?“
* braucht es ihn nicht, und er traegt Lauftitel und Geraetenamen.
*/
const ANLASS_TEXT: Record<Meldeanlass, string> = {
LAUF_ZUGEWIESEN: "zugewiesen",
LAUF_UEBERFAELLIG: "überfällig",
LAUF_ABGESCHLOSSEN: "abgeschlossen",
ABGLEICH_FEHLGESCHLAGEN: "Abgleich fehlgeschlagen",
};
const ZUSTAND_TEXT: Record<Zustellzustand, string> = {
OFFEN: "offen",
GESENDET: "gesendet",
FEHLGESCHLAGEN: "wird wiederholt",
AUFGEGEBEN: "aufgegeben",
};
const ZUSTAND_FARBE: Record<Zustellzustand, string> = {
OFFEN: "text-nebentext",
GESENDET: "text-erledigt",
FEHLGESCHLAGEN: "text-faellig",
AUFGEGEBEN: "text-fehler",
};
function zeitpunkt(wert: Date): string {
return wert.toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZone: "Europe/Berlin",
});
}
export default async function ZustellungenSeite({
searchParams,
}: {
searchParams: Promise<{ zustand?: string }>;
}) {
// Jede Seite prüft selbst – die Hülle ist das zweite Netz (CLAUDE.md).
await sitzungErforderlich("verwaltung", "/admin/zustellungen");
const { zustand: roherZustand } = await searchParams;
const gefiltert =
roherZustand && roherZustand in Zustellzustand ? (roherZustand as Zustellzustand) : undefined;
const [zeilen, zaehler] = await Promise.all([
zustellungenHolen(gefiltert ? { zustand: gefiltert } : {}),
zustellungenZaehlen(),
]);
const aufgegeben = zaehler[Zustellzustand.AUFGEGEBEN];
return (
<>
<h1 className="text-2xl font-bold">Zustellungen</h1>
<p className="mt-2 max-w-prose text-sm text-nebentext">
Was Laufzettel per Mail und Webhook hinausgeschickt hat. Eine fehlgeschlagene Zustellung wird
wiederholt — nach 1, 5, 30 und 120 Minuten — und bleibt nach dem fünften Fehlschlag als{" "}
<strong>aufgegeben</strong> stehen, damit es jemand sieht.
</p>
{aufgegeben > 0 ? (
<p
role="alert"
className="mt-4 flex max-w-prose items-start gap-2 rounded-karte border border-fehler/40 bg-flaeche p-3 text-sm"
>
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-fehler" strokeWidth={2} aria-hidden="true" />
<span>
<strong>{aufgegeben}</strong> {aufgegeben === 1 ? "Zustellung wurde" : "Zustellungen wurden"}{" "}
aufgegeben. Der Grund steht jeweils unter der Zeile; meist stimmt der SMTP-Zugang nicht mehr.
</span>
</p>
) : null}
<nav className="mt-6 flex flex-wrap gap-2" aria-label="Nach Zustand filtern">
<Link
href="/admin/zustellungen"
className={`flex min-h-11 items-center gap-2 rounded-pille border px-4 text-sm md:min-h-9 ${
gefiltert === undefined
? "border-akzent bg-akzent-flaeche text-akzent"
: "border-linie text-nebentext hover:text-text"
}`}
>
Alle
</Link>
{Object.values(Zustellzustand).map((wert) => (
<Link
key={wert}
href={`/admin/zustellungen?zustand=${wert}`}
className={`flex min-h-11 items-center gap-2 rounded-pille border px-4 text-sm md:min-h-9 ${
gefiltert === wert
? "border-akzent bg-akzent-flaeche text-akzent"
: "border-linie text-nebentext hover:text-text"
}`}
>
{ZUSTAND_TEXT[wert]}
<span className="text-xs">{zaehler[wert]}</span>
</Link>
))}
</nav>
{zeilen.length === 0 ? (
<p className="mt-8 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
{gefiltert === undefined
? "Noch nichts verschickt. Sobald ein Lauf jemandem zugewiesen, überfällig oder " +
"abgeschlossen wird, steht es hier."
: "In diesem Zustand steht gerade nichts."}
</p>
) : (
/* Am Handy nie waagerecht scrollen: dort Karten statt einer Tabelle. */
<ul className="mt-6 flex flex-col gap-3">
{zeilen.map((zeile) => (
<li key={zeile.id} className="rounded-karte border border-linie bg-flaeche p-4 text-sm">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className={`flex items-center gap-1.5 font-semibold ${ZUSTAND_FARBE[zeile.zustand]}`}>
{zeile.zustand === Zustellzustand.GESENDET ? (
<Check className="size-4" strokeWidth={2} aria-hidden="true" />
) : zeile.zustand === Zustellzustand.AUFGEGEBEN ? (
<AlertTriangle className="size-4" strokeWidth={2} aria-hidden="true" />
) : zeile.zustand === Zustellzustand.FEHLGESCHLAGEN ? (
<RotateCcw className="size-4" strokeWidth={2} aria-hidden="true" />
) : (
<Clock className="size-4" strokeWidth={2} aria-hidden="true" />
)}
{ZUSTAND_TEXT[zeile.zustand]}
</span>
<span className="rounded-pille border border-linie px-2 py-0.5 text-xs text-nebentext">
{ANLASS_TEXT[zeile.anlass]}
</span>
<span className="rounded-pille border border-linie px-2 py-0.5 text-xs text-nebentext">
{zeile.weg === Zustellweg.MAIL ? "Mail" : "Webhook"}
</span>
{zeile.versuche > 1 ? (
<span className="text-xs text-nebentext">{zeile.versuche} Versuche</span>
) : null}
</div>
<p className="mt-2 break-words">{zeile.betreff}</p>
<p className="mt-1 break-all text-xs text-nebentext">{zeile.empfaenger}</p>
<dl className="mt-3 flex flex-wrap gap-x-6 gap-y-1 text-xs text-nebentext">
<div>
<dt className="inline">Eingereiht: </dt>
<dd className="inline">{zeitpunkt(zeile.erstelltAm)}</dd>
</div>
{zeile.gesendetAm ? (
<div>
<dt className="inline">Gesendet: </dt>
<dd className="inline">{zeitpunkt(zeile.gesendetAm)}</dd>
</div>
) : null}
{zeile.zustand === Zustellzustand.FEHLGESCHLAGEN ? (
<div>
<dt className="inline">Nächster Versuch: </dt>
<dd className="inline">{zeitpunkt(zeile.naechsterVersuchAm)}</dd>
</div>
) : null}
{zeile.laufId ? (
<div>
<Link href={`/laeufe/${zeile.laufId}`} className="underline hover:text-text">
Zum Lauf
</Link>
</div>
) : null}
</dl>
{zeile.letzterFehler ? (
<p className="mono mt-3 rounded-karte border border-linie bg-grund px-3 py-2 text-xs break-words text-fehler">
{zeile.letzterFehler}
</p>
) : null}
</li>
))}
</ul>
)}
</>
);
}
+2 -9
View File
@@ -2,7 +2,6 @@ import Link from "next/link";
import { ShieldAlert } from "lucide-react"; import { ShieldAlert } from "lucide-react";
import { ROLLEN_TEXT } from "@/lib/rollen-text"; import { ROLLEN_TEXT } from "@/lib/rollen-text";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; 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). * „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 // Nur eigene Pfade anzeigen: ein fremder Wert in der Adresse waere sonst ein
// Text, den jemand anderes auf diese Seite schreibt. // Text, den jemand anderes auf diese Seite schreibt.
const gezeigt = bereich?.startsWith("/") && !bereich.startsWith("//") ? bereich : undefined; const gezeigt = bereich?.startsWith("/") && !bereich.startsWith("//") ? bereich : undefined;
const einstieg = startseite(benutzer.rolle);
return ( return (
<div className="mx-auto flex max-w-prose flex-col items-start gap-5 py-10"> <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. Anmeldung übernommen.
</p> </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 <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" 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> </Link>
</div> </div>
); );
+2 -2
View File
@@ -20,7 +20,7 @@ import { SchrittWert } from "@/components/laeufe/schritt-wert";
import { SchrittZustandWaehlen, ZustandKennzeichen } from "@/components/laeufe/schritt-zustand"; import { SchrittZustandWaehlen, ZustandKennzeichen } from "@/components/laeufe/schritt-zustand";
import { darf } from "@/server/auth/rollen"; import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; 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 { protokollDesLaufs } from "@/server/queries/protokoll";
import { import {
kommentareDesLaufs, kommentareDesLaufs,
@@ -322,7 +322,7 @@ export default async function LaufSeite({
const laufProtokoll = await protokollDesLaufs(id); const laufProtokoll = await protokollDesLaufs(id);
// Die Sicht hat `laufHolen` oben schon angewandt; hier nur die Metadaten der // Die Sicht hat `laufHolen` oben schon angewandt; hier nur die Metadaten der
// Anhaenge dieses Laufs (#032). // 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 * Die Personenliste nur fuer den Onboarding-Fall holen — sonst stuende in
* jedem Seitenaufbau eine Abfrage, die niemand braucht. * jedem Seitenaufbau eine Abfrage, die niemand braucht.
-221
View File
@@ -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
View File
@@ -2,8 +2,7 @@ import Link from "next/link";
import { ArrowRight, Clock, ListChecks, Rocket, TriangleAlert } from "lucide-react"; import { ArrowRight, Clock, ListChecks, Rocket, TriangleAlert } from "lucide-react";
import { LaufStatus } from "@/generated/prisma/enums"; import { LaufStatus } from "@/generated/prisma/enums";
import { fristVerstrichen } from "@/lib/fristen"; import { fristVerstrichen } from "@/lib/fristen";
import { darf, startseite } from "@/server/auth/rollen"; import { darf } from "@/server/auth/rollen";
import { redirect } from "next/navigation";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { import {
UEBERSICHT_JE_BLOCK, UEBERSICHT_JE_BLOCK,
@@ -224,17 +223,6 @@ function Schnellstart({ vorlagen }: { vorlagen: readonly HaeufigeVorlage[] }) {
} }
export default async function Uebersichtsseite() { 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 { benutzer } = await sitzungErforderlich("lesen", "/");
const darfArbeiten = darf(benutzer.rolle, "laeufeBearbeiten"); const darfArbeiten = darf(benutzer.rolle, "laeufeBearbeiten");
-17
View File
@@ -22,23 +22,6 @@ const MELDUNGEN = new Map<string, string>(
"oidc-aus": "Die Anmeldung über den Anbieter ist nicht eingerichtet.", "oidc-aus": "Die Anmeldung über den Anbieter ist nicht eingerichtet.",
"oidc-start": "Der Anbieter war nicht erreichbar. Bitte später erneut versuchen.", "oidc-start": "Der Anbieter war nicht erreichbar. Bitte später erneut versuchen.",
"oidc-rueckruf": "Die Anmeldung beim Anbieter ist fehlgeschlagen.", "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.",
}), }),
); );
+2 -21
View File
@@ -1,7 +1,7 @@
import { NextResponse, type NextRequest } from "next/server"; import { NextResponse, type NextRequest } from "next/server";
import { oeffentlicheAdresse } from "@/lib/adressen"; import { oeffentlicheAdresse } from "@/lib/adressen";
import { sicheresZiel, sitzungSetzen } from "@/server/auth/anmeldung"; 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"; import { OIDC_FLUSS_COOKIE, anmeldungAbschliessen } from "@/server/auth/oidc";
/** /**
@@ -35,27 +35,8 @@ export async function GET(anfrage: NextRequest): Promise<Response> {
`OIDC-Rueckruf fehlgeschlagen:`, `OIDC-Rueckruf fehlgeschlagen:`,
fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : String(fehler), 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( 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); antwort.cookies.delete(OIDC_FLUSS_COOKIE);
return antwort; return antwort;
+6 -35
View File
@@ -1,5 +1,4 @@
import { RechteFehler, rechtErforderlich, sitzungNochGueltig } from "@/server/auth/sitzungspflicht"; import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import type { Sitzung } from "@/lib/sitzung";
import { laufZustand } from "@/server/queries/laeufe"; import { laufZustand } from "@/server/queries/laeufe";
import { HERZSCHLAG, herzschlagMs, maxStroeme, sseEreignis, willStrom } from "@/lib/sse"; import { HERZSCHLAG, herzschlagMs, maxStroeme, sseEreignis, willStrom } from "@/lib/sse";
import { aufLaufHoeren, stroemeOffen } from "@/server/lauf-ereignisse"; import { aufLaufHoeren, stroemeOffen } from "@/server/lauf-ereignisse";
@@ -41,14 +40,10 @@ export async function GET(
): Promise<Response> { ): Promise<Response> {
let benutzerId: string; let benutzerId: string;
let rolle: Parameters<typeof laufZustand>[1]["rolle"]; 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 { try {
const zugang = await rechtErforderlich("lesen"); const { benutzer } = await rechtErforderlich("lesen");
benutzerId = zugang.benutzer.id; benutzerId = benutzer.id;
rolle = zugang.benutzer.rolle; rolle = benutzer.rolle;
sitzung = zugang.sitzung;
} catch (fehler) { } catch (fehler) {
if (fehler instanceof RechteFehler) { if (fehler instanceof RechteFehler) {
// 401 und nicht 403: der Client soll die Aktualisierung einstellen und // 401 und nicht 403: der Client soll die Aktualisierung einstellen und
@@ -59,7 +54,7 @@ export async function GET(
} }
const { id } = await params; const { id } = await params;
let benutzer = { id: benutzerId, rolle }; const benutzer = { id: benutzerId, rolle };
const zustand = await laufZustand(id, benutzer); const zustand = await laufZustand(id, benutzer);
if (!zustand) { if (!zustand) {
// Nicht vorhanden und nicht sichtbar sind dieselbe Antwort – die // Nicht vorhanden und nicht sichtbar sind dieselbe Antwort – die
@@ -156,31 +151,7 @@ export async function GET(
return; return;
} }
/* herzschlag = setInterval(() => schicken(HERZSCHLAG), herzschlagMs());
* **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());
anfrage.signal.addEventListener("abort", aufraeumen); 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,
});
}
-89
View File
@@ -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] },
});
}
-130
View File
@@ -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);
}
}
-61
View File
@@ -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);
}
}
@@ -1,236 +0,0 @@
"use client";
import { useActionState } from "react";
import { Mail, Send, Webhook } from "lucide-react";
import { Button } from "@/components/ui/button";
import { FELD } from "@/lib/formular";
import {
meldungenSpeichernAktion,
testmailAktion,
type AktionsErgebnis,
} from "@/server/actions/benachrichtigungen";
/**
* SMTP und Webhook einrichten (#034).
*
* Zwei Dinge sind hier bewusst:
*
* 1. **Ein gesetztes Passwort wird nie angezeigt**, nur dass es eines gibt. Das
* Feld bleibt leer, und leer heisst „unverändert lassen“ — sonst loeschte
* jedes Speichern der uebrigen Felder das Passwort mit.
* 2. **Die Testmail ist ein eigenes Formular** und geht direkt hinaus, nicht
* ueber die Warteschlange. Ein Knopf, der „eingereiht“ meldet und den Fehler
* eine Minute spaeter woanders zeigt, prueft nichts.
*/
export type MeldungsWerte = {
readonly smtpHost: string;
readonly smtpPort: string;
readonly smtpTls: boolean;
readonly smtpBenutzer: string;
readonly smtpAbsender: string;
readonly smtpPasswortGesetzt: boolean;
readonly webhookUrl: string;
readonly webhookGeheimnisGesetzt: boolean;
/** Ohne `APP_URL` traegt keine Meldung einen brauchbaren Link. */
readonly appUrlFehlt: boolean;
readonly eigeneMail: string;
};
function Rueckmeldung({ ergebnis }: { ergebnis: AktionsErgebnis }) {
if (ergebnis.fehler) {
return (
<p role="alert" className="max-w-prose text-sm text-fehler">
{ergebnis.fehler}
</p>
);
}
if (ergebnis.erfolg) {
return (
<p role="status" className="max-w-prose text-sm text-erledigt">
{ergebnis.erfolg}
</p>
);
}
return null;
}
export function BenachrichtigungenFormular({ werte }: { werte: MeldungsWerte }) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(meldungenSpeichernAktion, {});
const [testErgebnis, testAktion, testLaeuft] = useActionState<AktionsErgebnis, FormData>(
testmailAktion,
{},
);
return (
<div className="flex flex-col gap-8">
<section>
<div className="flex flex-wrap items-baseline gap-3">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<Mail className="size-[18px] text-nebentext" strokeWidth={1.75} aria-hidden="true" />
Benachrichtigungen
</h2>
</div>
<p className="mt-1 mb-4 max-w-prose text-sm text-nebentext">
Vier Anlässe gehen hinaus: ein Lauf wird jemandem <strong>zugewiesen</strong>, ein Lauf ist{" "}
<strong>überfällig</strong>, ein Lauf wird <strong>abgeschlossen</strong>, und ein{" "}
<strong>Verzeichnisabgleich schlägt fehl</strong>. Die Nachricht enthält Titel, Status und einen
Link — <strong>keine Schrittinhalte</strong>, und damit auch kein erzeugtes Passwort.
</p>
{werte.appUrlFehlt ? (
<p role="alert" className="mb-4 max-w-prose text-sm text-faellig">
<code className="mono">APP_URL</code> ist nicht gesetzt. Die Meldungen gehen trotzdem hinaus, aber
der Link darin führt nirgendwohin.
</p>
) : null}
</section>
<form action={aktion} className="flex flex-col gap-8">
<section>
<h3 className="text-sm font-semibold">SMTP</h3>
<p className="mt-1 mb-4 max-w-prose text-sm text-nebentext">
Ohne Host geht keine Mail hinaus — die Webhook-Zustellung unten funktioniert davon unabhängig.
</p>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Host</span>
<input
name="smtpHost"
defaultValue={werte.smtpHost}
placeholder="mail.firma.de"
className={FELD}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Port</span>
<input
name="smtpPort"
defaultValue={werte.smtpPort}
inputMode="numeric"
placeholder="587"
className={FELD}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Benutzer</span>
<input
name="smtpBenutzer"
defaultValue={werte.smtpBenutzer}
autoComplete="off"
className={FELD}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">
Passwort
{werte.smtpPasswortGesetzt ? (
<span className="ml-2 text-xs">(gesetzt — leer lassen, um es zu behalten)</span>
) : null}
</span>
<input
name="smtpPasswort"
type="password"
autoComplete="new-password"
placeholder={werte.smtpPasswortGesetzt ? "unverändert" : ""}
className={FELD}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Absender</span>
<input
name="smtpAbsender"
defaultValue={werte.smtpAbsender}
placeholder="laufzettel@firma.de"
className={FELD}
/>
</label>
<label className="flex min-h-11 items-center gap-3 self-end text-sm md:min-h-10">
<input
type="checkbox"
name="smtpTls"
value="an"
defaultChecked={werte.smtpTls}
className="size-4 accent-akzent"
/>
<span>
TLS verlangen
<span className="block text-xs text-nebentext">
Port 465 verschlüsselt von Anfang an, 587 per StartTLS.
</span>
</span>
</label>
</div>
</section>
<section>
<h3 className="flex items-center gap-2 text-sm font-semibold">
<Webhook className="size-4 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
Webhook
</h3>
<p className="mt-1 mb-4 max-w-prose text-sm text-nebentext">
Dieselben vier Anlässe als <code className="mono">POST</code> mit JSON. Mit Geheimnis trägt jede
Anfrage eine HMAC-SHA256-Signatur im Kopf <code className="mono">X-Laufzettel-Signatur</code>; wie
sie nachzurechnen ist, steht in <code className="mono">docs/betrieb/benachrichtigungen.md</code>.
</p>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Adresse</span>
<input
name="webhookUrl"
defaultValue={werte.webhookUrl}
placeholder="https://hook.firma.de/laufzettel"
className={FELD}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">
Geheimnis
{werte.webhookGeheimnisGesetzt ? (
<span className="ml-2 text-xs">(gesetzt — leer lassen, um es zu behalten)</span>
) : null}
</span>
<input
name="webhookGeheimnis"
type="password"
autoComplete="new-password"
placeholder={werte.webhookGeheimnisGesetzt ? "unverändert" : "ohne Signatur"}
className={FELD}
/>
</label>
</div>
</section>
<div className="flex flex-wrap items-center gap-4">
{/*
* Eigene Beschriftung, nicht bloss „Speichern“: auf dieser Seite gibt
* es zwei Formulare, und zwei gleich benannte Knoepfe sind fuer einen
* Menschen genauso verwirrend wie fuer einen Test.
*/}
<Button type="submit" disabled={laeuft}>
{laeuft ? "Wird gespeichert …" : "Benachrichtigungen speichern"}
</Button>
<Rueckmeldung ergebnis={ergebnis} />
</div>
</form>
<form action={testAktion} className="flex flex-col gap-3 border-t border-linie pt-6">
<h3 className="text-sm font-semibold">Testmail</h3>
<p className="max-w-prose text-sm text-nebentext">
Geht <strong>direkt</strong> hinaus, nicht über die Warteschlange — ein Fehler erscheint deshalb
sofort und im Klartext. Gespeichert sein muss der Zugang vorher.
</p>
<div className="flex flex-wrap items-end gap-3">
<label className="flex max-w-md flex-1 flex-col gap-1.5 text-sm">
<span className="text-nebentext">An</span>
<input name="an" type="email" defaultValue={werte.eigeneMail} className={FELD} />
</label>
<Button type="submit" variant="secondary" disabled={testLaeuft}>
<Send className="size-4" strokeWidth={2} aria-hidden="true" />
{testLaeuft ? "Wird geschickt …" : "Testmail schicken"}
</Button>
</div>
<Rueckmeldung ergebnis={testErgebnis} />
</form>
</div>
);
}
+2 -13
View File
@@ -4,7 +4,6 @@ import {
Building2, Building2,
FileText, FileText,
LayoutDashboard, LayoutDashboard,
Laptop,
ListChecks, ListChecks,
LogOut, LogOut,
Menu, Menu,
@@ -34,7 +33,6 @@ const SYMBOLE: Record<string, LucideIcon> = {
Building2, Building2,
Archive, Archive,
Settings, Settings,
Laptop,
}; };
function NavListe({ modus, aktiv, rolle }: { modus: Betriebsmodus; aktiv: string; rolle?: Rolle }) { function NavListe({ modus, aktiv, rolle }: { modus: Betriebsmodus; aktiv: string; rolle?: Rolle }) {
@@ -186,15 +184,6 @@ export function Huelle({
aktiv?: string; aktiv?: string;
benutzer?: AngemeldeterBenutzer; 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 ( return (
<div className="min-h-dvh md:flex"> <div className="min-h-dvh md:flex">
{/* Handy: Kopfzeile mit Menue */} {/* Handy: Kopfzeile mit Menue */}
@@ -202,7 +191,7 @@ export function Huelle({
<div className="flex items-center gap-3 px-4 py-3"> <div className="flex items-center gap-3 px-4 py-3">
<Bildmarke className="size-6 shrink-0 text-akzent" /> <Bildmarke className="size-6 shrink-0 text-akzent" />
<Wortmarke className="text-lg" /> <Wortmarke className="text-lg" />
{darfScannen ? <ScannenKnopf className="ml-auto" /> : null} <ScannenKnopf className="ml-auto" />
<details className="relative"> <details className="relative">
<summary <summary
className="flex size-11 cursor-pointer list-none items-center justify-center rounded-pille text-nebentext" 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" /> <Bildmarke className="size-7 shrink-0 text-akzent" />
<Wortmarke className="text-xl" /> <Wortmarke className="text-xl" />
</Link> </Link>
{darfScannen ? <ScannenKnopf className="mb-6" /> : null} <ScannenKnopf className="mb-6" />
<nav aria-label="Hauptnavigation"> <nav aria-label="Hauptnavigation">
<NavListe modus={modus} aktiv={aktiv} rolle={benutzer?.rolle} /> <NavListe modus={modus} aktiv={aktiv} rolle={benutzer?.rolle} />
</nav> </nav>
+8 -190
View File
@@ -5,10 +5,8 @@ import { KeyRound, Save, Server, ShieldAlert, ShieldCheck } from "lucide-react";
import { FELD, KAESTCHEN } from "@/lib/formular"; import { FELD, KAESTCHEN } from "@/lib/formular";
import { import {
AD_ZUORDNUNG, AD_ZUORDNUNG,
GRAPH_ZUORDNUNG,
INTERVALL_HOECHSTENS, INTERVALL_HOECHSTENS,
INTERVALL_MINDESTENS, INTERVALL_MINDESTENS,
QUELLEN_ART_TEXT,
ZUORDNUNGS_BESCHRIFTUNG, ZUORDNUNGS_BESCHRIFTUNG,
ZUORDNUNGS_FELDER, ZUORDNUNGS_FELDER,
} from "@/lib/verzeichnis"; } from "@/lib/verzeichnis";
@@ -38,22 +36,6 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
const [zuordnungOffen, setzeZuordnungOffen] = useState(false); const [zuordnungOffen, setzeZuordnungOffen] = useState(false);
const [adresse, setzeAdresse] = useState(quelle?.url ?? ""); const [adresse, setzeAdresse] = useState(quelle?.url ?? "");
const klartext = adresse.trim().startsWith("ldap://"); 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 ( return (
<form action={aktion} className="flex flex-col gap-4"> <form action={aktion} className="flex flex-col gap-4">
@@ -70,116 +52,6 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
/> />
</label> </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 * Die Erklärung steht **außerhalb** des `label` und hängt über
* `aria-describedby` am Feld. Im `label` wäre sie Teil des Feldnamens: * `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`} className={`${FELD} mono`}
/> />
<span id="quelle-url-hinweis" className="text-xs text-nebentext"> <span id="quelle-url-hinweis" className="text-xs text-nebentext">
Drei Formen: <span className="mono">ldaps://</span> (verschlüsselt ab dem ersten Byte, die Drei Formen: <span className="mono">ldaps://</span> (verschlüsselt ab dem ersten Byte, die richtige
richtige Wahl), <span className="mono">ldap+starttls://</span> (unverschlüsselt verbunden und Wahl), <span className="mono">ldap+starttls://</span> (unverschlüsselt verbunden und dann
dann hochgestuft) und <span className="mono">ldap://</span>. hochgestuft) und <span className="mono">ldap://</span>.
</span> </span>
{klartext ? ( {klartext ? (
/* /*
@@ -214,8 +86,8 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
<span role="status" className="flex items-start gap-2 text-xs text-faellig"> <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" /> <ShieldAlert className="mt-0.5 size-3.5 shrink-0" strokeWidth={2} aria-hidden="true" />
<span> <span>
Mit <span className="mono">ldap://</span> geht auch das Bind-Passwort im Klartext über das Mit <span className="mono">ldap://</span> geht auch das Bind-Passwort im Klartext über das Netz.
Netz. Für ein Dienstkonto im Active Directory ist das keine gute Idee. Für ein Dienstkonto im Active Directory ist das keine gute Idee.
</span> </span>
</span> </span>
) : null} ) : null}
@@ -278,42 +150,11 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
className={`${FELD} mono`} className={`${FELD} mono`}
/> />
<span id="quelle-filter-hinweis" className="text-xs text-nebentext"> <span id="quelle-filter-hinweis" className="text-xs text-nebentext">
Leer lassen nimmt den Standard: aktive Personenkonten. Er schließt deaktivierte Konten aus — wer Leer lassen nimmt den Standard: aktive Personenkonten. Er schließt deaktivierte Konten aus — wer aus
aus dem Ergebnis verschwindet, wird hier deaktiviert und nie gelöscht. dem Ergebnis verschwindet, wird hier deaktiviert und nie gelöscht.
</span> </span>
</div> </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"> <div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm"> <label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Abgleich alle … Minuten</span> <span className="text-nebentext">Abgleich alle … Minuten</span>
@@ -337,7 +178,6 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
Nach Zeitplan abgleichen Nach Zeitplan abgleichen
</label> </label>
</div> </div>
)}
{/* {/*
* Die Attributzuordnung ist einklappbar, aber vollständig da. Vorher stand * Die Attributzuordnung ist einklappbar, aber vollständig da. Vorher stand
@@ -345,21 +185,6 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
* OpenLDAP anbindet, ändert einzelne Zeilen“, und genau das ging über die * OpenLDAP anbindet, ändert einzelne Zeilen“, und genau das ging über die
* Oberfläche nicht (Reviewbefund zu #016). * Oberfläche nicht (Reviewbefund zu #016).
*/} */}
{istScim ? (
<p className="max-w-prose text-xs text-nebentext">
Eine Attributzuordnung gibt es hier nicht: bei SCIM bestimmt der Anbieter die Feldnamen, und sie
stehen in RFC 7643 fest. Abteilung und Titel kommen aus der Erweiterung{" "}
<span className="mono">enterprise:2.0:User</span>; eine OU kennt SCIM nicht, deshalb bleibt{" "}
<span className="mono">{"{{ou}}"}</span> bei dieser Art leer.
</p>
) : null}
{/*
* Bei SCIM gar nicht gerendert und nicht nur verborgen: ein verstecktes
* Feld wird mitgeschickt, und dann staende in der Datenbank eine
* Zuordnung, die nie etwas bedeutet.
*/}
{istScim ? null : (
<details open={zuordnungOffen} onToggle={(e) => setzeZuordnungOffen(e.currentTarget.open)}> <details open={zuordnungOffen} onToggle={(e) => setzeZuordnungOffen(e.currentTarget.open)}>
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10"> <summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Attributzuordnung anpassen Attributzuordnung anpassen
@@ -375,7 +200,7 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
<input <input
name={`attr.${feld}`} name={`attr.${feld}`}
defaultValue={quelle?.attributZuordnung[feld] ?? AD_ZUORDNUNG[feld]} defaultValue={quelle?.attributZuordnung[feld] ?? AD_ZUORDNUNG[feld]}
placeholder={(istGraph ? GRAPH_ZUORDNUNG : AD_ZUORDNUNG)[feld]} placeholder={AD_ZUORDNUNG[feld]}
className={`${FELD} mono`} className={`${FELD} mono`}
/> />
</label> </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. Abgleich eine Person wieder. Ein anderer Wert legt beim nächsten Lauf alle neu an.
</p> </p>
</details> </details>
)}
{/*
* Ein eigenes CA-Zertifikat gilt nur fuer LDAP – Graph laeuft ueber
* oeffentliches TLS, und bei SCIM baut Laufzettel keine Verbindung auf.
*/}
{istLdap ? (
<details open={zertifikatOffen} onToggle={(e) => setzeZertifikatOffen(e.currentTarget.open)}> <details open={zertifikatOffen} onToggle={(e) => setzeZertifikatOffen(e.currentTarget.open)}>
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10"> <summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Eigenes CA-Zertifikat hinterlegen Eigenes CA-Zertifikat hinterlegen
@@ -435,7 +254,6 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
</label> </label>
) : null} ) : null}
</details> </details>
) : null}
{ergebnis.fehler ? ( {ergebnis.fehler ? (
<p role="alert" className="max-w-prose text-sm text-fehler"> <p role="alert" className="max-w-prose text-sm text-fehler">
-181
View File
@@ -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>
);
}
+1 -9
View File
@@ -1,8 +1,7 @@
/** /**
* Der eine Einstiegspunkt, den Next je Server-Prozess genau einmal ruft (#016). * Der eine Einstiegspunkt, den Next je Server-Prozess genau einmal ruft (#016).
* *
* Hier starten die beiden Zeitplaene: der Verzeichnis-Abgleich (#016) und die * Hier startet der Zeitplan des Verzeichnis-Abgleichs. Zwei Wachen davor:
* Benachrichtigungen (#034). Zwei Wachen davor:
* *
* - **Nur in der Node-Laufzeit.** `register` laeuft auch fuer die Edge-Laufzeit, * - **Nur in der Node-Laufzeit.** `register` laeuft auch fuer die Edge-Laufzeit,
* und dort gibt es weder Datenbank noch `node-cron`. * und dort gibt es weder Datenbank noch `node-cron`.
@@ -16,11 +15,4 @@ export async function register(): Promise<void> {
const { zeitplanStarten } = await import("@/server/zeitplan/abgleich-plan"); const { zeitplanStarten } = await import("@/server/zeitplan/abgleich-plan");
zeitplanStarten(); zeitplanStarten();
/*
* Ein eigener Zeitplan und nicht derselbe: der Mailversand darf nicht warten,
* weil der Abgleich gerade ein grosses Verzeichnis liest.
*/
const { meldungsplanStarten } = await import("@/server/zeitplan/meldungs-plan");
meldungsplanStarten();
} }
-164
View File
@@ -1,164 +0,0 @@
/**
* Die Texte der Benachrichtigungen (#034).
*
* Framework-frei und ohne Datenbank, weil hier die wichtigste Zusage dieses
* Meilensteins sitzt und einzeln pruefbar sein muss:
*
* **In keine Meldung kommt ein Schrittinhalt.** Kein Wert, keine Notiz, kein
* Befehl — und damit auch kein `{{generiertes_passwort}}`. Eine Mail mit dem
* Lauf-Inhalt waere genau die Luecke, die dieses Projekt an jeder anderen Stelle
* geschlossen hat: das Passwort entsteht bei jeder Anzeige neu und wird nirgends
* gespeichert, aber eine Mail liegt fuer immer in einem Postfach, oft auf einem
* Server, der nicht im Haus steht.
*
* Deshalb nimmt jede Funktion hier **nur** Kopfdaten: Titel, Status, Beteiligte,
* Frist, Link. Der Typ laesst gar nichts anderes zu — das ist die Absicht. Wer
* einen Schrittinhalt in eine Meldung bekommen wollte, muesste diese Datei
* aendern, und dann steht er vor diesem Kommentar.
*/
import { Meldeanlass } from "@/generated/prisma/enums";
/**
* Die drei Anlaesse, die zu einem Lauf gehoeren.
*
* `ABGLEICH_FEHLGESCHLAGEN` ist ausgeschlossen, und zwar **im Typ**: er gehoert
* zu einer Verzeichnisquelle, nicht zu einem Lauf. Mit einem Wurf zur Laufzeit
* waere derselbe Fehler erst im Betrieb aufgefallen.
*/
export type LaufAnlass = Exclude<Meldeanlass, typeof Meldeanlass.ABGLEICH_FEHLGESCHLAGEN>;
/** Was eine Lauf-Meldung ueber den Lauf wissen darf. Mehr gibt es nicht. */
export type LaufKopf = {
readonly titel: string;
/** Hostname, Bezeichnung oder Tag-Code — was die Oberflaeche auch zeigt. */
readonly geraet?: string | null;
readonly person?: string | null;
readonly faelligAm?: Date | null;
readonly vorlage?: string | null;
/** Wer zugewiesen oder abgeschlossen hat. */
readonly handelnder?: string | null;
};
export type Meldung = { readonly betreff: string; readonly rumpf: string };
/** `05.10.2026` – ohne Uhrzeit, weil eine Frist ein Tag ist. */
function datum(wert: Date): string {
return wert.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
timeZone: "Europe/Berlin",
});
}
/** Die Zeilen, die jeden Lauf beschreiben – leere bleiben weg. */
function kopfzeilen(kopf: LaufKopf): string[] {
const zeilen: string[] = [];
if (kopf.vorlage) zeilen.push(`Vorlage: ${kopf.vorlage}`);
if (kopf.geraet) zeilen.push(`Gerät: ${kopf.geraet}`);
if (kopf.person) zeilen.push(`Person: ${kopf.person}`);
if (kopf.faelligAm) zeilen.push(`Fällig: ${datum(kopf.faelligAm)}`);
return zeilen;
}
function zusammensetzen(absaetze: readonly (string | undefined)[]): string {
return absaetze.filter((teil): teil is string => Boolean(teil)).join("\n\n");
}
/**
* Die Meldung zu einem Lauf.
*
* `link` ist die vollstaendige Adresse aus `APP_URL` — eine Mail mit einem
* relativen Pfad ist eine Mail ohne Link.
*/
export function laufMeldung(anlass: LaufAnlass, kopf: LaufKopf, link: string): Meldung {
const zeilen = kopfzeilen(kopf);
const daten = zeilen.length > 0 ? zeilen.join("\n") : undefined;
switch (anlass) {
case Meldeanlass.LAUF_ZUGEWIESEN:
return {
betreff: `Laufzettel: „${kopf.titel}“ ist dir zugewiesen`,
rumpf: zusammensetzen([
kopf.handelnder
? `${kopf.handelnder} hat dir diesen Laufzettel zugewiesen.`
: "Dir wurde ein Laufzettel zugewiesen.",
daten,
link,
]),
};
case Meldeanlass.LAUF_UEBERFAELLIG:
return {
betreff: `Laufzettel überfällig: „${kopf.titel}“`,
rumpf: zusammensetzen([
kopf.faelligAm
? `Dieser Laufzettel war am ${datum(kopf.faelligAm)} fällig und ist noch offen.`
: "Dieser Laufzettel ist überfällig und noch offen.",
daten,
link,
]),
};
case Meldeanlass.LAUF_ABGESCHLOSSEN:
return {
betreff: `Laufzettel abgeschlossen: „${kopf.titel}“`,
rumpf: zusammensetzen([
kopf.handelnder
? `${kopf.handelnder} hat diesen Laufzettel abgeschlossen.`
: "Dieser Laufzettel wurde abgeschlossen.",
daten,
link,
]),
};
default: {
/*
* Kaeme ein fuenfter Anlass dazu, der zu einem Lauf gehoert, ist diese
* Zuweisung der Compilerfehler, der daran erinnert — ohne eine Zeile
* totem Code im Ergebnis.
*/
const unbekannt: never = anlass;
throw new Error(`laufMeldung kennt den Anlass ${String(unbekannt)} nicht.`);
}
}
}
/** Die Meldung zu einem fehlgeschlagenen Verzeichnis-Abgleich. */
export function abgleichMeldung(quelle: string, grund: string, link: string): Meldung {
return {
betreff: `Laufzettel: Verzeichnis-Abgleich „${quelle}“ fehlgeschlagen`,
rumpf: zusammensetzen([
`Der Abgleich der Verzeichnisquelle „${quelle}“ ist fehlgeschlagen.`,
`Grund: ${grund}`,
"Bis das behoben ist, bleiben neue und geänderte Konten aus dem Verzeichnis aus. " +
"Vorhandene Personen bleiben unverändert — der Abgleich löscht nichts.",
link,
]),
};
}
/**
* Der Rumpf eines Webhooks.
*
* Dasselbe Prinzip: Kopfdaten und ein Link, keine Schrittinhalte. Als JSON, weil
* die Gegenseite ein Programm ist — und mit `anlass` als erstem Feld, damit sich
* ohne Auspacken filtern laesst.
*/
export function webhookRumpf(
anlass: Meldeanlass,
betreff: string,
link: string,
bezug: { readonly laufId?: string; readonly verzeichnisquelleId?: string },
gesendetAm: Date,
): string {
return JSON.stringify({
anlass,
betreff,
link,
laufId: bezug.laufId ?? null,
verzeichnisquelleId: bezug.verzeichnisquelleId ?? null,
zeitpunkt: gesendetAm.toISOString(),
});
}
+4 -28
View File
@@ -30,26 +30,15 @@ export type NavEintrag = {
readonly ausIssue?: number; 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[] = [ export const NAVIGATION: readonly NavEintrag[] = [
{ pfad: "/", beschriftung: "Übersicht", symbol: "LayoutDashboard", recht: "lesen", verfuegbar: true }, { pfad: "/", beschriftung: "Übersicht", symbol: "LayoutDashboard", verfuegbar: true },
{ pfad: "/laeufe", beschriftung: "Läufe", symbol: "ListChecks", recht: "lesen", verfuegbar: true }, { pfad: "/laeufe", beschriftung: "Läufe", symbol: "ListChecks", verfuegbar: true },
{ pfad: "/vorlagen", beschriftung: "Vorlagen", symbol: "FileText", recht: "lesen", verfuegbar: true }, { pfad: "/vorlagen", beschriftung: "Vorlagen", symbol: "FileText", verfuegbar: true },
{ {
pfad: "/mitarbeiter", pfad: "/mitarbeiter",
beschriftung: "Mitarbeiter & Geräte", beschriftung: "Mitarbeiter & Geräte",
beschriftungDienstleistung: "Personen & Geräte", beschriftungDienstleistung: "Personen & Geräte",
symbol: "Users", symbol: "Users",
recht: "lesen",
verfuegbar: true, verfuegbar: true,
}, },
{ {
@@ -57,23 +46,10 @@ export const NAVIGATION: readonly NavEintrag[] = [
beschriftung: "Kunden", beschriftung: "Kunden",
nurModus: "DIENSTLEISTUNG", nurModus: "DIENSTLEISTUNG",
symbol: "Building2", symbol: "Building2",
recht: "lesen",
verfuegbar: true, 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 }, { 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,
},
]; ];
/** /**
-8
View File
@@ -13,7 +13,6 @@ export const ROLLEN_TEXT: Record<Rolle, string> = {
[Rolle.REDAKTEUR]: "Redakteur", [Rolle.REDAKTEUR]: "Redakteur",
[Rolle.TECHNIKER]: "Techniker", [Rolle.TECHNIKER]: "Techniker",
[Rolle.LESER]: "Leser", [Rolle.LESER]: "Leser",
[Rolle.MITARBEITER]: "Mitarbeiter (Selbstauskunft)",
}; };
/** /**
@@ -25,11 +24,4 @@ export const ROLLEN_RANG: Record<Rolle, number> = {
[Rolle.REDAKTEUR]: 30, [Rolle.REDAKTEUR]: 30,
[Rolle.TECHNIKER]: 20, [Rolle.TECHNIKER]: 20,
[Rolle.LESER]: 10, [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
View File
@@ -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 };
}
-82
View File
@@ -1,82 +0,0 @@
import { createHmac, timingSafeEqual } from "node:crypto";
/**
* Die Signatur eines Webhooks (#034).
*
* Framework-frei, weil die Gegenseite sie nachrechnen muss — und weil genau das
* geprueft werden soll, ohne einen Server zu starten. Das Format ist an das von
* Stripe angelehnt, aus einem praktischen Grund: wer schon einmal einen Webhook
* verdrahtet hat, kennt es, und `docs/betrieb/` kann ein Beispiel in drei Zeilen
* zeigen.
*
* X-Laufzettel-Signatur: t=1762334400,v1=9f86d0818...
*
* **Der Zeitstempel ist Teil der signierten Nachricht, nicht Beiwerk.** Stuende
* er nur daneben, koennte ihn jeder aendern, ohne die Signatur zu brechen — und
* eine abgefangene Nachricht liesse sich in einem Jahr noch einspielen. Signiert
* wird deshalb `<zeitstempel>.<rumpf>`.
*/
/** Wie alt eine Nachricht hoechstens sein darf. Fuenf Minuten, wie bei Stripe. */
export const TOLERANZ_SEKUNDEN = 300;
export const SIGNATUR_KOPF = "X-Laufzettel-Signatur";
function berechnen(geheimnis: string, zeitstempel: number, rumpf: string): string {
return createHmac("sha256", geheimnis).update(`${zeitstempel}.${rumpf}`).digest("hex");
}
/** Den Wert der Kopfzeile bauen. `zeitstempel` in Sekunden. */
export function signaturBauen(geheimnis: string, rumpf: string, zeitstempel: number): string {
return `t=${zeitstempel},v1=${berechnen(geheimnis, zeitstempel, rumpf)}`;
}
export type SignaturPruefung =
{ readonly gueltig: true } | { readonly gueltig: false; readonly grund: "form" | "abgelaufen" | "falsch" };
/**
* Die Gegenprobe – so, wie die Gegenseite sie machen soll.
*
* Exportiert und geprueft, damit `docs/betrieb/` nicht beschreiben muss, was
* niemand nachgerechnet hat.
*/
export function signaturPruefen(
geheimnis: string,
rumpf: string,
kopf: string,
jetztSekunden: number,
toleranzSekunden = TOLERANZ_SEKUNDEN,
): SignaturPruefung {
const teile = new Map<string, string>();
for (const stueck of kopf.split(",")) {
const trenner = stueck.indexOf("=");
if (trenner > 0) teile.set(stueck.slice(0, trenner).trim(), stueck.slice(trenner + 1).trim());
}
const roherStempel = teile.get("t");
const mitgeschickt = teile.get("v1");
if (!roherStempel || !mitgeschickt) return { gueltig: false, grund: "form" };
const zeitstempel = Number.parseInt(roherStempel, 10);
if (!Number.isFinite(zeitstempel)) return { gueltig: false, grund: "form" };
/*
* In **beide** Richtungen pruefen. Nur „zu alt“ abzufangen liesse eine
* Nachricht mit Zeitstempel im Jahr 2040 fuer immer gueltig sein.
*/
if (Math.abs(jetztSekunden - zeitstempel) > toleranzSekunden) {
return { gueltig: false, grund: "abgelaufen" };
}
const erwartet = berechnen(geheimnis, zeitstempel, rumpf);
/*
* Zeitkonstant vergleichen. Ein `===` auf Zeichenketten bricht beim ersten
* Unterschied ab; aus der Dauer liesse sich die richtige Signatur Zeichen fuer
* Zeichen erraten. `timingSafeEqual` verlangt gleiche Laenge, deshalb die
* Pruefung davor — die Laenge ist kein Geheimnis, sie ist immer 64.
*/
const a = Buffer.from(erwartet, "utf8");
const b = Buffer.from(mitgeschickt, "utf8");
if (a.length !== b.length) return { gueltig: false, grund: "falsch" };
return timingSafeEqual(a, b) ? { gueltig: true } : { gueltig: false, grund: "falsch" };
}
+3 -95
View File
@@ -60,40 +60,6 @@ export const AD_ZUORDNUNG: AttributZuordnung = {
externeId: "objectGUID", 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. */ /** Die Felder in der Reihenfolge, in der das Formular sie zeigt. */
export const ZUORDNUNGS_FELDER = Object.keys(AD_ZUORDNUNG) as (keyof AttributZuordnung)[]; 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; export const ATTRIBUT_MUSTER = /^[A-Za-z][A-Za-z0-9-]*$/u;
/**
* Wie eine Quellenart heisst, wo ein Mensch sie liest.
*
* Liegt in `src/lib`, weil beide Seiten sie brauchen: das Formular ist eine
* Client-Komponente, und die darf nichts aus `src/server/*` importieren
* (CLAUDE.md). „LDAP“ und „SCIM“ bleiben als Lehnwoerter stehen — so heissen sie
* im Haus und in den Einstellungen der Anbieter.
*/
export const QUELLEN_ART_TEXT: Record<"LDAP" | "GRAPH" | "SCIM", string> = {
LDAP: "LDAP",
GRAPH: "Microsoft Graph",
SCIM: "SCIM",
};
/** /**
* Liest eine gespeicherte Zuordnung aus dem Json-Feld. * Liest eine gespeicherte Zuordnung aus dem Json-Feld.
* *
@@ -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 * mitschreiben muessen, und eine von Hand geleerte Zeile darf kein Attribut mit
* leerem Namen anfragen. * 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>; const felder = (typeof roh === "object" && roh !== null ? roh : {}) as Record<string, unknown>;
/* const gelesen = { ...AD_ZUORDNUNG };
* 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] };
for (const schluessel of ZUORDNUNGS_FELDER) { for (const schluessel of ZUORDNUNGS_FELDER) {
const wert = felder[schluessel]; const wert = felder[schluessel];
if (typeof wert !== "string" || !wert.trim()) continue; if (typeof wert === "string" && wert.trim()) gelesen[schluessel] = wert.trim();
/*
* **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();
} }
return gelesen; 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. * Die Attributzuordnung aus den Formularfeldern, geprueft.
* *
+2 -19
View File
@@ -18,30 +18,13 @@ import { SITZUNG_COOKIE } from "@/lib/sitzung";
* den Proxy zu haengen. * den Proxy zu haengen.
*/ */
/** /** Pfade, die ohne Anmeldung erreichbar sein muessen. */
* Pfade, die ohne Anmeldung erreichbar sein muessen. const OEFFENTLICH: readonly string[] = [
*
* **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[] = [
"/anmelden", "/anmelden",
// Der Anmeldevorgang selbst – ohne ihn koennte sich niemand anmelden. // Der Anmeldevorgang selbst – ohne ihn koennte sich niemand anmelden.
"/api/auth", "/api/auth",
// Healthcheck fuer Docker und den Reverse Proxy. // Healthcheck fuer Docker und den Reverse Proxy.
"/api/gesundheit", "/api/gesundheit",
/*
* Der SCIM-Endpunkt (#029). Ein Identitaetsanbieter hat kein Sitzungscookie —
* er weist sich mit einem Bearer-Token aus, und **jeder** Handler darunter
* prueft es selbst, samt der Quelle und damit des Kunden. Ohne diesen Eintrag
* bekaeme der Anbieter 401 vom Proxy, bevor sein Token ueberhaupt gelesen
* wird — und die Meldung waere „nicht angemeldet“ statt „Token ungueltig“.
*/
"/api/scim",
"/manifest.webmanifest", "/manifest.webmanifest",
"/icon.svg", "/icon.svg",
]; ];
-145
View File
@@ -1,145 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "@/lib/validierung";
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
import {
MeldungsFehler,
meldungseinstellungenSpeichern,
testmailSenden,
} from "@/server/services/benachrichtigungen";
import { prisma } from "@/lib/db";
/** Server Actions der Benachrichtigungen (#034). Recht prüfen, Zod, Service rufen. */
export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string };
/**
* Ein leeres Feld heisst „loeschen“, ein fehlendes „unverändert lassen“.
*
* Beim Passwort ist das der Unterschied, auf den es ankommt: das Formular zeigt
* es nie an, also kommt es leer zurueck. Wuerde leer als „loeschen“ gelesen,
* loeschte jedes Speichern der uebrigen Felder das Passwort mit.
*/
const MeldungsSchema = z.object({
smtpHost: z.string().trim().optional(),
smtpPort: z.string().trim().optional(),
smtpTls: z.string().optional(),
smtpBenutzer: z.string().trim().optional(),
smtpPasswort: z.string().optional(),
smtpAbsender: z.string().trim().optional(),
webhookUrl: z.string().trim().optional(),
webhookGeheimnis: z.string().optional(),
});
export async function meldungenSpeichernAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("verwaltung");
const eingabe = MeldungsSchema.safeParse({
smtpHost: formular.get("smtpHost") ?? undefined,
smtpPort: formular.get("smtpPort") ?? undefined,
smtpTls: formular.get("smtpTls") ?? undefined,
smtpBenutzer: formular.get("smtpBenutzer") ?? undefined,
smtpPasswort: formular.get("smtpPasswort") ?? undefined,
smtpAbsender: formular.get("smtpAbsender") ?? undefined,
webhookUrl: formular.get("webhookUrl") ?? undefined,
webhookGeheimnis: formular.get("webhookGeheimnis") ?? undefined,
});
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
const roherPort = eingabe.data.smtpPort ?? "";
let port: number | null = null;
if (roherPort !== "") {
const gelesen = Number.parseInt(roherPort, 10);
if (!Number.isFinite(gelesen)) return { fehler: "Der SMTP-Port muss eine Zahl sein." };
port = gelesen;
}
try {
await meldungseinstellungenSpeichern(
{
smtpHost: eingabe.data.smtpHost ?? null,
smtpPort: port,
smtpTls: eingabe.data.smtpTls === "an",
smtpBenutzer: eingabe.data.smtpBenutzer ?? null,
// Leer = unverändert. Das Formular zeigt ein gesetztes Passwort nie an.
...(eingabe.data.smtpPasswort ? { smtpPasswort: eingabe.data.smtpPasswort } : {}),
smtpAbsender: eingabe.data.smtpAbsender ?? null,
webhookUrl: eingabe.data.webhookUrl ?? null,
...(eingabe.data.webhookGeheimnis ? { webhookGeheimnis: eingabe.data.webhookGeheimnis } : {}),
},
benutzer.id,
);
} catch (fehler) {
if (fehler instanceof MeldungsFehler) return { fehler: fehler.message };
throw fehler;
}
revalidatePath("/admin");
return { erfolg: "Gespeichert." };
}
export async function testmailAktion(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("verwaltung");
const an = z.string().trim().min(1).safeParse(formular.get("an"));
// Ohne Angabe an den Administrator selbst – das ist der häufigste Fall.
const ziel = an.success ? an.data : benutzer.email;
try {
await testmailSenden(ziel);
} catch (fehler) {
if (fehler instanceof MeldungsFehler) return { fehler: fehler.message };
throw fehler;
}
return { erfolg: `Testmail an ${ziel} ist hinausgegangen.` };
}
/**
* Benachrichtigungen je Person an- oder abschalten.
*
* Recht `verwaltung`, wie alles an einem Konto — es gibt kein eigenes Recht fuer
* Benutzer, die Rechtetabelle kennt `verwaltung` fuer ADMIN. **Fuer das eigene
* Konto fehlt damit noch die Selbstbedienung**: eine Profilseite gibt es bis
* Phase 2 nicht, sie kommt mit der Selbstauskunft (#035). Bis dahin stellt ein
* Administrator es um, und das steht so auch in der Anleitung.
*/
export async function meldungenSchalterAktion(formular: FormData): Promise<void> {
const { benutzer } = await rechtErforderlich("verwaltung");
/*
* Einstellig und ohne Rueckgabe, wie `aktivSetzenAktion` daneben: der Schalter
* sitzt in einem gewoehnlichen Formular in der Liste, nicht in einem
* `useActionState`. Eine zweistellige Action passt dort nicht hinein — und
* eine zweite Form derselben Sache waere Lärm.
*/
const eingabe = z
.object({ benutzerId: z.string().min(1), aktiv: z.enum(["an", "aus"]) })
.parse({ benutzerId: formular.get("benutzerId"), aktiv: formular.get("aktiv") });
const ziel = await prisma.benutzer.findUnique({
where: { id: eingabe.benutzerId },
select: { id: true, name: true, meldungenAktiv: true },
});
if (!ziel) return;
const neu = eingabe.aktiv === "an";
if (ziel.meldungenAktiv !== neu) {
await prisma.benutzer.update({ where: { id: ziel.id }, data: { meldungenAktiv: neu } });
await prisma.protokoll.create({
data: {
entitaet: "Benutzer",
entitaetId: ziel.id,
aktion: neu ? "Benachrichtigungen eingeschaltet" : "Benachrichtigungen abgeschaltet",
benutzerId: benutzer.id,
vorher: { meldungenAktiv: ziel.meldungenAktiv },
nachher: { meldungenAktiv: neu },
},
});
}
revalidatePath("/admin/benutzer");
}
+24 -156
View File
@@ -14,7 +14,6 @@ import { z } from "@/lib/validierung";
import { MandantFehler, type HandelnderBenutzer } from "@/server/mandant"; import { MandantFehler, type HandelnderBenutzer } from "@/server/mandant";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht"; import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { abgleichAusfuehren, type AbgleichErgebnis } from "@/server/services/abgleich"; import { abgleichAusfuehren, type AbgleichErgebnis } from "@/server/services/abgleich";
import { scimTokenErzeugen, scimTokenWiderrufen } from "@/server/services/scim";
import { import {
VerzeichnisFehler, VerzeichnisFehler,
quelleAnlegen, quelleAnlegen,
@@ -86,30 +85,8 @@ function zuordnungAusFormular(formular: FormData) {
); );
} }
/** const QuelleSchema = z.object({
* 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({
name: z.string().trim().min(1, "Bitte einen Namen angeben."), name: z.string().trim().min(1, "Bitte einen Namen angeben."),
art: z.enum(["LDAP", "GRAPH", "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 url: z
.string() .string()
.trim() .trim()
@@ -122,46 +99,16 @@ const LdapSchema = z.object({
bindDn: z.string().trim().min(1, "Bitte den Benutzer für die Anmeldung angeben."), 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."), basisDn: z.string().trim().min(1, "Bitte angeben, ab welchem Knoten gesucht wird."),
filter: z.string().trim().optional(), 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( export async function quelleAnlegenAktion(
_vorher: AktionsErgebnis, _vorher: AktionsErgebnis,
formular: FormData, formular: FormData,
@@ -169,9 +116,12 @@ export async function quelleAnlegenAktion(
const zugang = await angemeldetOderMeldung(); const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang; if ("fehler" in zugang) return zugang;
const eingabe = GemeinsamSchema.safeParse({ const eingabe = QuelleSchema.safeParse({
name: text(formular, "name"), 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, caZertifikatPem: text(formular, "caZertifikatPem") || undefined,
abgleichIntervallMinuten: text(formular, "abgleichIntervallMinuten") || "60", abgleichIntervallMinuten: text(formular, "abgleichIntervallMinuten") || "60",
aktiv: formular.get("aktiv") === "1", 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." }; return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
} }
const felder = artFelder(eingabe.data.art, formular); const bindPasswort = text(formular, "bindPasswort");
if ("fehler" in felder) return felder; if (!bindPasswort) return { fehler: "Ohne Passwort kommt keine Anmeldung am Verzeichnis zustande." };
/*
* 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 zuordnung = zuordnungAusFormular(formular); const zuordnung = zuordnungAusFormular(formular);
if ("fehler" in zuordnung) return zuordnung; if ("fehler" in zuordnung) return zuordnung;
try { try {
const quelle = await quelleAnlegen( const quelle = await quelleAnlegen(
{ ...eingabe.data, ...felder.werte, ...geheimnis, attributZuordnung: zuordnung.zuordnung }, { ...eingabe.data, bindPasswort, attributZuordnung: zuordnung.zuordnung },
zugang.benutzer, zugang.benutzer,
); );
revalidatePath("/admin/verzeichnis"); revalidatePath("/admin/verzeichnis");
return { return { erfolg: `„${quelle.name}“ ist angelegt. Prüf als Nächstes die Verbindung.` };
erfolg:
eingabe.data.art === "SCIM"
? `„${quelle.name}“ ist angelegt. Erzeug als Nächstes das Token und trag es beim Anbieter ein.`
: `„${quelle.name}“ ist angelegt. Prüf als Nächstes die Verbindung.`,
};
} catch (fehler) { } catch (fehler) {
return alsErgebnis(fehler, "Die Quelle konnte nicht angelegt werden."); return alsErgebnis(fehler, "Die Quelle konnte nicht angelegt werden.");
} }
@@ -234,9 +158,12 @@ export async function quelleBearbeitenAktion(
const id = text(formular, "id"); const id = text(formular, "id");
if (!id) return { fehler: "Es fehlt die Quelle." }; if (!id) return { fehler: "Es fehlt die Quelle." };
const eingabe = GemeinsamSchema.safeParse({ const eingabe = QuelleSchema.safeParse({
name: text(formular, "name"), 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 // Das Zertifikat entscheidet sich weiter unten: es kennt drei Faelle, das
// Schema nur zwei. // Schema nur zwei.
caZertifikatPem: undefined, caZertifikatPem: undefined,
@@ -247,9 +174,6 @@ export async function quelleBearbeitenAktion(
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." }; 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 * Drei Faelle, und das Formular kann sie nicht auf ein Feld abbilden: ein neues
* Zertifikat eintragen, das hinterlegte behalten (Feld leer) und das hinterlegte * Zertifikat eintragen, das hinterlegte behalten (Feld leer) und das hinterlegte
@@ -276,15 +200,11 @@ export async function quelleBearbeitenAktion(
if ("fehler" in zuordnung) return zuordnung; if ("fehler" in zuordnung) return zuordnung;
try { try {
const clientSecret = text(formular, "clientSecret");
const quelle = await quelleBearbeiten( const quelle = await quelleBearbeiten(
id, id,
{ {
...eingabe.data, ...eingabe.data,
...felder.werte,
// Leer heisst unveraendert — bei beiden Geheimnissen.
...(bindPasswort ? { bindPasswort } : {}), ...(bindPasswort ? { bindPasswort } : {}),
...(clientSecret ? { clientSecret } : {}),
...(caZertifikatPem !== undefined ? { caZertifikatPem } : {}), ...(caZertifikatPem !== undefined ? { caZertifikatPem } : {}),
attributZuordnung: zuordnung.zuordnung, 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 & { export type TestErgebnis = AktionsErgebnis & {
readonly treffer?: readonly GemappterEintrag[]; readonly treffer?: readonly GemappterEintrag[];
readonly gesamtGelesen?: number; readonly gesamtGelesen?: number;
@@ -393,7 +261,7 @@ export async function verbindungTestenAktion(
const ergebnis = await verbindungTesten(id, zugang.benutzer); const ergebnis = await verbindungTesten(id, zugang.benutzer);
return { return {
erfolg: erfolg:
ergebnis.treffer.length === 0 && ergebnis.gesamtGelesen === 0 ergebnis.treffer.length === 0
? "Die Verbindung steht, aber der Filter findet niemanden." ? "Die Verbindung steht, aber der Filter findet niemanden."
: ergebnis.treffer.length === 0 : ergebnis.treffer.length === 0
? /* ? /*
+5 -134
View File
@@ -5,7 +5,7 @@ import type { Rolle } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { import {
kundenkuerzelAusGruppen, kundenkuerzelAusGruppen,
rolleAusGruppenGenau, rolleAusGruppen,
rollenZuordnungAktiv, rollenZuordnungAktiv,
type OidcAnspruecke, type OidcAnspruecke,
} from "@/server/auth/oidc"; } from "@/server/auth/oidc";
@@ -17,23 +17,10 @@ import {
* Verzeichnis. Hier geht es um die Menschen, die Laufzettel bedienen. * 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 { export class AnmeldeFehler extends Error {
readonly grund: AnmeldeGrund | undefined; constructor(nachricht: string) {
constructor(nachricht: string, grund?: AnmeldeGrund) {
super(nachricht); super(nachricht);
this.name = "AnmeldeFehler"; 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 * Aenderung im Verzeichnis greift damit ab der naechsten Anmeldung, ohne dass
* jemand hier nachpflegen muss. * 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> { export async function benutzerAusOidcUebernehmen(anspruecke: OidcAnspruecke): Promise<Benutzer> {
// Zuerst ueber sub suchen: die Adresse eines Kontos kann sich aendern, die // Zuerst ueber sub suchen: die Adresse eines Kontos kann sich aendern, die
// Kennung beim Anbieter nicht. // Kennung beim Anbieter nicht.
@@ -261,8 +133,9 @@ export async function benutzerAusOidcUebernehmen(anspruecke: OidcAnspruecke): Pr
throw new AnmeldeFehler("Dieses Konto ist deaktiviert."); throw new AnmeldeFehler("Dieses Konto ist deaktiviert.");
} }
const zuweisung = await rolleUndPersonBestimmen(anspruecke, vorhanden); // Ohne eingerichtete Gruppen-Zuordnung bleibt die Rolle, wie sie ist – sonst
const { rolle, mitarbeiterId } = zuweisung; // waere jede Anmeldung eine stille Herabstufung auf LESER.
const rolle = rollenZuordnungAktiv() ? rolleAusGruppen(anspruecke.gruppen) : (vorhanden?.rolle ?? "LESER");
const benutzer = vorhanden const benutzer = vorhanden
? await prisma.benutzer.update({ ? await prisma.benutzer.update({
@@ -272,7 +145,6 @@ export async function benutzerAusOidcUebernehmen(anspruecke: OidcAnspruecke): Pr
email: anspruecke.email, email: anspruecke.email,
name: anspruecke.name, name: anspruecke.name,
rolle, rolle,
mitarbeiterId,
letzteAnmeldungAm: new Date(), letzteAnmeldungAm: new Date(),
}, },
}) })
@@ -282,7 +154,6 @@ export async function benutzerAusOidcUebernehmen(anspruecke: OidcAnspruecke): Pr
email: anspruecke.email, email: anspruecke.email,
name: anspruecke.name, name: anspruecke.name,
rolle, rolle,
mitarbeiterId,
aktiv: true, aktiv: true,
letzteAnmeldungAm: new Date(), letzteAnmeldungAm: new Date(),
}, },
+3 -26
View File
@@ -1,5 +1,5 @@
import * as oidc from "openid-client"; 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 { kurzzeitTokenErzeugen, kurzzeitTokenLesen } from "@/lib/kurzzeit-token";
import { staerksteRolle } from "@/server/auth/rollen"; import { staerksteRolle } from "@/server/auth/rollen";
@@ -139,16 +139,6 @@ export type OidcAnspruecke = {
readonly emailBestaetigt: boolean; readonly emailBestaetigt: boolean;
readonly name: string; readonly name: string;
readonly gruppen: readonly 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. */ /** Ziel, das vor der Anmeldung angefordert wurde. */
readonly ziel: string; readonly ziel: string;
}; };
@@ -209,7 +199,6 @@ export async function anmeldungAbschliessen(
emailBestaetigt: anspruecke["email_verified"] === true, emailBestaetigt: anspruecke["email_verified"] === true,
name: textAus(anspruecke["name"]) ?? textAus(anspruecke["preferred_username"]) ?? email, name: textAus(anspruecke["name"]) ?? textAus(anspruecke["preferred_username"]) ?? email,
gruppen: gruppenAus(anspruecke["groups"]), gruppen: gruppenAus(anspruecke["groups"]),
upn: textAus(anspruecke["upn"]) ?? textAus(anspruecke["preferred_username"]) ?? email,
ziel: fluss["ziel"] ?? "/", ziel: fluss["ziel"] ?? "/",
}; };
} }
@@ -234,15 +223,7 @@ export function rollenZuordnungAktiv(): boolean {
); );
} }
/** export function rolleAusGruppen(gruppen: readonly string[]): Rolle {
* 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 {
const zuordnung: readonly (readonly [string | undefined, Rolle])[] = [ const zuordnung: readonly (readonly [string | undefined, Rolle])[] = [
[umgebung("OIDC_GROUP_ADMIN"), "ADMIN"], [umgebung("OIDC_GROUP_ADMIN"), "ADMIN"],
[umgebung("OIDC_GROUP_EDITOR"), "REDAKTEUR"], [umgebung("OIDC_GROUP_EDITOR"), "REDAKTEUR"],
@@ -253,11 +234,7 @@ export function rolleAusGruppenGenau(gruppen: readonly string[]): Rolle | undefi
const getroffen = zuordnung const getroffen = zuordnung
.filter(([gruppe]) => gruppe !== undefined && vorhanden.has(gruppe)) .filter(([gruppe]) => gruppe !== undefined && vorhanden.has(gruppe))
.map(([, rolle]) => rolle); .map(([, rolle]) => rolle);
return getroffen.length === 0 ? undefined : staerksteRolle(getroffen); return staerksteRolle(getroffen);
}
export function rolleAusGruppen(gruppen: readonly string[]): Rolle {
return rolleAusGruppenGenau(gruppen) ?? Rolle.LESER;
} }
/** /**
+7 -64
View File
@@ -20,24 +20,8 @@ import { ROLLEN_RANG, ROLLEN_TEXT } from "@/lib/rollen-text";
* des Cookies. * des Cookies.
*/ */
export const RECHTE = { export const RECHTE = {
/** /** Vorlagen, Laeufe, Geraete und Verzeichnis ansehen. Jede Rolle darf das. */
* 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`.
*/
lesen: ["ADMIN", "REDAKTEUR", "TECHNIKER", "LESER"], 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. */ /** Laeufe starten, abarbeiten, abschliessen und archivieren. */
laeufeBearbeiten: ["ADMIN", "REDAKTEUR", "TECHNIKER"], laeufeBearbeiten: ["ADMIN", "REDAKTEUR", "TECHNIKER"],
/** Geraete und deren Stammdaten pflegen, Tags registrieren. */ /** Geraete und deren Stammdaten pflegen, Tags registrieren. */
@@ -73,59 +57,18 @@ export function darf(rolle: Rolle, recht: Recht): boolean {
* Die staerkste der uebergebenen Rollen. * Die staerkste der uebergebenen Rollen.
* *
* Der OIDC-Login braucht das: ist jemand in mehreren abgebildeten Gruppen, gewinnt die * 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`. * 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
* **Das gilt weiter fuer eine eingerichtete Zuordnung ohne Treffer in dieser * und nichts aendern; ein Fehler an dieser Stelle wuerde stattdessen den Login blockieren.
* 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.
*/ */
export function staerksteRolle(rollen: readonly Rolle[]): Rolle { export function staerksteRolle(rollen: readonly Rolle[]): Rolle {
/* let staerkste: Rolle = Rolle.LESER;
* `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;
for (const rolle of rollen) { for (const rolle of rollen) {
if (staerkste === undefined || ROLLEN_RANG[rolle] > ROLLEN_RANG[staerkste]) { if (ROLLEN_RANG[rolle] > ROLLEN_RANG[staerkste]) {
staerkste = rolle; staerkste = rolle;
} }
} }
return staerkste ?? Rolle.LESER; return staerkste;
}
/**
* 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 "/";
} }
// Beschriftung und Rang liegen in src/lib, damit Client-Komponenten sie ohne // Beschriftung und Rang liegen in src/lib, damit Client-Komponenten sie ohne
-34
View File
@@ -5,7 +5,6 @@ import type { Benutzer } from "@/generated/prisma/client";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { SITZUNG_COOKIE, sitzungLesen, type Sitzung } from "@/lib/sitzung"; import { SITZUNG_COOKIE, sitzungLesen, type Sitzung } from "@/lib/sitzung";
import { darf, type Recht } from "@/server/auth/rollen"; import { darf, type Recht } from "@/server/auth/rollen";
import type { Rolle } from "@/generated/prisma/enums";
/** /**
* Wer ist angemeldet und was darf er (#007). * Wer ist angemeldet und was darf er (#007).
@@ -56,39 +55,6 @@ export type Angemeldet = {
readonly sitzung: Sitzung; 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 * Der angemeldete Benutzer oder `undefined`. Leitet nicht weiter – dafuer gibt es
* `sitzungErforderlich`. * `sitzungErforderlich`.
-308
View File
@@ -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 };
}
-173
View File
@@ -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;
}
+7 -32
View File
@@ -45,15 +45,7 @@ export type GemappterEintrag = {
mail: string | null; mail: string | null;
upn: string | null; upn: string | null;
sam: string | null; sam: string | null;
/** dn: string;
* 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;
abteilung: string | null; abteilung: string | null;
titel: string | null; titel: string | null;
vorgesetzterExterneId: string | null; vorgesetzterExterneId: string | null;
@@ -70,14 +62,6 @@ export type GemappterEintrag = {
*/ */
export type RoherEintrag = Omit<GemappterEintrag, "vorgesetzterExterneId"> & { export type RoherEintrag = Omit<GemappterEintrag, "vorgesetzterExterneId"> & {
vorgesetzterDn: string | null; 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, zuordnung: AttributZuordnung,
): { eintrag: RoherEintrag } | { uebersprungen: Uebersprungen } { ): { eintrag: RoherEintrag } | { uebersprungen: Uebersprungen } {
const dn = eintrag.dn.trim(); 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; let externeId: string | undefined;
try { try {
externeId = externeIdLesen(eintrag, zuordnung.externeId); 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 * wird er aus Nachname und Vorname gebaut; bleibt auch das leer, tritt der erste
* Teil des DN ein. * 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(", "); const gebaut = [nachname, vorname].filter((teil) => teil).join(", ");
if (gebaut) return gebaut; if (gebaut) return gebaut;
const ersterTeil = kennzeichen.split(",")[0]?.split("=")[1]?.trim(); const ersterTeil = dn.split(",")[0]?.split("=")[1]?.trim();
return ersterTeil ?? kennzeichen; return ersterTeil ?? dn;
} }
/** /**
@@ -244,16 +225,10 @@ export function vorgesetzteAufloesen(
): GemappterEintrag[] { ): GemappterEintrag[] {
const nachDn = new Map(zusaetzlicheKennungen ?? []); const nachDn = new Map(zusaetzlicheKennungen ?? []);
for (const eintrag of eintraege) { 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, ...rest,
/* vorgesetzterExterneId: vorgesetzterDn ? (nachDn.get(dnSchluessel(vorgesetzterDn)) ?? null) : null,
* **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),
})); }));
} }
-78
View File
@@ -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,
}));
}
-4
View File
@@ -9,8 +9,6 @@ export type BenutzerZeile = {
readonly name: string; readonly name: string;
readonly rolle: Rolle; readonly rolle: Rolle;
readonly aktiv: boolean; readonly aktiv: boolean;
/** Bekommt diese Person Benachrichtigungen (#034)? */
readonly meldungenAktiv: boolean;
/** true, wenn das Konto ein Passwort hat, sich also lokal anmelden kann. */ /** true, wenn das Konto ein Passwort hat, sich also lokal anmelden kann. */
readonly mitPasswort: boolean; readonly mitPasswort: boolean;
readonly ueberOidc: boolean; readonly ueberOidc: boolean;
@@ -26,7 +24,6 @@ export async function benutzerListe(): Promise<readonly BenutzerZeile[]> {
name: true, name: true,
rolle: true, rolle: true,
aktiv: true, aktiv: true,
meldungenAktiv: true,
// Der Hash selbst verlaesst die Service-Schicht nie – nur die Auskunft, // Der Hash selbst verlaesst die Service-Schicht nie – nur die Auskunft,
// ob ueberhaupt einer da ist. // ob ueberhaupt einer da ist.
passwortHash: true, passwortHash: true,
@@ -41,7 +38,6 @@ export async function benutzerListe(): Promise<readonly BenutzerZeile[]> {
name: zeile.name, name: zeile.name,
rolle: zeile.rolle, rolle: zeile.rolle,
aktiv: zeile.aktiv, aktiv: zeile.aktiv,
meldungenAktiv: zeile.meldungenAktiv,
mitPasswort: zeile.passwortHash !== null, mitPasswort: zeile.passwortHash !== null,
ueberOidc: zeile.oidcSub !== null, ueberOidc: zeile.oidcSub !== null,
letzteAnmeldungAm: zeile.letzteAnmeldungAm, letzteAnmeldungAm: zeile.letzteAnmeldungAm,
+2 -2
View File
@@ -1,7 +1,7 @@
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums"; import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { nurGeltende } from "@/lib/geltende-abschnitte"; 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 { platzhalterErsetzen } from "@/lib/platzhalter-werte";
import { kundenEbeneSichtbar, sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant"; import { kundenEbeneSichtbar, sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
import { kommentareDesLaufs, type KommentarZeile } from "@/server/queries/laeufe"; import { kommentareDesLaufs, type KommentarZeile } from "@/server/queries/laeufe";
@@ -226,7 +226,7 @@ export async function laufZumDrucken(
const alleKommentare = await kommentareDesLaufs(laufId, benutzer); const alleKommentare = await kommentareDesLaufs(laufId, benutzer);
// Nur Metadaten: der Ausdruck verweist auf die Adresse, er traegt die Datei // Nur Metadaten: der Ausdruck verweist auf die Adresse, er traegt die Datei
// nicht in sich (#032). // 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])); const ergebnisse = new Map(lauf.ergebnisse.map((ergebnis) => [ergebnis.vorlagenSchrittId, ergebnis]));
let nummer = 0; let nummer = 0;
-162
View File
@@ -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;
}
-80
View File
@@ -1,80 +0,0 @@
import { Zustellweg, Zustellzustand, type Meldeanlass } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
/**
* Die letzten Zustellungen – fuer Admin → Zustellungen (#034).
*
* **Warum es diese Seite gibt:** eine stille Warteschlange ist schlimmer als
* keine. Ohne sie merkt niemand, dass seit drei Wochen keine Mail mehr
* hinausgeht, weil das SMTP-Passwort abgelaufen ist — jeder einzelne Lauf
* funktioniert weiter, nur erfaehrt niemand davon.
*
* Keine Mandantensicht: das Recht `verwaltung` hat nur, wer alles sehen darf,
* und eine Zustellung gehoert zum Betrieb der Anwendung, nicht zu einem Kunden.
* Der **Rumpf** wird trotzdem nicht ausgeliefert — er traegt Lauftitel und
* Geraetenamen, und fuer „laeuft der Versand?“ braucht es ihn nicht.
*/
export type ZustellZeile = {
readonly id: string;
readonly anlass: Meldeanlass;
readonly weg: Zustellweg;
readonly empfaenger: string;
readonly betreff: string;
readonly zustand: Zustellzustand;
readonly versuche: number;
readonly letzterFehler: string | null;
readonly naechsterVersuchAm: Date;
readonly gesendetAm: Date | null;
readonly erstelltAm: Date;
readonly laufId: string | null;
};
export const LISTE_HOECHSTENS = 200;
export type ZustellFilter = {
readonly zustand?: Zustellzustand;
};
export async function zustellungenHolen(filter: ZustellFilter = {}): Promise<readonly ZustellZeile[]> {
const zeilen = await prisma.zustellung.findMany({
where: filter.zustand ? { zustand: filter.zustand } : {},
orderBy: { erstelltAm: "desc" },
take: LISTE_HOECHSTENS,
select: {
id: true,
anlass: true,
weg: true,
empfaenger: true,
betreff: true,
zustand: true,
versuche: true,
letzterFehler: true,
naechsterVersuchAm: true,
gesendetAm: true,
erstelltAm: true,
laufId: true,
},
});
return zeilen;
}
export type ZustellZaehler = Readonly<Record<Zustellzustand, number>>;
/**
* Wie viele in welchem Zustand.
*
* Die Zahl der **aufgegebenen** ist die eine, die jemand sehen muss: alles
* andere regelt sich von selbst.
*/
export async function zustellungenZaehlen(): Promise<ZustellZaehler> {
const gruppen = await prisma.zustellung.groupBy({ by: ["zustand"], _count: { _all: true } });
const leer: Record<Zustellzustand, number> = {
[Zustellzustand.OFFEN]: 0,
[Zustellzustand.GESENDET]: 0,
[Zustellzustand.FEHLGESCHLAGEN]: 0,
[Zustellzustand.AUFGEGEBEN]: 0,
};
for (const gruppe of gruppen) leer[gruppe.zustand] = gruppe._count._all;
return leer;
}
-115
View File
@@ -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") };
}
}
+74 -68
View File
@@ -1,15 +1,15 @@
import { hostname } from "node:os";
import { randomUUID } from "node:crypto";
import type { Mitarbeiter, Verzeichnisquelle } from "@/generated/prisma/client"; import type { Mitarbeiter, Verzeichnisquelle } from "@/generated/prisma/client";
import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums"; import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { satzEnde } from "@/lib/satzzeichen"; import { satzEnde } from "@/lib/satzzeichen";
import { darf } from "@/server/auth/rollen"; import { darf } from "@/server/auth/rollen";
import { betriebsmodus, eigeneOrganisation, type HandelnderBenutzer } from "@/server/mandant"; 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 { 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 { VerzeichnisFehler, quelleZeileHolen, zugangAus } from "@/server/services/verzeichnis";
import { mitSperre } from "@/server/services/sperre";
import { abgleichMeldungEinreihen } from "@/server/services/benachrichtigungen";
/** /**
* Der Abgleich (#016). * Der Abgleich (#016).
@@ -81,6 +81,55 @@ function sperrSchluessel(quelleId: string): string {
return `abgleich:${quelleId}`; return `abgleich:${quelleId}`;
} }
/**
* Wer die Sperre haelt: Rechner, Prozess und ein Zufallsanteil.
*
* Der Zufallsanteil ist nicht Zierde. Ohne ihn koennten zwei Abgleiche im
* **selben** Prozess (ein Klick und der Zeitplan im selben Moment) einander die
* Sperre wegnehmen und der zweite die des ersten beim Aufraeumen loeschen.
*/
function haltername(): string {
return `${hostname()}/${process.pid}/${randomUUID().slice(0, 8)}`;
}
/**
* Fuehrt `was` unter der Sperre aus; `undefined`, wenn sie belegt ist.
*
* Das Belegen ist **eine** Anweisung, und das ist der Punkt: `SELECT` und danach
* `INSERT` waeren zwei, und zwischen ihnen passt genau der zweite Abgleich, den
* die Sperre verhindern soll. Das `WHERE` am `ON CONFLICT` uebernimmt eine
* abgelaufene Sperre und laesst eine lebende in Ruhe, sodass die Zeile nie
* dauerhaft blockiert.
*/
export async function mitAbgleichSperre<T>(
schluessel: string,
dauerMinuten: number,
was: () => Promise<T>,
): Promise<T | undefined> {
const halter = haltername();
const bekommen = await prisma.$executeRaw`
INSERT INTO "AbgleichSperre" ("schluessel", "gehaltenVon", "gehaltenSeit", "laeuftAbAm")
VALUES (${schluessel}, ${halter}, now(), now() + make_interval(mins => ${dauerMinuten}))
ON CONFLICT ("schluessel") DO UPDATE
SET "gehaltenVon" = EXCLUDED."gehaltenVon",
"gehaltenSeit" = EXCLUDED."gehaltenSeit",
"laeuftAbAm" = EXCLUDED."laeuftAbAm"
WHERE "AbgleichSperre"."laeuftAbAm" <= now()
`;
if (bekommen === 0) return undefined;
try {
return await was();
} finally {
// Nur die eigene Sperre freigeben. Ist sie zwischenzeitlich abgelaufen und
// von einem anderen Lauf uebernommen worden, gehoert die Zeile diesem — sie
// hier zu loeschen hiesse, ihm die Sperre unter den Fuessen wegzuziehen.
await prisma.$executeRaw`
DELETE FROM "AbgleichSperre" WHERE "schluessel" = ${schluessel} AND "gehaltenVon" = ${halter}
`;
}
}
/** /**
* Quellen, deren Intervall abgelaufen ist – fuer den Zeitplan. * Quellen, deren Intervall abgelaufen ist – fuer den Zeitplan.
* *
@@ -105,19 +154,11 @@ export async function faelligeQuellen(
* `psql` und eine Migration schon. * `psql` und eine Migration schon.
*/ */
const eigene = await eigeneOrganisation(); const eigene = await eigeneOrganisation();
/*
* **Eine SCIM-Quelle wird nie abgeglichen** (#029). Bei ihr holt Laufzettel
* nicht, sie bekommt geschickt — und der Zeitplan haette jede Minute versucht
* zu lesen, waere an der Weiche gescheitert und haette „kann noch nicht
* abgeglichen werden“ an die Quelle geschrieben. Dauerhaft ein Fehler, der
* keiner ist, und `letzterAbgleichFehler` ist in der Oberflaeche sichtbar.
*/
return prisma.$queryRaw<{ id: string; name: string }[]>` return prisma.$queryRaw<{ id: string; name: string }[]>`
SELECT "id", "name" SELECT "id", "name"
FROM "Verzeichnisquelle" FROM "Verzeichnisquelle"
WHERE "aktiv" = true WHERE "aktiv" = true
AND "kundeId" = ${eigene.id} AND "kundeId" = ${eigene.id}
AND "art" <> 'SCIM'
AND ("letzterAbgleichAm" IS NULL AND ("letzterAbgleichAm" IS NULL
OR "letzterAbgleichAm" + make_interval(mins => "abgleichIntervallMinuten") <= ${jetzt}) OR "letzterAbgleichAm" + make_interval(mins => "abgleichIntervallMinuten") <= ${jetzt})
ORDER BY "letzterAbgleichAm" ASC NULLS FIRST, "name" ASC ORDER BY "letzterAbgleichAm" ASC NULLS FIRST, "name" ASC
@@ -155,16 +196,7 @@ async function vonHandAngelegteFinden(kundeId: string, eintrag: RoherEintrag): P
/** Was wir zwischen den beiden Durchgaengen ueber eine Person behalten muessen. */ /** Was wir zwischen den beiden Durchgaengen ueber eine Person behalten muessen. */
type OffenerVorgesetzter = { type OffenerVorgesetzter = {
readonly externeId: string; readonly externeId: string;
/** Bei LDAP: der DN, der im zweiten Durchgang aufgeloest wird. */ readonly vorgesetzterDn: string;
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 vorherigeKennung: string | null; readonly vorherigeKennung: string | null;
readonly neuAngelegt: boolean; readonly neuAngelegt: boolean;
}; };
@@ -186,7 +218,9 @@ export async function abgleichAusfuehren(
} }
const quelle = await quelleZeileHolen(quelleId); const quelle = await quelleZeileHolen(quelleId);
const ergebnis = await mitSperre(sperrSchluessel(quelleId), SPERRE_MINUTEN, () => lauf(quelle, benutzer)); const ergebnis = await mitAbgleichSperre(sperrSchluessel(quelleId), SPERRE_MINUTEN, () =>
lauf(quelle, benutzer),
);
if (ergebnis === undefined) { if (ergebnis === undefined) {
throw new AbgleichLaeuftBereitsFehler( throw new AbgleichLaeuftBereitsFehler(
`Für „${quelle.name}“ läuft gerade schon ein Abgleich. Bitte das Ergebnis abwarten.`, `Für „${quelle.name}“ läuft gerade schon ein Abgleich. Bitte das Ergebnis abwarten.`,
@@ -224,21 +258,21 @@ async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Pr
let bindPasswort: string | undefined; let bindPasswort: string | undefined;
try { try {
/* const zugang = zugangAus(quelle);
* Das Bind-Passwort nur bei einer LDAP-Quelle — es dient unten dem bindPasswort = zugang.bindPasswort;
* 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 kennungNachDn = new Map<string, string>(); const kennungNachDn = new Map<string, string>();
const offene: OffenerVorgesetzter[] = []; 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; verarbeitet += 1;
// Nur LDAP liefert einen DN; bei Graph bleibt die Tabelle leer, und der kennungNachDn.set(dnSchluessel(eintrag.dn), eintrag.externeId);
// Vorgesetzte kommt ueber `vorgesetzterDirekt`.
if (eintrag.dn) kennungNachDn.set(dnSchluessel(eintrag.dn), eintrag.externeId);
let vorhanden = await prisma.mitarbeiter.findFirst({ let vorhanden = await prisma.mitarbeiter.findFirst({
where: { quelleId, externeId: eintrag.externeId }, where: { quelleId, externeId: eintrag.externeId },
}); });
@@ -290,11 +324,10 @@ async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Pr
}, },
}); });
angelegt += 1; angelegt += 1;
if (eintrag.vorgesetzterDn || eintrag.vorgesetzterDirekt) { if (eintrag.vorgesetzterDn) {
offene.push({ offene.push({
externeId: eintrag.externeId, externeId: eintrag.externeId,
vorgesetzterDn: eintrag.vorgesetzterDn, vorgesetzterDn: eintrag.vorgesetzterDn,
vorgesetzterDirekt: eintrag.vorgesetzterDirekt ?? null,
vorherigeKennung: null, vorherigeKennung: null,
neuAngelegt: true, neuAngelegt: true,
}); });
@@ -304,9 +337,7 @@ async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Pr
// Steht im Verzeichnis kein Vorgesetzter mehr, wird ein alter Wert // Steht im Verzeichnis kein Vorgesetzter mehr, wird ein alter Wert
// geleert. Steht einer, entscheidet der zweite Durchgang. // geleert. Steht einer, entscheidet der zweite Durchgang.
// Kein Vorgesetzter heisst: keiner auf **beiden** Wegen. const vorgesetztenLeeren = eintrag.vorgesetzterDn === null;
const vorgesetztenLeeren =
eintrag.vorgesetzterDn === null && (eintrag.vorgesetzterDirekt ?? null) === null;
if ( if (
// Eine Uebernahme zaehlt in ihrer eigenen Spalte und nicht zusaetzlich // Eine Uebernahme zaehlt in ihrer eigenen Spalte und nicht zusaetzlich
// als Aktualisierung; sonst gingen die Zahlen nicht mehr auf. // als Aktualisierung; sonst gingen die Zahlen nicht mehr auf.
@@ -358,13 +389,13 @@ async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Pr
if (!vorgesetztenLeeren) { if (!vorgesetztenLeeren) {
offene.push({ offene.push({
externeId: eintrag.externeId, externeId: eintrag.externeId,
vorgesetzterDn: eintrag.vorgesetzterDn, vorgesetzterDn: eintrag.vorgesetzterDn as string,
vorgesetzterDirekt: eintrag.vorgesetzterDirekt ?? null,
vorherigeKennung: vorhanden.vorgesetzterExterneId, vorherigeKennung: vorhanden.vorgesetzterExterneId,
neuAngelegt: false, neuAngelegt: false,
}); });
} }
}); },
);
gelesen = bilanz.gesamtGelesen; gelesen = bilanz.gesamtGelesen;
// --- Zweiter Durchgang: Vorgesetzte -------------------------------------- // --- Zweiter Durchgang: Vorgesetzte --------------------------------------
@@ -374,14 +405,7 @@ async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Pr
// jeder Leseweg schoepft (#020). Vorher gab es nur das erste, und eine von // jeder Leseweg schoepft (#020). Vorher gab es nur das erste, und eine von
// Hand angelegte Person konnte keinen Vorgesetzten haben. // Hand angelegte Person konnte keinen Vorgesetzten haben.
for (const offen of offene) { for (const offen of offene) {
/* const kennung = kennungNachDn.get(dnSchluessel(offen.vorgesetzterDn)) ?? null;
* **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);
if (kennung === offen.vorherigeKennung) continue; if (kennung === offen.vorherigeKennung) continue;
const vorgesetzter = const vorgesetzter =
kennung === null kennung === null
@@ -575,22 +599,4 @@ async function abschlussSchreiben(
}, },
}); });
}); });
/*
* Ein fehlgeschlagener Abgleich ist der eine Fall, von dem niemand erfaehrt,
* solange er nicht nachsieht (#034). Eingereiht wird **nach** der Transaktion
* und mit abgefangenem Fehler: der Abgleich selbst ist fertig, und eine
* Benachrichtigung, die nicht eingereiht werden kann, darf sein Ergebnis nicht
* verwerfen.
*/
if (ergebnis.status !== "ERFOLG") {
try {
await abgleichMeldungEinreihen(quelleId, ergebnis.fehler ?? "ohne Angabe");
} catch (fehler) {
console.error(
`Benachrichtigung zum Abgleich der Quelle ${quelleId} konnte nicht eingereiht werden:`,
fehler instanceof Error ? fehler.message : fehler,
);
}
}
} }
+36
View File
@@ -213,6 +213,42 @@ export async function anhangEntfernen(anhangId: string, benutzer: HandelnderBenu
await rm(dateiPfad(anhang), { force: true }); 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. */ /** Nur fuer Tests und Werkzeuge: der Pfad einer Zeile. */
export function anhangPfad(anhang: { export function anhangPfad(anhang: {
readonly id: string; readonly id: string;
-675
View File
@@ -1,675 +0,0 @@
import { createTransport } from "nodemailer";
import type { Prisma } from "@/generated/prisma/client";
import { LaufStatus, Meldeanlass, Zustellweg, Zustellzustand } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
import { abgleichMeldung, laufMeldung, webhookRumpf, type LaufAnlass } from "@/lib/meldungen";
import { SIGNATUR_KOPF, signaturBauen } from "@/lib/signatur";
import { sichtbareKundenIds } from "@/server/mandant";
import { mitSperre } from "@/server/services/sperre";
/**
* Benachrichtigungen per Mail und Webhook (#034).
*
* Vier Anlaesse, nicht mehr: **zugewiesen**, **ueberfaellig**, **abgeschlossen**,
* **Abgleich fehlgeschlagen**. Ein allgemeiner Benachrichtigungsbaukasten ist
* eine Phase fuer sich; diese vier decken ab, was im Betrieb wirklich fehlt.
*
* Drei Entscheidungen tragen das Ganze:
*
* 1. **Eingereiht wird im Anfrage-Pfad, gesendet wird es nicht.** Ein haengender
* Mailserver darf kein Abhaken blockieren. Der Dienst schreibt eine Zeile in
* `Zustellung` — das ist eine Einfuegung in derselben Datenbank, die der
* Vorgang ohnehin anfasst —, und der Zeitplan schickt sie.
* 2. **Betreff und Rumpf stehen fertig in der Zeile.** Eine Meldung sagt, was
* **damals** war. Baute der Zeitplan den Text erst beim Senden, stuende in
* der Mail der Stand von jetzt.
* 3. **In keiner Meldung steht ein Schrittinhalt.** Darueber wacht
* `src/lib/meldungen.ts`; hier kommt nur hinzu, dass auch die Empfaengerwahl
* die Mandantengrenze achtet.
*/
export class MeldungsFehler extends Error {
constructor(meldung: string) {
super(meldung);
this.name = "MeldungsFehler";
}
}
/**
* 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.
*/
export const WARTEZEITEN_MINUTEN = [1, 5, 30, 120] as const;
/** Wie viele Zeilen ein Durchgang hoechstens anfasst. */
const BUENDEL = 50;
/**
* Wie lange die Sperre der Warteschlange gilt.
*
* Kurz, weil ein Durchgang kurz ist — und weil ein Absturz mitten im Senden die
* Warteschlange sonst fuer Stunden stillstellte.
*/
const SPERRE_MINUTEN = 10;
export const WARTESCHLANGE_SCHLUESSEL = "zustellung:warteschlange";
/* ------------------------------------------------------------------ */
/* Einstellungen */
/* ------------------------------------------------------------------ */
export type SmtpAngaben = {
readonly host: string | null;
readonly port: number | null;
readonly tls: boolean;
readonly benutzer: string | null;
readonly absender: string | null;
/**
* Nur, **ob** eines gesetzt ist — nie der Wert.
*
* Dieselbe Form wie beim LDAP-Bind-Passwort: ein Feld, das es nicht in eine
* Antwort schafft, kann auch nicht versehentlich hinausgehen.
*/
readonly passwortGesetzt: boolean;
};
export type WebhookAngaben = {
readonly url: string | null;
readonly geheimnisGesetzt: boolean;
};
export type MeldungsEinstellungen = {
readonly smtp: SmtpAngaben;
readonly webhook: WebhookAngaben;
/** Ohne `APP_URL` traegt keine Meldung einen brauchbaren Link. */
readonly appUrl: string | null;
};
export async function meldungseinstellungenHolen(): Promise<MeldungsEinstellungen> {
const zeile = await prisma.einstellung.findUnique({ where: { id: "global" } });
return {
smtp: {
host: zeile?.smtpHost ?? null,
port: zeile?.smtpPort ?? null,
tls: zeile?.smtpTls ?? true,
benutzer: zeile?.smtpBenutzer ?? null,
absender: zeile?.smtpAbsender ?? null,
passwortGesetzt: Boolean(zeile?.smtpPasswortVerschluesselt),
},
webhook: {
url: zeile?.webhookUrl ?? null,
geheimnisGesetzt: Boolean(zeile?.webhookGeheimnisVerschluesselt),
},
appUrl: process.env["APP_URL"]?.trim() || null,
};
}
export type MeldungsAenderung = {
readonly smtpHost?: string | null;
readonly smtpPort?: number | null;
readonly smtpTls?: boolean;
readonly smtpBenutzer?: string | null;
/** `undefined` laesst es stehen, `null` loescht es, ein Text setzt es neu. */
readonly smtpPasswort?: string | null;
readonly smtpAbsender?: string | null;
readonly webhookUrl?: string | null;
readonly webhookGeheimnis?: string | null;
};
function geputzt(wert: string | null | undefined): string | null | undefined {
if (wert === undefined) return undefined;
if (wert === null) return null;
return wert.trim() || null;
}
export async function meldungseinstellungenSpeichern(
aenderung: MeldungsAenderung,
durchBenutzerId: string,
): Promise<void> {
const host = geputzt(aenderung.smtpHost);
if (host && aenderung.smtpPort === null) {
throw new MeldungsFehler("Zu einem SMTP-Host gehört ein Port.");
}
if (aenderung.smtpPort !== undefined && aenderung.smtpPort !== null) {
if (!Number.isInteger(aenderung.smtpPort) || aenderung.smtpPort < 1 || aenderung.smtpPort > 65535) {
throw new MeldungsFehler("Der SMTP-Port muss zwischen 1 und 65535 liegen.");
}
}
const url = geputzt(aenderung.webhookUrl);
if (url) {
let geprueft: URL;
try {
geprueft = new URL(url);
} catch {
throw new MeldungsFehler("Die Webhook-Adresse ist keine gültige URL.");
}
if (geprueft.protocol !== "https:" && geprueft.protocol !== "http:") {
throw new MeldungsFehler("Die Webhook-Adresse muss mit http:// oder https:// beginnen.");
}
}
const vorher = await meldungseinstellungenHolen();
const passwort = geputzt(aenderung.smtpPasswort);
const geheimnis = geputzt(aenderung.webhookGeheimnis);
const daten = {
...(host !== undefined ? { smtpHost: host } : {}),
...(aenderung.smtpPort !== undefined ? { smtpPort: aenderung.smtpPort } : {}),
...(aenderung.smtpTls !== undefined ? { smtpTls: aenderung.smtpTls } : {}),
...(geputzt(aenderung.smtpBenutzer) !== undefined
? { smtpBenutzer: geputzt(aenderung.smtpBenutzer) }
: {}),
...(geputzt(aenderung.smtpAbsender) !== undefined
? { smtpAbsender: geputzt(aenderung.smtpAbsender) }
: {}),
...(passwort !== undefined
? { smtpPasswortVerschluesselt: passwort === null ? null : verschluesseln(passwort) }
: {}),
...(url !== undefined ? { webhookUrl: url } : {}),
...(geheimnis !== undefined
? { webhookGeheimnisVerschluesselt: geheimnis === null ? null : verschluesseln(geheimnis) }
: {}),
};
if (Object.keys(daten).length === 0) return;
await prisma.einstellung.upsert({
where: { id: "global" },
create: { id: "global", ...daten },
update: daten,
});
/*
* Ins Protokoll gehen **Namen, nicht Werte**. Ein Protokolleintrag mit dem
* SMTP-Passwort waere genau die Stelle, an der die Verschluesselung sinnlos
* wird — und das Protokoll ist ansehbar (#023).
*/
await prisma.protokoll.create({
data: {
entitaet: "Einstellung",
entitaetId: "global",
aktion: "Benachrichtigungen geändert",
benutzerId: durchBenutzerId,
vorher: {
smtpHost: vorher.smtp.host,
smtpPort: vorher.smtp.port,
smtpTls: vorher.smtp.tls,
smtpBenutzer: vorher.smtp.benutzer,
smtpAbsender: vorher.smtp.absender,
smtpPasswortGesetzt: vorher.smtp.passwortGesetzt,
webhookUrl: vorher.webhook.url,
webhookGeheimnisGesetzt: vorher.webhook.geheimnisGesetzt,
},
nachher: { geaenderteFelder: Object.keys(daten) },
},
});
}
/* ------------------------------------------------------------------ */
/* Versand */
/* ------------------------------------------------------------------ */
type SmtpZugang = {
readonly host: string;
readonly port: number;
readonly tls: boolean;
readonly benutzer: string | null;
readonly passwort: string | null;
readonly absender: string;
};
/** Der SMTP-Zugang aus der Datenbank, entschluesselt. `undefined` = nicht eingerichtet. */
async function smtpZugang(): Promise<SmtpZugang | undefined> {
const zeile = await prisma.einstellung.findUnique({ where: { id: "global" } });
if (!zeile?.smtpHost || !zeile.smtpPort) return undefined;
return {
host: zeile.smtpHost,
port: zeile.smtpPort,
tls: zeile.smtpTls,
benutzer: zeile.smtpBenutzer,
passwort: zeile.smtpPasswortVerschluesselt ? entschluesseln(zeile.smtpPasswortVerschluesselt) : null,
// Ohne eigenen Absender der Benutzername, sonst ein neutraler Name am Host.
absender: zeile.smtpAbsender ?? zeile.smtpBenutzer ?? `laufzettel@${zeile.smtpHost}`,
};
}
async function webhookZugang(): Promise<{ url: string; geheimnis: string | null } | undefined> {
const zeile = await prisma.einstellung.findUnique({ where: { id: "global" } });
if (!zeile?.webhookUrl) return undefined;
return {
url: zeile.webhookUrl,
geheimnis: zeile.webhookGeheimnisVerschluesselt
? entschluesseln(zeile.webhookGeheimnisVerschluesselt)
: null,
};
}
/**
* Eine Mail verschicken.
*
* `secure` nur auf 465 — das ist der Port fuer implizites TLS. Auf 587 und 25
* wird mit `requireTLS` auf StartTLS bestanden, sofern TLS verlangt ist. Ein
* `secure: true` auf 587 laeuft in einen Zeitablauf, und die Meldung dazu
* („greeting never received“) sagt nichts ueber die Ursache.
*/
async function mailSenden(zugang: SmtpZugang, an: string, betreff: string, text: string): Promise<void> {
const transport = createTransport({
host: zugang.host,
port: zugang.port,
secure: zugang.port === 465,
requireTLS: zugang.tls && zugang.port !== 465,
ignoreTLS: !zugang.tls,
...(zugang.benutzer ? { auth: { user: zugang.benutzer, pass: zugang.passwort ?? "" } } : {}),
// Ein haengender Mailserver darf den Durchgang nicht aufhalten.
connectionTimeout: 10_000,
greetingTimeout: 10_000,
socketTimeout: 20_000,
});
try {
await transport.sendMail({ from: zugang.absender, to: an, subject: betreff, text });
} finally {
transport.close();
}
}
/** Einen Webhook abschicken – mit Signatur und Zeitstempel. */
async function webhookSenden(url: string, geheimnis: string | null, rumpf: string): Promise<void> {
const sekunden = Math.floor(Date.now() / 1000);
const abbruch = AbortSignal.timeout(15_000);
const antwort = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(geheimnis ? { [SIGNATUR_KOPF]: signaturBauen(geheimnis, rumpf, sekunden) } : {}),
},
body: rumpf,
signal: abbruch,
});
if (!antwort.ok) {
throw new Error(`Die Gegenseite antwortete mit ${antwort.status} ${antwort.statusText}`);
}
}
/**
* Eine Testmail, direkt und ohne Warteschlange.
*
* Mit Warteschlange waere der Knopf „Testmail schicken“ wertlos: er wuerde
* „eingereiht“ melden, und ob es klappt, saehe man eine Minute spaeter
* woanders. Hier soll der Fehler **sofort** und im Klartext erscheinen.
*/
export async function testmailSenden(an: string): Promise<void> {
const zugang = await smtpZugang();
if (!zugang) throw new MeldungsFehler("Es ist kein SMTP-Server eingerichtet.");
const ziel = an.trim();
if (!ziel.includes("@")) throw new MeldungsFehler("Das ist keine Mailadresse.");
try {
await mailSenden(
zugang,
ziel,
"Laufzettel: Testmail",
"Wenn diese Nachricht angekommen ist, kann Laufzettel Mail verschicken.\n\n" +
`Server: ${zugang.host}:${zugang.port}${zugang.tls ? " mit TLS" : " ohne TLS"}\n` +
`Absender: ${zugang.absender}`,
);
} catch (fehler) {
throw new MeldungsFehler(
`Die Testmail ging nicht hinaus: ${fehler instanceof Error ? fehler.message : "unbekannter Fehler"}`,
);
}
}
/* ------------------------------------------------------------------ */
/* Einreihen */
/* ------------------------------------------------------------------ */
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}`;
}
/**
* Die Empfaenger einer Lauf-Meldung.
*
* Wer zugewiesen ist, und bei einem Abschluss zusaetzlich, wer den Lauf angelegt
* hat. **Nie der Handelnde selbst** — eine Mail darueber, was man gerade eben
* geklickt hat, ist der haeufigste Grund, Benachrichtigungen ganz abzuschalten.
*
* Und: **die Mandantengrenze gilt auch hier.** Ein Benutzer, der den Kunden des
* Laufs nicht sehen darf, bekommt keine Mail mit dessen Lauftitel und Geraet.
* Ohne diese Pruefung waere die Benachrichtigung der eine Weg nach draussen, den
* `sichtbareKundenIds` nicht abdeckt.
*/
async function laufEmpfaenger(
lauf: {
readonly id: string;
readonly kundeId: string;
readonly zugewiesenAnId: string | null;
readonly erstelltVonId: string | null;
},
anlass: LaufAnlass,
handelnderId: string | null,
): Promise<{ id: string; email: string; name: string }[]> {
const kennungen = new Set<string>();
if (lauf.zugewiesenAnId) kennungen.add(lauf.zugewiesenAnId);
if (anlass === Meldeanlass.LAUF_ABGESCHLOSSEN && lauf.erstelltVonId) {
kennungen.add(lauf.erstelltVonId);
}
if (handelnderId) kennungen.delete(handelnderId);
if (kennungen.size === 0) return [];
const kandidaten = await prisma.benutzer.findMany({
where: { id: { in: [...kennungen] }, aktiv: true, meldungenAktiv: true },
select: { id: true, email: true, name: true, rolle: true },
});
const erlaubt: { id: string; email: string; name: string }[] = [];
for (const person of kandidaten) {
const sichtbar = await sichtbareKundenIds({ id: person.id, rolle: person.rolle });
if (sichtbar === undefined || sichtbar.includes(lauf.kundeId)) {
erlaubt.push({ id: person.id, email: person.email, name: person.name });
}
}
return erlaubt;
}
/** Eine Zeile je Empfaenger, und eine fuer den Webhook. */
async function einreihen(
anlass: Meldeanlass,
betreff: string,
rumpf: string,
link: string,
bezug: { readonly laufId?: string; readonly verzeichnisquelleId?: string },
empfaenger: readonly { readonly id: string; readonly email: string }[],
): Promise<number> {
// Ausdruecklich typisiert: aus dem Literal allein leitete TypeScript
// `weg: "MAIL"` und `benutzerId: string` ab, und die Webhook-Zeile darunter
// passte dann nicht mehr hinein.
const zeilen: Prisma.ZustellungCreateManyInput[] = empfaenger.map((person) => ({
anlass,
weg: Zustellweg.MAIL,
empfaenger: person.email,
betreff,
rumpf,
benutzerId: person.id,
laufId: bezug.laufId ?? null,
verzeichnisquelleId: bezug.verzeichnisquelleId ?? null,
}));
const webhook = await webhookZugang();
if (webhook) {
zeilen.push({
anlass,
weg: Zustellweg.WEBHOOK,
empfaenger: webhook.url,
betreff,
rumpf: webhookRumpf(anlass, betreff, link, bezug, new Date()),
// Ein Webhook gehoert keiner Person – die Datenbank verlangt das sogar.
benutzerId: null,
laufId: bezug.laufId ?? null,
verzeichnisquelleId: bezug.verzeichnisquelleId ?? null,
});
}
if (zeilen.length === 0) return 0;
/*
* `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;
}
/** Meldung zu einem Lauf einreihen. Gibt die Zahl der erzeugten Zeilen zurueck. */
export async function laufMeldungEinreihen(
anlass: LaufAnlass,
laufId: string,
handelnderId: string | null = null,
): Promise<number> {
const lauf = await prisma.lauf.findUnique({
where: { id: laufId },
select: {
id: true,
kundeId: true,
titel: true,
faelligAm: true,
zugewiesenAnId: true,
erstelltVonId: true,
mitarbeiterName: true,
mitarbeiter: { select: { anzeigename: true } },
geraet: { select: { hostname: true, bezeichnung: true, tagCode: true } },
vorlagenVersion: { select: { vorlage: { select: { name: true } } } },
},
});
if (!lauf) return 0;
const empfaenger = await laufEmpfaenger(lauf, anlass, handelnderId);
const webhook = await webhookZugang();
// Niemand da und kein Webhook: dann auch keine Zeile, die nie ankommt.
if (empfaenger.length === 0 && !webhook) return 0;
const handelnder = handelnderId
? await prisma.benutzer.findUnique({ where: { id: handelnderId }, select: { name: true } })
: null;
const { betreff, rumpf } = laufMeldung(
anlass,
{
titel: lauf.titel,
geraet: lauf.geraet?.hostname ?? lauf.geraet?.bezeichnung ?? lauf.geraet?.tagCode ?? null,
person: lauf.mitarbeiter?.anzeigename ?? lauf.mitarbeiterName,
faelligAm: lauf.faelligAm,
vorlage: lauf.vorlagenVersion.vorlage.name,
handelnder: handelnder?.name ?? null,
},
laufLink(lauf.id),
);
return einreihen(anlass, betreff, rumpf, laufLink(lauf.id), { laufId: lauf.id }, empfaenger);
}
/**
* Meldung zu einem fehlgeschlagenen Abgleich einreihen.
*
* Empfaenger sind **alle aktiven Administratoren**: eine Verzeichnisquelle
* gehoert niemandem persoenlich, und wer sie eingerichtet hat, ist vielleicht
* nicht mehr im Haus.
*/
export async function abgleichMeldungEinreihen(quelleId: string, grund: string): Promise<number> {
const quelle = await prisma.verzeichnisquelle.findUnique({
where: { id: quelleId },
select: { id: true, name: true },
});
if (!quelle) return 0;
const admins = await prisma.benutzer.findMany({
where: { rolle: "ADMIN", aktiv: true, meldungenAktiv: true },
select: { id: true, email: true },
});
const webhook = await webhookZugang();
if (admins.length === 0 && !webhook) return 0;
const link = `${appUrl()}${MELDUNGS_PFADE.verzeichnis}`;
const { betreff, rumpf } = abgleichMeldung(quelle.name, grund, link);
return einreihen(
Meldeanlass.ABGLEICH_FEHLGESCHLAGEN,
betreff,
rumpf,
link,
{ verzeichnisquelleId: quelle.id },
admins,
);
}
/**
* Ueberfaellige Laeufe einreihen – fuer den Zeitplan.
*
* Faellig **und** nicht vom Tisch. Der eindeutige Index sorgt dafuer, dass das
* je Lauf und Empfaenger genau einmal passiert, auch wenn der Zeitplan jede
* Minute nachsieht.
*/
export async function ueberfaelligeEinreihen(jetzt = new Date()): Promise<number> {
const laeufe = await prisma.lauf.findMany({
where: {
faelligAm: { lt: jetzt },
status: { in: [LaufStatus.OFFEN, LaufStatus.IN_ARBEIT] },
zugewiesenAnId: { not: null },
},
select: { id: true },
});
let erzeugt = 0;
for (const lauf of laeufe) {
erzeugt += await laufMeldungEinreihen(Meldeanlass.LAUF_UEBERFAELLIG, lauf.id);
}
return erzeugt;
}
/* ------------------------------------------------------------------ */
/* Abarbeiten */
/* ------------------------------------------------------------------ */
export type DurchgangErgebnis = {
readonly gesendet: number;
readonly fehlgeschlagen: number;
readonly aufgegeben: number;
};
/**
* Was nach einem Fehlschlag mit der Zeile passiert.
*
* `versucheGesamt` zaehlt **einschliesslich** des gerade gescheiterten: nach dem
* ersten Fehlschlag wird eine Minute gewartet, nach dem zweiten fuenf, dann
* dreissig, dann zwei Stunden — und nach dem fuenften ist Schluss. Vier
* Wartezeiten ergeben also fuenf Versuche.
*
* Eine reine Funktion, damit sich die Wiederholungsregel pruefen laesst, ohne
* einen Mailserver zu brauchen oder zwei Stunden zu warten.
*/
export function naechsterVersuch(
versucheGesamt: number,
jetzt: Date,
): { readonly zustand: Zustellzustand; readonly naechsterVersuchAm: Date } {
const wartezeit = WARTEZEITEN_MINUTEN[versucheGesamt - 1];
if (wartezeit === undefined) {
/*
* Aufgegeben — aber **sichtbar**. Eine stille Warteschlange ist schlimmer
* als keine: niemand wuerde merken, dass seit Wochen keine Mail mehr
* hinausgeht. Der Zeitpunkt bleibt stehen, damit die Liste die Reihenfolge
* behaelt.
*/
return { zustand: Zustellzustand.AUFGEGEBEN, naechsterVersuchAm: jetzt };
}
return {
zustand: Zustellzustand.FEHLGESCHLAGEN,
naechsterVersuchAm: new Date(jetzt.getTime() + wartezeit * 60_000),
};
}
/**
* Einen Durchgang der Warteschlange fahren.
*
* `undefined`, wenn ein anderer Durchgang laeuft — dieselbe Sperre wie beim
* Abgleich, in der Datenbank und damit auch ueber zwei Instanzen hinweg. Ohne
* sie bekaeme der Empfaenger bei zwei Instanzen jede Mail zweimal.
*/
export async function warteschlangeAbarbeiten(jetzt = new Date()): Promise<DurchgangErgebnis | undefined> {
return mitSperre(WARTESCHLANGE_SCHLUESSEL, SPERRE_MINUTEN, async () => {
const offene = await prisma.zustellung.findMany({
where: {
zustand: { in: [Zustellzustand.OFFEN, Zustellzustand.FEHLGESCHLAGEN] },
naechsterVersuchAm: { lte: jetzt },
},
orderBy: { naechsterVersuchAm: "asc" },
take: BUENDEL,
});
let gesendet = 0;
let fehlgeschlagen = 0;
let aufgegeben = 0;
// Die Zugaenge einmal je Durchgang, nicht je Zeile.
const smtp = await smtpZugang();
const webhook = await webhookZugang();
for (const zeile of offene) {
try {
if (zeile.weg === Zustellweg.MAIL) {
if (!smtp) throw new Error("Es ist kein SMTP-Server eingerichtet.");
await mailSenden(smtp, zeile.empfaenger, zeile.betreff, zeile.rumpf);
} else {
if (!webhook) throw new Error("Es ist kein Webhook eingerichtet.");
await webhookSenden(webhook.url, webhook.geheimnis, zeile.rumpf);
}
await prisma.zustellung.update({
where: { id: zeile.id },
data: {
zustand: Zustellzustand.GESENDET,
gesendetAm: new Date(),
versuche: zeile.versuche + 1,
letzterFehler: null,
},
});
gesendet += 1;
} catch (fehler) {
const naechster = naechsterVersuch(zeile.versuche + 1, jetzt);
await prisma.zustellung.update({
where: { id: zeile.id },
data: {
zustand: naechster.zustand,
naechsterVersuchAm: naechster.naechsterVersuchAm,
versuche: zeile.versuche + 1,
// Gekappt: eine TLS-Fehlermeldung kann Kilobyte lang sein.
letzterFehler: (fehler instanceof Error ? fehler.message : "unbekannter Fehler").slice(0, 500),
},
});
if (naechster.zustand === Zustellzustand.AUFGEGEBEN) aufgegeben += 1;
else fehlgeschlagen += 1;
}
}
return { gesendet, fehlgeschlagen, aufgegeben };
});
}
+9 -66
View File
@@ -1,9 +1,7 @@
import { Prisma, type Lauf } from "@/generated/prisma/client"; import { Prisma, type Lauf } from "@/generated/prisma/client";
import { LaufStatus, Meldeanlass, SchrittZustand, VariablenTyp } from "@/generated/prisma/enums"; import { LaufStatus, SchrittZustand, VariablenTyp } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { geltendeAbschnitteErmitteln, nurGeltende } from "@/lib/geltende-abschnitte"; import { geltendeAbschnitteErmitteln, nurGeltende } from "@/lib/geltende-abschnitte";
import { laufMeldungEinreihen } from "@/server/services/benachrichtigungen";
import type { LaufAnlass } from "@/lib/meldungen";
import { darf } from "@/server/auth/rollen"; import { darf } from "@/server/auth/rollen";
import { import {
MandantFehler, MandantFehler,
@@ -457,30 +455,6 @@ function variablenPruefen(
* Techniker, die gleichzeitig denselben Schritt anfassen, koennen sich so nicht * Techniker, die gleichzeitig denselben Schritt anfassen, koennen sich so nicht
* in den eindeutigen Index `(laufId, vorlagenSchrittId)` laufen. * in den eindeutigen Index `(laufId, vorlagenSchrittId)` laufen.
*/ */
/**
* Eine Meldung einreihen, ohne den Vorgang daran haengen zu lassen (#034).
*
* **Nach** der Transaktion und mit abgefangenem Fehler. Zwei Gruende: der Dienst
* liest den Lauf selbst, und innerhalb der Transaktion saehe er die noch nicht
* festgeschriebene Zeile nicht. Und ein Abhaken darf nicht daran scheitern, dass
* eine Benachrichtigung nicht eingereiht werden konnte — gesendet wird sie
* ohnehin erst spaeter vom Zeitplan.
*/
async function meldenOhneBruch(
anlass: LaufAnlass,
laufId: string,
handelnderId: string | null,
): Promise<void> {
try {
await laufMeldungEinreihen(anlass, laufId, handelnderId);
} catch (fehler) {
console.error(
`Benachrichtigung ${anlass} fuer Lauf ${laufId} konnte nicht eingereiht werden:`,
fehler instanceof Error ? fehler.message : fehler,
);
}
}
export async function laufAnlegen(eingabe: NeuerLauf, benutzer: HandelnderBenutzer): Promise<Lauf> { export async function laufAnlegen(eingabe: NeuerLauf, benutzer: HandelnderBenutzer): Promise<Lauf> {
const kundeId = await kundeErmitteln(eingabe.kundeId, benutzer); const kundeId = await kundeErmitteln(eingabe.kundeId, benutzer);
const version = await versionFuerLaufHolen(eingabe.vorlagenVersionId, kundeId); const version = await versionFuerLaufHolen(eingabe.vorlagenVersionId, kundeId);
@@ -520,7 +494,7 @@ export async function laufAnlegen(eingabe: NeuerLauf, benutzer: HandelnderBenutz
.flatMap((abschnitt) => abschnitt.schrittIds), .flatMap((abschnitt) => abschnitt.schrittIds),
); );
const angelegt = await prisma.$transaction(async (tx) => { return prisma.$transaction(async (tx) => {
const lauf = await tx.lauf.create({ const lauf = await tx.lauf.create({
data: { data: {
vorlagenVersionId: version.id, vorlagenVersionId: version.id,
@@ -566,14 +540,7 @@ export async function laufAnlegen(eingabe: NeuerLauf, benutzer: HandelnderBenutz
vorlage: version.vorlagenName, vorlage: version.vorlagenName,
vorlagenVersionId: version.id, vorlagenVersionId: version.id,
status: LaufStatus.OFFEN, status: LaufStatus.OFFEN,
/* anzahlSchritte: version.schrittIds.length,
* 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,
zugewiesenAnId, zugewiesenAnId,
}, },
}, },
@@ -581,12 +548,6 @@ export async function laufAnlegen(eingabe: NeuerLauf, benutzer: HandelnderBenutz
return lauf; return lauf;
}); });
// Zugewiesen heisst: jemand soll es erfahren.
if (zugewiesenAnId) {
await meldenOhneBruch(Meldeanlass.LAUF_ZUGEWIESEN, angelegt.id, benutzer.id);
}
return angelegt;
} }
/** /**
@@ -805,9 +766,9 @@ export async function abschlussPruefenNachAenderung(
* Protokolleintrag. Ein Doppelklick soll keine Fehlermeldung ergeben. * Protokolleintrag. Ein Doppelklick soll keine Fehlermeldung ergeben.
*/ */
export async function laufAbschliessen(laufId: string, benutzer: HandelnderBenutzer): Promise<Lauf> { export async function laufAbschliessen(laufId: string, benutzer: HandelnderBenutzer): Promise<Lauf> {
const abgeschlossen = await prisma.$transaction(async (tx) => { return prisma.$transaction(async (tx) => {
const lauf = await laufZumSchreibenHolen(tx, laufId, benutzer); const lauf = await laufZumSchreibenHolen(tx, laufId, benutzer);
if (lauf.status === LaufStatus.ABGESCHLOSSEN) return { lauf, warSchonFertig: true }; if (lauf.status === LaufStatus.ABGESCHLOSSEN) return lauf;
const offen = await offenePflichtschritte(tx, laufId); const offen = await offenePflichtschritte(tx, laufId);
if (offen.length > 0) throw new LaufFehler(offeneMeldung(offen)); if (offen.length > 0) throw new LaufFehler(offeneMeldung(offen));
@@ -837,17 +798,8 @@ export async function laufAbschliessen(laufId: string, benutzer: HandelnderBenut
nachher: { status: LaufStatus.ABGESCHLOSSEN, abgeschlossenAm: jetzt.toISOString() }, nachher: { status: LaufStatus.ABGESCHLOSSEN, abgeschlossenAm: jetzt.toISOString() },
}, },
}); });
return { lauf: nachher, warSchonFertig: false }; return nachher;
}); });
/*
* Nur bei einem echten Abschluss melden. Ein zweiter Aufruf gibt den Lauf
* unveraendert zurueck — eine zweite Mail darueber waere falsch.
*/
if (!abgeschlossen.warSchonFertig) {
await meldenOhneBruch(Meldeanlass.LAUF_ABGESCHLOSSEN, laufId, benutzer.id);
}
return abgeschlossen.lauf;
} }
/** /**
@@ -972,7 +924,7 @@ export async function laufKopfdatenAendern(
aenderung: LaufKopfdaten, aenderung: LaufKopfdaten,
benutzer: HandelnderBenutzer, benutzer: HandelnderBenutzer,
): Promise<Lauf> { ): Promise<Lauf> {
const geaendertesErgebnis = await prisma.$transaction(async (tx) => { return prisma.$transaction(async (tx) => {
const vorher = await laufZumSchreibenHolen(tx, laufId, benutzer); const vorher = await laufZumSchreibenHolen(tx, laufId, benutzer);
if ( if (
@@ -1066,7 +1018,7 @@ export async function laufKopfdatenAendern(
} }
} }
if (geaendert.length === 0) return { lauf: vorher, neuZugewiesenAn: null }; if (geaendert.length === 0) return vorher;
const nachher = await tx.lauf.update({ where: { id: laufId }, data: daten }); const nachher = await tx.lauf.update({ where: { id: laufId }, data: daten });
await tx.protokoll.create({ await tx.protokoll.create({
@@ -1082,17 +1034,8 @@ export async function laufKopfdatenAendern(
nachher: kopfAuszug(nachher, geaendert), nachher: kopfAuszug(nachher, geaendert),
}, },
}); });
return { return nachher;
lauf: nachher,
// Nur eine **neue** Zuweisung an eine Person meldet; ein Entzug nicht.
neuZugewiesenAn: geaendert.includes("Zuweisung") ? nachher.zugewiesenAnId : null,
};
}); });
if (geaendertesErgebnis.neuZugewiesenAn) {
await meldenOhneBruch(Meldeanlass.LAUF_ZUGEWIESEN, laufId, benutzer.id);
}
return geaendertesErgebnis.lauf;
} }
/** /**
-478
View File
@@ -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);
}
-66
View File
@@ -1,66 +0,0 @@
import { hostname } from "node:os";
import { randomUUID } from "node:crypto";
import { prisma } from "@/lib/db";
/**
* Eine Sperre in der Datenbank, ueber Prozesse und Instanzen hinweg (#016, #034).
*
* Erst war sie Teil des Abgleichs; mit der Zustell-Warteschlange braucht sie ein
* zweiter Aufrufer, und zwei Fassungen derselben Mechanik waere die schlechteste
* aller Loesungen. Die Tabelle heisst weiter `AbgleichSperre` — ein Umbenennen
* waere eine Migration, die nichts verbessert, und der Schluessel sagt ohnehin,
* wer gerade sperrt (`abgleich:<quelleId>`, `zustellung:warteschlange`).
*
* **In der Datenbank und nicht im Prozess**, weil hinter einem Lastverteiler
* zwei Instanzen laufen koennen. Ein `let laeuft = false` im Modul wuesste nichts
* von der zweiten.
*/
/**
* Wer die Sperre haelt: Rechner, Prozess und ein Zufallsanteil.
*
* Der Zufallsanteil ist nicht Zierde. Ohne ihn koennten zwei Durchgaenge im
* **selben** Prozess (ein Klick und der Zeitplan im selben Moment) einander die
* Sperre wegnehmen und der zweite die des ersten beim Aufraeumen loeschen.
*/
function haltername(): string {
return `${hostname()}/${process.pid}/${randomUUID().slice(0, 8)}`;
}
/**
* Fuehrt `was` unter der Sperre aus; `undefined`, wenn sie belegt ist.
*
* Das Belegen ist **eine** Anweisung, und das ist der Punkt: `SELECT` und danach
* `INSERT` waeren zwei, und zwischen ihnen passt genau der zweite Durchgang, den
* die Sperre verhindern soll. Das `WHERE` am `ON CONFLICT` uebernimmt eine
* abgelaufene Sperre und laesst eine lebende in Ruhe, sodass die Zeile nie
* dauerhaft blockiert.
*/
export async function mitSperre<T>(
schluessel: string,
dauerMinuten: number,
was: () => Promise<T>,
): Promise<T | undefined> {
const halter = haltername();
const bekommen = await prisma.$executeRaw`
INSERT INTO "AbgleichSperre" ("schluessel", "gehaltenVon", "gehaltenSeit", "laeuftAbAm")
VALUES (${schluessel}, ${halter}, now(), now() + make_interval(mins => ${dauerMinuten}))
ON CONFLICT ("schluessel") DO UPDATE
SET "gehaltenVon" = EXCLUDED."gehaltenVon",
"gehaltenSeit" = EXCLUDED."gehaltenSeit",
"laeuftAbAm" = EXCLUDED."laeuftAbAm"
WHERE "AbgleichSperre"."laeuftAbAm" <= now()
`;
if (bekommen === 0) return undefined;
try {
return await was();
} finally {
// Nur die eigene Sperre freigeben. Ist sie zwischenzeitlich abgelaufen und
// von einem anderen Lauf uebernommen worden, gehoert die Zeile diesem — sie
// hier zu loeschen hiesse, ihm die Sperre unter den Fuessen wegzuziehen.
await prisma.$executeRaw`
DELETE FROM "AbgleichSperre" WHERE "schluessel" = ${schluessel} AND "gehaltenVon" = ${halter}
`;
}
}
+51 -277
View File
@@ -1,28 +1,17 @@
import type { Prisma, Verzeichnisquelle } from "@/generated/prisma/client"; 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 { prisma } from "@/lib/db";
import { verschluesseln } from "@/lib/geheimnisse"; import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
import { darf } from "@/server/auth/rollen"; import { darf } from "@/server/auth/rollen";
import { betriebsmodus, eigeneOrganisation, kundeErmitteln, type HandelnderBenutzer } from "@/server/mandant"; import { betriebsmodus, eigeneOrganisation, kundeErmitteln, type HandelnderBenutzer } from "@/server/mandant";
import { VerzeichnisFehler } from "@/server/ldap/fehler"; import { VerzeichnisFehler } from "@/server/ldap/fehler";
// Die Zugangs-Helfer liegen seit #028 in einem eigenen Modul – sonst waere die import { INTERVALL_HOECHSTENS, INTERVALL_MINDESTENS, intervallMeldung } from "@/lib/verzeichnis";
// Weiche in `quellenleser.ts` ein Import-Zyklus. Hier weitergereicht, damit import { eintraegeLesen } from "@/server/ldap/leser";
// bestehende Aufrufer ihre Anlaufstelle behalten. import { type LdapZugang, urlPruefen } from "@/server/ldap/verbindung";
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 { import {
AD_ZUORDNUNG, AD_ZUORDNUNG,
type AttributZuordnung, type AttributZuordnung,
type GemappterEintrag, type GemappterEintrag,
type RoherEintrag,
STANDARD_FILTER, STANDARD_FILTER,
type Uebersprungen, type Uebersprungen,
vorgesetzteAufloesen, vorgesetzteAufloesen,
@@ -59,28 +48,16 @@ export { AD_ZUORDNUNG, STANDARD_FILTER };
* Aufrufer sie von hier kennt. * Aufrufer sie von hier kennt.
*/ */
export { VerzeichnisFehler }; export { VerzeichnisFehler };
export { ldapVorgabeAus, zugangAus, zuordnungsArt };
/** Ohne Passwort – diese Form geht an die Oberflaeche. */ /** Ohne Passwort – diese Form geht an die Oberflaeche. */
export type QuellenSicht = { export type QuellenSicht = {
readonly id: string; readonly id: string;
readonly name: string; readonly name: string;
readonly aktiv: boolean; readonly aktiv: boolean;
/** Woher gelesen wird (#028). Bis dahin war jede Quelle LDAP. */ readonly url: string;
readonly art: QuellenArt; readonly bindDn: string;
/** Nur bei `art = LDAP` belegt. */ readonly basisDn: string;
readonly url: string | null; readonly filter: string;
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 attributZuordnung: AttributZuordnung; readonly attributZuordnung: AttributZuordnung;
readonly caZertifikatVorhanden: boolean; readonly caZertifikatVorhanden: boolean;
readonly abgleichIntervallMinuten: number; readonly abgleichIntervallMinuten: number;
@@ -91,18 +68,11 @@ export type QuellenSicht = {
export type NeueQuelle = { export type NeueQuelle = {
name: string; name: string;
/** Ohne Angabe `LDAP` – so verhalten sich Aufrufer von vor #028 unveraendert. */ url: string;
art?: QuellenArt; bindDn: string;
/** Pflicht bei `art = LDAP`, bei Graph nicht erlaubt. */ bindPasswort: string;
url?: string; basisDn: string;
bindDn?: string;
bindPasswort?: string;
basisDn?: string;
filter?: string; filter?: string;
/** Pflicht bei `art = GRAPH`. */
tenantId?: string;
clientId?: string;
clientSecret?: string;
attributZuordnung?: Partial<AttributZuordnung>; attributZuordnung?: Partial<AttributZuordnung>;
caZertifikatPem?: string | null; caZertifikatPem?: string | null;
abgleichIntervallMinuten?: number; abgleichIntervallMinuten?: number;
@@ -180,28 +150,11 @@ function alsSicht(zeile: Verzeichnisquelle): QuellenSicht {
id: zeile.id, id: zeile.id,
name: zeile.name, name: zeile.name,
aktiv: zeile.aktiv, aktiv: zeile.aktiv,
art: zeile.art,
url: zeile.url, url: zeile.url,
bindDn: zeile.bindDn, bindDn: zeile.bindDn,
basisDn: zeile.basisDn, basisDn: zeile.basisDn,
filter: zeile.filter, filter: zeile.filter,
tenantId: zeile.tenantId, attributZuordnung: zuordnungLesen(zeile.attributZuordnung),
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)),
// Der Inhalt bleibt hier: die Oberflaeche muss nur wissen, ob eines // Der Inhalt bleibt hier: die Oberflaeche muss nur wissen, ob eines
// hinterlegt ist, und braucht dafuer keinen Zertifikatstext. // hinterlegt ist, und braucht dafuer keinen Zertifikatstext.
caZertifikatVorhanden: Boolean(zeile.caZertifikatPem?.trim()), 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. */ /** Auch fuer die Lesemodelle: die eine Stelle, die aus einer Zeile eine Sicht macht. */
export { alsSicht as quellenSichtBauen }; 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( export async function quelleAnlegen(
eingabe: NeueQuelle, eingabe: NeueQuelle,
benutzer: HandelnderBenutzer, benutzer: HandelnderBenutzer,
@@ -307,40 +177,31 @@ export async function quelleAnlegen(
const kundeId = await kundeErmitteln(null, benutzer); const kundeId = await kundeErmitteln(null, benutzer);
const name = pflichtText(eingabe.name, "Der Name der Quelle"); 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); 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 zeile = await prisma.$transaction(async (tx) => {
const angelegt = await tx.verzeichnisquelle.create({ const angelegt = await tx.verzeichnisquelle.create({
data: { data: {
kundeId, kundeId,
name, name,
art, // Gespeichert wird die getippte Adresse samt Schema. `ldap+starttls://`
...felder, // ist Teil der Angabe und wird erst beim Verbinden abgetrennt — sonst
attributZuordnung: zuordnungLesen( // waere nach dem Speichern nicht mehr zu erkennen, dass StartTLS
{ ...ZUORDNUNGS_VORGABE[zuordnungsArt(art)], ...eingabe.attributZuordnung }, // gewuenscht war.
zuordnungsArt(art), url,
), bindDn,
bindPasswortVerschluesselt: verschluesseln(bindPasswort),
basisDn,
filter: eingabe.filter?.trim() || STANDARD_FILTER,
attributZuordnung: zuordnungLesen({ ...AD_ZUORDNUNG, ...eingabe.attributZuordnung }),
caZertifikatPem: zertifikatPruefen(eingabe.caZertifikatPem), caZertifikatPem: zertifikatPruefen(eingabe.caZertifikatPem),
// Ohne Angabe bleibt der Standardwert des Schemas (60 Minuten) stehen. // Ohne Angabe bleibt der Standardwert des Schemas (60 Minuten) stehen.
...(intervall === undefined ? {} : { abgleichIntervallMinuten: intervall }), ...(intervall === undefined ? {} : { abgleichIntervallMinuten: intervall }),
@@ -354,20 +215,8 @@ export async function quelleAnlegen(
entitaetId: angelegt.id, entitaetId: angelegt.id,
aktion: "Verzeichnisquelle angelegt", aktion: "Verzeichnisquelle angelegt",
benutzerId: benutzer.id, benutzerId: benutzer.id,
/* // Kein Passwort, auch nicht verschluesselt: das Protokoll wird gelesen.
* Kein Passwort und kein Client-Secret, auch nicht verschluesselt: das nachher: { name: angelegt.name, url: angelegt.url, basisDn: angelegt.basisDn },
* 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 } : {}),
},
}, },
}); });
return angelegt; return angelegt;
@@ -387,35 +236,6 @@ export async function quelleBearbeiten(
const daten: Prisma.VerzeichnisquelleUpdateInput = {}; const daten: Prisma.VerzeichnisquelleUpdateInput = {};
const geaendert: string[] = []; 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) { if (aenderung.name !== undefined) {
const name = pflichtText(aenderung.name, "Der Name der Quelle"); const name = pflichtText(aenderung.name, "Der Name der Quelle");
if (name !== vorher.name) { if (name !== vorher.name) {
@@ -423,40 +243,7 @@ export async function quelleBearbeiten(
geaendert.push("Name"); 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) { if (aenderung.url !== undefined) {
nurBei("LDAP", "Adresse");
const url = pflichtText(aenderung.url, "Die Adresse des Verzeichnisses"); const url = pflichtText(aenderung.url, "Die Adresse des Verzeichnisses");
urlPruefen(url); urlPruefen(url);
if (url !== vorher.url) { if (url !== vorher.url) {
@@ -465,7 +252,6 @@ export async function quelleBearbeiten(
} }
} }
if (aenderung.bindDn !== undefined) { if (aenderung.bindDn !== undefined) {
nurBei("LDAP", "Bind-DN");
const bindDn = pflichtText(aenderung.bindDn, "Der Bind-DN"); const bindDn = pflichtText(aenderung.bindDn, "Der Bind-DN");
if (bindDn !== vorher.bindDn) { if (bindDn !== vorher.bindDn) {
daten.bindDn = bindDn; daten.bindDn = bindDn;
@@ -473,7 +259,6 @@ export async function quelleBearbeiten(
} }
} }
if (aenderung.bindPasswort !== undefined) { if (aenderung.bindPasswort !== undefined) {
nurBei("LDAP", "Bind-Passwort");
// Ein leeres Feld heisst „unveraendert“. Ein Formular, das das Passwort nicht // Ein leeres Feld heisst „unveraendert“. Ein Formular, das das Passwort nicht
// anzeigen kann (und das darf es nicht), schickt sonst bei jedem Speichern // anzeigen kann (und das darf es nicht), schickt sonst bei jedem Speichern
// eine leere Zeichenkette und loeschte das Geheimnis. // eine leere Zeichenkette und loeschte das Geheimnis.
@@ -484,7 +269,6 @@ export async function quelleBearbeiten(
} }
} }
if (aenderung.basisDn !== undefined) { if (aenderung.basisDn !== undefined) {
nurBei("LDAP", "Basis-DN");
const basisDn = pflichtText(aenderung.basisDn, "Der Basis-DN"); const basisDn = pflichtText(aenderung.basisDn, "Der Basis-DN");
if (basisDn !== vorher.basisDn) { if (basisDn !== vorher.basisDn) {
daten.basisDn = basisDn; daten.basisDn = basisDn;
@@ -492,7 +276,6 @@ export async function quelleBearbeiten(
} }
} }
if (aenderung.filter !== undefined) { if (aenderung.filter !== undefined) {
nurBei("LDAP", "Filter");
const filter = aenderung.filter.trim() || STANDARD_FILTER; const filter = aenderung.filter.trim() || STANDARD_FILTER;
if (filter !== vorher.filter) { if (filter !== vorher.filter) {
daten.filter = filter; daten.filter = filter;
@@ -500,8 +283,6 @@ export async function quelleBearbeiten(
} }
} }
if (aenderung.attributZuordnung !== undefined) { if (aenderung.attributZuordnung !== undefined) {
const mangel = zuordnungPruefen(aenderung.attributZuordnung);
if (mangel) throw new VerzeichnisFehler(mangel);
const zuordnung = zuordnungLesen({ const zuordnung = zuordnungLesen({
...zuordnungLesen(vorher.attributZuordnung), ...zuordnungLesen(vorher.attributZuordnung),
...aenderung.attributZuordnung, ...aenderung.attributZuordnung,
@@ -617,32 +398,15 @@ export async function verbindungTesten(
await nurIntern(); await nurIntern();
rechtPruefen(benutzer); rechtPruefen(benutzer);
const quelle = await quelleZeileHolen(quelleId); const quelle = await quelleZeileHolen(quelleId);
/*
* **Bei SCIM gibt es nichts zu testen, und das ist keine Lücke** (#029):
* Laufzettel baut keine Verbindung auf, sondern nimmt eine an. Ob sie steht,
* zeigt die erste Zustellung — deshalb steht in der Oberfläche „Letzte
* Zustellung“ dort, wo bei LDAP der Verbindungstest sitzt.
*/
if (quelle.art === "SCIM") {
throw new VerzeichnisFehler(
`„${quelle.name}“ ist eine SCIM-Quelle: es gibt keine Verbindung, die sich von hier aus ` +
"testen ließe. Trag das Token beim Anbieter ein und lass ihn eine Zuweisung schicken — " +
"danach steht hier, wann die letzte angekommen ist.",
);
}
const wieViele = Math.min(Math.max(Math.trunc(grenze), 1), PROBE_GRENZE_HOECHSTENS); const wieViele = Math.min(Math.max(Math.trunc(grenze), 1), PROBE_GRENZE_HOECHSTENS);
/* const { eintraege, gesamtGelesen, uebersprungen } = await eintraegeLesen(
* **Dieselbe Weiche wie der Abgleich** (#028). Eine eigene Verzweigung hier zugangAus(quelle),
* waere eine zweite Stelle, die sich an eine neue Quellenart erinnern muss — {
* und die vergisst man. basisDn: quelle.basisDn,
*/ filter: quelle.filter,
const eintraege: RoherEintrag[] = []; zuordnung: zuordnungLesen(quelle.attributZuordnung),
const { gesamtGelesen, uebersprungen } = await eintraegeDerQuelleStroemen( },
quelle,
(eintrag) => void eintraege.push(eintrag),
wieViele, wieViele,
); );
return { treffer: vorgesetzteAufloesen(eintraege), gesamtGelesen, uebersprungen }; return { treffer: vorgesetzteAufloesen(eintraege), gesamtGelesen, uebersprungen };
@@ -669,3 +433,13 @@ export async function quelleZeileHolen(id: string): Promise<Verzeichnisquelle> {
} }
return zeile; 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,
};
}
+7 -54
View File
@@ -42,22 +42,7 @@ export async function vorlageExportieren(versionId: string): Promise<Austausch>
variablen: { orderBy: [{ position: "asc" }, { id: "asc" }] }, variablen: { orderBy: [{ position: "asc" }, { id: "asc" }] },
abschnitte: { abschnitte: {
orderBy: [{ position: "asc" }, { id: "asc" }], orderBy: [{ position: "asc" }, { id: "asc" }],
include: { include: { schritte: { orderBy: [{ position: "asc" }, { id: "asc" }] } },
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" }] },
},
},
},
}, },
}, },
}); });
@@ -83,37 +68,13 @@ export async function vorlageExportieren(versionId: string): Promise<Austausch>
optionen: [...variable.optionen], optionen: [...variable.optionen],
pflicht: variable.pflicht, pflicht: variable.pflicht,
})), })),
abschnitte: version.abschnitte.map((abschnitt) => { abschnitte: version.abschnitte.map((abschnitt) => ({
/* titel: abschnitt.titel,
* **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,
// Wandert mit: ein Abteilungsname bedeutet in einer anderen Installation // Wandert mit: ein Abteilungsname bedeutet in einer anderen Installation
// dasselbe. // dasselbe. Der Baustein-Verweis wandert bewusst **nicht** mit (#030).
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen], 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 * nachsichtige Schema soll. Exportiert wird deshalb der rohe Inhalt; nur was gar
* kein Objekt ist, gilt als leer. * kein Objekt ist, gilt als leer.
*/ */
function schrittAbbilden( function schrittAbbilden(schritt: VorlagenSchritt): AustauschSchritt {
/*
* 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 {
const roh = schritt.konfig; const roh = schritt.konfig;
const konfig = const konfig =
typeof roh === "object" && roh !== null && !Array.isArray(roh) ? (roh as AustauschSchritt["konfig"]) : {}; typeof roh === "object" && roh !== null && !Array.isArray(roh) ? (roh as AustauschSchritt["konfig"]) : {};
-69
View File
@@ -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.",
);
}
-74
View File
@@ -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";
}
-83
View File
@@ -1,83 +0,0 @@
import cron, { type ScheduledTask } from "node-cron";
import { ueberfaelligeEinreihen, warteschlangeAbarbeiten } from "@/server/services/benachrichtigungen";
/**
* Der Zeitplan der Benachrichtigungen (#034).
*
* **Im selben Prozess** wie die Anwendung und wie der Abgleich-Zeitplan — kein
* zweiter Dienst, aus demselben Grund wie dort: beide Betriebswege dieses
* Projekts starten genau einen Prozess, und eine zweite Einheit waere eine
* zweite Stelle, an der jemand `APP_SECRET` und `DATABASE_URL` hinterlegen muss.
*
* Ein eigener Zeitplan und nicht derselbe wie der Abgleich: der eine darf nicht
* warten, weil der andere gerade ein grosses Verzeichnis liest. Zwei Takte sind
* billig, ein verzoegerter Mailversand nicht.
*
* Drei Dinge halten das zusammen:
*
* 1. **Die Sperre setzt der Dienst, nicht der Zeitplan.**
* `warteschlangeAbarbeiten` nimmt sie selbst — in der Datenbank und damit
* auch ueber zwei Instanzen hinweg. Eine zweite Sperre hier waere schlimmer
* als keine: sie truege einen anderen Schluessel und liefe an der ersten
* vorbei.
* 2. **Ein Fehler beendet den Zeitplan nicht.** Ein Mailserver, der gerade nicht
* antwortet, ist der Normalfall im Betrieb; was passiert ist, steht an der
* Zustellung.
* 3. **Ueberfaelligkeit wird jede Minute geprueft, aber je Lauf nur einmal
* gemeldet.** Dafuer sorgt der partielle eindeutige Index
* `Zustellung_ueberfaellig_einmal` — ohne ihn bekaeme der Zustaendige fuer
* einen Lauf, der ueber das Wochenende steht, 2880 Mails.
*/
/** Jede Minute. Was wirklich faellig ist, entscheidet die Datenbank. */
const TAKT = "* * * * *";
let plan: ScheduledTask | undefined;
/**
* Ein Durchgang: ueberfaellige einreihen, dann die Warteschlange abarbeiten.
*
* In dieser Reihenfolge, damit eine gerade erkannte Ueberfaelligkeit nicht erst
* eine Minute spaeter hinausgeht. Exportiert, damit ein Test den Durchgang
* aufrufen kann, ohne eine Minute zu warten.
*/
export async function meldungsDurchgang(): Promise<void> {
try {
await ueberfaelligeEinreihen();
} catch (fehler) {
console.error(
"Überfällige Läufe konnten nicht eingereiht werden:",
fehler instanceof Error ? fehler.message : fehler,
);
}
try {
/*
* `undefined` heisst: eine andere Instanz ist dran. Kein Fehler und keine
* Zeile im Protokoll — im Normalbetrieb mit zwei Instanzen waere das jede
* Minute eine.
*/
await warteschlangeAbarbeiten();
} catch (fehler) {
console.error(
"Die Zustell-Warteschlange konnte nicht abgearbeitet werden:",
fehler instanceof Error ? fehler.message : fehler,
);
}
}
export function meldungsplanStarten(): void {
if (plan) return;
plan = cron.schedule(TAKT, () => {
void meldungsDurchgang();
});
}
export function meldungsplanAnhalten(): void {
plan?.stop();
plan = undefined;
}
export function meldungsplanLaeuft(): boolean {
return plan !== undefined;
}
+16 -13
View File
@@ -1,10 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums"; import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums";
import { AbgleichLaeuftBereitsFehler, abgleichAusfuehren, faelligeQuellen } from "@/server/services/abgleich"; import {
// Seit #034 liegt die Sperre in ihrem eigenen Modul: die Zustell-Warteschlange AbgleichLaeuftBereitsFehler,
// braucht dieselbe Mechanik, und zwei Fassungen davon waeren die schlechteste abgleichAusfuehren,
// aller Loesungen. faelligeQuellen,
import { mitSperre } from "@/server/services/sperre"; mitAbgleichSperre,
} from "@/server/services/abgleich";
import type { HandelnderBenutzer } from "@/server/mandant"; import type { HandelnderBenutzer } from "@/server/mandant";
import { quelleAnlegen, quelleBearbeiten, VerzeichnisFehler } from "@/server/services/verzeichnis"; import { quelleAnlegen, quelleBearbeiten, VerzeichnisFehler } from "@/server/services/verzeichnis";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
@@ -98,14 +99,14 @@ describe.runIf(datenbankVerfuegbar)("Abgleich-Sperre", () => {
const [haeltDieSperre, ersterIstDrin] = signal(); const [haeltDieSperre, ersterIstDrin] = signal();
const [freigeben, darfWeiter] = signal(); const [freigeben, darfWeiter] = signal();
const erster = mitSperre("abgleich:test", 5, async () => { const erster = mitAbgleichSperre("abgleich:test", 5, async () => {
haeltDieSperre(); haeltDieSperre();
await darfWeiter; await darfWeiter;
return "erster"; return "erster";
}); });
await ersterIstDrin; await ersterIstDrin;
const zweiter = await mitSperre("abgleich:test", 5, async () => "zweiter"); const zweiter = await mitAbgleichSperre("abgleich:test", 5, async () => "zweiter");
freigeben(); freigeben();
// Abgewiesen, nicht eingereiht: `undefined` und nicht ein zweites Ergebnis. // Abgewiesen, nicht eingereiht: `undefined` und nicht ein zweites Ergebnis.
@@ -113,26 +114,26 @@ describe.runIf(datenbankVerfuegbar)("Abgleich-Sperre", () => {
expect(await erster).toBe("erster"); expect(await erster).toBe("erster");
// Danach ist die Sperre wieder frei. // Danach ist die Sperre wieder frei.
expect(await testPrisma.abgleichSperre.count()).toBe(0); expect(await testPrisma.abgleichSperre.count()).toBe(0);
expect(await mitSperre("abgleich:test", 5, async () => "dritter")).toBe("dritter"); expect(await mitAbgleichSperre("abgleich:test", 5, async () => "dritter")).toBe("dritter");
}); });
it("sperrt je Schluessel und nicht global", async () => { it("sperrt je Schluessel und nicht global", async () => {
const [haeltDieSperre, ersterIstDrin] = signal(); const [haeltDieSperre, ersterIstDrin] = signal();
const [freigeben, darfWeiter] = signal(); const [freigeben, darfWeiter] = signal();
const eine = mitSperre("abgleich:eine", 5, async () => { const eine = mitAbgleichSperre("abgleich:eine", 5, async () => {
haeltDieSperre(); haeltDieSperre();
await darfWeiter; await darfWeiter;
return "eine"; return "eine";
}); });
await ersterIstDrin; await ersterIstDrin;
expect(await mitSperre("abgleich:andere", 5, async () => "andere")).toBe("andere"); expect(await mitAbgleichSperre("abgleich:andere", 5, async () => "andere")).toBe("andere");
freigeben(); freigeben();
expect(await eine).toBe("eine"); expect(await eine).toBe("eine");
}); });
it("gibt die Sperre auch frei, wenn der Lauf wirft", async () => { it("gibt die Sperre auch frei, wenn der Lauf wirft", async () => {
await expect( await expect(
mitSperre("abgleich:test", 5, async () => { mitAbgleichSperre("abgleich:test", 5, async () => {
throw new Error("mitten im Abgleich"); throw new Error("mitten im Abgleich");
}), }),
).rejects.toThrow("mitten im Abgleich"); ).rejects.toThrow("mitten im Abgleich");
@@ -150,7 +151,7 @@ describe.runIf(datenbankVerfuegbar)("Abgleich-Sperre", () => {
laeuftAbAm: new Date(Date.now() - 60 * 60 * 1000), laeuftAbAm: new Date(Date.now() - 60 * 60 * 1000),
}, },
}); });
expect(await mitSperre("abgleich:test", 5, async () => "uebernommen")).toBe("uebernommen"); expect(await mitAbgleichSperre("abgleich:test", 5, async () => "uebernommen")).toBe("uebernommen");
expect(await testPrisma.abgleichSperre.count()).toBe(0); expect(await testPrisma.abgleichSperre.count()).toBe(0);
}); });
@@ -162,7 +163,9 @@ describe.runIf(datenbankVerfuegbar)("Abgleich-Sperre", () => {
laeuftAbAm: new Date(Date.now() + 60 * 60 * 1000), laeuftAbAm: new Date(Date.now() + 60 * 60 * 1000),
}, },
}); });
expect(await mitSperre("abgleich:test", 5, async () => "haette nicht laufen duerfen")).toBeUndefined(); expect(
await mitAbgleichSperre("abgleich:test", 5, async () => "haette nicht laufen duerfen"),
).toBeUndefined();
// Und sie wird auch nicht beim Aufraeumen mitgeloescht: sie gehoert einem // Und sie wird auch nicht beim Aufraeumen mitgeloescht: sie gehoert einem
// anderen Lauf. // anderen Lauf.
const zeile = await testPrisma.abgleichSperre.findUniqueOrThrow({ const zeile = await testPrisma.abgleichSperre.findUniqueOrThrow({
-22
View File
@@ -170,28 +170,6 @@ describe.runIf(datenbankVerfuegbar)("Abteilungsabschnitte am Lauf", () => {
expect(ergebnisse.map((e) => e.vorlagenSchritt.titel).sort()).toEqual(["Papiere", "Übergabe"]); 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 () => { 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 * Der Grund für das Festschreiben: ein archivierter Lauf muss genau die
+2 -50
View File
@@ -12,12 +12,12 @@ import {
} from "@/lib/anhaenge"; } from "@/lib/anhaenge";
import { import {
AnhangFehler, AnhangFehler,
anhaengeDesLaufs,
anhangEntfernen, anhangEntfernen,
anhangLesen, anhangLesen,
anhangPfad, anhangPfad,
anhangSpeichern, anhangSpeichern,
} from "@/server/services/anhaenge"; } from "@/server/services/anhaenge";
import { anhaengeDesLaufs } from "@/server/queries/anhaenge";
import { MandantFehler } from "@/server/mandant"; import { MandantFehler } from "@/server/mandant";
import { LaufFehler } from "@/server/services/laeufe"; import { LaufFehler } from "@/server/services/laeufe";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
@@ -393,7 +393,7 @@ describe.runIf(datenbankVerfuegbar)("Anhänge", () => {
admin, admin,
); );
const liste = await anhaengeDesLaufs(laufId, admin); const liste = await anhaengeDesLaufs(laufId);
expect(liste).toHaveLength(1); expect(liste).toHaveLength(1);
expect(liste[0]).toMatchObject({ expect(liste[0]).toMatchObject({
schrittergebnisId: ergebnisId, 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 () => { it("hält die Mandantengrenze in der Datenbank, nicht nur im Dienst", async () => {
const { laufId, schrittId, ergebnisId } = await aufbau(); const { laufId, schrittId, ergebnisId } = await aufbau();
const fremd = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FREMD" } }); const fremd = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FREMD" } });
-276
View File
@@ -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);
});
});
-54
View File
@@ -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 () => { it("übernimmt beim Import keinen mitgeschickten Verweis", async () => {
const { adminId } = await grundbestand(); const { adminId } = await grundbestand();
const { versionId: bausteinVersionId } = await bausteinMitSchritten( const { versionId: bausteinVersionId } = await bausteinMitSchritten(
-988
View File
@@ -1,988 +0,0 @@
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,
LaufStatus,
Meldeanlass,
VersionStatus,
Zustellweg,
Zustellzustand,
} from "@/generated/prisma/enums";
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,
meldungseinstellungenHolen,
meldungseinstellungenSpeichern,
naechsterVersuch,
ueberfaelligeEinreihen,
warteschlangeAbarbeiten,
} from "@/server/services/benachrichtigungen";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Benachrichtigungen per Mail und Webhook (#034).
*
* Fuenf Zusagen tragen das Ganze, und jede hat hier ihren Test:
*
* 1. Die vier Anlaesse gehen an die richtigen Empfaenger — **gegen einen echten
* SMTP-Faenger**, nicht gegen eine abgefangene Funktion.
* 2. In **keiner** Nachricht steht ein Schrittinhalt und damit kein erzeugtes
* Passwort.
* 3. Eine fehlgeschlagene Zustellung wird wiederholt und ist danach sichtbar.
* 4. Zwei gleichzeitige Durchgaenge schicken nichts zweimal.
* 5. Wer Benachrichtigungen abgeschaltet hat, bekommt nichts.
*/
const MAILPIT = "http://127.0.0.1:8025";
const SMTP_PORT = 1025;
/**
* Steht der Faenger?
*
* **Auf Modulebene geprueft, nicht in `beforeAll`.** `it.runIf(...)` wird beim
* Einlesen der Datei ausgewertet — ein in `beforeAll` gesetztes Flag kaeme zu
* spaet, und alle Mailtests waeren stillschweigend uebersprungen. Genau so ist
* es beim ersten Lauf passiert: 3 skipped, gruener Durchlauf, nichts geprueft.
*/
async function mailpitAntwortet(): Promise<boolean> {
try {
const antwort = await fetch(`${MAILPIT}/api/v1/info`, { signal: AbortSignal.timeout(2_000) });
return antwort.ok;
} catch {
return false;
}
}
const mailpitDa = await mailpitAntwortet();
if (!mailpitDa && process.env["CI"]) {
throw new Error(
"Mailpit antwortet nicht auf 8025. In CI duerfen die Mailtests nicht uebersprungen werden — " +
"`docker compose -f docker-compose.test.yml up -d --wait mailpit`.",
);
}
if (!mailpitDa) {
console.warn(
"Mailpit laeuft nicht; die Mailtests werden uebersprungen. " +
"Start: docker compose -f docker-compose.test.yml up -d --wait mailpit",
);
}
type MailpitNachricht = {
readonly ID: string;
readonly Subject: string;
readonly To: readonly { readonly Address: string }[];
};
async function mailpitLeeren(): Promise<void> {
await fetch(`${MAILPIT}/api/v1/messages`, { method: "DELETE" });
}
async function mailpitNachrichten(): Promise<MailpitNachricht[]> {
const antwort = await fetch(`${MAILPIT}/api/v1/messages?limit=100`);
const daten = (await antwort.json()) as { messages?: MailpitNachricht[] };
return daten.messages ?? [];
}
/** Der Klartext einer Nachricht – fuer die Passwort-Pruefung. */
async function mailpitText(id: string): Promise<string> {
const antwort = await fetch(`${MAILPIT}/api/v1/message/${id}`);
const daten = (await antwort.json()) as { Text?: string; Subject?: string };
return `${daten.Subject ?? ""}\n${daten.Text ?? ""}`;
}
/* ------------------------------------------------------------------ */
/* Die reinen Teile */
/* ------------------------------------------------------------------ */
describe("Die Signatur eines Webhooks", () => {
const GEHEIMNIS = "sehr-geheim";
const RUMPF = '{"anlass":"LAUF_ZUGEWIESEN"}';
const JETZT = 1_762_334_400;
it("lässt sich mit dem Geheimnis nachrechnen", () => {
const kopf = signaturBauen(GEHEIMNIS, RUMPF, JETZT);
expect(kopf).toMatch(/^t=1762334400,v1=[0-9a-f]{64}$/);
expect(signaturPruefen(GEHEIMNIS, RUMPF, kopf, JETZT)).toEqual({ gueltig: true });
});
it("mit einem falschen Geheimnis nicht", () => {
const kopf = signaturBauen(GEHEIMNIS, RUMPF, JETZT);
expect(signaturPruefen("falsch", RUMPF, kopf, JETZT)).toEqual({
gueltig: false,
grund: "falsch",
});
});
it("und auch nicht, wenn der Rumpf nachträglich geändert wurde", () => {
const kopf = signaturBauen(GEHEIMNIS, RUMPF, JETZT);
expect(signaturPruefen(GEHEIMNIS, '{"anlass":"ETWAS_ANDERES"}', kopf, JETZT).gueltig).toBe(false);
});
it("weist eine zu alte und eine zu junge Nachricht ab", () => {
const kopf = signaturBauen(GEHEIMNIS, RUMPF, JETZT);
// Zu alt: eine abgefangene Nachricht soll sich nicht morgen einspielen lassen.
expect(signaturPruefen(GEHEIMNIS, RUMPF, kopf, JETZT + 301)).toEqual({
gueltig: false,
grund: "abgelaufen",
});
/*
* Und zu jung. Nur „zu alt“ abzufangen liesse eine Nachricht mit Zeitstempel
* im Jahr 2040 fuer immer gueltig sein.
*/
expect(signaturPruefen(GEHEIMNIS, RUMPF, kopf, JETZT - 301)).toEqual({
gueltig: false,
grund: "abgelaufen",
});
// Innerhalb der Toleranz geht beides.
expect(signaturPruefen(GEHEIMNIS, RUMPF, kopf, JETZT + 299).gueltig).toBe(true);
});
it("weist einen kaputten Kopf ab, statt zu werfen", () => {
for (const kopf of ["", "unsinn", "t=abc,v1=ff", "v1=ff", "t=1762334400"]) {
expect(signaturPruefen(GEHEIMNIS, RUMPF, kopf, JETZT)).toEqual({
gueltig: false,
grund: "form",
});
}
});
it("signiert den Zeitstempel mit", () => {
/*
* Stuende er nur daneben, koennte ihn jeder aendern, ohne die Signatur zu
* brechen. Dieselbe Signatur mit einem anderen `t` muss also scheitern.
*/
const echt = signaturBauen(GEHEIMNIS, RUMPF, JETZT);
const geschoben = echt.replace(`t=${JETZT}`, `t=${JETZT + 10}`);
expect(signaturPruefen(GEHEIMNIS, RUMPF, geschoben, JETZT + 10)).toEqual({
gueltig: false,
grund: "falsch",
});
});
});
describe("Die Texte der Meldungen", () => {
const KOPF = {
titel: "PC Neuinstallation – PC-001",
geraet: "PC-001",
person: "Maria Huber",
faelligAm: new Date("2026-10-07T10:00:00Z"),
vorlage: "PC Neuinstallation",
handelnder: "Petra Klein",
};
it("nennt den Anlass im Betreff", () => {
expect(laufMeldung(Meldeanlass.LAUF_ZUGEWIESEN, KOPF, "u").betreff).toContain("ist dir zugewiesen");
expect(laufMeldung(Meldeanlass.LAUF_UEBERFAELLIG, KOPF, "u").betreff).toContain("überfällig");
expect(laufMeldung(Meldeanlass.LAUF_ABGESCHLOSSEN, KOPF, "u").betreff).toContain("abgeschlossen");
expect(abgleichMeldung("AD Zentrale", "Verbindung abgelehnt", "u").betreff).toContain("fehlgeschlagen");
});
it("schreibt das Fälligkeitsdatum deutsch", () => {
expect(laufMeldung(Meldeanlass.LAUF_UEBERFAELLIG, KOPF, "u").rumpf).toContain("07.10.2026");
});
it("lässt leere Felder weg, statt „null“ zu schreiben", () => {
const dürftig = laufMeldung(Meldeanlass.LAUF_ZUGEWIESEN, { titel: "Nur ein Titel" }, "u");
expect(dürftig.rumpf).not.toContain("null");
expect(dürftig.rumpf).not.toContain("Gerät:");
expect(dürftig.rumpf).not.toContain("undefined");
});
it("trägt den Link, damit die Mail zu etwas führt", () => {
const link = "https://laufzettel.firma.de/laeufe/abc";
expect(laufMeldung(Meldeanlass.LAUF_ABGESCHLOSSEN, KOPF, link).rumpf).toContain(link);
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
* die **Form**: `laufMeldung` nimmt nur Kopfdaten. Dass auch nichts aus der
* Datenbank durchsickert, prueft der Test „in keiner Nachricht steht ein
* erzeugtes Passwort“ weiter unten — ueber alle Rumpftexte, wie in #012.
*/
const alle = [
laufMeldung(Meldeanlass.LAUF_ZUGEWIESEN, KOPF, "u").rumpf,
laufMeldung(Meldeanlass.LAUF_UEBERFAELLIG, KOPF, "u").rumpf,
laufMeldung(Meldeanlass.LAUF_ABGESCHLOSSEN, KOPF, "u").rumpf,
abgleichMeldung("AD", "Grund", "u").rumpf,
].join("\n");
expect(alle).not.toContain("{{");
expect(alle).not.toContain("Password");
expect(alle).not.toContain("New-ADUser");
});
it("baut einen Webhook-Rumpf mit dem Anlass als Feld", () => {
const rumpf = webhookRumpf(
Meldeanlass.LAUF_ABGESCHLOSSEN,
"Betreff",
"https://x/laeufe/1",
{ laufId: "l1" },
new Date("2026-10-05T08:00:00Z"),
);
expect(JSON.parse(rumpf)).toEqual({
anlass: "LAUF_ABGESCHLOSSEN",
betreff: "Betreff",
link: "https://x/laeufe/1",
laufId: "l1",
verzeichnisquelleId: null,
zeitpunkt: "2026-10-05T08:00:00.000Z",
});
});
});
describe("Die Wiederholungsregel", () => {
const JETZT = new Date("2026-10-05T08:00:00Z");
it("wartet nach dem ersten Fehlschlag 1 Minute, dann 5, 30 und 120", () => {
expect(WARTEZEITEN_MINUTEN).toEqual([1, 5, 30, 120]);
for (const [index, minuten] of WARTEZEITEN_MINUTEN.entries()) {
// `versucheGesamt` zaehlt den gerade gescheiterten mit: 1 → eine Minute.
const ergebnis = naechsterVersuch(index + 1, JETZT);
expect(ergebnis.zustand).toBe(Zustellzustand.FEHLGESCHLAGEN);
expect(ergebnis.naechsterVersuchAm.getTime() - JETZT.getTime()).toBe(minuten * 60_000);
}
});
it("gibt nach dem fünften Fehlschlag auf — aber sichtbar", () => {
const ergebnis = naechsterVersuch(WARTEZEITEN_MINUTEN.length + 1, JETZT);
expect(ergebnis.zustand).toBe(Zustellzustand.AUFGEGEBEN);
// Eine stille Warteschlange ist schlimmer als keine: der Zeitpunkt bleibt
// stehen, damit die Liste ihre Reihenfolge behaelt.
expect(ergebnis.naechsterVersuchAm).toEqual(JETZT);
});
});
/* ------------------------------------------------------------------ */
/* Mit Datenbank */
/* ------------------------------------------------------------------ */
const APP_URL_VORHER = process.env["APP_URL"];
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: "Petra Klein", rolle: "ADMIN" },
});
const techniker = await testPrisma.benutzer.create({
data: { email: "technik@firma.example", name: "Ali Demir", rolle: "TECHNIKER" },
});
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 },
});
/*
* Ein Befehlsschritt mit dem Platzhalter – damit der Passwort-Test etwas zu
* finden haette, wenn irgendwo ein Schrittinhalt in eine Mail geriete.
*/
const schritt = await testPrisma.vorlagenSchritt.create({
data: {
abschnittId: abschnitt.id,
titel: "Konto anlegen",
typ: "BEFEHL",
pflicht: true,
position: 0,
konfig: {
shell: "powershell",
befehl: 'New-ADUser -AccountPassword "{{generiertes_passwort}}" -Name "Maria Huber"',
},
},
});
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
});
return { kunde, admin, techniker, versionId: version.id, schrittId: schritt.id };
}
async function laufAnlegenRoh(
versionId: string,
kundeId: string,
daten: {
titel?: string;
zugewiesenAnId?: string | null;
erstelltVonId?: string | null;
faelligAm?: Date | null;
status?: LaufStatus;
} = {},
) {
return testPrisma.lauf.create({
data: {
vorlagenVersionId: versionId,
kundeId,
titel: daten.titel ?? "PC Neuinstallation – PC-001",
zugewiesenAnId: daten.zugewiesenAnId ?? null,
erstelltVonId: daten.erstelltVonId ?? null,
faelligAm: daten.faelligAm ?? null,
status: daten.status ?? LaufStatus.OFFEN,
/*
* Die Zeitstempel gehoeren zum Status — `Lauf_begonnen_mit_zeitpunkt` und
* `Lauf_abgeschlossen_mit_zeitpunkt` verlangen das in der Datenbank. Ein
* Status ohne seinen Zeitpunkt ist hier gar nicht anlegbar, und das ist
* richtig so.
*/
...(daten.status === LaufStatus.IN_ARBEIT ? { gestartetAm: new Date() } : {}),
...(daten.status === LaufStatus.ABGESCHLOSSEN
? { gestartetAm: new Date(), abgeschlossenAm: new Date() }
: {}),
},
});
}
/** SMTP auf den Faenger zeigen lassen. */
async function smtpEinrichten(): Promise<void> {
await testPrisma.einstellung.update({
where: { id: "global" },
data: {
smtpHost: "127.0.0.1",
smtpPort: SMTP_PORT,
smtpTls: false,
smtpAbsender: "laufzettel@firma.example",
},
});
}
describe.runIf(datenbankVerfuegbar)("Einstellungen der Benachrichtigungen", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("gibt das SMTP-Passwort nie heraus, nur ob eines gesetzt ist", async () => {
const { admin } = await aufbau();
await meldungseinstellungenSpeichern(
{
smtpHost: "mail.firma.de",
smtpPort: 587,
smtpBenutzer: "laufzettel",
smtpPasswort: "streng-geheim",
smtpAbsender: "laufzettel@firma.de",
},
admin.id,
);
const gelesen = await meldungseinstellungenHolen();
expect(gelesen.smtp.host).toBe("mail.firma.de");
expect(gelesen.smtp.passwortGesetzt).toBe(true);
// Der Wert selbst taucht in der Antwort nirgends auf.
expect(JSON.stringify(gelesen)).not.toContain("streng-geheim");
// Und in der Datenbank steht er verschlüsselt, nicht im Klartext.
const zeile = await testPrisma.einstellung.findUniqueOrThrow({ where: { id: "global" } });
expect(zeile.smtpPasswortVerschluesselt).toMatch(/^v[0-9]+\./);
expect(zeile.smtpPasswortVerschluesselt).not.toContain("streng-geheim");
});
it("schreibt kein Geheimnis ins Protokoll", async () => {
const { admin } = await aufbau();
await meldungseinstellungenSpeichern(
{ smtpHost: "mail.firma.de", smtpPort: 587, smtpPasswort: "streng-geheim" },
admin.id,
);
await meldungseinstellungenSpeichern(
{ webhookUrl: "https://hook.firma.de/laufzettel", webhookGeheimnis: "hook-geheim" },
admin.id,
);
const protokoll = await testPrisma.protokoll.findMany();
const alsText = JSON.stringify(protokoll);
// Das Protokoll ist ansehbar (#023) – hier wäre die Verschlüsselung sinnlos.
expect(alsText).not.toContain("streng-geheim");
expect(alsText).not.toContain("hook-geheim");
expect(alsText).toContain("Benachrichtigungen geändert");
});
it("weist eine unbrauchbare Webhook-Adresse und einen falschen Port ab", async () => {
const { admin } = await aufbau();
await expect(
meldungseinstellungenSpeichern({ webhookUrl: "nicht-mal-eine-url" }, admin.id),
).rejects.toThrow(/gültige URL/);
await expect(
meldungseinstellungenSpeichern({ webhookUrl: "ftp://hook.firma.de" }, admin.id),
).rejects.toThrow(/http/);
await expect(
meldungseinstellungenSpeichern({ smtpHost: "mail.firma.de", smtpPort: 70_000 }, admin.id),
).rejects.toThrow(/zwischen 1 und 65535/);
});
});
describe.runIf(datenbankVerfuegbar)("Einreihen", () => {
beforeAll(() => {
process.env["APP_URL"] = "https://laufzettel.firma.example";
});
afterAll(() => {
if (APP_URL_VORHER === undefined) delete process.env["APP_URL"];
else process.env["APP_URL"] = APP_URL_VORHER;
});
beforeEach(async () => {
await datenbankLeeren();
});
it("meldet eine Zuweisung an den Zugewiesenen, nicht an den Zuweisenden", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
const lauf = await laufAnlegenRoh(versionId, kunde.id, {
zugewiesenAnId: techniker.id,
erstelltVonId: admin.id,
});
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
const zustellungen = await testPrisma.zustellung.findMany();
expect(zustellungen).toHaveLength(1);
expect(zustellungen[0]).toMatchObject({
anlass: Meldeanlass.LAUF_ZUGEWIESEN,
weg: Zustellweg.MAIL,
empfaenger: "technik@firma.example",
zustand: Zustellzustand.OFFEN,
versuche: 0,
});
expect(zustellungen[0]?.rumpf).toContain("https://laufzettel.firma.example/laeufe/");
expect(zustellungen[0]?.rumpf).toContain("Petra Klein");
});
it("schickt niemandem eine Mail über die eigene Tat", async () => {
/*
* Der haeufigste Grund, Benachrichtigungen ganz abzuschalten: Post ueber
* das, was man gerade selbst geklickt hat.
*/
const { kunde, techniker, versionId } = await aufbau();
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
const erzeugt = await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, techniker.id);
expect(erzeugt).toBe(0);
expect(await testPrisma.zustellung.count()).toBe(0);
});
it("meldet einen Abschluss auch dem, der den Lauf angelegt hat", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
const lauf = await laufAnlegenRoh(versionId, kunde.id, {
zugewiesenAnId: techniker.id,
erstelltVonId: admin.id,
});
// Abgeschlossen hat der Techniker – also erfaehrt es der Ersteller.
await laufMeldungEinreihen(Meldeanlass.LAUF_ABGESCHLOSSEN, lauf.id, techniker.id);
const empfaenger = (await testPrisma.zustellung.findMany()).map((z) => z.empfaenger);
expect(empfaenger).toEqual(["admin@firma.example"]);
});
it("schickt nichts an jemanden, der Benachrichtigungen abgeschaltet hat", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
await testPrisma.benutzer.update({
where: { id: techniker.id },
data: { meldungenAktiv: false },
});
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
expect(await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id)).toBe(0);
});
it("schickt nichts an ein deaktiviertes Konto", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
await testPrisma.benutzer.update({ where: { id: techniker.id }, data: { aktiv: false } });
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
expect(await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id)).toBe(0);
});
it("achtet die Mandantengrenze", async () => {
/*
* Eine Meldung traegt Lauftitel und Geraet. Ohne diese Pruefung waere die
* Benachrichtigung der eine Weg nach draussen, den `sichtbareKundenIds`
* nicht abdeckt.
*/
const { kunde, admin, techniker, versionId } = await aufbau();
await testPrisma.einstellung.update({
where: { id: "global" },
data: { modus: Betriebsmodus.DIENSTLEISTUNG },
});
const vorher = process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "kunde-";
try {
const fremd = await testPrisma.kunde.create({
data: { name: "Fremd GmbH", kuerzel: "FREMD" },
});
// Der Techniker darf nur den eigenen Kunden sehen …
await testPrisma.benutzerKunde.create({
data: { benutzerId: techniker.id, kundeId: kunde.id },
});
// … ist aber einem Lauf des fremden zugewiesen.
const lauf = await laufAnlegenRoh(versionId, fremd.id, { zugewiesenAnId: techniker.id });
expect(await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id)).toBe(0);
} finally {
if (vorher === undefined) delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
else process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = vorher;
}
});
it("meldet einen fehlgeschlagenen Abgleich allen Administratoren", async () => {
const { kunde, admin } = await aufbau();
const zweiterAdmin = await testPrisma.benutzer.create({
data: { email: "admin2@firma.example", name: "Zweiter Admin", rolle: "ADMIN" },
});
const quelle = await testPrisma.verzeichnisquelle.create({
data: {
kundeId: kunde.id,
name: "AD Zentrale",
url: "ldaps://dc1.firma.test:636",
bindDn: "cn=admin,dc=firma,dc=test",
bindPasswortVerschluesselt: verschluesseln("egal"),
basisDn: "dc=firma,dc=test",
filter: "(objectClass=user)",
attributZuordnung: {},
},
});
await abgleichMeldungEinreihen(quelle.id, "Verbindung abgelehnt");
const zustellungen = await testPrisma.zustellung.findMany({ orderBy: { empfaenger: "asc" } });
expect(zustellungen.map((z) => z.empfaenger)).toEqual(["admin2@firma.example", "admin@firma.example"]);
expect(zustellungen[0]?.rumpf).toContain("Verbindung abgelehnt");
expect(zustellungen[0]?.verzeichnisquelleId).toBe(quelle.id);
expect(admin.id).toBeTruthy();
expect(zweiterAdmin.id).toBeTruthy();
});
it("meldet einen überfälligen Lauf genau einmal, auch bei jedem Durchgang", async () => {
/*
* Der Zeitplan sieht **jede Minute** nach. Ohne den partiellen eindeutigen
* Index bekaeme der Zustaendige fuer einen Lauf, der ueber das Wochenende
* steht, 2880 Mails — und jede einzelne davon waere fuer sich korrekt.
*/
const { kunde, techniker, versionId } = await aufbau();
await laufAnlegenRoh(versionId, kunde.id, {
zugewiesenAnId: techniker.id,
faelligAm: new Date("2026-10-01T00:00:00Z"),
status: LaufStatus.IN_ARBEIT,
});
expect(await ueberfaelligeEinreihen()).toBe(1);
expect(await ueberfaelligeEinreihen()).toBe(0);
expect(await ueberfaelligeEinreihen()).toBe(0);
expect(await testPrisma.zustellung.count()).toBe(1);
});
it("hält die Einmaligkeit auch gegen einen Schreiber an der Service-Schicht vorbei", async () => {
const { kunde, techniker, versionId } = await aufbau();
const lauf = await laufAnlegenRoh(versionId, kunde.id, {
zugewiesenAnId: techniker.id,
faelligAm: new Date("2026-10-01T00:00:00Z"),
status: LaufStatus.IN_ARBEIT,
});
await ueberfaelligeEinreihen();
await expect(
testPrisma.zustellung.create({
data: {
anlass: Meldeanlass.LAUF_UEBERFAELLIG,
weg: Zustellweg.MAIL,
empfaenger: "technik@firma.example",
betreff: "noch einmal",
rumpf: "noch einmal",
laufId: lauf.id,
benutzerId: techniker.id,
},
}),
).rejects.toThrow();
});
it("meldet einen überfälligen Lauf nicht, wenn er abgeschlossen ist", async () => {
const { kunde, techniker, versionId } = await aufbau();
await laufAnlegenRoh(versionId, kunde.id, {
zugewiesenAnId: techniker.id,
faelligAm: new Date("2026-10-01T00:00:00Z"),
status: LaufStatus.ABGESCHLOSSEN,
});
expect(await ueberfaelligeEinreihen()).toBe(0);
});
it("legt für einen Lauf ohne Zuweisung keine Zeile an", async () => {
const { kunde, admin, versionId } = await aufbau();
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: null });
expect(await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id)).toBe(0);
});
it("reiht neben der Mail eine Webhook-Zeile ein, wenn einer eingerichtet ist", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
await meldungseinstellungenSpeichern(
{ webhookUrl: "https://hook.firma.example/laufzettel", webhookGeheimnis: "geheim" },
admin.id,
);
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
const zustellungen = await testPrisma.zustellung.findMany({ orderBy: { weg: "asc" } });
expect(zustellungen.map((z) => z.weg)).toEqual([Zustellweg.MAIL, Zustellweg.WEBHOOK]);
const hook = zustellungen.find((z) => z.weg === Zustellweg.WEBHOOK);
// Ein Webhook gehoert keiner Person – die Datenbank verlangt das sogar.
expect(hook?.benutzerId).toBeNull();
expect(JSON.parse(hook?.rumpf ?? "{}")).toMatchObject({ anlass: "LAUF_ZUGEWIESEN" });
});
});
/* ------------------------------------------------------------------ */
/* Senden */
/* ------------------------------------------------------------------ */
describe.runIf(datenbankVerfuegbar)("Die Warteschlange", () => {
beforeAll(() => {
process.env["APP_URL"] = "https://laufzettel.firma.example";
});
afterAll(() => {
if (APP_URL_VORHER === undefined) delete process.env["APP_URL"];
else process.env["APP_URL"] = APP_URL_VORHER;
});
beforeEach(async () => {
await datenbankLeeren();
if (mailpitDa) await mailpitLeeren();
});
it.runIf(mailpitDa)("schickt alle vier Anlässe an die richtigen Empfänger", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
await smtpEinrichten();
const lauf = await laufAnlegenRoh(versionId, kunde.id, {
zugewiesenAnId: techniker.id,
erstelltVonId: admin.id,
faelligAm: new Date("2026-10-01T00:00:00Z"),
status: LaufStatus.IN_ARBEIT,
});
const quelle = await testPrisma.verzeichnisquelle.create({
data: {
kundeId: kunde.id,
name: "AD Zentrale",
url: "ldaps://dc1.firma.test:636",
bindDn: "cn=admin,dc=firma,dc=test",
bindPasswortVerschluesselt: verschluesseln("egal"),
basisDn: "dc=firma,dc=test",
filter: "(objectClass=user)",
attributZuordnung: {},
},
});
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
await ueberfaelligeEinreihen();
await laufMeldungEinreihen(Meldeanlass.LAUF_ABGESCHLOSSEN, lauf.id, techniker.id);
await abgleichMeldungEinreihen(quelle.id, "Verbindung abgelehnt");
const ergebnis = await warteschlangeAbarbeiten();
expect(ergebnis).toMatchObject({ fehlgeschlagen: 0, aufgegeben: 0 });
expect(ergebnis?.gesendet).toBe(4);
const nachrichten = await mailpitNachrichten();
expect(nachrichten).toHaveLength(4);
const nachEmpfaenger = new Map<string, string[]>();
for (const nachricht of nachrichten) {
for (const an of nachricht.To) {
nachEmpfaenger.set(an.Address, [...(nachEmpfaenger.get(an.Address) ?? []), nachricht.Subject]);
}
}
/*
* Der Techniker: zugewiesen und überfällig. Der Admin: abgeschlossen (er hat
* den Lauf angelegt) und der Abgleich.
*
* Geprueft wird **ohne Annahme ueber die Reihenfolge** — die haengt an der
* Sortierung der Betreffe, und die hatte ich beim ersten Lauf falsch
* geraten. Eine Sortierung ist hier auch nicht die Zusage; die Zusage ist,
* wer was bekommt.
*/
const anTechniker = nachEmpfaenger.get("technik@firma.example") ?? [];
expect(anTechniker).toHaveLength(2);
expect(anTechniker.some((betreff) => betreff.includes("ist dir zugewiesen"))).toBe(true);
expect(anTechniker.some((betreff) => betreff.includes("überfällig"))).toBe(true);
const anAdmin = nachEmpfaenger.get("admin@firma.example") ?? [];
expect(anAdmin).toHaveLength(2);
expect(anAdmin.some((betreff) => betreff.includes("abgeschlossen"))).toBe(true);
expect(anAdmin.some((betreff) => betreff.includes("Verzeichnis-Abgleich"))).toBe(true);
// Und alle Zeilen stehen jetzt auf GESENDET, mit Zeitpunkt.
const zeilen = await testPrisma.zustellung.findMany();
expect(zeilen).toHaveLength(4);
for (const zeile of zeilen) {
expect(zeile.zustand).toBe(Zustellzustand.GESENDET);
expect(zeile.gesendetAm).toBeInstanceOf(Date);
expect(zeile.versuche).toBe(1);
}
});
it.runIf(mailpitDa)("hat in keiner Nachricht ein erzeugtes Passwort", async () => {
/*
* Ueber **alle** Rumpftexte, wie in #012. Die Vorlage im Aufbau hat einen
* Befehlsschritt mit `{{generiertes_passwort}}` und `New-ADUser` — kaeme
* irgendwo ein Schrittinhalt in eine Mail, stuende es hier.
*/
const { kunde, admin, techniker, versionId } = await aufbau();
await smtpEinrichten();
const lauf = await laufAnlegenRoh(versionId, kunde.id, {
zugewiesenAnId: techniker.id,
erstelltVonId: admin.id,
faelligAm: new Date("2026-10-01T00:00:00Z"),
status: LaufStatus.IN_ARBEIT,
});
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
await ueberfaelligeEinreihen();
await laufMeldungEinreihen(Meldeanlass.LAUF_ABGESCHLOSSEN, lauf.id, techniker.id);
await warteschlangeAbarbeiten();
const nachrichten = await mailpitNachrichten();
expect(nachrichten.length).toBeGreaterThan(0);
for (const nachricht of nachrichten) {
const text = await mailpitText(nachricht.ID);
expect(text).not.toContain("{{generiertes_passwort}}");
expect(text).not.toContain("New-ADUser");
expect(text).not.toContain("AccountPassword");
// Auch kein aufgeloestes Passwort: 20 Zeichen aus dem Alphabet von #012.
expect(text).not.toMatch(/-AccountPassword/);
}
// Und in der Datenbank steht es auch nicht.
const alsText = JSON.stringify(await testPrisma.zustellung.findMany());
expect(alsText).not.toContain("{{");
expect(alsText).not.toContain("New-ADUser");
});
it("wiederholt eine fehlgeschlagene Zustellung und macht sie danach sichtbar", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
// **Kein** SMTP eingerichtet: jeder Versuch muss scheitern.
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
/*
* **Nicht** ein festes Datum: `naechsterVersuchAm` steht beim Einreihen auf
* `now()` der Datenbank. Ein Zeitpunkt in der Vergangenheit liesse den
* Durchgang die Zeile gar nicht erst anfassen — beim ersten Lauf war der
* Test deshalb rot, und zwar ohne Fehler im Code.
*/
const jetzt = new Date(Date.now() + 1_000);
const erster = await warteschlangeAbarbeiten(jetzt);
expect(erster).toMatchObject({ gesendet: 0, fehlgeschlagen: 1, aufgegeben: 0 });
let zeile = await testPrisma.zustellung.findFirstOrThrow();
expect(zeile.zustand).toBe(Zustellzustand.FEHLGESCHLAGEN);
expect(zeile.versuche).toBe(1);
expect(zeile.letzterFehler).toContain("SMTP");
// Eine Minute später, nicht sofort.
expect(zeile.naechsterVersuchAm.getTime()).toBe(jetzt.getTime() + 60_000);
// Vor der Wartezeit fasst der Durchgang die Zeile nicht an.
expect(await warteschlangeAbarbeiten(jetzt)).toMatchObject({ gesendet: 0, fehlgeschlagen: 0 });
expect((await testPrisma.zustellung.findFirstOrThrow()).versuche).toBe(1);
// Danach die Versuche zwei bis vier, jeder nach seiner Wartezeit.
for (const erwarteteWartezeit of WARTEZEITEN_MINUTEN.slice(1)) {
const spaeter = new Date(zeile.naechsterVersuchAm.getTime() + 1_000);
const vorherVersuche = zeile.versuche;
await warteschlangeAbarbeiten(spaeter);
zeile = await testPrisma.zustellung.findFirstOrThrow();
expect(zeile.versuche).toBe(vorherVersuche + 1);
expect(zeile.zustand).toBe(Zustellzustand.FEHLGESCHLAGEN);
expect(zeile.naechsterVersuchAm.getTime() - spaeter.getTime()).toBe(erwarteteWartezeit * 60_000);
}
expect(zeile.versuche).toBe(WARTEZEITEN_MINUTEN.length);
// Der fünfte Fehlschlag gibt auf – und bleibt sichtbar.
const zuletzt = new Date(zeile.naechsterVersuchAm.getTime() + 1_000);
const ergebnis = await warteschlangeAbarbeiten(zuletzt);
expect(ergebnis).toMatchObject({ aufgegeben: 1 });
zeile = await testPrisma.zustellung.findFirstOrThrow();
expect(zeile.zustand).toBe(Zustellzustand.AUFGEGEBEN);
expect(zeile.versuche).toBe(WARTEZEITEN_MINUTEN.length + 1);
expect(zeile.letzterFehler).toBeTruthy();
// Und wird nicht mehr angefasst.
expect(await warteschlangeAbarbeiten(new Date(zuletzt.getTime() + 86_400_000))).toMatchObject({
gesendet: 0,
fehlgeschlagen: 0,
aufgegeben: 0,
});
});
it.runIf(mailpitDa)("schickt bei zwei gleichzeitigen Durchgängen nichts zweimal", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
await smtpEinrichten();
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
/*
* Beide zugleich starten. Einer bekommt die Sperre, der andere `undefined` —
* ohne sie bekaeme der Empfaenger bei zwei Instanzen jede Mail zweimal.
*/
const [eins, zwei] = await Promise.all([warteschlangeAbarbeiten(), warteschlangeAbarbeiten()]);
const ergebnisse = [eins, zwei];
expect(ergebnisse.filter((e) => e === undefined)).toHaveLength(1);
expect(ergebnisse.find((e) => e !== undefined)).toMatchObject({ gesendet: 1 });
expect(await mailpitNachrichten()).toHaveLength(1);
});
});
/* ------------------------------------------------------------------ */
/* Webhook gegen einen echten Empfaenger */
/* ------------------------------------------------------------------ */
describe.runIf(datenbankVerfuegbar)("Der Webhook", () => {
let server: Server | undefined;
let port = 0;
let empfangen: { kopf: string | undefined; rumpf: string }[] = [];
let antwortStatus = 200;
beforeAll(async () => {
process.env["APP_URL"] = "https://laufzettel.firma.example";
server = createServer((anfrage, antwort) => {
let rumpf = "";
anfrage.on("data", (stueck) => {
rumpf += stueck;
});
anfrage.on("end", () => {
empfangen.push({
kopf: anfrage.headers[SIGNATUR_KOPF.toLowerCase()] as string | undefined,
rumpf,
});
antwort.writeHead(antwortStatus).end(antwortStatus === 200 ? "ok" : "kaputt");
});
});
await new Promise<void>((fertig) => {
server?.listen(0, "127.0.0.1", () => fertig());
});
const adresse = server?.address();
port = typeof adresse === "object" && adresse ? adresse.port : 0;
});
afterAll(async () => {
if (APP_URL_VORHER === undefined) delete process.env["APP_URL"];
else process.env["APP_URL"] = APP_URL_VORHER;
await new Promise<void>((fertig) => {
server?.close(() => fertig());
});
});
beforeEach(async () => {
await datenbankLeeren();
empfangen = [];
antwortStatus = 200;
});
afterEach(() => {
antwortStatus = 200;
});
it("kommt an, und die Signatur lässt sich mit dem Geheimnis nachrechnen", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
await testPrisma.einstellung.update({
where: { id: "global" },
data: {
webhookUrl: `http://127.0.0.1:${port}/laufzettel`,
webhookGeheimnisVerschluesselt: verschluesseln("hook-geheim"),
},
});
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
// Nur die Webhook-Zeile: kein SMTP eingerichtet, die Mailzeile scheitert.
const ergebnis = await warteschlangeAbarbeiten();
expect(ergebnis?.gesendet).toBe(1);
expect(empfangen).toHaveLength(1);
const nachricht = empfangen[0];
if (!nachricht?.kopf) throw new Error("Keine Signatur im Kopf.");
const jetzt = Math.floor(Date.now() / 1000);
expect(signaturPruefen("hook-geheim", nachricht.rumpf, nachricht.kopf, jetzt)).toEqual({
gueltig: true,
});
// Mit einem falschen Geheimnis nicht.
expect(signaturPruefen("falsch", nachricht.rumpf, nachricht.kopf, jetzt).gueltig).toBe(false);
expect(JSON.parse(nachricht.rumpf)).toMatchObject({
anlass: "LAUF_ZUGEWIESEN",
laufId: lauf.id,
});
});
it("gilt als fehlgeschlagen, wenn die Gegenseite nicht mit 2xx antwortet", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
await testPrisma.einstellung.update({
where: { id: "global" },
data: { webhookUrl: `http://127.0.0.1:${port}/laufzettel` },
});
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
antwortStatus = 500;
await warteschlangeAbarbeiten(new Date(Date.now() + 1_000));
const hook = await testPrisma.zustellung.findFirstOrThrow({ where: { weg: Zustellweg.WEBHOOK } });
expect(hook.zustand).toBe(Zustellzustand.FEHLGESCHLAGEN);
expect(hook.letzterFehler).toContain("500");
});
it("geht ohne Geheimnis hinaus, dann aber ohne Signatur", async () => {
/*
* Ein Webhook ohne Geheimnis ist zulaessig — manche Gegenstellen haben keine
* Pruefung. Dann fehlt die Kopfzeile, statt dass eine leere Signatur
* Sicherheit vortaeuscht.
*/
const { kunde, admin, techniker, versionId } = await aufbau();
await testPrisma.einstellung.update({
where: { id: "global" },
data: { webhookUrl: `http://127.0.0.1:${port}/laufzettel` },
});
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
await warteschlangeAbarbeiten();
expect(empfangen).toHaveLength(1);
expect(empfangen[0]?.kopf).toBeUndefined();
});
});
+8 -49
View File
@@ -22,26 +22,13 @@ import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
const UMGEBUNG = { ...process.env }; 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 { function anspruecke(ueberschreiben: Partial<OidcAnspruecke> = {}): OidcAnspruecke {
return { return {
sub: "anbieter-sub-1", sub: "anbieter-sub-1",
email: "maria.huber@firma.example", email: "maria.huber@firma.example",
emailBestaetigt: true, emailBestaetigt: true,
name: "Maria Huber", name: "Maria Huber",
gruppen: ["lz-techniker"], gruppen: [],
// 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",
ziel: "/", ziel: "/",
...ueberschreiben, ...ueberschreiben,
}; };
@@ -149,23 +136,9 @@ describe.runIf(datenbankVerfuegbar)("Anmeldung und Benutzerpflege", () => {
const befoerdert = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-admin"] })); const befoerdert = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-admin"] }));
expect(befoerdert.rolle).toBe("ADMIN"); expect(befoerdert.rolle).toBe("ADMIN");
/* // Und wieder zurueck: eine entzogene Gruppe entzieht die Rolle.
* Und wieder zurueck. **Seit #035 ist das kein `LESER` mehr:** wem die const entzogen = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: [] }));
* Rollengruppe entzogen wird und wer zu keiner Person im Verzeichnis expect(entzogen.rolle).toBe("LESER");
* 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");
}); });
it("protokolliert jede Rollenaenderung aus den Gruppen", async () => { it("protokolliert jede Rollenaenderung aus den Gruppen", async () => {
@@ -222,22 +195,12 @@ describe.runIf(datenbankVerfuegbar)("Anmeldung und Benutzerpflege", () => {
}); });
describe("Kundenbindung", () => { 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 () => { it("bindet den Benutzer an die Kunden aus seinen Gruppen", async () => {
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-"; process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
const mus = await testPrisma.kunde.create({ data: { name: "Musterfirma", kuerzel: "MUS" } }); const mus = await testPrisma.kunde.create({ data: { name: "Musterfirma", kuerzel: "MUS" } });
await testPrisma.kunde.create({ data: { name: "Baeckerei Reiter", kuerzel: "REI" } }); await testPrisma.kunde.create({ data: { name: "Baeckerei Reiter", kuerzel: "REI" } });
const benutzer = await benutzerAusOidcUebernehmen( const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-kunde-MUS"] }));
anspruecke({ gruppen: ["lz-leser", "lz-kunde-MUS"] }),
);
const bindungen = await testPrisma.benutzerKunde.findMany({ const bindungen = await testPrisma.benutzerKunde.findMany({
where: { benutzerId: benutzer.id }, where: { benutzerId: benutzer.id },
}); });
@@ -252,10 +215,8 @@ describe.runIf(datenbankVerfuegbar)("Anmeldung und Benutzerpflege", () => {
data: { name: "Baeckerei Reiter", kuerzel: "REI" }, data: { name: "Baeckerei Reiter", kuerzel: "REI" },
}); });
const benutzer = await benutzerAusOidcUebernehmen( const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-kunde-MUS"] }));
anspruecke({ gruppen: ["lz-leser", "lz-kunde-MUS"] }), await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-kunde-REI"] }));
);
await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-leser", "lz-kunde-REI"] }));
const bindungen = await testPrisma.benutzerKunde.findMany({ const bindungen = await testPrisma.benutzerKunde.findMany({
where: { benutzerId: benutzer.id }, where: { benutzerId: benutzer.id },
@@ -266,9 +227,7 @@ describe.runIf(datenbankVerfuegbar)("Anmeldung und Benutzerpflege", () => {
it("bindet ohne Praefix niemanden ein", async () => { it("bindet ohne Praefix niemanden ein", async () => {
await testPrisma.kunde.create({ data: { name: "Musterfirma", kuerzel: "MUS" } }); await testPrisma.kunde.create({ data: { name: "Musterfirma", kuerzel: "MUS" } });
const benutzer = await benutzerAusOidcUebernehmen( const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-kunde-MUS"] }));
anspruecke({ gruppen: ["lz-leser", "lz-kunde-MUS"] }),
);
expect(await testPrisma.benutzerKunde.count({ where: { benutzerId: benutzer.id } })).toBe(0); expect(await testPrisma.benutzerKunde.count({ where: { benutzerId: benutzer.id } })).toBe(0);
}); });
}); });
+1 -58
View File
@@ -1,6 +1,5 @@
import { readFileSync, readdirSync } from "node:fs"; import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { E2E_PRAEFIX } from "./e2e/aufraeumen";
import { parse } from "yaml"; 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", () => { describe("Betriebsstand: Entwicklungsdatenbank", () => {
it("bindet nur auf localhost und auf einen eigenen, umstellbaren Port", () => { it("bindet nur auf localhost und auf einen eigenen, umstellbaren Port", () => {
const stand = compose("docker-compose.dev.yml") as unknown as { const stand = compose("docker-compose.dev.yml") as unknown as {
-42
View File
@@ -63,12 +63,6 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
).toEqual([ ).toEqual([
"BausteinSchritt_unveraenderlich", "BausteinSchritt_unveraenderlich",
"BausteinVersion_status_einbahn", "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_baustein_veroeffentlicht",
"VorlagenAbschnitt_unveraenderlich", "VorlagenAbschnitt_unveraenderlich",
"VorlagenSchritt_unveraenderlich", "VorlagenSchritt_unveraenderlich",
@@ -105,12 +99,6 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
"Kunde_eigene_organisation_einmalig", "Kunde_eigene_organisation_einmalig",
"Vorlage_seedSchluessel_einmalig", "Vorlage_seedSchluessel_einmalig",
"VorlagenVersion_ein_entwurf", "VorlagenVersion_ein_entwurf",
/*
* Ohne diesen bekaeme der Zustaendige fuer einen Lauf, der ueber das
* Wochenende ueberfaellig steht, 2880 Mails — und zwar ohne dass ein
* einziger Test rot waere, denn jede davon ist fuer sich korrekt (#034).
*/
"Zustellung_ueberfaellig_einmal",
]); ]);
}); });
@@ -122,17 +110,7 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
expect(vorhanden).toEqual([ expect(vorhanden).toEqual([
"Anhang_groesse_positiv", "Anhang_groesse_positiv",
"Anhang_pruefsumme_form", "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_einzelne_zeile",
"Einstellung_smtp_passwort_verschluesselt",
"Einstellung_smtp_port_bereich",
"Einstellung_webhook_geheimnis_verschluesselt",
"Geraet_registriert_braucht_namen", "Geraet_registriert_braucht_namen",
"Geraet_tagcode_form", "Geraet_tagcode_form",
"Kunde_kuerzel_form", "Kunde_kuerzel_form",
@@ -144,27 +122,7 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
"Schrittergebnis_begruendung_noetig", "Schrittergebnis_begruendung_noetig",
"TagCodeVergabe_form", "TagCodeVergabe_form",
"Verzeichnisquelle_bindpasswort_verschluesselt", "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", "VorlagenVersion_veroeffentlicht_mit_zeitpunkt",
"Zustellung_bezug_passt_zum_anlass",
"Zustellung_empfaenger_nicht_leer",
"Zustellung_gesendet_mit_zeitpunkt",
"Zustellung_versuche_nicht_negativ",
"Zustellung_webhook_ohne_benutzer",
]); ]);
}); });
-185
View File
@@ -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}.`,
);
}

Some files were not shown because too many files have changed in this diff Show More