diff --git a/.env.example b/.env.example index 6fb7932..cda3587 100644 --- a/.env.example +++ b/.env.example @@ -70,6 +70,14 @@ SEED_ORG_NAME=Eigene Organisation # DIENSTLEISTUNG steht das Kuerzel auf Etiketten und in der Hostname-Regel. SEED_ORG_KUERZEL=EIGEN +# ---------- Verzeichnis-Abgleich ---------- + +# Der Zeitplan laeuft im selben Prozess wie die Anwendung und sieht jede Minute +# nach, welche Verzeichnisquelle faellig ist; das Intervall steht je Quelle in +# der Datenbank. `false` schaltet ihn ab — fuer Einmal-Container, die nur +# Migrationen fahren, und fuer Testlaeufe. +ZEITPLAN_AKTIV=true + # ---------- Tests ---------- # Datenbank fuer Vitest. Ohne diese Variable ueberspringen sich die Datenbanktests. diff --git a/CHANGELOG.md b/CHANGELOG.md index 12be833..a4c6a40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -136,6 +136,28 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). - Druckansicht eines Laufs: hell, ohne Navigation, auf A4, mit allen Schritten, Werten, Notizen, Namen und Zeitstempeln. Sie ersetzt im MVP den PDF-Export (#015) +- Verzeichnis-Abgleich über LDAP: Quelle anlegen, Verbindung testen, nach Zeitplan oder auf Knopfdruck + abgleichen. Wer im Verzeichnis verschwindet oder deaktiviert wird, wird hier inaktiv — gelöscht wird + nie, sonst verlöre die Historie ihre Namen (#016) +- „Verbindung testen“ zeigt die ersten Einträge **mit den gemappten Feldern** und schreibt nichts: erst + daran sieht ein Administrator, ob die Zuordnung stimmt (#016) +- Der Zeitplan läuft im selben Prozess wie die Anwendung, sieht jede Minute nach, welche Quelle fällig + ist, und verlässt sich auf eine Sperre in der Datenbank — zwei Instanzen kommen sich nicht in die + Quere (#016) +- Personen aus einer CSV-Datei einlesen: je Zeile ein Ergebnis, eine fehlerhafte Zeile bricht den + Import nicht ab. Eine leere Zelle löscht nichts, und wer schon aus dem Abgleich stammt, wird nicht + überschrieben (#016) +- Abteilungsvorschläge aus den Verzeichnis-Werten: ein Klick macht daraus eine gepflegte Abteilung + (#016) +- Die Attributzuordnung ist in der Oberfläche bearbeitbar: zehn Felder mit den AD-Vorgaben, damit ein + OpenLDAP nicht in der Datenbank nachgepflegt werden muss (#016) +- Abgleich-Log je Quelle: die letzten zehn Läufe mit gelesen, angelegt, übernommen, aktualisiert, + deaktiviert und übersprungen — und wer sie angestoßen hat, Mensch oder Zeitplan (#016) +- Wer schon von Hand angelegt wurde und dann im Verzeichnis auftaucht, wird **übernommen** statt + verdoppelt: dieselbe Zeile behält ihre Läufe und Geräte (#016) +- Ein hinterlegtes CA-Zertifikat ist als vorhanden erkennbar und lässt sich entfernen; der + Zertifikatstext selbst kommt nie zurück in die Oberfläche (#016) + ### Sicherheit - Der Compose-Stand bricht ab, wenn `APP_SECRET`, `POSTGRES_PASSWORD` oder `APP_URL` fehlen, @@ -239,3 +261,18 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). desselben Laufs sind deshalb Zeichen für Zeichen gleich — der Test prüft genau das (#015) - Die Archivsuche setzt jeden Wert als Parameter und maskiert `%`, `_` und `\` für `ILIKE`: die Suche nach „50 %“ trifft nicht „5099“ (#015) +- Das Bind-Passwort einer Verzeichnisquelle wird mit AES-256-GCM verschlüsselt, erreicht keine + Antwort und kein Log: Fehlermeldungen der LDAP-Bibliothek werden gefiltert, bevor sie gespeichert + werden, und die Ausnahme trägt keine `cause` — sonst stünde die ganze Kette im Server-Log (#016) +- Gespeichert werden nur die zugeordneten Attribute: keine Passwörter, keine + Gruppenmitgliedschaften (#016) +- Die Hostnamen-Prüfung von TLS lässt sich nicht abschalten; ein Test hält fest, dass weder + `rejectUnauthorized` noch `checkServerIdentity` gesetzt werden (#016) +- Das Verzeichnis gibt es nur im Modus Intern — Reiter, Seite und Dienst prüfen das je selbst (#016) +- Der Abgleich deaktiviert niemanden, wenn sich **kein einziger** gelesener Eintrag zuordnen ließ: + ein falsch gesetztes Kennungsattribut hätte sonst die gesamte Belegschaft abgeschaltet und dabei + Erfolg gemeldet (#016) +- Übersprungene Einträge werden nicht deaktiviert: sie standen im Ergebnis, nur ohne brauchbare + Kennung — wer sie mit abschaltete, nähme einem anwesenden Menschen den Zugang (#016) +- Ergebniscodes des Verzeichnisses werden in Sätze übersetzt: falsches Passwort, fehlendes Leserecht + und ein unbekannter Basis-DN sahen vorher gleich aus (#016) diff --git a/README.md b/README.md index c826f6c..b756fce 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,29 @@ zugeordnet. Unbekannte Benutzer werden beim ersten Login angelegt. Die Anbindung an authentik ist in [`docs/betrieb/authentik.md`](docs/betrieb/authentik.md) Schritt für Schritt beschrieben. +## Verzeichnis-Abgleich (nur Modus `INTERN`) + +Unter **Admin → Verzeichnis** wird eine LDAP-Quelle angelegt: Adresse (`ldaps://` empfohlen, +`ldap+starttls://` und `ldap://` gehen auch), ein Dienstkonto mit **Leserecht**, der Basis-DN und +optional ein eigener Filter. Ein Dienstkonto genügt — Laufzettel schreibt nie ins Verzeichnis. + +- **„Verbindung testen“** liest die ersten Einträge und zeigt sie **mit den gemappten Feldern**, + ohne etwas zu speichern. Erst daran sieht man, ob die Zuordnung stimmt. +- **Die Attributzuordnung** ist einklappbar im Formular und trägt die AD-Vorgaben; ein OpenLDAP + bekommt hier etwa `entryUUID` als Kennung. +- **Der Zeitplan** läuft im selben Prozess wie die Anwendung und sieht jede Minute nach, welche + Quelle fällig ist; das Intervall steht je Quelle. `ZEITPLAN_AKTIV=false` schaltet ihn ab — für + Einmal-Container, die nur Migrationen fahren. +- **Gelöscht wird nie.** Wer im Verzeichnis verschwindet oder deaktiviert wird, wird hier inaktiv. + Lässt sich **kein einziger** Eintrag zuordnen, deaktiviert der Abgleich niemanden und meldet + stattdessen einen Fehler — ein falsch gesetztes Kennungsattribut soll nicht die halbe Firma + abschalten. +- **Das Bind-Passwort** wird verschlüsselt gespeichert und nie wieder angezeigt; ein leeres Feld + beim Bearbeiten heißt „unverändert“. + +Ohne Verzeichnis geht es auch: Personen lassen sich von Hand anlegen oder aus einer CSV-Datei +einlesen. + ## Umgebungsvariablen Alle Variablen mit Erklärung stehen in [`.env.example`](.env.example). Pflicht sind `APP_URL`, diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 315914d..1c4c8e3 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -1,6 +1,4 @@ # Dienste fuer die Testsuite. Nicht fuer den Betrieb. -# Die LDAP-Quelle fuer den Verzeichnis-Abgleich kommt in #016 dazu, dort wird sie -# auch tatsaechlich benutzt und geprueft. name: laufzettel-test @@ -18,3 +16,48 @@ services: interval: 5s timeout: 5s retries: 10 + + # Das Verzeichnis fuer den Abgleich (#016). OpenLDAP mit einem AD-aehnlichen + # Schema: sAMAccountName, userPrincipalName, binaeres objectGUID, + # userAccountControl, objectCategory und department bringt OpenLDAP nicht mit, + # sie stehen deshalb in tests/fixtures/ldap/schema/ad-fixture.ldif. + # + # LDAP_TLS ist aus, und das ist eine Einschraenkung mit Ansage: das Zertifikat, + # das osixia sonst selbst erzeugt, laeutet auf den Containernamen, waehrend der + # Test von aussen 127.0.0.1 anspricht -- die Pruefung des Hostnamens schlaege + # fehl. Ein Schalter, der sie abstellt, waere ein Schalter zu viel im + # Produktionscode. Der Container wird deshalb im Klartext angesprochen; die + # TLS-Optionen (ldaps, StartTLS, eigenes CA-Zertifikat, minVersion) sind als + # reine Funktion `tlsOptionenBauen` in tests/verzeichnis.test.ts geprueft. + ldap: + image: osixia/openldap:1.5.0 + # Ohne `--copy-service` spielt der Startskript die Bootstrap-Dateien nicht + # ein, wenn sie nur eingehaengt sind. + command: ["--copy-service"] + environment: + LDAP_ORGANISATION: Firma Test + LDAP_DOMAIN: firma.test + LDAP_ADMIN_PASSWORD: ldap-test-geheim + LDAP_TLS: "false" + ports: + - "127.0.0.1:1389:389" + volumes: + # Schema und Daten liegen in getrennten Verzeichnissen, weil osixia die + # Daten-LDIFs mit `sed -i` nach Platzhaltern durchsucht. Eingehaengt wird + # deshalb das Verzeichnis und nicht die Datei: `sed -i` schreibt ueber eine + # Umbenennung, und die scheitert auf einer einzeln eingehaengten Datei -- + # das Startskript laeuft mit `bash -e` und der Container waere tot. + - ./tests/fixtures/ldap/schema:/container/service/slapd/assets/config/bootstrap/schema/custom:ro + - ./tests/fixtures/ldap/daten:/container/service/slapd/assets/config/bootstrap/ldif/custom + healthcheck: + # Die Suche laeuft ueber den authentifizierten Bind, weil anonymes Lesen + # abgeschaltet ist. Gesund ist der Dienst erst, wenn die Fixture-Personen + # wirklich da sind -- sonst liest der Test gegen ein leeres Verzeichnis. + test: + [ + "CMD-SHELL", + "ldapsearch -x -H ldap://localhost:389 -D cn=admin,dc=firma,dc=test -w ldap-test-geheim -b ou=Benutzer,dc=firma,dc=test -s sub '(sAMAccountName=mhuber)' dn | grep -q 'cn=Maria Huber'", + ] + interval: 5s + timeout: 5s + retries: 20 diff --git a/docker-compose.yml b/docker-compose.yml index 925d1b4..e5996ac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -68,6 +68,9 @@ services: OIDC_GROUP_TECHNICIAN: ${OIDC_GROUP_TECHNICIAN:-} OIDC_GROUP_VIEWER: ${OIDC_GROUP_VIEWER:-} OIDC_CUSTOMER_GROUP_PREFIX: ${OIDC_CUSTOMER_GROUP_PREFIX:-} + # Der Verzeichnis-Abgleich laeuft im selben Prozess (#016). Hier steht er + # an, weil dieser Dienst der einzige ist, der dauerhaft laeuft. + ZEITPLAN_AKTIV: ${ZEITPLAN_AKTIV:-true} # Standardmaessig nur auf localhost; davor gehoert ein Reverse Proxy. ports: - "${BIND_ADDR:-127.0.0.1}:${APP_PORT:-3000}:3000" diff --git a/docs/architektur.md b/docs/architektur.md index f188e99..fa30299 100644 --- a/docs/architektur.md +++ b/docs/architektur.md @@ -276,6 +276,61 @@ Vom Mitarbeiter holt dieses Lesemodell **nur Name, UPN und Abteilung** — nicht alles und dann gefiltert. Ein Ausdruck liegt herum, wird kopiert und weitergegeben; was darauf steht, ist aus der Hand gegeben. +## 5g. Der Verzeichnis-Abgleich: vier Schichten und eine Notbremse + +Das LDAP-Modul liegt unter `src/server/ldap/` und ist absichtlich in vier Dateien +geteilt, von denen nur eine das Netz anfasst: + +| Datei | Was darin steht | +|---|---| +| `fehler.ts` | `VerzeichnisFehler` — die Basis, unterhalb der Dienstschicht, damit `verbindung.ts` von ihr erben kann, ohne die Schichtung umzudrehen | +| `verbindung.ts` | Adressformen (`ldaps://`, `ldap+starttls://`, `ldap://`), TLS-Optionen, `mitVerbindung()` und das Saeubern fremder Fehlermeldungen | +| `zuordnung.ts` | reine Funktionen: aus einem `Entry` wird ein Datensatz in unseren Feldern, DNs werden vergleichbar, Vorgesetzte aufgeloest | +| `leser.ts` | `searchPaginated` in 1000er-Seiten, Eintrag fuer Eintrag weitergegeben | + +Die **Attributzuordnung** und die Grenzen des Intervalls stehen in +`src/lib/verzeichnis.ts` und nicht bei den LDAP-Modulen: das Formular braucht +dieselbe Liste und dieselben Zahlen, und eine Client-Komponente importiert nichts +aus `src/server/*`. Als beides zweimal geschrieben war, lief es auseinander — die +Oberflaeche liess hoechstens 1440 Minuten zu, der Dienst 10080. + +**Der Abgleich laeuft nicht in einer Transaktion.** Das ist eine Entscheidung mit +Folgen: bricht er in der Mitte ab, bleiben die bis dahin geschriebenen Personen +stehen. Eine Transaktion ueber das gesamte Verzeichnis haette die Tabelle so lange +gesperrt, wie das Lesen dauert. **Deaktiviert wird dagegen nur nach einem +vollstaendigen Durchlauf** — sonst haette ein Netzwerkfehler nach der halben Liste +die andere Haelfte der Firma abgeschaltet. + +Darum herum liegen drei Regeln, die alle denselben Fehler verhindern — dass eine +kaputte Eingabe wie „alle sind gegangen“ aussieht: + +1. **Kein einziger Eintrag zuordenbar ⇒ Fehler, kein Abschalten.** Steht in der + Zuordnung ein Kennungsattribut, das es am Server nicht gibt, wird jeder Eintrag + uebersprungen: das Lesen hat geklappt, niemand wurde wiedergesehen, und die + Abschaltung traefe die gesamte Belegschaft — mit Status `ERFOLG`. Danach sucht + niemand mehr nach dem Grund. +2. **Uebersprungene Eintraege sind nicht verschwunden.** Sie werden am `dn` erkannt + und aus der Abschaltung herausgenommen. +3. **Von Hand angelegte Personen werden uebernommen, nicht verdoppelt.** Beim + Onboarding entsteht die Person hier und das AD-Konto danach; der Abgleich findet + sie ueber `upn` oder `sam` (ohne Rucksicht auf Gross- und Kleinschreibung) und + haengt Quelle und Kennung an dieselbe Zeile. Eine Person, die schon eine andere + Quelle fuehrt, wird nie uebernommen. + +Uebersprungene Eintraege kommen als `hinweis` zurueck und **nicht** als `fehler`: +`Verzeichnisquelle.letzterAbgleichFehler` soll heissen, dass etwas schiefging, und +nicht „ein Konto war krumm“. + +Der **Zeitplan** ist `node-cron` im selben Prozess wie die Anwendung, gestartet aus +`src/instrumentation.ts` — nur in der Node-Laufzeit und nur, wenn `ZEITPLAN_AKTIV` +nicht `false` ist. Zwei Gruende fuer „im selben Prozess“: der nativ ausgerollte LXC +braucht keine zweite systemd-Unit, und der Abgleich benutzt denselben Dienst wie +„Jetzt abgleichen“, also gibt es keinen zweiten Weg mit eigenen Regeln. Die Sperre +liegt in der Datenbank (`AbgleichSperre`) und **nicht** im Prozess: zwei Instanzen +hinter einem Reverse Proxy kommen sich sonst in die Quere. Der Zeitplan nimmt +ausdruecklich **keine** eigene Sperre — sie traege einen anderen Schluessel und +liefe an der des Dienstes vorbei. + ## 6. Betrieb: zwei Wege, eine Regel Die Datenbank ist auf beiden Wegen von außen nicht erreichbar: diff --git a/docs/devlog.md b/docs/devlog.md index 87b73f2..701331a 100644 --- a/docs/devlog.md +++ b/docs/devlog.md @@ -18,11 +18,12 @@ als Unterpunkte mit Aufgabe und Dauer. | #013 | M8 – Läufe: Service und Abarbeitung | 3 h 05 min | | #014 | M9 – QR-Tags, Geräteseite, Etiketten, Scanner | 3 h 35 min | | #015 | M10 – Dashboard und Archiv | 2 h 20 min | -| | **Summe** | **22 h 45 min** | +| #016 | M11 – Verzeichnis und LDAP-Abgleich | 3 h 25 min | +| | **Summe** | **26 h 10 min** | | Meilenstein | Tatsächlich | Status | |---|---|---| -| v0.1.0 | 22 h 45 min | in Arbeit | +| v0.1.0 | 26 h 10 min | in Arbeit | --- @@ -680,3 +681,74 @@ als Unterpunkte mit Aufgabe und Dauer. - #016 Verzeichnis und LDAP-Abgleich — der größte verbleibende Brocken: `ldapts`, Attribut-Mapping, Zeitplan, verschlüsseltes Bind-Passwort und ein Integrationstest gegen einen OpenLDAP-Container. + +--- + +## 2026-09-26 · Sitzung 12 · 23:05–02:30 + +**Issue:** #016 (M11) +**Version:** 0.1.0 + +### Erledigt + +- **Vier Teile parallel**: LDAP-Schicht und Abgleich (Sub-Agent), Oberfläche Admin → Verzeichnis + (Sub-Agent), CSV-Import und Abteilungsvorschläge (Sub-Agent), Zeitplan und Server Actions (selbst) +- `ldapts` mit `ldaps://`, `ldap+starttls://` und `ldap://`, eigenem CA-Zertifikat, TLS ab 1.2 und + `searchPaginated` in 1000er-Seiten — Eintrag für Eintrag verarbeitet, nicht erst gesammelt +- `objectGUID` als Puffer gelesen und gemischt-endian zur GUID umsortiert; ein Puffer, der keine + GUID ist, wird nur dann als Text genommen, wenn dabei lesbarer Text herauskommt +- Abgleich mit Anlegen, Aktualisieren, Übernehmen und Deaktivieren, Vorgesetzten-Auflösung in einem + zweiten Durchgang und Ergebnis in `Verzeichnisquelle` **und** `Protokoll` +- Zeitplan mit `node-cron` im selben Prozess, gestartet aus `src/instrumentation.ts`, Sperre in der + Datenbank; „Jetzt abgleichen“ nimmt denselben Dienst +- Admin → Verzeichnis mit Quelle anlegen und bearbeiten, Verbindungstest samt gemappter Felder, + bearbeitbarer Attributzuordnung, CA-Zertifikat und Abgleich-Log +- Personen aus CSV einlesen, Abteilungsvorschläge aus den `department`-Werten +- Unabhängiger Review: **ein Blocker**, acht Befunde, sieben Anmerkungen — alle abgearbeitet +- **1098 Tests** (vorher 980) und **71 Ende-zu-Ende-Tests** (vorher 67) grün, Screenshots in + `docs/screenshots/m11/` + +### Entscheidungen + +- **Der Zeitplan nimmt keine eigene Sperre.** Der Dienst sperrt selbst, in der Datenbank. Eine + zweite Sperre im Zeitplan trüge einen anderen Schlüssel, liefe an der ersten vorbei — und ein von + Hand angestoßener Abgleich sähe für den Zeitplan wie ein unerwarteter Fehler aus. +- **Übersprungene Einträge sind ein Hinweis, kein Fehler.** `letzterAbgleichFehler` soll heißen, + dass etwas schiefging, und nicht „ein Konto war krumm“. Der Hinweis steht in der Erfolgsmeldung + und im Log; der Lauf gilt als gelungen. +- **Von Hand angelegte Personen werden übernommen, nicht verdoppelt.** Beim Onboarding entsteht die + Person hier und das AD-Konto danach. Gesucht wird über `upn`, dann `sam`, ohne Rücksicht auf + Groß- und Kleinschreibung — und nur unter Zeilen ohne Quelle: wer schon einer anderen Quelle + gehört, wandert nicht zwischen zwei Verzeichnissen hin und her. +- **Attributzuordnung und Intervallgrenzen liegen in `src/lib/verzeichnis.ts`.** Das Formular + braucht dieselbe Liste wie der Dienst, und eine Client-Komponente importiert nichts aus + `src/server/*`. + +### Probleme + +- **Der Blocker hätte die ganze Firma gekostet.** Steht in der Attributzuordnung ein + Kennungsattribut, das es am Server nicht gibt, wird **jeder** Eintrag übersprungen: das Lesen + klappt, niemand wird wiedergesehen, und die Abschaltung trifft die gesamte Belegschaft — mit + Status `ERFOLG`. Das Abgleich-Log hat den Fall danach selbst dokumentiert: der Lauf um 01:20 zeigt + „4 gelesen, 0 angelegt, 3 deaktiviert, 4 übersprungen“, die Läufe danach nie wieder. +- **Der Code versprach mehr als die Oberfläche hielt.** Ein Kommentar sagte „wer ein OpenLDAP + anbindet, ändert einzelne Zeilen“ — über die Oberfläche ging genau das nicht. Achter Meilenstein + in Folge mit einer Zusage ohne Deckung; diesmal war es die Zusage einer Bedienbarkeit. +- **Ein hinterlegtes CA-Zertifikat war unsichtbar und unlöschbar.** Das Feld stand beim Bearbeiten + leer da, der Hinweis darunter behauptete „ohne Eintrag gelten die Systemwurzeln“ — und ein + leeres Feld hieß „unverändert“. Das Zertifikat entschied also unsichtbar weiter mit. +- **Ein Test, der sich selbst aus dem Weg ging:** die Zusicherung im Nebenläufigkeitstest der + Vorlagen stand in einem `if`. Gewann das Speichern das Rennen, lief der Test durch, ohne etwas zu + belegen. Jetzt werden beide Ausgänge geprüft. +- **Eine Stunde gegen einen alten Server geprüft.** Der gebaute Stand lief, sah richtig aus und + zeigte trotzdem keine der neuen Funktionen — auf Port 3300 hing noch ein `next start` aus einer + früheren Sitzung, und der neue Start scheiterte still an `EADDRINUSE`. Seitdem: erst + nachsehen, wer auf dem Port sitzt, dann glauben, was die Seite zeigt. +- **Das Screenshot-Skript hat bei jedem Lauf zwei PNG-Dateien angefasst.** `kachelnErzeugen` + schrieb `public/icon-*.png` immer neu; `git status` zeigte Änderungen, die niemand gemacht hatte. + Jetzt wird verglichen und nur bei echtem Unterschied geschrieben. + +### Nächster Schritt + +- #017 Seed der sieben Vorlagen — alle sieben wortgetreu als veröffentlichte Version 1, dazu die + Beispieldaten und der Modus `DIENSTLEISTUNG` mit „Musterfirma GmbH“ und „Bäckerei Reiter“. diff --git a/docs/issues/016-verzeichnis-ldap.md b/docs/issues/016-verzeichnis-ldap.md index 9817d58..c245f09 100644 --- a/docs/issues/016-verzeichnis-ldap.md +++ b/docs/issues/016-verzeichnis-ldap.md @@ -1,9 +1,9 @@ # #016 M11 – Verzeichnis und LDAP-Abgleich -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.1.0 **Labels:** phase-1, backend, frontend, security -**Geschätzt:** 12 h **Tatsächlich:** – +**Geschätzt:** 12 h **Tatsächlich:** 3 h 25 min ## Ziel @@ -11,29 +11,61 @@ Die Mitarbeiter der eigenen Firma kommen aus dem Active Directory, damit beim St ## Aufgaben -- [ ] `ldapts` 9.x: `ldaps` bevorzugt, StartTLS optional, eigenes CA-Zertifikat hinterlegbar (`caCertPem`), `minVersion: TLSv1.2` -- [ ] `searchPaginated` mit `pageSize: 1000` — **nicht** `search` mit `paged`, das puffert alles im Speicher -- [ ] `explicitBufferAttributes: ['objectGUID']` und korrekte Umsortierung der gemischt-endianen Bytes zur GUID-Zeichenkette als `externeId` -- [ ] Standardfilter `(&(objectCategory=person)(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))` -- [ ] Attribut-Mapping mit AD-Vorgaben: `givenName`, `sn`, `displayName`, `mail`, `userPrincipalName` → `upn`, `sAMAccountName` → `sam`, `department`, `title`, `manager` → `vorgesetzterExterneId`, `distinguishedName` → `dn` -- [ ] „Verbindung testen“: zeigt die ersten 10 Treffer mit gemappten Feldern, ohne etwas zu schreiben -- [ ] `abgleichService`: anlegen, aktualisieren, verschwundene oder deaktivierte Konten auf `aktiv = false` — **nie löschen**; Ergebnis (angelegt/aktualisiert/deaktiviert/Fehler) in `letzterAbgleichStatus`, `letzterAbgleichFehler`, `letzterAbgleichAm` und im `Protokoll` -- [ ] Zeitplan mit `node-cron` im selben Prozess, Intervall je Quelle (Standard 60 min), Sperre über `AbgleichSperre` in der Datenbank, damit nur eine Instanz läuft; zusätzlich „Jetzt abgleichen“ -- [ ] Bind-Passwort verschlüsselt über `src/lib/geheimnisse.ts`, Serializer entfernt `bindPasswortVerschluesselt` aus jeder Antwort -- [ ] Admin → Verzeichnis: Quelle anlegen und bearbeiten, Verbindung testen, Abgleich-Status und -Log, „Jetzt abgleichen“ -- [ ] Manuelle Mitarbeiter und CSV-Import (`givenName`, `sn`, `displayName`, `mail`, `upn`, `sam`, `department`, `title`) -- [ ] Abteilungsvorschläge aus den `department`-Werten des Abgleichs, vom Admin in `Abteilung`-Einträge übernehmbar -- [ ] Nur die gemappten Attribute speichern: keine Passwörter, keine Gruppenmitgliedschaften des Mitarbeiters -- [ ] `docker-compose.test.yml` um OpenLDAP erweitern, mit AD-ähnlichem Fixture-Schema (`sAMAccountName`, `userPrincipalName`, binäres `objectGUID`) — aus #005 hierher verschoben, weil der Dienst erst hier benutzt wird -- [ ] Screenshots Verzeichnis, Verbindungstest, Abgleich-Log, CSV-Import in `docs/screenshots/m11/` +- [x] `ldapts` 9.x: `ldaps` bevorzugt, StartTLS optional, eigenes CA-Zertifikat hinterlegbar (`caCertPem`), `minVersion: TLSv1.2` +- [x] `searchPaginated` mit `pageSize: 1000` — **nicht** `search` mit `paged`, das puffert alles im Speicher +- [x] `explicitBufferAttributes: ['objectGUID']` und korrekte Umsortierung der gemischt-endianen Bytes zur GUID-Zeichenkette als `externeId` +- [x] Standardfilter `(&(objectCategory=person)(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))` +- [x] Attribut-Mapping mit AD-Vorgaben: `givenName`, `sn`, `displayName`, `mail`, `userPrincipalName` → `upn`, `sAMAccountName` → `sam`, `department`, `title`, `manager` → `vorgesetzterExterneId`, `distinguishedName` → `dn` +- [x] „Verbindung testen“: zeigt die ersten 10 Treffer mit gemappten Feldern, ohne etwas zu schreiben +- [x] `abgleichService`: anlegen, aktualisieren, verschwundene oder deaktivierte Konten auf `aktiv = false` — **nie löschen**; Ergebnis (angelegt/aktualisiert/deaktiviert/Fehler) in `letzterAbgleichStatus`, `letzterAbgleichFehler`, `letzterAbgleichAm` und im `Protokoll` +- [x] Zeitplan mit `node-cron` im selben Prozess, Intervall je Quelle (Standard 60 min), Sperre über `AbgleichSperre` in der Datenbank, damit nur eine Instanz läuft; zusätzlich „Jetzt abgleichen“ +- [x] Bind-Passwort verschlüsselt über `src/lib/geheimnisse.ts`, Serializer entfernt `bindPasswortVerschluesselt` aus jeder Antwort +- [x] Admin → Verzeichnis: Quelle anlegen und bearbeiten, Verbindung testen, Abgleich-Status und -Log, „Jetzt abgleichen“ +- [x] Manuelle Mitarbeiter und CSV-Import (`givenName`, `sn`, `displayName`, `mail`, `upn`, `sam`, `department`, `title`) +- [x] Abteilungsvorschläge aus den `department`-Werten des Abgleichs, vom Admin in `Abteilung`-Einträge übernehmbar +- [x] Nur die gemappten Attribute speichern: keine Passwörter, keine Gruppenmitgliedschaften des Mitarbeiters +- [x] `docker-compose.test.yml` um OpenLDAP erweitern, mit AD-ähnlichem Fixture-Schema (`sAMAccountName`, `userPrincipalName`, binäres `objectGUID`) — aus #005 hierher verschoben, weil der Dienst erst hier benutzt wird +- [x] Screenshots Verzeichnis, Verbindungstest, Abgleich-Log, CSV-Import in `docs/screenshots/m11/` ## Akzeptanzkriterien -- [ ] Integrationstest gegen den Container: erster Lauf legt Mitarbeiter an, zweiter Lauf aktualisiert sie ohne Duplikate -- [ ] Integrationstest: ein aus dem Verzeichnis entferntes Konto wird `aktiv = false` und bleibt in der Datenbank -- [ ] Integrationstest: ein per `userAccountControl` deaktiviertes Konto wird `aktiv = false` -- [ ] Test: `{{ou}}` wird korrekt aus dem `dn` abgeleitet -- [ ] Test: `objectGUID` als Buffer ergibt reproduzierbar dieselbe GUID-Zeichenkette (bekannter Byte-Vektor als Fixture) -- [ ] Test: keine API-Antwort und kein Server-Log enthält das Bind-Passwort im Klartext -- [ ] Test: zwei gleichzeitig gestartete Abgleiche derselben Quelle — der zweite wird von der Sperre abgewiesen -- [ ] Im Modus `DIENSTLEISTUNG` ist Admin → Verzeichnis nicht erreichbar +- [x] Integrationstest gegen den Container: erster Lauf legt Mitarbeiter an, zweiter Lauf aktualisiert sie ohne Duplikate +- [x] Integrationstest: ein aus dem Verzeichnis entferntes Konto wird `aktiv = false` und bleibt in der Datenbank +- [x] Integrationstest: ein per `userAccountControl` deaktiviertes Konto wird `aktiv = false` +- [x] Test: `{{ou}}` wird korrekt aus dem `dn` abgeleitet +- [x] Test: `objectGUID` als Buffer ergibt reproduzierbar dieselbe GUID-Zeichenkette (bekannter Byte-Vektor als Fixture) +- [x] Test: keine API-Antwort und kein Server-Log enthält das Bind-Passwort im Klartext +- [x] Test: zwei gleichzeitig gestartete Abgleiche derselben Quelle — der zweite wird von der Sperre abgewiesen +- [x] Im Modus `DIENSTLEISTUNG` ist Admin → Verzeichnis nicht erreichbar + +## Ergebnis + +Erledigt am 2026-09-26. **1098 Tests** (vorher 980) und **71 Ende-zu-Ende-Tests** (vorher 67) grün, +Screenshots in `docs/screenshots/m11/`. + +Über die Vorgabe hinaus entstanden drei Dinge, die der Review verlangt hat und die zum Kern der +Sache gehören: + +- **Eine Notbremse gegen den teuersten Fehler.** Lässt sich kein einziger gelesener Eintrag + zuordnen, wird niemand deaktiviert — der Lauf meldet einen Fehler mit Namen. Ein Tippfehler im + Kennungsattribut hätte sonst die gesamte Belegschaft abgeschaltet und dabei `ERFOLG` gemeldet. +- **Die Attributzuordnung ist in der Oberfläche bearbeitbar.** Vorher stand sie nur in der + Datenbank, obwohl Code und Hilfetext etwas anderes versprachen. +- **Ein Abgleich-Log je Quelle** mit den letzten zehn Läufen: gelesen, angelegt, übernommen, + aktualisiert, deaktiviert, übersprungen — und wer sie angestoßen hat, Mensch oder Zeitplan. + +### Der unabhängige Review + +Ein Blocker, acht Befunde, sieben Anmerkungen: + +- **Blocker: Abgleich ohne zuordenbare Einträge schaltete alle ab.** Siehe oben. Das Log hat den + Fall danach selbst dokumentiert — der Lauf um 01:20 zeigt „3 deaktiviert, 4 übersprungen“. +- Übersprungene Einträge wurden mitdeaktiviert, obwohl sie im Ergebnis standen. +- `VerbindungsFehler` erbte nicht von `VerzeichnisFehler`: falsches Passwort, falscher Basis-DN und + ein toter Server sahen in der Oberfläche identisch aus. +- Ergebniscodes wie `0x31` standen roh in der Meldung. +- Bei einem Abbruch mitten im Lesen stand „angelegt: 1500, gelesen: 0“. +- `faelligeQuellen` fragte ohne Mandantengrenze. +- Eine von Hand angelegte Person mit demselben `sam` wurde vom Abgleich verdoppelt. +- Ein hinterlegtes CA-Zertifikat war unsichtbar und nicht entfernbar. +- Die Intervallgrenzen liefen auseinander: 1440 Minuten in der Oberfläche, 10080 im Dienst. diff --git a/docs/issues/README.md b/docs/issues/README.md index 767ae1b..cb7af6e 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -20,8 +20,8 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac | #013 | [M8 – Läufe: Service und Abarbeitung](013-laeufe-abarbeitung.md) | erledigt | v0.1.0 | 14 h | 3 h 05 min | | #014 | [M9 – QR-Tags, Geräteseite, Etiketten, Scanner](014-qr-tags-geraeteseite.md) | erledigt | v0.1.0 | 12 h | 3 h 35 min | | #015 | [M10 – Dashboard und Archiv](015-dashboard-archiv.md) | erledigt | v0.1.0 | 8 h | 2 h 20 min | -| #016 | [M11 – Verzeichnis und LDAP-Abgleich](016-verzeichnis-ldap.md) | offen | v0.1.0 | 12 h | – | +| #016 | [M11 – Verzeichnis und LDAP-Abgleich](016-verzeichnis-ldap.md) | erledigt | v0.1.0 | 12 h | 3 h 25 min | | #017 | [M12 – Seed der sieben Vorlagen](017-seed-sieben-vorlagen.md) | offen | v0.1.0 | 8 h | – | | #018 | [M13 – E2E-Tests, Anleitung, PDF](018-e2e-anleitung-pdf.md) | offen | v0.1.0 | 14 h | – | | #019 | [M14 – Betriebswege und Release v0.1.0](019-betrieb-release.md) | offen | v0.1.0 | 10 h | – | -| | **Summe** | | | **136 h** | **22 h 45 min** | +| | **Summe** | | | **136 h** | **26 h 10 min** | diff --git a/docs/screenshots/m11/abgleich-log-desktop.png b/docs/screenshots/m11/abgleich-log-desktop.png new file mode 100644 index 0000000..3b33229 Binary files /dev/null and b/docs/screenshots/m11/abgleich-log-desktop.png differ diff --git a/docs/screenshots/m11/abgleich-log-handy.png b/docs/screenshots/m11/abgleich-log-handy.png new file mode 100644 index 0000000..9604c61 Binary files /dev/null and b/docs/screenshots/m11/abgleich-log-handy.png differ diff --git a/docs/screenshots/m11/abteilungen-vorschlaege-desktop.png b/docs/screenshots/m11/abteilungen-vorschlaege-desktop.png new file mode 100644 index 0000000..1f9f8b9 Binary files /dev/null and b/docs/screenshots/m11/abteilungen-vorschlaege-desktop.png differ diff --git a/docs/screenshots/m11/abteilungen-vorschlaege-handy.png b/docs/screenshots/m11/abteilungen-vorschlaege-handy.png new file mode 100644 index 0000000..4a43e5f Binary files /dev/null and b/docs/screenshots/m11/abteilungen-vorschlaege-handy.png differ diff --git a/docs/screenshots/m11/personen-import-desktop.png b/docs/screenshots/m11/personen-import-desktop.png new file mode 100644 index 0000000..3ffdb4e Binary files /dev/null and b/docs/screenshots/m11/personen-import-desktop.png differ diff --git a/docs/screenshots/m11/personen-import-handy.png b/docs/screenshots/m11/personen-import-handy.png new file mode 100644 index 0000000..e8f916d Binary files /dev/null and b/docs/screenshots/m11/personen-import-handy.png differ diff --git a/docs/screenshots/m11/verbindungstest-desktop.png b/docs/screenshots/m11/verbindungstest-desktop.png new file mode 100644 index 0000000..239f620 Binary files /dev/null and b/docs/screenshots/m11/verbindungstest-desktop.png differ diff --git a/docs/screenshots/m11/verbindungstest-handy.png b/docs/screenshots/m11/verbindungstest-handy.png new file mode 100644 index 0000000..ddd574f Binary files /dev/null and b/docs/screenshots/m11/verbindungstest-handy.png differ diff --git a/docs/screenshots/m11/verzeichnis-desktop.png b/docs/screenshots/m11/verzeichnis-desktop.png new file mode 100644 index 0000000..c13dcd6 Binary files /dev/null and b/docs/screenshots/m11/verzeichnis-desktop.png differ diff --git a/docs/screenshots/m11/verzeichnis-handy.png b/docs/screenshots/m11/verzeichnis-handy.png new file mode 100644 index 0000000..01dfc0b Binary files /dev/null and b/docs/screenshots/m11/verzeichnis-handy.png differ diff --git a/scripts/screenshots.ts b/scripts/screenshots.ts index f42a613..70f48bd 100644 --- a/scripts/screenshots.ts +++ b/scripts/screenshots.ts @@ -18,7 +18,7 @@ * Bild sieht dann richtig aus, zeigt aber eine Seite, auf der kein Knopf etwas * tut. Siehe CLAUDE.md, Abschnitt „Oberflaeche pruefen“. */ -import { mkdir } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { chromium, type Browser, type Cookie, type Locator, type Page } from "@playwright/test"; @@ -191,6 +191,56 @@ const SEITEN: readonly Seite[] = [ }, }, { name: "archiv", pfad: "/archiv?suche=Inventar" }, + { name: "verzeichnis", pfad: "/admin/verzeichnis", nurModus: "INTERN" }, + { + /* + * Der Verbindungstest zeigt seinen Wert erst mit Ergebnis: die Tabelle der + * gemappten Felder. Also wird der Knopf gedrueckt und gewartet, bis der + * Server geantwortet hat — ohne Container steht dort eine Fehlermeldung, + * und auch die ist ein ehrliches Bild. + */ + name: "verbindungstest", + pfad: "/admin/verzeichnis", + nurModus: "INTERN", + vorbereiten: async (tab) => { + const knopf = tab.getByRole("button", { name: "Verbindung testen" }).first(); + if ((await knopf.count()) === 0) return; + await knopf.click(); + await tab.waitForTimeout(4000); + }, + }, + { + /* + * Das Abgleich-Log braucht einen Lauf, damit ueberhaupt etwas darin steht. + * Also erst „Jetzt abgleichen“ druecken und dann aufklappen — ob der Lauf + * gelingt, haengt am Testcontainer, und beide Ausgaenge sind ein ehrliches + * Bild: die Zeile zeigt Zahlen oder den Grund. + */ + name: "abgleich-log", + pfad: "/admin/verzeichnis", + nurModus: "INTERN", + vorbereiten: async (tab) => { + const knopf = tab.getByRole("button", { name: "Jetzt abgleichen" }).first(); + if ((await knopf.count()) > 0) { + await knopf.click(); + await tab.waitForTimeout(5000); + await tab.reload({ waitUntil: "load" }); + } + const aufklappen = tab.locator("summary", { hasText: "Abgleich-Log" }).first(); + if ((await aufklappen.count()) === 0) return; + await aufklappen.click(); + await tab.waitForTimeout(150); + }, + }, + { + name: "personen-import", + pfad: "/mitarbeiter", + vorbereiten: async (tab) => { + await tab.locator("summary", { hasText: "CSV" }).first().click(); + await tab.waitForTimeout(150); + }, + }, + { name: "abteilungen-vorschlaege", pfad: "/admin/abteilungen" }, { /* * Die Druckansicht braucht einen Lauf, und welcher das ist, weiss nur die @@ -263,15 +313,30 @@ async function anmelden(browser: Browser): Promise { return kekse; } -/** Erzeugt die PWA-Kacheln aus dem SVG, damit keine Bilddatei von Hand entsteht. */ +/** + * Erzeugt die PWA-Kacheln aus dem SVG, damit keine Bilddatei von Hand entsteht. + * + * Geschrieben wird nur, wenn sich die Bytes wirklich unterscheiden. Vorher legte + * jeder Screenshot-Lauf zwei geaenderte PNG-Dateien in den Arbeitsbaum, obwohl das + * Bild dasselbe war — und `git status` zeigte Aenderungen, die niemand gemacht + * hatte (Reviewbefund zu #016). + */ async function kachelnErzeugen(browser: Browser): Promise { const svg = new URL(`${APP_URL}/icon-kachel.svg`).toString(); for (const kante of [192, 512]) { const seite = await browser.newPage({ viewport: { width: kante, height: kante } }); await seite.goto(svg, { waitUntil: "load" }); - await seite.screenshot({ path: join("public", `icon-${kante}.png`), omitBackground: false }); + const neu = await seite.screenshot({ omitBackground: false }); await seite.close(); - console.log(` public/icon-${kante}.png`); + + const datei = join("public", `icon-${kante}.png`); + const alt = await readFile(datei).catch(() => undefined); + if (alt && alt.equals(neu)) { + console.log(` ${datei} (unverändert)`); + continue; + } + await writeFile(datei, neu); + console.log(` ${datei}`); } } diff --git a/src/app/(app)/admin/abteilungen/page.tsx b/src/app/(app)/admin/abteilungen/page.tsx index c71597f..6cc4db9 100644 --- a/src/app/(app)/admin/abteilungen/page.tsx +++ b/src/app/(app)/admin/abteilungen/page.tsx @@ -1,6 +1,7 @@ import { Building, Plus } from "lucide-react"; import { AbteilungArchivieren } from "@/components/abteilung-archivieren"; import { AbteilungFormular } from "@/components/abteilung-formular"; +import { AbteilungVorschlaege } from "@/components/abteilung-vorschlaege"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { kundenEbeneSichtbar } from "@/server/mandant"; import { kundenListe } from "@/server/queries/kunden"; @@ -44,19 +45,16 @@ export default async function AbteilungenSeite() { Standardgruppen bleiben die Platzhalter {"{{ou}}"} und{" "} {"{{gruppen}}"} im Lauf leer.

- + {/* Der Knopf je Vorschlag ruft dieselbe Server Action wie das Formular + unten (#016) — eine zweite Regel zum Anlegen gibt es nicht. */} + ({ + ...eintrag, + kundeName: kundenEbene + ? (kunden.find((kunde) => kunde.id === eintrag.kundeId)?.name ?? null) + : null, + }))} + /> ) : null} diff --git a/src/app/(app)/admin/layout.tsx b/src/app/(app)/admin/layout.tsx index a4053b3..42e77a2 100644 --- a/src/app/(app)/admin/layout.tsx +++ b/src/app/(app)/admin/layout.tsx @@ -1,6 +1,8 @@ import Link from "next/link"; import { headers } from "next/headers"; +import { Betriebsmodus } from "@/generated/prisma/enums"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { betriebsmodus } from "@/server/mandant"; /** * Der Verwaltungsbereich. Die Rechtepruefung steht hier **und** in jeder Seite @@ -11,21 +13,31 @@ const REITER = [ { pfad: "/admin", beschriftung: "Einstellungen" }, { pfad: "/admin/benutzer", beschriftung: "Benutzer" }, { pfad: "/admin/abteilungen", beschriftung: "Abteilungen" }, + /* + * 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 + * fremden Verzeichnissen — genau das ist ausgeschlossen (#008). Der Reiter + * verschwindet deshalb mit dem Modus, und die Seite selbst leitet ebenfalls + * um: ein Reiter ist eine Bequemlichkeit, keine Sperre (#016). + */ + { pfad: "/admin/verzeichnis", beschriftung: "Verzeichnis", nurIntern: true }, ] as const; export default async function AdminLayout({ children }: { children: React.ReactNode }) { await sitzungErforderlich("verwaltung", "/admin"); const pfad = (await headers()).get("x-pfad") ?? "/admin"; + const intern = (await betriebsmodus()) === Betriebsmodus.INTERN; + const reiter = REITER.filter((eintrag) => intern || !("nurIntern" in eintrag && eintrag.nurIntern)); return ( <>