Acht Meilensteine lagen ohne fremden Blick auf main. Ein Review mit eigener
Datenbank, Gegenproben und vollem Suitenlauf hat sie gegen die
Akzeptanzkriterien und gegen CLAUDE.md geprueft.
Die Kernregeln halten: fuenf von fuenf Gegenproben gegen Versionierung,
Mandantengrenze, Abschlussregel und Selbstauskunft wurden rot. Alle Befunde
lagen am Rand — und die drei wertvollsten waren Gegenproben, die gruen blieben.
Schwer:
- Der Link in der Mail "Verzeichnis-Abgleich fehlgeschlagen" war ein 404
(/verwaltung/verzeichnis statt /admin/verzeichnis). Die eine Meldung, die an
alle Administratoren geht, im Moment eines Ausfalls. Ein Test prueft jetzt
jeden Meldungspfad gegen die vorhandenen Routen.
- Der Export eines Entwurfs mit Baustein lieferte einen leeren Abschnitt unter
dem Platzhalter "wird ersetzt" — der Zweck des Formats ist, eine Vorlage in
eine zweite Installation zu bringen. Bausteine werden jetzt aufgeloest.
Mittel:
- Der Anhang-Handler hatte keinen Test; die ganze Rechtepruefung liess sich
durch einen hartcodierten ADMIN ersetzen, Suite gruen. Es ist die einzige
Stelle, die rohe Dateibytes ausliefert. tests/anhang-route.test.ts faehrt ihn
jetzt echt, acht Pruefungen.
- /api/scim in der Proxy-Allowlist stand in keinem Test. OEFFENTLICH ist
exportiert, der Test vergleicht beide Richtungen: eine Liste, die nur in einer
Richtung geprueft wird, laeuft dem Code hinterher.
- Ein offener Ereignisstrom ueberlebte Abmelden, Deaktivieren und
Sitzungswiderruf. Der Herzschlag prueft die Sitzung jetzt nach. Dass es kein
Test zeigte, lag an einer Attrappe `sitzung: {}` — eine Attrappe ohne die Form
der Wirklichkeit prueft etwas anderes als die Wirklichkeit.
- README kannte MITARBEITER nicht und behauptete, unbekannte Benutzer wuerden
angelegt. Wer auf v0.2.0 hebt und nur das README liest, haette genau diese
Leute ausgesperrt.
- sicherung.md nannte einen Anhaenge-Pfad, den es nirgends gibt — als
Rueckfallwert in einem Skript mit rm -rf. Jetzt bricht es ab statt zu raten.
- Attributnamen wurden nur in der Server Action geprueft, waehrend der
Graph-Leser sich im Kommentar darauf berief.
Dazu #027: die Ende-zu-Ende-Ablaeufe raeumen jetzt zentral auf, vor und nach
dem Lauf, und ein struktureller Test bewacht es. Beim ersten echten Lauf seit
dem 26. September waren sieben Ablaeufe rot, vier Ursachen, keine ein
Produktfehler — unbemerkt, weil pnpm test:e2e in keinem Tor steht. Das ist jetzt
#038 und keine Fussnote.
Ein Befund des Reviews war falsch (der Zustands-Handler habe keinen Test; er hat
acht direkte Aufrufe). Nachgeprueft, bevor etwas geaendert wurde.
1454 Vitest-Tests (vorher 1432), 113 E2E-Ablaeufe gruen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
262 lines
14 KiB
Markdown
262 lines
14 KiB
Markdown
# laufzettel
|
|
|
|
Checklisten für wiederkehrende IT-Arbeiten. Versionierte Vorlagen, daraus gestartete Läufe, im
|
|
Team abgearbeitet, abgeschlossen und unveränderlich archiviert — mit Historie je Gerät und je
|
|
Mitarbeiter. Einstieg über QR-Aufkleber: Code scannen, Geräteseite öffnen, loslegen.
|
|
|
|
> **Stand:** `v0.1.0` ist veröffentlicht, Phase 2 liegt auf `main` und wird `v0.2.0`. Der
|
|
> Fortschritt steht in [`docs/issues/README.md`](docs/issues/README.md), Entscheidungen im
|
|
> [Entwicklungstagebuch](docs/devlog.md).
|
|
|
|
## Zwei Betriebsmodi
|
|
|
|
| Modus | Für wen | Kunden-Ebene | Verzeichnis |
|
|
|---|---|---|---|
|
|
| `INTERN` (Standard) | eine Firma, eigene Mitarbeiter | unsichtbar | eigenes Verzeichnis per LDAP, Microsoft Graph oder SCIM |
|
|
| `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
|
|
Zeile trägt immer eine Kunden-Zuordnung, im Modus `INTERN` die eigene Organisation — deshalb ist
|
|
der Wechsel eine Datenänderung und keine Migration.
|
|
|
|
## Voraussetzungen
|
|
|
|
- Node 24 (`.nvmrc`) und pnpm 12 über corepack, **oder** nur Docker mit Compose-Plugin
|
|
- PostgreSQL 17 (im Compose-Stand enthalten)
|
|
|
|
## Entwicklung
|
|
|
|
```bash
|
|
corepack enable pnpm
|
|
pnpm install
|
|
cp .env.example .env # APP_SECRET und Passwörter ausfüllen
|
|
pnpm db:up # PostgreSQL auf 127.0.0.1:5434
|
|
pnpm db:migrate
|
|
pnpm db:seed
|
|
pnpm dev # http://localhost:3000
|
|
```
|
|
|
|
`APP_SECRET` erzeugen:
|
|
|
|
```bash
|
|
openssl rand -base64 48
|
|
```
|
|
|
|
### Qualitätsschranken
|
|
|
|
```bash
|
|
docker compose -f docker-compose.test.yml up -d --wait ldap mailpit # sonst fehlen 25 Tests
|
|
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
|
|
Verzeichnis-Abgleich (22 Tests), ohne `mailpit` den echten Mailversand (3 Tests) — sie sagt das auf
|
|
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.
|
|
Ende-zu-Ende-Tests: `pnpm test:e2e` (verlangt einen gebauten, laufenden Stand — siehe
|
|
`docs/anleitung/`).
|
|
|
|
## Betrieb mit Docker Compose
|
|
|
|
```bash
|
|
cp .env.example .env # APP_URL, APP_SECRET, POSTGRES_PASSWORD sind Pflicht
|
|
docker compose up -d --build
|
|
```
|
|
|
|
Die Migrationen laufen beim Start automatisch in einem eigenen Einmal-Dienst. Der **Seed läuft
|
|
absichtlich nicht automatisch** — ihn startest du einmal von Hand:
|
|
|
|
```bash
|
|
docker compose run --rm migrate pnpm db:seed
|
|
```
|
|
|
|
### Was der Seed anlegt
|
|
|
|
| | Inhalt |
|
|
|---|---|
|
|
| Grundbestand | Betriebsmodus, eigene Organisation, Administrator aus `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORD` |
|
|
| Vorlagen | die sieben Abläufe des Hauses als **veröffentlichte Version 1** — PC Neuinstallation, Onboarding, Offboarding, Server-Inbetriebnahme, Firewall-Rollout, Patchday, Restore-Test |
|
|
| Beispielbestand | 3 Abteilungen, 5 Personen, `PC-001` und `NB-001` mit Tag-Code, 5 vorgedruckte Tags, Schnellstart-Vorgaben je Gerätetyp |
|
|
| Verzeichnis | eine **deaktivierte** Beispielquelle zum Abschauen — Adresse und Bind-Passwort ersetzen, dann einschalten |
|
|
|
|
Mit `SEED_MODE=DIENSTLEISTUNG` (Alias `SEED_MODE=SERVICE`) kommen zwei Demo-Kunden dazu,
|
|
„Musterfirma GmbH" (`MUS`) und „Bäckerei Reiter" (`REI`), mit je einer Abteilung, einer Person und
|
|
einem Gerät nach der Hostname-Regel (`MUS-PC-001`).
|
|
|
|
**Der Seed richtet ein, er räumt nicht auf.** Was schon da ist, bleibt: eine bearbeitete Vorlage,
|
|
eine geänderte Ziel-OU oder ein umgeschalteter Betriebsmodus überstehen jeden weiteren Lauf. Zweimal
|
|
hintereinander aufgerufen ändert der zweite Lauf nichts.
|
|
|
|
Die sieben Vorlagen liegen als JSON im [Austauschformat](docs/vorlagen-format.md) unter
|
|
`prisma/seed-daten/vorlagen/`. Wer eine anpassen will, ändert die Datei — oder besser: die Vorlage
|
|
in der Oberfläche, denn eine veröffentlichte Version wird nie verändert, und der Seed fasst eine
|
|
vorhandene Vorlage nicht mehr an.
|
|
|
|
### Die Datenbank ist von außen nicht erreichbar
|
|
|
|
Das ist bewusst so gebaut und in `tests/betriebsstand.test.ts` festgehalten:
|
|
|
|
- `postgres` hat **kein** `ports:`-Mapping, nur `expose`
|
|
- `postgres` hängt ausschließlich im Netz `intern`, das `internal: true` ist — ein Netz ohne
|
|
Gateway, also weder vom Host noch aus dem LAN erreichbar
|
|
- nur `web` hängt zusätzlich im Netz `extern` (Reverse Proxy, OIDC, LDAP)
|
|
- `web` bindet standardmäßig auf `127.0.0.1:3000`; davor gehört ein Reverse Proxy
|
|
|
|
Prüfen:
|
|
|
|
```bash
|
|
nc -z localhost 5432 && echo "FEHLER: offen" || echo "dicht"
|
|
docker compose exec web nc -z postgres 5432 && echo "von innen erreichbar"
|
|
```
|
|
|
|
Für die Entwicklung gibt es `docker-compose.dev.yml`, das PostgreSQL auf `5434` öffnet — umstellbar
|
|
über `DEV_DB_PORT`. 5432 ist oft lokal belegt und 5433 von einem anderen Projekt, daher dieser Port.
|
|
Diese Datei ist nicht für den Betrieb gedacht.
|
|
|
|
### Reverse Proxy
|
|
|
|
Die App läuft hinter einem Reverse Proxy und hat keine fest verdrahteten Adressen. Maßgeblich ist
|
|
`APP_URL` — sie steckt in den QR-Codes und in der OIDC-Rückruf-Adresse. Der Proxy muss
|
|
`X-Forwarded-Proto` und `X-Forwarded-Host` setzen.
|
|
|
|
### Sicherung
|
|
|
|
Der geschlossene Port ändert nichts an der Sicherung, sie läuft über den Dienstnamen:
|
|
|
|
```bash
|
|
docker compose exec -T postgres pg_dump -U laufzettel laufzettel | gzip > sicherung-$(date +%F).sql.gz
|
|
```
|
|
|
|
Zurückspielen:
|
|
|
|
```bash
|
|
gunzip -c sicherung-2026-09-24.sql.gz | docker compose exec -T postgres psql -U laufzettel laufzettel
|
|
```
|
|
|
|
Gesichert werden müssen **zwei** Dinge: die Datenbank (das Volume heißt `laufzettel_datenbank`; die
|
|
App selbst hält keinen veränderlichen Zustand auf der Platte) **und die Geheimnisse aus der `.env`**.
|
|
Ohne das alte `APP_SECRET` sind nach dem Zurückspielen alle gespeicherten LDAP-Bind-Passwörter
|
|
unlesbar — und das merkt man erst beim nächsten Abgleich. Einzelheiten in
|
|
[`docs/betrieb/sicherung.md`](docs/betrieb/sicherung.md).
|
|
|
|
## Anmeldung
|
|
|
|
Zwei Wege, beide gleichzeitig möglich:
|
|
|
|
- **OIDC** (Ziel: authentik) für den normalen Betrieb
|
|
- **lokal mit E-Mail und Passwort**, nur wenn `AUTH_LOCAL_ENABLED=true` — für die Entwicklung und
|
|
als Notfallzugang, wenn der Identitätsanbieter ausfällt
|
|
|
|
Die Rolle kommt bei jedem OIDC-Login aus dem `groups`-Claim und wird über
|
|
`OIDC_GROUP_ADMIN`, `OIDC_GROUP_EDITOR`, `OIDC_GROUP_TECHNICIAN` und `OIDC_GROUP_VIEWER`
|
|
zugeordnet. Unbekannte Benutzer werden beim ersten Login angelegt.
|
|
|
|
| Rolle | Darf |
|
|
|---|---|
|
|
| `ADMIN` | alles, inkl. Benutzer, Verzeichnis, Abteilungen, Archiv-Verwaltung |
|
|
| `REDAKTEUR` | Vorlagen anlegen, bearbeiten, veröffentlichen; alles was `TECHNIKER` darf |
|
|
| `TECHNIKER` | Läufe starten, abarbeiten, abschließen, archivieren; Geräte pflegen |
|
|
| `LESER` | nur lesen |
|
|
| `MITARBEITER` | **nur die eigenen Geräte und deren Läufe** — ohne Schritte, Notizen und Kommentare |
|
|
|
|
**`MITARBEITER` hat keine eigene Gruppe, und das ist Absicht** (seit #035). Wer sich anmeldet und in
|
|
keiner der vier abgebildeten Gruppen steht, bekommt **nicht mehr** `LESER` — das hieße Lesezugriff
|
|
auf alle Läufe des Hauses. Stattdessen versucht Laufzettel, ihn über seinen UPN einer Person im
|
|
Verzeichnis zuzuordnen: klappt das, bekommt er die Selbstauskunft; klappt es nicht, wird die
|
|
Anmeldung mit einer Begründung abgewiesen.
|
|
|
|
Das greift **nur, wenn Rollengruppen konfiguriert sind** — ohne `OIDC_GROUP_*` bleibt alles wie
|
|
vorher, denn ohne abgebildete Gruppen kann die Anwendung nicht unterscheiden, ob jemand Techniker
|
|
oder Mitarbeiter ist. Wer das alte Verhalten für bestimmte Leute braucht, trägt sie in die
|
|
Leser-Gruppe ein. Einzelheiten in
|
|
[`docs/betrieb/authentik.md`](docs/betrieb/authentik.md).
|
|
|
|
Die Anbindung an authentik ist in [`docs/betrieb/authentik.md`](docs/betrieb/authentik.md)
|
|
Schritt für Schritt beschrieben.
|
|
|
|
## Verzeichnis (nur Modus `INTERN`)
|
|
|
|
Unter **Admin → Verzeichnis** wird eine Quelle angelegt. Es gibt drei Arten, und sie unterscheiden
|
|
sich in der **Richtung**:
|
|
|
|
| Art | Für wen | Richtung |
|
|
|---|---|---|
|
|
| `LDAP` | lokales Active Directory oder anderes LDAP-Verzeichnis | Laufzettel holt nach Zeitplan |
|
|
| `GRAPH` | Entra ID über Microsoft Graph, für Häuser ohne lokales AD | Laufzettel holt nach Zeitplan |
|
|
| `SCIM` | authentik, Entra ID, Okta — wenn Laufzettel nicht ins Verzeichnis sehen soll oder es nicht erreicht | der Anbieter schickt, sofort |
|
|
|
|
**Die Art ist unveränderlich.** An ihr hängt, was die dauerhafte Kennung einer Person bedeutet;
|
|
nach einem Wechsel würde niemand wiedererkannt. Alle drei verhalten sich danach gleich: anlegen,
|
|
übernehmen, aktualisieren, deaktivieren, **nie löschen**.
|
|
|
|
Für **LDAP** gehören ins Formular: Adresse (`ldaps://` empfohlen, `ldap+starttls://` und `ldap://`
|
|
gehen auch), ein Dienstkonto mit **Leserecht**, der Basis-DN und optional ein eigener Filter. Ein
|
|
Dienstkonto genügt — Laufzettel schreibt nie ins Verzeichnis. Für **Graph** eine App-Registrierung
|
|
mit der Anwendungsberechtigung `User.Read.All`, siehe `docs/betrieb/entra-id.md`.
|
|
|
|
Für **SCIM** gibt es keine Zugangsdaten: Quelle anlegen, Token erzeugen (es erscheint **einmal**,
|
|
gespeichert wird nur ein argon2-Hash), Token und die Adresse `…/api/scim/v2` beim Anbieter
|
|
eintragen. Verbindungstest, Zeitplan und Abgleich-Log gibt es dort nicht — an ihrer Stelle steht
|
|
die letzte Zustellung. Ein `DELETE` des Anbieters **deaktiviert** die Person und löscht nichts.
|
|
Einzelheiten in `docs/betrieb/scim.md`.
|
|
|
|
- **„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
|
|
|
|
Jede Variable steht mit Erklärung in [`.env.example`](.env.example) — hier die Übersicht. **Pflicht**
|
|
sind `APP_URL`, `APP_SECRET`, `POSTGRES_PASSWORD` und `DATABASE_URL`; fehlt eine davon, bricht der
|
|
Compose-Stand ab, statt mit Standardwerten zu starten.
|
|
|
|
| Variable | Bedeutung |
|
|
|---|---|
|
|
| `APP_URL` | Die **öffentliche** Adresse, unter der die App erreichbar ist. Steht sie hinter einem Reverse Proxy, ist es dessen Adresse — nicht die interne. Aus ihr entstehen die QR-Adressen auf den Aufklebern und die OIDC-Rücksprungadresse. |
|
|
| `APP_SECRET` | Schlüssel für Sitzungs-Cookies und die Verschlüsselung des LDAP-Bind-Passworts. Mindestens 32 Zeichen, erzeugen mit `openssl rand -base64 48`. **Ändert man ihn, sind alle Sitzungen ungültig und gespeicherte Bind-Passwörter unlesbar.** |
|
|
| `DATABASE_URL` | Verbindung zur PostgreSQL-Datenbank. |
|
|
| `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` | Zugangsdaten des Datenbank-Containers im Compose-Stand. |
|
|
| `AUTH_LOCAL_ENABLED` | `true` schaltet die lokale Anmeldung mit E-Mail und Passwort frei — Notfallzugang und Entwicklung. Auf `false` verschwindet das Formular **und** der Endpunkt lehnt ab. |
|
|
| `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` | Der Identitätsanbieter. Discovery über `<issuer>/.well-known/openid-configuration`. |
|
|
| `OIDC_NAME` | Beschriftung des Anmeldeknopfs, etwa „Firmenanmeldung“. |
|
|
| `OIDC_SCOPE` | Angefragte Scopes; muss einen enthalten, der `groups` liefert. |
|
|
| `OIDC_GROUP_ADMIN`, `OIDC_GROUP_EDITOR`, `OIDC_GROUP_TECHNICIAN`, `OIDC_GROUP_VIEWER` | Welche Gruppe welche Rolle bekommt. Die Rolle wird bei **jeder** Anmeldung neu gesetzt. |
|
|
| `OIDC_CUSTOMER_GROUP_PREFIX` | Nur im Modus `DIENSTLEISTUNG`: Gruppen mit diesem Präfix weisen einen Benutzer einem Kunden zu. |
|
|
| `BIND_ADDR`, `APP_PORT` | Wohin der Compose-Stand die App bindet. Standard `127.0.0.1:3000` — davor gehört ein Reverse Proxy. |
|
|
| `APP_VERSION`, `GIT_SHA` | Erscheinen unten links in der Oberfläche. Im Compose-Stand Build-Argumente. |
|
|
| `SEED_ADMIN_EMAIL`, `SEED_ADMIN_PASSWORD` | Der Administrator, den `pnpm db:seed` anlegt. Das Passwort wird nur als argon2-Hash gespeichert. Fehlt eines, überspringt der Seed ihn. |
|
|
| `SEED_MODE` | `INTERN` (Standard) oder `DIENSTLEISTUNG` — der Alias `SERVICE` tut dasselbe. Betrifft nur den **ersten** Seed-Lauf; danach steht der Modus in der Datenbank. |
|
|
| `SEED_ORG_NAME`, `SEED_ORG_KUERZEL` | Name und Kürzel der eigenen Organisation. Das Kürzel steht auf Etiketten und in der Hostname-Regel. |
|
|
| `ZEITPLAN_AKTIV` | `false` schaltet den Verzeichnis-Abgleich nach Zeitplan ab. |
|
|
| `TEST_DATABASE_URL` | Datenbank für die Tests. Ohne sie überspringen sich alle Datenbanktests. |
|
|
|
|
## Dokumentation
|
|
|
|
- [Anleitung für Technik und Verwaltung](docs/anleitung/) — acht Kapitel mit Bildern. `pnpm docs:pdf`
|
|
macht daraus ein PDF; jedem Release liegt es bei
|
|
- [Architektur und Entscheidungen](docs/architektur.md)
|
|
- [Austauschformat für Vorlagen](docs/vorlagen-format.md) — wie eine Vorlage als JSON aussieht
|
|
- [Betrieb](docs/betrieb/) — [Inbetriebnahme mit Compose](docs/betrieb/inbetriebnahme.md),
|
|
[nativ im Proxmox-LXC](docs/betrieb/proxmox.md), [Reverse Proxy](docs/betrieb/reverse-proxy.md),
|
|
[Anbindung an authentik](docs/betrieb/authentik.md), [Sichern und Zurückspielen](docs/betrieb/sicherung.md)
|
|
- [Konventionen](CLAUDE.md) — Sprache, Schichtung, feste Regeln
|
|
- [Änderungen](CHANGELOG.md) · [Entwicklungstagebuch](docs/devlog.md)
|
|
|
|
## Lizenz
|
|
|
|
Noch nicht festgelegt.
|