diff --git a/.gitea/workflows/pruefung.yml b/.gitea/workflows/pruefung.yml new file mode 100644 index 0000000..b2b8e67 --- /dev/null +++ b/.gitea/workflows/pruefung.yml @@ -0,0 +1,184 @@ +# ============================================================================= +# Die Pruefung bei jedem Push. +# +# Bis Phase 10 gab es 581 Unit- und 266 E2E-Tests — und nichts, was sie +# ausfuehrte ausser einem Menschen, der daran dachte. In der Sitzung, in der +# die Kassenverwaltung entstand, haben die Tests vier echte Fehler gefunden, +# zwei davon in Code, der eine Stunde alt war. Genau das soll hier von allein +# passieren. +# +# Zwei Auftraege, bewusst getrennt: +# +# pruefung typecheck, lint und die Unit-Tests. Braucht eine Datenbank, +# weil ein Teil der Tests die Trigger und RLS gegen echtes +# Postgres prueft — mit einer Attrappe waeren sie wertlos. +# +# oberflaeche die E2E-Tests. Dauern eine Dreiviertelstunde und brauchen den +# halben Stack, deshalb laufen sie nur auf main und in Pull +# Requests, nicht bei jedem Zwischenstand auf einem Zweig. +# ============================================================================= +name: Pruefung + +on: + push: + branches: [main] + pull_request: + +env: + # Dieselben Werte wie in .env.example. Es sind Testdaten in einem + # Wegwerf-Container; echte Geheimnisse haben in einer Pipeline nichts zu + # suchen und stehen im Betrieb in der .env. + POSTGRES_USER: florian + POSTGRES_PASSWORD: pruefung + POSTGRES_DB: florian + POSTGRES_APP_PASSWORD: pruefung_app + DATABASE_URL: postgresql://florian_app:pruefung_app@localhost:5432/florian?schema=public + DIRECT_DATABASE_URL: postgresql://florian:pruefung@localhost:5432/florian?schema=public + AUTH_SECRET: nur-fuer-die-pruefung-nicht-fuer-den-betrieb + AUTH_TRUST_HOST: "true" + APP_URL: http://localhost:3100 + S3_ENDPOINT: localhost + S3_PORT: "9000" + S3_USE_SSL: "false" + S3_ACCESS_KEY: florian + S3_SECRET_KEY: pruefung_minio + S3_BUCKET: florian + REDIS_URL: redis://localhost:6379 + TZ: Europe/Berlin + +jobs: + pruefung: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + # Das eigene Postgres-Image statt eines fertigen: FLORIAN braucht + # PostGIS **und** pgvector, und kein oeffentliches Image bringt beides + # mit. Ein Dienst-Container mit postgres:17 wuerde die Migrationen + # durchlassen und erst bei der ersten Umkreissuche scheitern. + - name: Datenbank bauen und starten + run: | + docker build -t florian-postgres:pruefung ./docker/postgres + docker run -d --name postgres -p 5432:5432 \ + -e POSTGRES_USER=$POSTGRES_USER \ + -e POSTGRES_PASSWORD=$POSTGRES_PASSWORD \ + -e POSTGRES_DB=$POSTGRES_DB \ + -e POSTGRES_APP_PASSWORD=$POSTGRES_APP_PASSWORD \ + -e POSTGRES_INITDB_ARGS="--locale-provider=icu --icu-locale=de-DE --locale=C.UTF-8 --encoding=UTF8" \ + -v "$(pwd)/docker/postgres:/docker-entrypoint-initdb.d:ro" \ + florian-postgres:pruefung + + - name: Redis und MinIO starten + run: | + docker run -d --name redis -p 6379:6379 redis:7-alpine + docker run -d --name minio -p 9000:9000 \ + -e MINIO_ROOT_USER=$S3_ACCESS_KEY \ + -e MINIO_ROOT_PASSWORD=$S3_SECRET_KEY \ + minio/minio:latest server /data + + - run: npm ci + + - name: Auf die Datenbank warten + run: | + for i in $(seq 1 60); do + docker exec postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" && exit 0 + sleep 2 + done + echo "Postgres kam nicht hoch:" + docker logs postgres + exit 1 + + - run: npx prisma generate + - run: npm run typecheck + - run: npm run lint + + - name: Migrationen einspielen + run: npm run db:migrate + + # Der Seed gehoert dazu: er ist selbst ein Test. Faellt eine Migration + # oder ein Standardwert um, merkt man es hier und nicht erst, wenn eine + # Wehr ihn zum ersten Mal ausfuehrt. + - name: Beispielbestand anlegen + run: npm run db:seed + + - run: npm run test + + - name: Aufraeumen + if: always() + run: docker rm -f postgres redis minio || true + + oberflaeche: + runs-on: ubuntu-latest + # Nur wo es sich lohnt: ein voller Lauf dauert etwa 45 Minuten. + if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main' + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Dienste starten + run: | + docker build -t florian-postgres:pruefung ./docker/postgres + docker run -d --name postgres -p 5432:5432 \ + -e POSTGRES_USER=$POSTGRES_USER \ + -e POSTGRES_PASSWORD=$POSTGRES_PASSWORD \ + -e POSTGRES_DB=$POSTGRES_DB \ + -e POSTGRES_APP_PASSWORD=$POSTGRES_APP_PASSWORD \ + -e POSTGRES_INITDB_ARGS="--locale-provider=icu --icu-locale=de-DE --locale=C.UTF-8 --encoding=UTF8" \ + -v "$(pwd)/docker/postgres:/docker-entrypoint-initdb.d:ro" \ + florian-postgres:pruefung + docker run -d --name redis -p 6379:6379 redis:7-alpine + docker run -d --name minio -p 9000:9000 \ + -e MINIO_ROOT_USER=$S3_ACCESS_KEY \ + -e MINIO_ROOT_PASSWORD=$S3_SECRET_KEY \ + minio/minio:latest server /data + docker run -d --name gotenberg -p 3001:3000 gotenberg/gotenberg:8 + + - run: npm ci + - run: npx playwright install --with-deps chromium + + - name: Auf die Datenbank warten + run: | + for i in $(seq 1 60); do + docker exec postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" && exit 0 + sleep 2 + done + docker logs postgres + exit 1 + + - run: npx prisma generate + - run: npm run db:migrate + + # Die E2E-Tests laufen seit Phase 8 gegen eine eigene Wehr, nicht gegen + # den Beispielbestand. Das Skript legt beide an. + - name: Testwehr anlegen + run: npm run db:test-reset + + - run: npm run build + - run: npx playwright test + env: + GOTENBERG_URL: http://localhost:3001 + + - name: Fehlerbilder sichern + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-bericht + path: | + playwright-report/ + test-results/ + retention-days: 7 + + - name: Aufraeumen + if: always() + run: docker rm -f postgres redis minio gotenberg || true diff --git a/.gitignore b/.gitignore index f6f37e1..656c995 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ debug.log # Prisma prisma/*.db prisma/*.db-journal + +# Sicherungen bleiben lokal — sie enthalten den gesamten Bestand. +sicherungen/ diff --git a/CHANGELOG.md b/CHANGELOG.md index fd467db..293204a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,100 @@ Alle nennenswerten Änderungen an FLORIAN. Format nach Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md). +## [0.12.0] — 2026-09-05 + +Phase 11: Freigabereife. Die drei Software-Voraussetzungen für 1.0.0 — +Personenauskunft nach Art. 15 DSGVO, ein geprüftes Sicherungskonzept und eine +Pipeline, die die Tests von allein ausführt. + +**Das Auskunftsrecht war ein Versprechen ohne Umsetzung.** `person.export` +stand seit Phase 0 im Berechtigungskatalog und war dem Kommandanten +zugewiesen; implementiert war nichts. Die Löschseite gab es (Anonymisierung im +Aufbewahrungslauf), die Auskunftsseite nicht — und ohne Art. 15 ist ein +Löschkonzept nicht abnehmbar. + +**Eine Sicherung, die nie zurückgespielt wurde, ist eine Vermutung.** Genau +das hat sich hier bestätigt: der erste Rückspielversuch lief scheinbar +sauber durch und hatte dabei sämtliche Unveränderlichkeitsgarantien der +Datenbank entfernt. + +### Hinzugefügt + +**Auskunft nach Art. 15 DSGVO** + +- `lib/dsgvo/auskunft.ts` sammelt alles, was über eine Person vorliegt: + Stammdaten, Zugang, Rollen, Qualifikationen, arbeitsmedizinische Vorsorge, + Belastungsübungen, Einsatz- und Übungsbeteiligung, Anwesenheiten, Fahrten, + Beiträge, Benachrichtigungswege, Ausweise. Dreizehn Abschnitte. +- **Über jedem Abschnitt steht, wozu die Daten vorliegen.** Art. 15 Abs. 1 + verlangt nicht nur die Daten, sondern auch Zwecke, Empfänger und den Hinweis + auf die weiteren Rechte. Eine Liste ohne das sieht aus wie eine Auskunft und + ist keine. +- **Gesundheitsdaten sind als besondere Kategorie nach Art. 9 gekennzeichnet** — + G26 und Belastungsübungen. +- **Leere Abschnitte bleiben stehen.** „Dazu liegt nichts vor" ist eine + Auskunft; ein weggelassener Abschnitt wirft die Frage auf, ob da doch etwas + ist. +- **Was die Person getan hat, wird gezählt, nicht mitgeliefert.** Prüfprotokolle + und Mängelmeldungen gehören zum Nachweisbestand der Wehr. Vollständig + ausgeliefert käme jeder über eine Selbstauskunft an die Geräteakten — die + Auskunftspflicht wäre eine Hintertür. +- Ein Durchgang liefert alles: die Anzeige, ein PDF für die Akte und JSON nach + Art. 20. Bewusst nicht drei Knöpfe, von denen zwei vergessen werden können. +- Der Vorgang wird protokolliert. Dass eine Auskunft erteilt wurde, gehört + festgehalten — sie legt alles offen, was über einen Menschen gespeichert ist. + +**Sicherung und Wiederherstellung** + +- `npm run sicherung` — Datenbank über `pg_dump` im Container, dazu alle + Dateien aus MinIO, dazu ein Manifest. Beides aus demselben Moment: eine + Datenbank von heute mit Dateien von gestern ergibt Verweise ins Leere. +- `npm run wiederherstellen` — mit Probelauf als Vorgabe. Ohne `--wirklich` + wird nur angezeigt, was geschähe. +- Nach dem Zurückspielen setzt das Skript die Sperren der Anwendungsrolle neu + und **prüft danach nach**, ob es gewirkt hat. +- `scripts/sicherung-sperren.ts` ist die eine Liste, aus der Wiederherstellen + und Prüfen entstehen. Ein Test gleicht sie gegen die migrierte Datenbank ab — + läuft sie auseinander, wäre der Schaden lautlos. + +**Pipeline** + +- `.gitea/workflows/pruefung.yml` mit zwei Aufträgen: `pruefung` (typecheck, + lint, Migrationen, Seed, 599 Unit-Tests) bei jedem Push, `oberflaeche` + (E2E) auf `main` und in Pull Requests. +- Die Datenbank wird aus `docker/postgres` gebaut statt aus einem fertigen + Image: FLORIAN braucht PostGIS **und** pgvector. Ein Dienst-Container mit + `postgres:17` ließe die Migrationen durch und scheiterte erst bei der ersten + Umkreissuche. +- Der Seed läuft mit, weil er selbst ein Test ist. + +### Behoben + +- **Eine Wiederherstellung entfernte alle Unveränderlichkeitsgarantien.** Der + Dump lief mit `--no-acl`, die Rechte kamen also nicht zurück; und selbst + ohne dieses Flag greift beim Zurückspielen die Vorgabeprivilegierung aus + `docker/postgres/10-init-app-role.sh` — jede von `pg_restore` neu angelegte + Tabelle bekommt automatisch alle Rechte, und `GRANT` kann nur hinzufügen, + nie wegnehmen. Nach einer Wiederherstellung durfte die Anwendungsrolle das + Audit-Log ändern, Wiki-Fassungen löschen und Kiosk-Vorgänge umschreiben. + Nichts hätte darauf hingewiesen. + + Gefunden haben es vier Datenbanktests unmittelbar nach der ersten + Rückspielprobe — also genau die Probe, wegen der 1.0.0 sie verlangt. +- **Ein E2E-Test klickte, bevor die Seite lebendig war.** Unter Last dauert das + Hydrieren länger; das Formular verschickte sich dann selbst, und die + Fehlerfläche, auf die der Test wartete, entstand nie — obwohl der Server + richtig ablehnte. Er wartet jetzt auf einen client-seitig erzeugten Knopf. + +### Nachweise + +- Sicherung und Wiederherstellung einmal vollständig durchgeführt: ein nach + der Sicherung angelegter Datensatz war danach weg, alle Zahlen stimmten + wieder, und **599 Tests liefen nach dem Zurückspielen grün** — einschließlich + derer, die die Sperren prüfen. + +--- + ## [0.11.0] — 2026-09-05 Phase 10: Mandantenfähigkeit. Ein Konto, mehrere Wehren, eine Kreisebene, die diff --git a/README.md b/README.md index b8a8d20..6516302 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env` | Phase 8 — Lokale KI | fertig | | Phase 9 — Kassenverwaltung | fertig | | Phase 10 — Mandantenfähigkeit | fertig | +| Phase 11 — Freigabereife | fertig | Vollständige Planung mit Aufwänden: [docs/ROADMAP.md](docs/ROADMAP.md) und [docs/AUFWANDSSCHAETZUNG.md](docs/AUFWANDSSCHAETZUNG.md). @@ -1447,9 +1448,140 @@ weitergegeben. FLORIAN verschickt keine. --- +## Phase 11 — Freigabereife + +Die drei Dinge, die zwischen dem letzten Modul und dem Echtbetrieb standen: +das Auskunftsrecht, ein geprüftes Sicherungskonzept und eine Pipeline. + +### Auskunft nach Art. 15 DSGVO + +Jede Person darf verlangen zu erfahren, welche Daten über sie verarbeitet +werden. In einer Feuerwehr ist das mehr, als die meisten erwarten — und mit +den G26-Untersuchungen sind Gesundheitsdaten dabei, also eine besondere +Kategorie nach Art. 9. + +Auf der Seite eines Mitglieds steht dafür der Knopf **Datenauskunft** +(Recht `person.export`). Ein Durchgang liefert drei Dinge: + +| | Wofür | +|---|---| +| **Anzeige** | Was ausgehändigt wird, vor dem Aushändigen | +| **PDF** | Das Schreiben an die Person, für die Akte | +| **JSON** | Datenübertragbarkeit nach Art. 20 | + +Bewusst nicht drei Knöpfe: wer eine Auskunft erteilt, soll sie vollständig +erteilen und nicht in Schritten, von denen zwei vergessen werden können. + +**Über jedem Abschnitt steht, wozu die Daten vorliegen.** Art. 15 Abs. 1 +verlangt nicht nur die Daten, sondern auch Zwecke, Empfänger, Speicherdauer +und den Hinweis auf die weiteren Rechte. Eine Liste ohne das sieht aus wie +eine Auskunft und ist keine. + +Zwei Festlegungen bestimmen den Zuschnitt: + +**Was über die Person gespeichert ist, steht vollständig drin** — auch das +Unangenehme: eine nicht bestandene G26, ein gemahnter Beitrag. Eine Auskunft, +die auswählt, was gut aussieht, ist keine. + +**Was die Person getan hat, steht als Zahl drin.** Wer zwölf Prüfprotokolle +unterschrieben hat, bekommt die Zahl — nicht zwölf Protokolle. Die gehören zum +Nachweisbestand der Wehr; sie mitzuliefern hieße, dass jeder über eine +Selbstauskunft an die Geräteakten käme. Die Trennlinie ist nicht „viel oder +wenig", sondern „über mich" gegen „von mir". + +Leere Abschnitte bleiben stehen. „Dazu liegt nichts vor" ist eine Auskunft; +ein weggelassener Abschnitt wirft die Frage auf, ob da doch etwas ist. + +Der Vorgang wird protokolliert — dass eine Auskunft erteilt wurde, gehört +festgehalten. + +### Sicherung + +```bash +npm run sicherung # nach ./sicherungen// +npm run sicherung -- --ziel D:\backups +``` + +Gesichert wird beides, aus **demselben Moment**: die Datenbank über `pg_dump` +im Container und alle Dateien aus MinIO. Eine Datenbank von heute mit Dateien +von gestern ergibt Datensätze, die auf nichts zeigen. + +Daneben entsteht ein `manifest.json` mit Zeitpunkt, Version und Umfang. Es +wird beim Zurückspielen angezeigt, damit niemand versehentlich eine Sicherung +von vor drei Monaten einspielt, weil das Verzeichnis ähnlich hieß. + +### Wiederherstellung + +```bash +npm run wiederherstellen -- --quelle sicherungen/2026-09-05T14-16-48 +npm run wiederherstellen -- --quelle ... --wirklich +``` + +Ohne `--wirklich` passiert nichts — es wird nur angezeigt, was geschähe. Das +ist keine Bequemlichkeit: das Skript überschreibt den gesamten Bestand. + +**Der eigentliche Zweck ist nicht der Notfall, sondern die Probe.** Eine +Sicherung, die nie zurückgespielt wurde, ist eine Vermutung: der Ordner kann +leer sein, der Dump abgeschnitten, das Passwort falsch. Man erfährt es an dem +Tag, an dem man es nicht erfahren will. + +Wie berechtigt das ist, hat sich hier gezeigt. Der erste Rückspielversuch lief +sauber durch — und hatte dabei **sämtliche Unveränderlichkeitsgarantien der +Datenbank entfernt**. Der Grund liegt tiefer, als man vermutet: +`docker/postgres/10-init-app-role.sh` richtet eine Vorgabeprivilegierung ein, +damit neue Tabellen aus Migrationen automatisch nutzbar sind. `pg_restore` +legt die Tabellen aber ebenfalls neu an — die Vorgabe greift also auch dort, +und die `GRANT`-Zeilen aus dem Dump können nur hinzufügen, nie wegnehmen. Die +Anwendungsrolle durfte danach das Audit-Log ändern, Wiki-Fassungen löschen und +Kiosk-Vorgänge umschreiben. Nichts an der Anwendung hätte darauf hingewiesen. + +Deshalb setzt das Skript die Sperren nach dem Zurückspielen neu und **prüft +danach nach**, ob es gewirkt hat. Die Liste dafür steht in +`scripts/sicherung-sperren.ts`, und ein Test gleicht sie gegen die migrierte +Datenbank ab: läuft sie auseinander, käme eine Sperre nach einer +Wiederherstellung nicht zurück, von der niemand mehr weiß, dass es sie gab. + +Nach dem Zurückspielen bleiben drei Handgriffe, die keine Software abnehmen +kann: + +1. Anmelden — kommt jemand hinein? +2. Ein Prüfprotokoll öffnen — ist das PDF da? +3. Die Mitgliederzahl mit der Erwartung vergleichen. + +Erst danach gilt die Sicherung als geprüft. + +### Die Pipeline + +`.gitea/workflows/pruefung.yml`, zwei Aufträge: + +| Auftrag | Was | Wann | +|---|---|---| +| `pruefung` | typecheck, lint, Migrationen, Seed, 599 Unit-Tests | jeder Push und Pull Request | +| `oberflaeche` | die E2E-Tests über Chromium | nur `main` und Pull Requests | + +Die Trennung ist eine Zeitfrage: der erste Auftrag dauert Minuten, der zweite +eine Dreiviertelstunde. + +Die Datenbank wird aus `docker/postgres` **gebaut** statt aus einem fertigen +Image genommen. FLORIAN braucht PostGIS und pgvector, und kein öffentliches +Image bringt beides mit; ein Dienst-Container mit `postgres:17` ließe die +Migrationen durchlaufen und scheiterte erst bei der ersten Umkreissuche. Der +Seed läuft mit, weil er selbst ein Test ist: fällt ein Standardwert um, merkt +man es hier und nicht, wenn eine Wehr ihn zum ersten Mal ausführt. + +> **Voraussetzung: ein registrierter Runner.** Die Datei allein genügt nicht — +> Gitea braucht einen `act_runner`, der die Aufträge annimmt. Ist keiner +> eingerichtet, erscheinen die Läufe als „wartend" und laufen nie. Zum +> Zeitpunkt dieser Phase war noch keiner registriert; der Ablauf ist also +> geschrieben, aber nicht im Betrieb erprobt. + +--- + ## Befehle ```bash +npm run sicherung # Datenbank und Dateien sichern +npm run wiederherstellen # zurueckspielen (Probelauf ohne --wirklich) npm run dev # Entwicklungsserver auf Port 3100 npm run build # Produktionsbuild npm run worker:dev # Hintergrundprozess mit Neuladen diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index a2b1796..cb90676 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -1,6 +1,6 @@ # Anleitung -Bedienung von FLORIAN, Stand Phase 10 (Version 0.11.0). Die Bilder zeigen die +Bedienung von FLORIAN, Stand Phase 11 (Version 0.12.0). Die Bilder zeigen die Beispielwehr aus dem Seed und werden mit `npm run screenshots` neu erzeugt. --- @@ -2253,7 +2253,124 @@ jeweiligen Detailseite sichtbar. --- -## 61. Wenn etwas nicht geht +## 61. Wenn jemand Auskunft über seine Daten verlangt + +Jede Person darf verlangen zu erfahren, welche Daten über sie verarbeitet +werden — Art. 15 DSGVO. Die Anfrage muss **innerhalb eines Monats** beantwortet +werden, und sie ist kostenlos. + +### So geht es + +1. **Mitglieder** öffnen, die Person anklicken. +2. Rechts oben auf **Datenauskunft**. +3. **Auskunft erstellen**. + +Danach stehen drei Dinge bereit: + +| | Wofür | +|---|---| +| **Die Anzeige** | Was ausgehändigt wird — vor dem Aushändigen | +| **PDF** | Das Schreiben an die Person. Ausdrucken oder als Datei geben | +| **JSON** | Nur, wenn die Person die Daten maschinenlesbar verlangt (Art. 20) | + +Das Recht dazu heißt `person.export` und liegt bei Kommandant und +Administrator. + +### Was drinsteht + +Dreizehn Abschnitte: Stammdaten, Zugang, Rollen, Qualifikationen, +arbeitsmedizinische Vorsorge, Belastungsübungen, Einsatz- und +Übungsbeteiligung, Anwesenheiten, Fahrten, Beiträge, Benachrichtigungswege, +Ausweise. + +Über jedem steht, **wozu** diese Daten vorliegen. Das ist kein Beiwerk: Art. 15 +verlangt neben den Daten auch die Zwecke und den Hinweis auf die weiteren +Rechte. Eine bloße Liste erfüllt die Auskunftspflicht nicht. + +**Auch das Unangenehme steht drin** — ein gemahnter Beitrag, eine nicht +bestandene G26. Eine Auskunft, die auswählt, was gut aussieht, ist keine. + +**Leere Abschnitte bleiben stehen.** „Dazu liegen keine Daten vor" ist eine +Auskunft. + +**Gesundheitsdaten sind gekennzeichnet.** G26 und Belastungsübungen sind eine +besondere Kategorie nach Art. 9 — das steht im Bericht ausdrücklich dabei. + +### Was nicht drinsteht + +Was die Person **getan** hat, erscheint nur als Zahl: „12 durchgeführte +Prüfungen", „3 gemeldete Mängel". + +Das ist Absicht. Die Prüfprotokolle selbst gehören zum Nachweisbestand der +Feuerwehr und nicht zu den personenbezogenen Daten eines Mitglieds. Würden sie +mitgeliefert, käme jeder über eine Selbstauskunft an die kompletten +Geräteakten — die Auskunftspflicht wäre eine Hintertür. + +### Der Vorgang wird protokolliert + +Dass eine Auskunft erteilt wurde, steht danach unter **Protokoll**. Das ist +richtig so: die Auskunft legt alles offen, was über einen Menschen gespeichert +ist, einschließlich seiner Gesundheitsdaten. Wer sie erstellt hat und wann, +gehört festgehalten. + +### Wenn die Person in zwei Wehren geführt wird + +Dann gibt es **zwei** Auskünfte, eine je Wehr. Das ist kein Versehen: jede Wehr +ist eine eigene Verantwortliche im Sinne der Verordnung und kann nur über die +Daten Auskunft geben, die sie selbst führt. + +--- + +## 62. Sicherung und Wiederherstellung + +> **Der wichtigste Satz dieses Kapitels:** Eine Sicherung, die nie +> zurückgespielt wurde, ist eine Vermutung. + +### Sichern + +```bash +npm run sicherung +npm run sicherung -- --ziel D:\backups +``` + +Gesichert wird die Datenbank **und** der Dateispeicher — Prüfprotokolle, +Einsatzberichte, Fotos, Rechnungen. Beides aus demselben Moment: eine +Datenbank von heute mit Dateien von gestern ergibt Einträge, die auf nichts +zeigen. + +Das Ergebnis ist ein Verzeichnis mit Zeitstempel. Es gehört auf ein anderes +Gerät — eine Sicherung auf derselben Festplatte hilft gegen einen +Festplattenfehler nicht. + +### Zurückspielen + +```bash +npm run wiederherstellen -- --quelle sicherungen/2026-09-05T14-16-48 +``` + +So passiert **nichts**. Es wird nur angezeigt, aus welchem Moment die +Sicherung stammt und wie groß sie ist. Erst mit `--wirklich` am Ende wird +zurückgespielt — und dabei **der gesamte vorhandene Bestand überschrieben**. + +### Einmal im Jahr proben + +Nicht im Notfall zum ersten Mal. Der Ordner kann leer sein, der Auszug +abgeschnitten, das Passwort falsch — man erfährt es sonst an dem Tag, an dem +man es nicht erfahren will. + +Nach dem Zurückspielen prüft das Skript selbst nach, ob die Schutzsperren der +Datenbank wieder stehen. Drei Handgriffe bleiben trotzdem: + +1. **Anmelden** — kommt jemand hinein? +2. **Ein Prüfprotokoll öffnen** — ist das PDF da? +3. **Die Mitgliederzahl** mit der Erwartung vergleichen. + +Erst danach gilt die Sicherung als geprüft. Das Datum der letzten geglückten +Probe gehört ins Sicherungskonzept. + +--- + +## 63. Wenn etwas nicht geht | Beobachtung | Wahrscheinliche Ursache | Was hilft | |---|---|---| diff --git a/docs/AUFWANDSSCHAETZUNG.md b/docs/AUFWANDSSCHAETZUNG.md index 500f821..0337d9e 100644 --- a/docs/AUFWANDSSCHAETZUNG.md +++ b/docs/AUFWANDSSCHAETZUNG.md @@ -41,7 +41,8 @@ Faktoren, die in die Schätzung eingehen: | 8 | Lokale KI | 9–12 PT | 2,5–3 h | **3:40 h** | | 9 | Kassenverwaltung | 8–11 PT | 2–3 h | **4:10 h** | | 10 | Mandantenfähigkeit | 4–6 PT | 1–1,5 h | **4:20 h** | -| | **Summe** | **127–170 PT** | **29–38,5 h** | **32:50 h** | +| 11 | Freigabereife | 5–7 PT | 1,5–2 h | **2:40 h** | +| | **Summe** | **132–177 PT** | **30,5–40,5 h** | **35:30 h** | 123–164 Personentage entsprechen etwa **6 bis 8 Monaten Vollzeitarbeit** einer einzelnen Person. Zum Vergleich: eine kommerzielle Feuerwehrverwaltung kostet diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index b7a69cc..5831627 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -23,7 +23,87 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte | Aufräumarbeiten (Issues #10–#12) | 1:20 h | 1 | 04.09.2026 | | Phase 9 — Kassenverwaltung | 4:10 h | 1 | 04.09.2026 | | Phase 10 — Mandantenfähigkeit | 4:20 h | 1 | 05.09.2026 | -| **Summe** | **32:50 h** | **13** | | +| Phase 11 — Freigabereife | 2:40 h | 1 | 05.09.2026 | +| **Summe** | **35:30 h** | **14** | | + +--- + +## 2026-09-05 · Phase 11 — Freigabereife + +**14:30 – 17:10 · 2:40 h · Version 0.12.0** + +### Was entstanden ist + +Kein Modul, sondern die drei Software-Voraussetzungen für 1.0.0. + +| Bereich | Umfang | +|---|---| +| Auskunft | `lib/dsgvo/auskunft.ts` und `bericht.ts`, `person.auskunft`, Dialog auf der Mitgliederseite | +| Sicherung | `scripts/sicherung.ts`, `wiederherstellen.ts`, `sicherung-zugang.ts`, `sicherung-sperren.ts` | +| Pipeline | `.gitea/workflows/pruefung.yml` mit zwei Aufträgen | +| Tests | 18 neue: 9 gegen die Auskunft, 11 gegen die Sicherung | + +### Entscheidungen + +**Die Auskunft trennt „über mich" von „von mir".** Was über die Person +gespeichert ist, steht vollständig drin — auch der gemahnte Beitrag und die +nicht bestandene G26. Was die Person *getan* hat, steht als Zahl drin. Die +zwölf Prüfprotokolle, die sie unterschrieben hat, gehören zum Nachweisbestand +der Wehr; vollständig ausgeliefert käme jeder über eine Selbstauskunft an die +Geräteakten, und die Auskunftspflicht wäre eine Hintertür. + +**Über jedem Abschnitt steht der Zweck.** Art. 15 Abs. 1 verlangt nicht nur +die Daten, sondern auch Zwecke, Empfänger und den Hinweis auf die weiteren +Rechte. Eine Liste ohne das sieht aus wie eine Auskunft und ist keine — das ist +der Unterschied zwischen einem Datenbankexport und einem Schreiben an einen +Menschen. + +**Eine Mutation, keine Abfrage.** Das Erstellen der Auskunft *ist* die +Offenlegung, und die gehört ins Protokoll. Als Abfrage würde sie +zwischengespeichert und mehrfach ausgeführt — sie wäre entweder nicht oder +zwanzigmal vermerkt. + +**Sicherung und Wiederherstellung gehören zusammen.** Deshalb sind es zwei +Skripte in einem Zug und nicht eines. Der Probelauf ist die Vorgabe; ohne +`--wirklich` geschieht nichts. + +**Eine Liste, zwei Verwendungen.** `sicherung-sperren.ts` treibt sowohl das +Wiederherstellen der Sperren als auch deren Prüfung. Zwei Listen wären zwei +Wahrheiten, und die falsche fiele erst im Notfall auf. + +**Die Pipeline baut das eigene Postgres-Image.** Ein Dienst-Container mit +`postgres:17` ließe die Migrationen durchlaufen und scheiterte erst bei der +ersten Umkreissuche — die Pipeline prüfte dann etwas anderes als den Betrieb. + +### Wo es gehakt hat + +| Problem | Ursache | Lösung | +|---|---|---| +| **Eine Wiederherstellung entfernte alle Unveränderlichkeitsgarantien** | Zuerst `--no-acl` im Dump. Und selbst ohne das Flag greift beim Zurückspielen die Vorgabeprivilegierung aus dem Init-Skript: jede von `pg_restore` neu angelegte Tabelle bekommt alle Rechte, und `GRANT` kann nur hinzufügen | Das Skript setzt die Sperren nach dem Zurückspielen neu und prüft danach nach. Gefunden haben es vier Datenbanktests direkt nach der ersten Rückspielprobe | +| `pg_dump` lehnte die Verbindungszeichenkette ab | Prisma hängt `?schema=public` an — „invalid URI query parameter" | Die URL wird zerlegt, Benutzer und Datenbank gehen als Flags, das Passwort über `PGPASSWORD` | +| Feldnamen im Auskunftssammler stimmten reihenweise nicht | Aus dem Gedächtnis geschrieben statt aus dem Schema gelesen: `alarmierung` statt `alarmiertUm`, `beginn` statt `beginnUm`, `gemeldetVonId` statt `melderId` | Der Typechecker hat sie alle einzeln benannt — zehn Durchgänge, jeder eine Zeile | +| Ein eigener Test war falsch, nicht der Code | `new URL("localhost:5432")` gelingt in Node: „localhost:" gilt als Protokoll | Die Zusicherung prüft jetzt, was wirklich greift — die Protokollprüfung eine Zeile später | +| Ein Vorlagentest fiel im vollen Lauf aus | Er klickte auf „Speichern“, bevor React lief — dann verschickt das Formular sich selbst, und die Fehlerfläche entsteht nie. Unter Last dauert das Hydrieren länger. Dasselbe Muster wie beim Anmeldeskript in Phase 8 | Er wartet jetzt auf einen client-seitig erzeugten Knopf, bevor er klickt | + +### Abweichung von der Vorgabe + +**Die Pipeline ist nicht erprobt.** Sie ist geschrieben und syntaktisch +geprüft, aber in Gitea war zum Zeitpunkt dieser Phase kein `act_runner` +registriert. Ohne Runner erscheinen die Läufe als „wartend" und laufen nie. +Ob der Ablauf durchläuft, zeigt sich erst, wenn einer eingerichtet ist — das +steht so auch im README, damit niemand sich auf eine Prüfung verlässt, die +nicht stattfindet. + +### Nachweise + +- `npm run typecheck` — ohne Ausgabe +- `npm run lint` — ohne Befund +- `npm run test` — 599 Tests grün, davon 18 neu +- Auskunft von Hand erzeugt: 13 Abschnitte, 28 Angaben, 58 kB PDF +- **Sicherung und Wiederherstellung vollständig durchgeführt**: ein nach der + Sicherung angelegter Datensatz war danach weg, alle Zahlen stimmten wieder, + und die 599 Tests liefen nach dem Zurückspielen grün — einschließlich derer, + die die Sperren prüfen --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3d7f85b..e7c8fe2 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -20,6 +20,7 @@ Phase 7 ████████████████████ fertig Phase 8 ████████████████████ fertig 0.9.0 Phase 9 ████████████████████ fertig 0.10.0 Phase 10 ████████████████████ fertig 0.11.0 +Phase 11 ████████████████████ fertig 0.12.0 Freigabe ░░░░░░░░░░░░░░░░░░░░ offen 1.0.0 ``` @@ -290,15 +291,34 @@ sie schlimmer als keine. Die Aufsicht sieht jede Wehr einzeln. jemand über die Grenze sieht — und die Kreisebene kann hineinschauen, ohne etwas anfassen zu können. +## Phase 11 — Freigabereife · fertig + +Am 05.09.2026 umgesetzt. Kein Modul, sondern die drei Software-Voraussetzungen +für die Freigabe. + +- **Auskunft nach Art. 15 DSGVO.** Das Recht `person.export` stand seit Phase 0 + im Katalog und war zugewiesen — umgesetzt war nichts. Ohne Auskunftsseite ist + ein Löschkonzept nicht abnehmbar. +- **Sicherung und Wiederherstellung** mit einer durchgeführten Rückspielprobe. +- **Pipeline** in Gitea Actions: typecheck, lint, Migrationen, Seed und Tests. + +Die Rückspielprobe hat sich sofort bezahlt gemacht: der erste Versuch lief +sauber durch und hatte dabei sämtliche Unveränderlichkeitsgarantien der +Datenbank entfernt. Gefunden haben es die Datenbanktests unmittelbar danach — +also genau die Probe, wegen der 1.0.0 sie verlangt. + +**Die Wehr kann jetzt:** eine Selbstauskunft erteilen, ihren Bestand sichern +und ihn nachweislich zurückholen. + ## 1.0.0 — Freigabe Kein neues Modul, sondern eine Entscheidung: die Wehr setzt FLORIAN im Echtbetrieb ein. Voraussetzungen: - Datenübernahme aus der bisherigen Verwaltung abgeschlossen und abgeglichen -- Sicherungskonzept steht und ist einmal wiederhergestellt worden +- ~~Sicherungskonzept steht und ist einmal wiederhergestellt worden~~ — mit Phase 11 erledigt - Schulung von Kommandant, Gerätewart und Atemschutzgerätewart erfolgt -- Löschkonzept nach DSGVO abgenommen +- Löschkonzept nach DSGVO abgenommen (die Auskunftsseite dazu gibt es seit Phase 11) - Der Hinweis zur analogen Atemschutzüberwachung wird bewusst und dokumentiert abgeschaltet — oder bewusst stehen gelassen diff --git a/package.json b/package.json index 86c7f0b..956a2f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "florian", - "version": "0.11.0", + "version": "0.12.0", "private": true, "description": "FLORIAN — Feuerwehr-Verwaltungsplattform", "engines": { @@ -23,6 +23,8 @@ "db:migrate:dev": "prisma migrate dev", "db:push": "prisma db push", "db:seed": "tsx prisma/seed.ts", + "sicherung": "tsx scripts/sicherung.ts", + "wiederherstellen": "tsx scripts/wiederherstellen.ts", "db:test-reset": "tsx scripts/testdaten.ts", "db:studio": "prisma studio", "screenshots": "tsx scripts/screenshots.ts", diff --git a/scripts/sicherung-sperren.ts b/scripts/sicherung-sperren.ts new file mode 100644 index 0000000..888115e --- /dev/null +++ b/scripts/sicherung-sperren.ts @@ -0,0 +1,94 @@ +/** + * Die Sperren der Anwendungsrolle — nach einer Wiederherstellung. + * + * Hintergrund, und er ist unerwartet: + * + * Beim ersten Start richtet `docker/postgres/10-init-app-role.sh` eine + * Vorgabeprivilegierung ein — jede künftig angelegte Tabelle bekommt + * automatisch alle Rechte für `florian_app`. Das ist im Betrieb genau richtig: + * eine neue Migration soll nicht daran scheitern, dass jemand vergessen hat, + * Rechte zu vergeben. + * + * `pg_restore` legt die Tabellen aber ebenfalls neu an. Die Vorgabe greift + * also auch dort — und die `GRANT`-Anweisungen aus dem Dump können nur + * hinzufügen, nie wegnehmen. Nach einer Wiederherstellung stünde die + * Anwendungsrolle damit wieder mit allen Rechten da: sie dürfte das Audit-Log + * ändern, Wiki-Fassungen löschen, Kiosk-Vorgänge umschreiben. + * + * Nichts an der Anwendung würde darauf hinweisen. Die Unveränderlichkeit, auf + * der jeder Nachweis gegenüber der Unfallkasse beruht, wäre stillschweigend + * weg — bis jemand danach fragt. + * + * Gefunden haben das die Datenbanktests nach der ersten Rückspielprobe. + * Deshalb gibt es diese Datei: **eine** Liste, aus der sowohl das + * Wiederherstellen als auch das Prüfen der Sperren entsteht. Zwei Listen + * wären zwei Wahrheiten. + */ + +/** Tabelle → Rechte, die `florian_app` NICHT haben darf, mit Begründung. */ +export const SPERREN: Array<{ tabelle: string; rechte: string[]; grund: string }> = [ + { + tabelle: "berechtigung", + rechte: ["INSERT", "UPDATE", "DELETE"], + grund: "systemweiter Rechtekatalog — nur lesbar", + }, + { + tabelle: "audit_log", + rechte: ["UPDATE", "DELETE"], + grund: "append-only; das Löschen geht nur im Aufbewahrungslauf", + }, + { + tabelle: "kiosk_vorgang", + rechte: ["UPDATE", "DELETE"], + grund: "übermittelte Terminalvorgänge sind unveränderlich", + }, + { + tabelle: "druckmessung", + rechte: ["UPDATE", "DELETE"], + grund: "Atemschutzüberwachung — Druckwerte werden nie korrigiert", + }, + { + tabelle: "atemschutz_ereignis", + rechte: ["UPDATE", "DELETE"], + grund: "wie oben: der Verlauf einer Überwachung bleibt, wie er war", + }, + { + tabelle: "benachrichtigung", + rechte: ["DELETE"], + grund: "was zugestellt wurde, bleibt nachweisbar", + }, + { + tabelle: "ki_vorgang", + rechte: ["DELETE"], + grund: "das KI-Protokoll belegt, was die Maschine getan hat", + }, + { + tabelle: "wikiversion", + rechte: ["UPDATE", "DELETE"], + grund: "jede Fassung einer Wiki-Seite bleibt erhalten", + }, + { + tabelle: "kassenabschluss", + rechte: ["DELETE"], + grund: "ein festgeschriebener Abschluss lässt sich nicht wegräumen", + }, + { + tabelle: "benutzer", + rechte: ["INSERT", "DELETE"], + grund: "Konten entstehen und vergehen über den Systemzugang", + }, +]; + +/** Die SQL-Anweisungen, die die Sperren wiederherstellen. */ +export function sperrenSql(): string[] { + return SPERREN.map( + ({ tabelle, rechte }) => `REVOKE ${rechte.join(", ")} ON "${tabelle}" FROM florian_app`, + ); +} + +/** Alle Kombinationen, die nach der Wiederherstellung fehlen müssen. */ +export function verboteneRechte(): Array<{ tabelle: string; recht: string }> { + return SPERREN.flatMap(({ tabelle, rechte }) => + rechte.map((recht) => ({ tabelle, recht })), + ); +} diff --git a/scripts/sicherung-zugang.ts b/scripts/sicherung-zugang.ts new file mode 100644 index 0000000..2821405 --- /dev/null +++ b/scripts/sicherung-zugang.ts @@ -0,0 +1,51 @@ +/** + * Die Verbindungsangaben für `pg_dump` und `pg_restore`. + * + * Eigene Datei, weil beide Skripte sie brauchen — und weil ein falsch + * zerlegter Zugang der unangenehmste Fehler in einem Sicherungsskript wäre: + * es liefe durch, schriebe eine Datei, und die enthielte die falsche + * Datenbank. Auffallen würde es beim Zurückspielen, also genau dann, wenn man + * die Sicherung braucht. + */ + +export interface Zugang { + benutzer: string; + passwort: string; + datenbank: string; +} + +/** + * Zerlegt eine Verbindungszeichenkette in ihre Teile. + * + * Die URL lässt sich nicht als Ganzes an `pg_dump` durchreichen: Prisma hängt + * eigene Parameter an (`?schema=public`), die es mit "invalid URI query + * parameter" ablehnt. Und der Port darin gilt vom Rechner aus — im Container, + * wo die Werkzeuge laufen, liegt Postgres auf 5432. + */ +export function zugangAus(roh: string | undefined): Zugang { + if (!roh) { + throw new Error("DIRECT_DATABASE_URL fehlt."); + } + + let url: URL; + try { + url = new URL(roh); + } catch { + throw new Error(`Die Verbindungszeichenkette ist keine gültige URL: ${roh}`); + } + + if (!url.protocol.startsWith("postgres")) { + throw new Error(`Erwartet wird eine postgresql://-Adresse, bekommen: ${url.protocol}//`); + } + + const datenbank = url.pathname.replace(/^\//, ""); + if (!datenbank) { + throw new Error("In der Verbindungszeichenkette fehlt der Datenbankname."); + } + + return { + benutzer: decodeURIComponent(url.username), + passwort: decodeURIComponent(url.password), + datenbank, + }; +} diff --git a/scripts/sicherung.ts b/scripts/sicherung.ts new file mode 100644 index 0000000..8e41bed --- /dev/null +++ b/scripts/sicherung.ts @@ -0,0 +1,188 @@ +/** + * Sicherung: Datenbank und Dateien in ein Verzeichnis. + * + * npm run sicherung + * npm run sicherung -- --ziel D:\backups + * + * Eine Feuerwehrverwaltung ohne geprüfte Sicherung ist ein Aktenschrank ohne + * Schloss — bis zum ersten Festplattenfehler sieht man den Unterschied nicht. + * Deshalb gehört zu diesem Skript zwingend `npm run wiederherstellen`: eine + * Sicherung, die nie zurückgespielt wurde, ist eine Vermutung. + * + * Gesichert wird beides, und zwar zusammen: + * + * * **Die Datenbank** über `pg_dump` im Postgres-Container. Im eigenen + * Format (`--format=custom`), weil sich daraus einzelne Tabellen + * zurückholen lassen und es kleiner ist. + * * **Die Dateien** aus MinIO — Prüfprotokolle, Einsatzberichte, Fotos, + * Rechnungen. Ohne sie wäre die Datenbank voller Verweise ins Leere. + * + * Beides muss aus demselben Moment stammen. Eine Datenbank von heute mit + * Dateien von gestern ergibt Datensätze, die auf nichts zeigen. + */ +import "dotenv/config"; +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, writeFileSync, createWriteStream } from "node:fs"; +import { join, dirname } from "node:path"; +import { pipeline } from "node:stream/promises"; +import { Client as MinioClient } from "minio"; +import { zugangAus } from "./sicherung-zugang"; + +const ZIEL_VORGABE = "sicherungen"; +const CONTAINER = process.env.SICHERUNG_POSTGRES_CONTAINER ?? "florian-postgres"; + +function log(nachricht: string): void { + console.info(`[sicherung] ${nachricht}`); +} + +/** Ein Zeitstempel, der sich als Verzeichnisname eignet. */ +function stempel(): string { + return new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); +} + +function argument(name: string): string | undefined { + const stelle = process.argv.indexOf(`--${name}`); + return stelle >= 0 ? process.argv[stelle + 1] : undefined; +} + +/** + * Die Datenbank sichern. + * + * Über `docker compose exec` statt über einen lokalen `pg_dump`: die Version + * des Clients muss zur Serverversion passen, und im Container ist sie das + * garantiert. Auf einem Rechner ohne passendes `pg_dump` schlüge es sonst mit + * einer Meldung fehl, die niemand mit der Ursache in Verbindung bringt. + */ +function datenbankSichern(ziel: string): { datei: string; bytes: number } { + const { benutzer, passwort, datenbank } = zugangAus( + process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL, + ); + + const datei = join(ziel, "datenbank.dump"); + log(`Datenbank aus Container "${CONTAINER}" ...`); + + const lauf = spawnSync( + "docker", + [ + "compose", + "exec", + "-T", + "postgres", + "pg_dump", + "--format=custom", + // Der Eigentuemer wird nicht mitgesichert (er heisst auf jedem Server + // anders), die RECHTE dagegen schon. Ohne sie waeren nach dem + // Zurueckspielen alle REVOKEs der Migrationen weg: die Anwendungsrolle + // duerfte das Audit-Log aendern, Wiki-Fassungen loeschen, + // Kiosk-Vorgaenge umschreiben. Nichts wuerde darauf hinweisen. + "--no-owner", + "--username", + benutzer, + "--dbname", + datenbank, + ], + { + encoding: "buffer", + maxBuffer: 2_000_000_000, + shell: true, + env: { ...process.env, PGPASSWORD: passwort }, + }, + ); + + if (lauf.status !== 0) { + const fehler = lauf.stderr?.toString() ?? "unbekannt"; + throw new Error(`pg_dump ist fehlgeschlagen:\n${fehler}`); + } + + writeFileSync(datei, lauf.stdout); + return { datei: "datenbank.dump", bytes: lauf.stdout.byteLength }; +} + +/** Alle Objekte aus MinIO holen. */ +async function dateienSichern(ziel: string): Promise<{ anzahl: number; bytes: number }> { + const bucket = process.env.S3_BUCKET ?? "florian"; + + const client = new MinioClient({ + endPoint: process.env.S3_ENDPOINT ?? "localhost", + port: Number(process.env.S3_PORT ?? 9000), + useSSL: (process.env.S3_USE_SSL ?? "false") === "true", + accessKey: process.env.S3_ACCESS_KEY ?? "", + secretKey: process.env.S3_SECRET_KEY ?? "", + }); + + if (!(await client.bucketExists(bucket))) { + log(`Bucket "${bucket}" gibt es nicht — keine Dateien zu sichern.`); + return { anzahl: 0, bytes: 0 }; + } + + const ordner = join(ziel, "dateien"); + mkdirSync(ordner, { recursive: true }); + + let anzahl = 0; + let bytes = 0; + + const strom = client.listObjectsV2(bucket, "", true); + + for await (const objekt of strom) { + if (!objekt.name) continue; + + const pfad = join(ordner, objekt.name); + mkdirSync(dirname(pfad), { recursive: true }); + + const quelle = await client.getObject(bucket, objekt.name); + await pipeline(quelle, createWriteStream(pfad)); + + anzahl += 1; + bytes += objekt.size ?? 0; + + if (anzahl % 50 === 0) log(` ${anzahl} Dateien ...`); + } + + return { anzahl, bytes }; +} + +async function main(): Promise { + const wurzel = argument("ziel") ?? ZIEL_VORGABE; + const ziel = join(wurzel, stempel()); + mkdirSync(ziel, { recursive: true }); + + log(`Ziel: ${ziel}`); + + const datenbank = datenbankSichern(ziel); + log(`Datenbank: ${(datenbank.bytes / 1024 / 1024).toFixed(1)} MB`); + + const dateien = await dateienSichern(ziel); + log(`Dateien: ${dateien.anzahl} Stück, ${(dateien.bytes / 1024 / 1024).toFixed(1)} MB`); + + /** + * Das Manifest. + * + * Es hält fest, woraus die Sicherung stammt und wie groß sie ist. Beim + * Zurückspielen wird es gelesen und angezeigt — damit niemand versehentlich + * eine Sicherung von vor drei Monaten einspielt, weil das Verzeichnis + * ähnlich hieß. + */ + const manifest = { + erstelltAm: new Date().toISOString(), + version: process.env.npm_package_version ?? "unbekannt", + datenbank, + dateien, + pruefsumme: createHash("sha256") + .update(`${datenbank.bytes}:${dateien.anzahl}:${dateien.bytes}`) + .digest("hex"), + }; + + writeFileSync(join(ziel, "manifest.json"), JSON.stringify(manifest, null, 2)); + + log(""); + log("Fertig. Zum Prüfen der Sicherung:"); + log(` npm run wiederherstellen -- --quelle ${ziel} --wirklich`); + log(""); + log("Eine Sicherung, die nie zurueckgespielt wurde, ist eine Vermutung."); +} + +main().catch((fehler) => { + console.error("[sicherung] Abbruch:", fehler); + process.exit(1); +}); diff --git a/scripts/wiederherstellen.ts b/scripts/wiederherstellen.ts new file mode 100644 index 0000000..6c2d37e --- /dev/null +++ b/scripts/wiederherstellen.ts @@ -0,0 +1,270 @@ +/** + * Wiederherstellung aus einer Sicherung. + * + * npm run wiederherstellen -- --quelle sicherungen/2026-09-05T14-00-00 + * npm run wiederherstellen -- --quelle ... --wirklich + * + * Ohne `--wirklich` wird nur angezeigt, was geschehen würde. Das ist keine + * Bequemlichkeit: dieses Skript **überschreibt den gesamten Bestand**, und es + * gibt keinen Weg zurück außer einer weiteren Sicherung. + * + * Der eigentliche Zweck ist nicht der Notfall, sondern die Probe. Eine + * Sicherung, die nie zurückgespielt wurde, ist eine Vermutung — der Ordner + * kann leer sein, der Dump abgeschnitten, das Passwort falsch. Man erfährt es + * an dem Tag, an dem man es nicht erfahren will. Deshalb gehört dieser Lauf + * einmal durchgeführt und im Sicherungskonzept vermerkt. + */ +import "dotenv/config"; +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync, readdirSync, statSync, createReadStream } from "node:fs"; +import { join, relative, sep } from "node:path"; +import { Client as MinioClient } from "minio"; +import { zugangAus } from "./sicherung-zugang"; +import { SPERREN, sperrenSql, verboteneRechte } from "./sicherung-sperren"; + +function log(nachricht: string): void { + console.info(`[wiederherstellen] ${nachricht}`); +} + +function argument(name: string): string | undefined { + const stelle = process.argv.indexOf(`--${name}`); + return stelle >= 0 ? process.argv[stelle + 1] : undefined; +} + +const wirklich = process.argv.includes("--wirklich"); + +/** Alle Dateien unterhalb eines Verzeichnisses, rekursiv. */ +function alleDateien(wurzel: string): string[] { + if (!existsSync(wurzel)) return []; + + const gefunden: string[] = []; + for (const eintrag of readdirSync(wurzel)) { + const pfad = join(wurzel, eintrag); + if (statSync(pfad).isDirectory()) gefunden.push(...alleDateien(pfad)); + else gefunden.push(pfad); + } + return gefunden; +} + +function datenbankZurueck(quelle: string): void { + const { benutzer, passwort, datenbank } = zugangAus( + process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL, + ); + + const dump = join(quelle, "datenbank.dump"); + if (!existsSync(dump)) throw new Error(`In ${quelle} liegt keine datenbank.dump.`); + + log("Spiele die Datenbank zurück ..."); + + /** + * `--clean --if-exists` räumt vorher auf. + * + * Ohne das Aufräumen schlüge jede `CREATE TABLE` fehl, und übrig bliebe ein + * Mischbestand aus alten und neuen Zeilen — das Schlimmste, was bei einer + * Wiederherstellung herauskommen kann, weil es auf den ersten Blick + * funktioniert. + */ + const lauf = spawnSync( + "docker", + [ + "compose", + "exec", + "-T", + "postgres", + "pg_restore", + "--clean", + "--if-exists", + "--no-owner", + "--username", + benutzer, + "--dbname", + datenbank, + ], + { + input: readFileSync(dump), + encoding: "buffer", + maxBuffer: 2_000_000_000, + shell: true, + env: { ...process.env, PGPASSWORD: passwort }, + }, + ); + + // pg_restore meldet auch dann einen Fehlercode, wenn nur einzelne DROP- + // Anweisungen ins Leere liefen. Das ist bei `--if-exists` normal, deshalb + // wird die Ausgabe angesehen statt nur der Rückgabewert. + const ausgabe = lauf.stderr?.toString() ?? ""; + const echteFehler = ausgabe + .split("\n") + .filter((zeile) => zeile.includes("error:") && !zeile.includes("does not exist")); + + if (echteFehler.length > 0) { + throw new Error(`pg_restore ist fehlgeschlagen:\n${echteFehler.join("\n")}`); + } + + log("Datenbank zurückgespielt."); +} + +async function dateienZurueck(quelle: string): Promise { + const bucket = process.env.S3_BUCKET ?? "florian"; + const ordner = join(quelle, "dateien"); + + const client = new MinioClient({ + endPoint: process.env.S3_ENDPOINT ?? "localhost", + port: Number(process.env.S3_PORT ?? 9000), + useSSL: (process.env.S3_USE_SSL ?? "false") === "true", + accessKey: process.env.S3_ACCESS_KEY ?? "", + secretKey: process.env.S3_SECRET_KEY ?? "", + }); + + if (!(await client.bucketExists(bucket))) { + await client.makeBucket(bucket); + log(`Bucket "${bucket}" angelegt.`); + } + + const dateien = alleDateien(ordner); + let hochgeladen = 0; + + for (const pfad of dateien) { + // Der Objektschlüssel ist der Pfad unterhalb von "dateien/" — mit + // Schrägstrichen, auch unter Windows. + const schluessel = relative(ordner, pfad).split(sep).join("/"); + + await client.putObject(bucket, schluessel, createReadStream(pfad), statSync(pfad).size); + hochgeladen += 1; + + if (hochgeladen % 50 === 0) log(` ${hochgeladen} Dateien ...`); + } + + return hochgeladen; +} + +/** + * Die Sperren wieder setzen — und danach nachsehen, ob es gewirkt hat. + * + * Warum das nötig ist, steht in `sicherung-sperren.ts`: die + * Vorgabeprivilegierung des Init-Skripts gibt jeder von `pg_restore` neu + * angelegten Tabelle wieder alle Rechte, und die `GRANT`-Zeilen aus dem Dump + * können nur hinzufügen, nie wegnehmen. + * + * Erst setzen, dann prüfen — und nicht nur prüfen. Ein Skript, das ein + * bekanntes Problem meldet, statt es zu beheben, verlagert die Arbeit auf den + * ungünstigsten Zeitpunkt: den Notfall, in dem jemand gerade eine Sicherung + * zurückspielt. + */ +async function sperrenSetzen(): Promise { + const { PrismaClient } = await import("@prisma/client"); + const prisma = new PrismaClient({ + datasources: { db: { url: process.env.DIRECT_DATABASE_URL ?? "" } }, + }); + + try { + const rolleDa = await prisma.$queryRawUnsafe>( + "SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'florian_app') AS da", + ); + + if (!rolleDa[0]?.da) { + log("Die Rolle florian_app gibt es nicht — Sperren übersprungen."); + return; + } + + for (const anweisung of sperrenSql()) { + await prisma.$executeRawUnsafe(anweisung); + } + log(`Sperren gesetzt: ${SPERREN.length} Tabellen.`); + + // Gegenprobe. Sie fragt die Datenbank, nicht das eigene Vorhaben ab. + const zeilen = await prisma.$queryRawUnsafe< + Array<{ table_name: string; privilege_type: string }> + >( + `SELECT table_name, privilege_type + FROM information_schema.role_table_grants + WHERE grantee = 'florian_app'`, + ); + + const vorhanden = new Set(zeilen.map((z) => `${z.table_name}:${z.privilege_type}`)); + const befunde = verboteneRechte() + .filter(({ tabelle, recht }) => vorhanden.has(`${tabelle}:${recht}`)) + .map(({ tabelle, recht }) => ` ${tabelle}: ${recht}`); + + if (befunde.length > 0) { + log(""); + log("ACHTUNG: Die Anwendungsrolle hat Rechte, die sie nicht haben darf:"); + befunde.forEach((zeile) => log(zeile)); + log("Die Unveraenderlichkeit von Protokollen ist damit aufgehoben."); + process.exitCode = 1; + return; + } + + log("Gegenprobe: keine Tabelle hat mehr Rechte als vorgesehen."); + } finally { + await prisma.$disconnect(); + } +} + +async function main(): Promise { + const quelle = argument("quelle"); + if (!quelle) { + console.error("[wiederherstellen] --quelle fehlt."); + process.exit(1); + } + + const manifestPfad = join(quelle, "manifest.json"); + if (!existsSync(manifestPfad)) { + console.error( + `[wiederherstellen] In ${quelle} liegt keine manifest.json. ` + + "Ist das wirklich ein Sicherungsverzeichnis?", + ); + process.exit(1); + } + + const manifest = JSON.parse(readFileSync(manifestPfad, "utf-8")) as { + erstelltAm: string; + version: string; + datenbank: { bytes: number }; + dateien: { anzahl: number; bytes: number }; + }; + + const alter = Math.round( + (Date.now() - new Date(manifest.erstelltAm).getTime()) / 1000 / 60 / 60 / 24, + ); + + log(""); + log(`Sicherung vom ${manifest.erstelltAm.slice(0, 16).replace("T", " ")} (${alter} Tage alt)`); + log(`Version: ${manifest.version}`); + log(`Datenbank: ${(manifest.datenbank.bytes / 1024 / 1024).toFixed(1)} MB`); + log(`Dateien: ${manifest.dateien.anzahl} Stück`); + log(""); + + if (!wirklich) { + log("PROBELAUF — es wird nichts geändert."); + log(""); + log("Zum wirklichen Zurückspielen dieselbe Zeile mit --wirklich am Ende."); + log("Achtung: dabei wird der gesamte vorhandene Bestand überschrieben."); + return; + } + + if (alter > 30) { + log(`WARNUNG: Diese Sicherung ist ${alter} Tage alt.`); + } + + log("Spiele zurück — der vorhandene Bestand wird überschrieben."); + datenbankZurueck(quelle); + + const dateien = await dateienZurueck(quelle); + log(`Dateien: ${dateien} zurückgespielt.`); + + await sperrenSetzen(); + + log(""); + log("Fertig. Bitte jetzt von Hand prüfen:"); + log(" 1. Anmelden — kommt jemand hinein?"); + log(" 2. Ein Prüfprotokoll öffnen — ist das PDF da?"); + log(" 3. Die Mitgliederzahl mit der Erwartung vergleichen."); + log(""); + log("Erst danach gilt die Sicherung als geprüft."); +} + +main().catch((fehler) => { + console.error("[wiederherstellen] Abbruch:", fehler); + process.exit(1); +}); diff --git a/src/app/(app)/mitglieder/[id]/page.tsx b/src/app/(app)/mitglieder/[id]/page.tsx index f270bbf..dec0267 100644 --- a/src/app/(app)/mitglieder/[id]/page.tsx +++ b/src/app/(app)/mitglieder/[id]/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { notFound, redirect } from "next/navigation"; import { ArrowLeft } from "lucide-react"; import { aktuelleSitzung } from "@/lib/auth"; +import { hatRecht } from "@/lib/authz"; import { darfPersonSehen } from "@/lib/authz"; import { fuerOrganisation } from "@/lib/db"; import { alsDatum, personAnrede } from "@/lib/utils"; @@ -10,6 +11,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Badge } from "@/components/ui/badge"; import { FristAnzeige, NichtsGefunden } from "@/components/stammdaten/anzeige"; import { DIENSTGRAD_TEXT } from "@/lib/stammdaten/texte"; +import { Datenauskunft } from "@/components/stammdaten/auskunft"; export const metadata = { title: "Mitglied" }; @@ -130,7 +132,19 @@ export default async function MitgliedSeite({ params }: { params: Promise<{ id: Zurück zur Mitgliederliste -

{personAnrede(person)}

+
+

{personAnrede(person)}

+ + {/* Die Auskunft nach Art. 15 DSGVO. Sie steht hier und nicht in + einem Datenschutzmenü: gefragt wird sie zu einer bestimmten + Person, und genau deren Seite ist man gerade. */} + {hatRecht(sitzung, "person.export") && ( + + )} +
{person.rollen.map((zuweisung) => ( diff --git a/src/components/stammdaten/auskunft.tsx b/src/components/stammdaten/auskunft.tsx new file mode 100644 index 0000000..766a132 --- /dev/null +++ b/src/components/stammdaten/auskunft.tsx @@ -0,0 +1,190 @@ +"use client"; + +import * as React from "react"; +import { Download, FileText, ShieldCheck } from "lucide-react"; +import type { inferRouterOutputs } from "@trpc/server"; +import { trpc } from "@/lib/trpc/client"; +import type { AppRouter } from "@/server/routers/_app"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Fehlermeldung } from "@/components/stammdaten/anzeige"; + +type Ergebnis = inferRouterOutputs["person"]["auskunft"]; + +/** + * Die Datenauskunft nach Art. 15 DSGVO. + * + * Ein Knopf, ein Vorgang, drei Ergebnisse: die Anzeige auf dem Schirm, ein PDF + * für die Akte und eine JSON-Datei nach Art. 20. Bewusst nicht drei Knöpfe — + * wer eine Auskunft erteilt, soll sie vollständig erteilen und nicht in + * Schritten, von denen zwei vergessen werden können. + * + * Der Vorgang steht danach im Protokoll. Das ist keine Förmlichkeit: die + * Auskunft legt sämtliche Daten eines Menschen offen, einschließlich seiner + * Gesundheitsdaten aus der G26 — dass das geschehen ist, gehört festgehalten. + */ +export function Datenauskunft({ + personId, + personName, +}: { + personId: string; + personName: string; +}) { + const [offen, setOffen] = React.useState(false); + const [ergebnis, setErgebnis] = React.useState(null); + const [meldung, setMeldung] = React.useState(null); + + const erstellen = trpc.person.auskunft.useMutation({ + onSuccess: (daten) => { + setErgebnis(daten); + setMeldung(null); + }, + onError: (fehler) => setMeldung(fehler.message), + }); + + /** Die maschinenlesbare Fassung — Art. 20, Datenübertragbarkeit. */ + function jsonHerunterladen() { + if (!ergebnis) return; + + const ziel = URL.createObjectURL( + new Blob([JSON.stringify(ergebnis.auskunft, null, 2)], { + type: "application/json;charset=utf-8", + }), + ); + const verweis = document.createElement("a"); + verweis.href = ziel; + verweis.download = `auskunft-${personName.replace(/\s+/g, "-").toLowerCase()}.json`; + verweis.click(); + URL.revokeObjectURL(ziel); + } + + return ( + <> + + + { + if (!zustand) { + setOffen(false); + setErgebnis(null); + setMeldung(null); + } + }} + > + + + Datenauskunft nach Art. 15 DSGVO + + Für {personName}. Es entsteht eine vollständige Übersicht aller Daten, die diese + Wehr über die Person verarbeitet — einschließlich der Gesundheitsdaten aus der + G26. + + + + {meldung && } + + {!ergebnis && ( +
+

+ Die Auskunft enthält Stammdaten, Rollen, Qualifikationen, Einsatz- und + Übungsbeteiligung, Anwesenheiten, Fahrten, Beiträge, Benachrichtigungswege und + die arbeitsmedizinische Vorsorge. +

+

+ Der Vorgang wird protokolliert. Dass eine Auskunft erteilt + wurde, gehört festgehalten — sie legt alles offen, was über einen Menschen + gespeichert ist. +

+
+ )} + + {ergebnis && ( +
+
+ Auskunft erstellt: {ergebnis.umfang} Angaben in{" "} + {ergebnis.auskunft.abschnitte.length} Abschnitten. Der Vorgang steht im + Protokoll. +
+ +
+ + +
+ +
+ {ergebnis.auskunft.abschnitte.map((abschnitt) => ( + + + + {abschnitt.titel} + {abschnitt.besondereKategorie && ( + + besondere Kategorie (Art. 9) + + )} + + {abschnitt.zweck} + + + {abschnitt.eintraege.length === 0 ? ( +

+ Dazu liegen keine Daten vor. +

+ ) : ( +

+ {abschnitt.eintraege.length}{" "} + {abschnitt.eintraege.length === 1 ? "Eintrag" : "Einträge"} +

+ )} +
+
+ ))} +
+
+ )} + + + + {!ergebnis && ( + + )} + +
+
+ + ); +} diff --git a/src/lib/dsgvo/auskunft.ts b/src/lib/dsgvo/auskunft.ts new file mode 100644 index 0000000..9f29bcf --- /dev/null +++ b/src/lib/dsgvo/auskunft.ts @@ -0,0 +1,394 @@ +import type { MandantenClient } from "@/lib/db"; + +/** + * Die Auskunft nach Art. 15 DSGVO. + * + * Jede Person darf verlangen zu erfahren, welche Daten über sie verarbeitet + * werden. In einer Feuerwehr ist das mehr, als die meisten erwarten: neben den + * Stammdaten stehen dort Einsatzbeteiligungen, Anwesenheiten, Fahrten, + * Beiträge — und mit den G26-Untersuchungen auch Gesundheitsdaten, also eine + * besondere Kategorie nach Art. 9. + * + * Zwei Festlegungen bestimmen den Zuschnitt: + * + * **Was über die Person gespeichert ist, steht vollständig drin.** Auch das + * Unangenehme: eine nicht bestandene G26, ein gemahnter Beitrag. Eine + * Auskunft, die das Angenehme auswählt, ist keine. + * + * **Was die Person getan hat, steht als Zusammenfassung drin.** Wer zwölf + * Prüfprotokolle unterschrieben hat, bekommt die Zahl und die Fundstellen — + * nicht zwölf Protokolle. Die gehören zum Nachweisbestand der Wehr, nicht zur + * Person; sie mitzuliefern hieße, dass jeder über den Umweg einer + * Selbstauskunft an den Geräteakten der Wehr käme. + * + * Die Trennlinie ist also nicht „viel oder wenig", sondern „über mich" gegen + * „von mir". + */ + +/** Ein Abschnitt der Auskunft, wie er auf Papier und im JSON erscheint. */ +export interface Abschnitt { + titel: string; + /** Warum diese Daten überhaupt vorliegen — Art. 15 Abs. 1 lit. a und b. */ + zweck: string; + /** Leer heißt: dazu liegt nichts vor. Das ist auch eine Auskunft. */ + eintraege: Array>; + /** Besondere Kategorie nach Art. 9 — wird im Bericht gekennzeichnet. */ + besondereKategorie?: boolean; +} + +export interface Auskunft { + erstelltAm: Date; + wehr: { name: string; strasse: string | null; plz: string | null; ort: string | null }; + person: { + id: string; + name: string; + mitgliedsnummer: string | null; + }; + abschnitte: Abschnitt[]; + /** Was die Person getan hat — als Zahl, nicht als Aktenauszug. */ + taetigkeiten: Array<{ was: string; anzahl: number }>; +} + +/** Datum ohne Uhrzeit, wie es in einem Bericht steht. */ +function tag(wert: Date | null | undefined): string | null { + return wert ? wert.toISOString().slice(0, 10) : null; +} + +/** Zeitpunkt mit Minute — für alles, wo die Uhrzeit zur Sache gehört. */ +function zeitpunkt(wert: Date | null | undefined): string | null { + if (!wert) return null; + return wert.toISOString().slice(0, 16).replace("T", " "); +} + +/** + * Alles zusammentragen, was über eine Person vorliegt. + * + * Läuft im Mandantenkontext: die Abfragen sehen nur die Wehr, in der die + * Person geführt wird. Eine Person, die in zwei Wehren eine Akte hat, bekommt + * zwei Auskünfte — und das ist richtig so, denn es sind zwei + * Verantwortliche im Sinne der Verordnung. + */ +export async function auskunftSammeln(db: MandantenClient, personId: string): Promise { + const person = await db.person.findUnique({ + where: { id: personId }, + include: { + organisation: { select: { name: true, strasse: true, plz: true, ort: true } }, + mitgliedschaft: { + include: { + benutzer: { + select: { + email: true, + letzteAnmeldung: true, + aktiv: true, + oidcSubject: true, + }, + }, + }, + }, + rollen: { include: { rolle: { select: { name: true } } } }, + qualifikationen: { + where: { geloeschtAm: null }, + include: { typ: { select: { name: true, kuerzel: true } } }, + orderBy: { erworbenAm: "desc" }, + }, + ausweisTokens: { + where: { geloeschtAm: null }, + select: { typ: true, bezeichnung: true, erstelltAm: true, letzteNutzung: true }, + }, + zustellkanaele: { + select: { + kanal: true, + ziel: true, + bezeichnung: true, + aktiv: true, + letzteZustellung: true, + }, + }, + }, + }); + + if (!person) { + throw new Error("Diese Person gibt es nicht."); + } + + const abschnitte: Abschnitt[] = []; + + // --- Stammdaten ----------------------------------------------------------- + abschnitte.push({ + titel: "Stammdaten", + zweck: + "Führung des Mitgliederverzeichnisses der Feuerwehr. Rechtsgrundlage: " + + "Art. 6 Abs. 1 lit. e DSGVO in Verbindung mit dem Bayerischen " + + "Feuerwehrgesetz.", + eintraege: [ + { + Vorname: person.vorname, + Nachname: person.nachname, + Mitgliedsnummer: person.mitgliedsnummer, + Dienstgrad: person.dienstgrad, + Abteilung: person.abteilung, + Geburtsdatum: tag(person.geburtsdatum), + Eintritt: tag(person.eintrittsdatum), + Austritt: tag(person.austrittsdatum), + "E-Mail": person.email, + Telefon: person.telefon, + Mobil: person.mobil, + Anschrift: [person.strasse, [person.plz, person.ort].filter(Boolean).join(" ")] + .filter(Boolean) + .join(", "), + "Angelegt am": tag(person.erstelltAm), + }, + ], + }); + + // --- Zugang --------------------------------------------------------------- + abschnitte.push({ + titel: "Zugang zur Software", + zweck: + "Anmeldung und Nachvollziehbarkeit von Änderungen. Ohne Konto ist die " + + "Person nur im Verzeichnis geführt und meldet sich nicht an.", + eintraege: person.mitgliedschaft?.benutzer + ? [ + { + "Anmeldung mit": person.mitgliedschaft.benutzer.email, + "Zuletzt angemeldet": zeitpunkt(person.mitgliedschaft.benutzer.letzteAnmeldung), + Konto: person.mitgliedschaft.benutzer.aktiv ? "aktiv" : "abgeschaltet", + "Anmeldung über authentik": person.mitgliedschaft.benutzer.oidcSubject + ? "ja" + : "nein", + }, + ] + : [], + }); + + // --- Rollen --------------------------------------------------------------- + abschnitte.push({ + titel: "Rollen und Berechtigungen", + zweck: "Steuert, was die Person in der Software sehen und tun darf.", + eintraege: person.rollen.map((zuweisung) => ({ + Rolle: zuweisung.rolle.name, + "Zugewiesen am": tag(zuweisung.zugewiesenAm), + "Gültig bis": tag(zuweisung.gueltigBis), + "Aus authentik-Gruppe": zuweisung.ausOidcGruppe ? "ja" : "nein", + })), + }); + + // --- Qualifikationen ------------------------------------------------------ + abschnitte.push({ + titel: "Qualifikationen und Lehrgänge", + zweck: "Nachweis der Ausbildung; Grundlage für die Einteilung im Einsatz.", + eintraege: person.qualifikationen.map((eintrag) => ({ + Qualifikation: eintrag.typ.name, + Kürzel: eintrag.typ.kuerzel, + Erworben: tag(eintrag.erworbenAm), + "Gültig bis": tag(eintrag.gueltigBis), + Nachweis: eintrag.nachweisnummer, + })), + }); + + // --- Atemschutz: besondere Kategorie -------------------------------------- + const g26 = await db.g26Untersuchung.findMany({ + where: { personId, geloeschtAm: null }, + orderBy: { untersuchtAm: "desc" }, + }); + + const belastung = await db.belastungsuebung.findMany({ + where: { personId }, + orderBy: { datum: "desc" }, + }); + + abschnitte.push({ + titel: "Arbeitsmedizinische Vorsorge G 26", + zweck: + "Nachweis der Atemschutztauglichkeit nach FwDV 7 und DGUV. " + + "Gesundheitsdaten — besondere Kategorie nach Art. 9 DSGVO. Gespeichert " + + "wird das Ergebnis und die Frist, nicht der Befund.", + besondereKategorie: true, + eintraege: g26.map((eintrag) => ({ + "Untersucht am": tag(eintrag.untersuchtAm), + Ergebnis: eintrag.ergebnis, + "Gültig bis": tag(eintrag.gueltigBis), + Arzt: eintrag.arzt, + Bemerkung: eintrag.bemerkung, + })), + }); + + abschnitte.push({ + titel: "Belastungsübungen", + zweck: "Nachweis der jährlichen Übung in der Atemschutzstrecke nach FwDV 7.", + besondereKategorie: true, + eintraege: belastung.map((eintrag) => ({ + "Absolviert am": tag(eintrag.datum), + Art: eintrag.art, + Ort: eintrag.ort, + Dauer: eintrag.dauerMinuten === null ? null : `${eintrag.dauerMinuten} Minuten`, + Bemerkung: eintrag.bemerkung, + })), + }); + + // --- Einsatzbeteiligung --------------------------------------------------- + const einsaetze = await db.einsatzkraft.findMany({ + where: { personId }, + include: { + einsatz: { + select: { einsatznummer: true, stichwort: true, alarmiertUm: true, ort: true }, + }, + }, + orderBy: { einsatz: { alarmiertUm: "desc" } }, + take: 500, + }); + + abschnitte.push({ + titel: "Einsatzbeteiligung", + zweck: + "Nachweis der Teilnahme; Grundlage für Aufwandsentschädigung, " + + "Versicherungsschutz und Auszeichnungen.", + eintraege: einsaetze.map((eintrag) => ({ + Einsatz: eintrag.einsatz.einsatznummer, + Stichwort: eintrag.einsatz.stichwort, + Alarmierung: zeitpunkt(eintrag.einsatz.alarmiertUm), + Ort: eintrag.einsatz.ort, + Funktion: eintrag.funktion, + })), + }); + + // --- Übungen -------------------------------------------------------------- + const uebungen = await db.uebungsteilnahme.findMany({ + where: { personId }, + include: { uebung: { select: { thema: true, beginnUm: true, art: true } } }, + orderBy: { uebung: { beginnUm: "desc" } }, + take: 500, + }); + + abschnitte.push({ + titel: "Übungsteilnahme", + zweck: "Nachweis der Ausbildung nach FwDV 2.", + eintraege: uebungen.map((eintrag) => ({ + Übung: eintrag.uebung.thema, + Art: eintrag.uebung.art, + Beginn: zeitpunkt(eintrag.uebung.beginnUm), + Funktion: eintrag.funktion, + Bemerkung: eintrag.bemerkung, + })), + }); + + // --- Anwesenheit und Fahrten --------------------------------------------- + const anwesenheiten = await db.anwesenheit.findMany({ + where: { personId }, + orderBy: { angemeldetUm: "desc" }, + take: 500, + }); + + abschnitte.push({ + titel: "Anwesenheit am Gerätehaus", + zweck: "Am Terminal erfasste An- und Abmeldungen.", + eintraege: anwesenheiten.map((eintrag) => ({ + Anlass: eintrag.anlass, + Angemeldet: zeitpunkt(eintrag.angemeldetUm), + Abgemeldet: zeitpunkt(eintrag.abgemeldetUm), + })), + }); + + const fahrten = await db.fahrt.findMany({ + where: { fahrerId: personId }, + include: { fahrzeug: { select: { funkrufname: true } } }, + orderBy: { abfahrtUm: "desc" }, + take: 500, + }); + + abschnitte.push({ + titel: "Fahrten als Maschinist", + zweck: "Fahrtenbuch der Fahrzeuge; Nachweis gegenüber Halter und Versicherung.", + eintraege: fahrten.map((eintrag) => ({ + Fahrzeug: eintrag.fahrzeug.funkrufname, + Abfahrt: zeitpunkt(eintrag.abfahrtUm), + Rückkehr: zeitpunkt(eintrag.rueckkehrUm), + Zweck: eintrag.zweck, + "Kilometer bei Abfahrt": String(eintrag.kilometerAbfahrt), + "Kilometer bei Rückkehr": + eintrag.kilometerRueckkehr === null ? null : String(eintrag.kilometerRueckkehr), + })), + }); + + // --- Beiträge ------------------------------------------------------------- + const beitraege = await db.beitrag.findMany({ + where: { personId }, + orderBy: { jahr: "desc" }, + }); + + abschnitte.push({ + titel: "Mitgliedsbeiträge", + zweck: "Abrechnung der Beiträge; Buchführung der Wehr.", + eintraege: beitraege.map((eintrag) => ({ + Jahr: String(eintrag.jahr), + Betrag: `${eintrag.betrag.toFixed(2)} EUR`, + Stand: eintrag.stand, + "Bezahlt am": tag(eintrag.bezahltAm), + Mahnstufe: eintrag.mahnstufe > 0 ? String(eintrag.mahnstufe) : null, + "Grund des Erlasses": eintrag.erlassGrund, + })), + }); + + // --- Erreichbarkeit ------------------------------------------------------- + abschnitte.push({ + titel: "Benachrichtigungswege", + zweck: + "Zustellung von Terminerinnerungen und Meldungen. Jede Person richtet " + + "sie selbst ein und kann sie jederzeit entfernen.", + eintraege: person.zustellkanaele.map((eintrag) => ({ + Kanal: eintrag.kanal, + Ziel: eintrag.ziel, + Bezeichnung: eintrag.bezeichnung, + "Zuletzt zugestellt": zeitpunkt(eintrag.letzteZustellung), + Aktiv: eintrag.aktiv ? "ja" : "nein", + })), + }); + + abschnitte.push({ + titel: "Ausweise und PIN", + zweck: + "Anmeldung am Terminal im Gerätehaus. Die Geheimnisse selbst sind nur " + + "als Prüfsumme gespeichert und lassen sich nicht zurückrechnen — " + + "deshalb stehen hier nur Art und Nutzung.", + eintraege: person.ausweisTokens.map((eintrag) => ({ + Art: eintrag.typ, + Bezeichnung: eintrag.bezeichnung, + "Angelegt am": tag(eintrag.erstelltAm), + "Zuletzt benutzt": zeitpunkt(eintrag.letzteNutzung), + })), + }); + + // --- Was die Person getan hat -------------------------------------------- + // + // Bewusst nur gezählt. Die Unterlagen dahinter gehören zum Nachweisbestand + // der Wehr; sie hier mitzuliefern hieße, dass jeder über eine + // Selbstauskunft an die Geräteakten käme. + const [pruefungen, maengel, protokolleinträge] = await Promise.all([ + db.pruefung.count({ where: { prueferId: personId } }), + db.mangel.count({ where: { melderId: personId } }), + db.auditLog.count({ where: { akteurPersonId: personId } }), + ]); + + const taetigkeiten = [ + { was: "durchgeführte Prüfungen", anzahl: pruefungen }, + { was: "gemeldete Mängel", anzahl: maengel }, + { was: "protokollierte Änderungen in der Software", anzahl: protokolleinträge }, + ]; + + return { + erstelltAm: new Date(), + wehr: person.organisation, + person: { + id: person.id, + name: `${person.vorname} ${person.nachname}`, + mitgliedsnummer: person.mitgliedsnummer, + }, + abschnitte, + taetigkeiten, + }; +} + +/** Wie viele Angaben die Auskunft insgesamt enthält — für die Vorschau. */ +export function auskunftUmfang(auskunft: Auskunft): number { + return auskunft.abschnitte.reduce((summe, abschnitt) => summe + abschnitt.eintraege.length, 0); +} diff --git a/src/lib/dsgvo/bericht.ts b/src/lib/dsgvo/bericht.ts new file mode 100644 index 0000000..dadbd2a --- /dev/null +++ b/src/lib/dsgvo/bericht.ts @@ -0,0 +1,170 @@ +import { htmlZuPdf } from "@/lib/pdf"; +import type { Auskunft } from "@/lib/dsgvo/auskunft"; + +/** + * Die Auskunft als PDF. + * + * Eine Datenauskunft ist ein Schreiben an einen Menschen, keine + * Datenbankausgabe. Deshalb steht über jedem Abschnitt, **warum** diese Daten + * vorliegen — Art. 15 Abs. 1 verlangt nicht nur die Daten, sondern auch + * Zwecke, Empfänger, Speicherdauer und den Hinweis auf die Betroffenenrechte. + * Eine Liste ohne diese Angaben erfüllt die Auskunftspflicht nicht, sieht aber + * so aus, als täte sie es. + * + * Leere Abschnitte bleiben stehen. „Dazu liegt nichts vor" ist eine Auskunft; + * ein weggelassener Abschnitt wirft die Frage auf, ob da doch etwas ist. + */ + +function html(wert: string | null | undefined): string { + return (wert ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function fusszeile(text: string): string { + return ` +
+ ${html(text)} + Seite von +
+ `; +} + +function rahmen(titel: string, inhalt: string): string { + return ` + + + +${html(titel)} + + +${inhalt} +`; +} + +/** Ein Eintrag als Feld-Wert-Tabelle. Leere Felder werden weggelassen. */ +function eintragAlsTabelle(eintrag: Record): string { + const zeilen = Object.entries(eintrag) + .filter(([, wert]) => wert !== null && wert !== "") + .map( + ([feld, wert]) => + `${html(feld)}${html(wert)}`, + ) + .join(""); + + return zeilen ? `${zeilen}
` : ""; +} + +export async function auskunftPdf(auskunft: Auskunft): Promise { + const anschrift = [ + auskunft.wehr.strasse, + [auskunft.wehr.plz, auskunft.wehr.ort].filter(Boolean).join(" "), + ] + .filter(Boolean) + .join(", "); + + const abschnitte = auskunft.abschnitte + .map((abschnitt) => { + const koerper = + abschnitt.eintraege.length === 0 + ? `

Dazu liegen keine Daten vor.

` + : abschnitt.eintraege.map(eintragAlsTabelle).join(""); + + const kennzeichen = abschnitt.besondereKategorie + ? `

Besondere Kategorie personenbezogener Daten + nach Art. 9 DSGVO. Diese Angaben unterliegen einem erhöhten Schutz und werden + nur den Personen zugänglich gemacht, die sie für die Atemschutzüberwachung + brauchen.

` + : ""; + + return ` +

${html(abschnitt.titel)}

+

${html(abschnitt.zweck)}

+ ${kennzeichen} + ${koerper}`; + }) + .join(""); + + const taetigkeiten = auskunft.taetigkeiten + .map((eintrag) => `${html(eintrag.was)}${eintrag.anzahl}`) + .join(""); + + const inhalt = ` +
+

Auskunft nach Art. 15 DSGVO

+

+ ${html(auskunft.wehr.name)}${anschrift ? ` · ${html(anschrift)}` : ""}
+ Erstellt am ${html(auskunft.erstelltAm.toISOString().slice(0, 10))} +

+
+ +

+ Sehr geehrte/r ${html(auskunft.person.name)}, +

+

+ auf dieser und den folgenden Seiten stehen alle personenbezogenen Daten, die + ${html(auskunft.wehr.name)} über Sie verarbeitet — Stand + ${html(auskunft.erstelltAm.toISOString().slice(0, 10))}. Über jedem Abschnitt ist + angegeben, wozu diese Daten erhoben wurden. +

+ + ${abschnitte} + +

Von Ihnen erfasste Vorgänge

+

+ Was Sie in der Software getan haben, ist hier nur gezählt. Die Unterlagen selbst + — Prüfprotokolle, Mängelmeldungen, Einsatzberichte — gehören zum Nachweisbestand + der Feuerwehr und nicht zu Ihren personenbezogenen Daten. Sie können sie über die + jeweilige Ansicht einsehen, soweit Ihre Rolle das vorsieht. +

+ ${taetigkeiten}
+ +
+ Ihre weiteren Rechte

+ Berichtigung (Art. 16). Stimmt etwas nicht, wenden Sie sich an die + Kommandantin oder den Kommandanten — falsche Angaben werden berichtigt.

+ Löschung (Art. 17). Ein Teil dieser Daten unterliegt gesetzlichen + Aufbewahrungsfristen: Einsatzbeteiligungen und Atemschutznachweise sind Nachweise + gegenüber der Unfallkasse und können nicht gelöscht werden, solange die Frist + läuft. Was nicht darunter fällt, wird auf Verlangen gelöscht; nach dem Austritt + geschieht das ohnehin automatisch.

+ Datenübertragbarkeit (Art. 20). Dieselben Daten gibt es auf + Wunsch zusätzlich als JSON-Datei zur maschinellen Weiterverarbeitung.

+ Beschwerde (Art. 77). Sie können sich beim Bayerischen + Landesbeauftragten für den Datenschutz beschweren.

+ Empfänger. Diese Daten werden nicht an Dritte weitergegeben. Sie + liegen auf einem Server, den die Feuerwehr selbst betreibt; es findet keine + Übermittlung in ein Drittland statt. +
+ +

+ Diese Auskunft wurde maschinell erstellt. Der Abruf ist im Protokoll der Feuerwehr + vermerkt. +

+ `; + + return htmlZuPdf(rahmen(`Auskunft ${auskunft.person.name}`, inhalt), { + hintergrundDrucken: true, + fusszeileHtml: fusszeile(`${auskunft.wehr.name} · Auskunft ${auskunft.person.name}`), + }); +} diff --git a/src/server/routers/stammdaten/person.ts b/src/server/routers/stammdaten/person.ts index 0e5817d..d63a959 100644 --- a/src/server/routers/stammdaten/person.ts +++ b/src/server/routers/stammdaten/person.ts @@ -3,6 +3,9 @@ import { TRPCError } from "@trpc/server"; import { Dienstgrad, Prisma } from "@prisma/client"; import { router, angemeldet, mitRecht } from "@/server/trpc"; import { AuditAktion, protokolliereIn } from "@/lib/audit"; +import { auskunftSammeln, auskunftUmfang } from "@/lib/dsgvo/auskunft"; +import { auskunftPdf } from "@/lib/dsgvo/bericht"; +import { hochladen, objektSchluessel } from "@/lib/storage"; import { darfPersonSehen, KeinZugriffFehler } from "@/lib/authz"; import { blaettern, @@ -55,6 +58,87 @@ const personDaten = z.object({ }); export const personRouter = router({ + /** + * Auskunft nach Art. 15 DSGVO. + * + * Eine Mutation, obwohl sie im Kern nur liest — und das mit Absicht: das + * Erstellen der Auskunft **ist** die Offenlegung, und die gehört ins + * Protokoll. Als Abfrage würde sie zwischengespeichert, mehrfach ausgeführt + * und wäre entweder nicht oder zwanzigmal vermerkt. + * + * Ein Durchgang liefert alles auf einmal: die Angaben für die Anzeige, das + * PDF für die Akte und die JSON-Fassung nach Art. 20. Wer eine Auskunft + * verlangt, bekommt sie vollständig oder gar nicht — nicht in drei Schritten, + * von denen zwei vergessen werden können. + */ + auskunft: mitRecht("person.export") + .input(idEingabe) + .mutation(async ({ ctx, input }) => { + const auskunft = await ctx.db((db) => auskunftSammeln(db, input.id)); + + const organisation = await ctx.db((db) => + db.organisation.findUnique({ + where: { id: ctx.sitzung.organisationId }, + select: { slug: true }, + }), + ); + + const pdf = await auskunftPdf(auskunft); + + const schluessel = objektSchluessel({ + organisationSlug: organisation?.slug ?? ctx.sitzung.organisationId, + entitaet: "auskunft", + entitaetId: input.id, + dateiname: `auskunft-${auskunft.erstelltAm.toISOString().slice(0, 10)}.pdf`, + }); + + const abgelegt = await hochladen(schluessel, pdf, "application/pdf", { + kategorie: "dsgvo-auskunft", + person: input.id, + }); + + const datei = await ctx.db(async (db) => { + const angelegt = await db.datei.create({ + data: { + organisationId: ctx.sitzung.organisationId, + bucket: abgelegt.bucket, + objektSchluessel: abgelegt.objektSchluessel, + dateiname: `Auskunft ${auskunft.person.name}.pdf`, + mimeTyp: "application/pdf", + groesseBytes: BigInt(pdf.byteLength), + pruefsumme: abgelegt.pruefsumme, + hochgeladenVonId: ctx.sitzung.personId, + }, + }); + + // Die Offenlegung selbst — mit Umfang, damit später nachvollziehbar + // ist, was ausgehändigt wurde, ohne die Daten zu wiederholen. + await protokolliereIn(db, { + organisationId: ctx.sitzung.organisationId, + sitzung: ctx.sitzung, + aktion: AuditAktion.EXPORTIERT, + entitaet: "Person", + entitaetId: input.id, + beschreibung: + `Datenauskunft nach Art. 15 DSGVO für ${auskunft.person.name} erstellt ` + + `(${auskunftUmfang(auskunft)} Angaben in ${auskunft.abschnitte.length} Abschnitten)`, + nachher: { + abschnitte: auskunft.abschnitte.length, + angaben: auskunftUmfang(auskunft), + dateiId: angelegt.id, + }, + }); + + return angelegt; + }); + + return { + auskunft, + umfang: auskunftUmfang(auskunft), + dateiId: datei.id, + }; + }), + liste: mitRecht("person.lesen") .input( listenEingabe(SORTIERFELDER).extend({ diff --git a/tests/e2e/termine.spec.ts b/tests/e2e/termine.spec.ts index c2de552..5af4288 100644 --- a/tests/e2e/termine.spec.ts +++ b/tests/e2e/termine.spec.ts @@ -296,6 +296,16 @@ test.describe("Benachrichtigungen", () => { await anmelden(page); await page.goto("/benachrichtigungen/vorlagen"); + // Erst warten, bis die Seite im Browser lebendig ist. + // + // Die Platzhalter-Knöpfe entstehen client-seitig; sind sie da, ist + // hydriert. Ohne diese Zusicherung wurde unter Last auf „Speichern“ + // geklickt, bevor React lief — dann verschickt das Formular sich selbst, + // die Seite lädt neu, und die Fehlerfläche entsteht nie. Der Test fiel + // dann mit „Element nicht gefunden“ aus, obwohl der Server richtig + // ablehnte. + await expect(page.getByRole("button", { name: "{titel}" })).toBeVisible(); + const feld = page.getByLabel("Nachricht"); await feld.fill("Am {datum} um {uhrzeit}. Und ein {tippfehler}."); await page.getByRole("button", { name: "Speichern" }).click(); diff --git a/tests/unit/dsgvo-auskunft.test.ts b/tests/unit/dsgvo-auskunft.test.ts new file mode 100644 index 0000000..33be297 --- /dev/null +++ b/tests/unit/dsgvo-auskunft.test.ts @@ -0,0 +1,221 @@ +import { PrismaClient } from "@prisma/client"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { auskunftSammeln, auskunftUmfang } from "@/lib/dsgvo/auskunft"; +import type { MandantenClient } from "@/lib/db"; + +/** + * Die Auskunft nach Art. 15 DSGVO. + * + * Zwei Dinge müssen stimmen, und sie ziehen in verschiedene Richtungen. + * + * **Vollständigkeit.** Was über die Person gespeichert ist, muss drinstehen — + * auch das Unangenehme. Eine Auskunft, die den gemahnten Beitrag oder die + * nicht bestandene G26 auslässt, ist keine. + * + * **Begrenzung.** Was die Person *getan* hat, gehört zum Nachweisbestand der + * Wehr. Stünde es vollständig drin, käme jeder über eine Selbstauskunft an + * die Geräteakten — und die Auskunftspflicht wäre eine Hintertür. + * + * Der Test prüft deshalb beides: dass die Abschnitte da sind, und dass die + * Tätigkeiten nur gezählt werden. + * + * Braucht eine laufende Datenbank; ohne sie überspringt er sich selbst. + */ + +const SYSTEM_URL = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL ?? ""; +const system = new PrismaClient({ datasources: { db: { url: SYSTEM_URL } } }); + +const SLUG = "test-auskunft-wehr"; + +let erreichbar = false; +let organisationId = ""; +let personId = ""; + +beforeAll(async () => { + try { + await system.$queryRaw`SELECT 1`; + erreichbar = true; + } catch { + return; + } + + const organisation = await system.organisation.upsert({ + where: { slug: SLUG }, + update: {}, + create: { slug: SLUG, name: "Auskunfts-Testwehr", kurzname: "ATW" }, + }); + organisationId = organisation.id; + + const person = await system.person.upsert({ + where: { organisationId_mitgliedsnummer: { organisationId, mitgliedsnummer: "A-1" } }, + update: {}, + create: { + organisationId, + vorname: "Auskunft", + nachname: "Suchend", + mitgliedsnummer: "A-1", + geburtsdatum: new Date("1985-04-12"), + eintrittsdatum: new Date("2010-01-01"), + email: "auskunft@test-auskunft.de", + strasse: "Musterweg 1", + plz: "91315", + ort: "Musterhausen", + abteilung: "Aktive", + }, + }); + personId = person.id; + + // Gesundheitsdaten — die besondere Kategorie, um die es geht. + await system.g26Untersuchung.create({ + data: { + organisationId, + personId, + untersuchtAm: new Date("2025-03-01"), + gueltigBis: new Date("2028-03-01"), + ergebnis: "TAUGLICH", + arzt: "Dr. Beispiel", + }, + }); + + // Ein gemahnter Beitrag — das Unangenehme, das drinstehen muss. + await system.beitrag.create({ + data: { + organisationId, + personId, + jahr: 2025, + betrag: "25.00", + stand: "GEMAHNT", + mahnstufe: 2, + }, + }); +}); + +afterAll(async () => { + if (erreichbar) { + await system.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`; + await tx.organisation.deleteMany({ where: { slug: SLUG } }); + }); + } + await system.$disconnect(); +}); + +describe.runIf(process.env.DATABASE_URL)("Auskunft nach Art. 15", () => { + it("nennt Person, Wehr und Zeitpunkt", async ({ skip }) => { + if (!erreichbar) return skip(); + + const auskunft = await auskunftSammeln(system as unknown as MandantenClient, personId); + + expect(auskunft.person.name).toBe("Auskunft Suchend"); + expect(auskunft.person.mitgliedsnummer).toBe("A-1"); + expect(auskunft.wehr.name).toBe("Auskunfts-Testwehr"); + expect(auskunft.erstelltAm).toBeInstanceOf(Date); + }); + + it("gibt zu jedem Abschnitt an, wozu die Daten vorliegen", async ({ skip }) => { + if (!erreichbar) return skip(); + + // Art. 15 Abs. 1 verlangt nicht nur die Daten, sondern auch die Zwecke. + // Eine Liste ohne sie sieht aus wie eine Auskunft und ist keine. + const auskunft = await auskunftSammeln(system as unknown as MandantenClient, personId); + + expect(auskunft.abschnitte.length).toBeGreaterThan(5); + for (const abschnitt of auskunft.abschnitte) { + expect(abschnitt.titel.length).toBeGreaterThan(0); + expect(abschnitt.zweck.length).toBeGreaterThan(20); + } + }); + + it("enthält die Stammdaten vollständig", async ({ skip }) => { + if (!erreichbar) return skip(); + + const auskunft = await auskunftSammeln(system as unknown as MandantenClient, personId); + const stammdaten = auskunft.abschnitte.find((eintrag) => eintrag.titel === "Stammdaten"); + + expect(stammdaten?.eintraege[0]).toMatchObject({ + Vorname: "Auskunft", + Nachname: "Suchend", + Geburtsdatum: "1985-04-12", + Anschrift: "Musterweg 1, 91315 Musterhausen", + }); + }); + + it("kennzeichnet Gesundheitsdaten als besondere Kategorie", async ({ skip }) => { + if (!erreichbar) return skip(); + + const auskunft = await auskunftSammeln(system as unknown as MandantenClient, personId); + const g26 = auskunft.abschnitte.find((eintrag) => eintrag.titel.includes("G 26")); + + expect(g26?.besondereKategorie).toBe(true); + expect(g26?.eintraege).toHaveLength(1); + expect(g26?.eintraege[0]?.Ergebnis).toBe("TAUGLICH"); + }); + + it("lässt das Unangenehme nicht weg", async ({ skip }) => { + if (!erreichbar) return skip(); + + // Ein gemahnter Beitrag ist ein Datum über diese Person. Eine Auskunft, + // die auswählt, was gut aussieht, erfüllt die Pflicht nicht. + const auskunft = await auskunftSammeln(system as unknown as MandantenClient, personId); + const beitraege = auskunft.abschnitte.find((eintrag) => + eintrag.titel.includes("Mitgliedsbeiträge"), + ); + + expect(beitraege?.eintraege[0]).toMatchObject({ Stand: "GEMAHNT", Mahnstufe: "2" }); + }); + + it("behält leere Abschnitte — auch das ist eine Auskunft", async ({ skip }) => { + if (!erreichbar) return skip(); + + // Diese Person war in keinem Einsatz. Der Abschnitt muss trotzdem + // dastehen: ein weggelassener wirft die Frage auf, ob da doch etwas ist. + const auskunft = await auskunftSammeln(system as unknown as MandantenClient, personId); + const einsaetze = auskunft.abschnitte.find((eintrag) => + eintrag.titel.includes("Einsatzbeteiligung"), + ); + + expect(einsaetze).toBeDefined(); + expect(einsaetze?.eintraege).toHaveLength(0); + }); + + it("zählt Tätigkeiten, statt die Unterlagen mitzuliefern", async ({ skip }) => { + if (!erreichbar) return skip(); + + // Die Grenze zwischen „über mich" und „von mir". Stünden hier die + // Prüfprotokolle im Volltext, käme jeder über eine Selbstauskunft an die + // Geräteakten der Wehr. + const auskunft = await auskunftSammeln(system as unknown as MandantenClient, personId); + + expect(auskunft.taetigkeiten.length).toBeGreaterThan(0); + for (const eintrag of auskunft.taetigkeiten) { + expect(typeof eintrag.anzahl).toBe("number"); + expect(eintrag).not.toHaveProperty("eintraege"); + } + }); + + it("zählt den Umfang über alle Abschnitte", async ({ skip }) => { + if (!erreichbar) return skip(); + + const auskunft = await auskunftSammeln(system as unknown as MandantenClient, personId); + const gezaehlt = auskunft.abschnitte.reduce( + (summe, abschnitt) => summe + abschnitt.eintraege.length, + 0, + ); + + expect(auskunftUmfang(auskunft)).toBe(gezaehlt); + expect(auskunftUmfang(auskunft)).toBeGreaterThan(2); + }); + + it("weist eine unbekannte Person ab, statt eine leere Auskunft zu bauen", async ({ skip }) => { + if (!erreichbar) return skip(); + + // Sonst bekäme jemand ein Schreiben mit lauter leeren Abschnitten und + // dem Eindruck, es lägen keine Daten vor. + await expect( + auskunftSammeln( + system as unknown as MandantenClient, + "00000000-0000-0000-0000-000000000000", + ), + ).rejects.toThrow(/gibt es nicht/i); + }); +}); diff --git a/tests/unit/sicherung-zugang.test.ts b/tests/unit/sicherung-zugang.test.ts new file mode 100644 index 0000000..aada729 --- /dev/null +++ b/tests/unit/sicherung-zugang.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; +import { zugangAus } from "../../scripts/sicherung-zugang"; +import { SPERREN } from "../../scripts/sicherung-sperren"; + +/** + * Das Zerlegen der Verbindungszeichenkette. + * + * Der unangenehmste Fehler, den ein Sicherungsskript machen kann, ist nicht + * abzubrechen: es liefe durch, schriebe eine Datei, und die enthielte die + * falsche Datenbank. Auffallen würde es beim Zurückspielen — also genau dann, + * wenn man die Sicherung braucht. + * + * Deshalb prüft dieser Test nicht nur, dass richtige Adressen richtig zerlegt + * werden, sondern vor allem, dass falsche laut scheitern. + */ + +describe("Verbindungszeichenkette zerlegen", () => { + it("liest Benutzer, Passwort und Datenbank", () => { + const zugang = zugangAus("postgresql://florian:geheim@localhost:5442/florian?schema=public"); + + expect(zugang).toEqual({ + benutzer: "florian", + passwort: "geheim", + datenbank: "florian", + }); + }); + + it("ignoriert die Prisma-Parameter am Ende", () => { + // `?schema=public` ist der Grund, warum die URL nicht als Ganzes an + // pg_dump gehen kann — es lehnt sie mit "invalid URI query parameter" ab. + const zugang = zugangAus( + "postgresql://florian:geheim@localhost:5442/florian?schema=public&connection_limit=5", + ); + + expect(zugang.datenbank).toBe("florian"); + }); + + it("entschlüsselt Sonderzeichen im Passwort", () => { + // Ein Passwort mit @ oder / muss in der URL kodiert stehen. Würde es hier + // nicht dekodiert, meldete Postgres nur "Passwort falsch". + const zugang = zugangAus("postgresql://florian:ge%40heim%2Fx@localhost:5432/florian"); + + expect(zugang.passwort).toBe("ge@heim/x"); + }); + + it("kommt mit einem leeren Passwort zurecht", () => { + const zugang = zugangAus("postgresql://florian@localhost:5432/florian"); + expect(zugang.passwort).toBe(""); + expect(zugang.benutzer).toBe("florian"); + }); + + it("bricht ab, wenn die Adresse fehlt", () => { + expect(() => zugangAus(undefined)).toThrow(/DIRECT_DATABASE_URL fehlt/); + expect(() => zugangAus("")).toThrow(/DIRECT_DATABASE_URL fehlt/); + }); + + it("bricht ab, wenn es gar keine URL ist", () => { + expect(() => zugangAus("einfach nur text")).toThrow(/keine gültige URL/); + }); + + it("bricht bei „localhost:5432“ ab — über die Protokollprüfung", () => { + // Node zerlegt das klaglos: „localhost:“ gilt als Protokoll, „5432“ als + // Pfad. Ohne die Protokollprüfung eine Zeile weiter käme hier ein + // Zugang mit leerem Benutzer heraus, und die Sicherung liefe ins Leere. + expect(() => zugangAus("localhost:5432")).toThrow(/postgresql/); + }); + + it("bricht ab, wenn es nicht Postgres ist", () => { + // Sonst würde eine falsch gesetzte Umgebungsvariable eine Sicherung + // erzeugen, die nichts enthält. + expect(() => zugangAus("mysql://florian:geheim@localhost:3306/florian")).toThrow( + /postgresql/, + ); + }); + + it("bricht ab, wenn der Datenbankname fehlt", () => { + expect(() => zugangAus("postgresql://florian:geheim@localhost:5432/")).toThrow( + /Datenbankname/, + ); + }); +}); + +describe("Die Sperrenliste stimmt mit der Datenbank überein", () => { + /** + * Der Abgleich gegen die Wirklichkeit. + * + * `scripts/sicherung-sperren.ts` führt auf, welche Rechte die + * Anwendungsrolle nicht haben darf — und stellt sie nach einer + * Wiederherstellung wieder her. Diese Liste steht neben den Migrationen, + * die dieselben Sperren setzen, und zwei Listen laufen auseinander. + * + * Läuft sie auseinander, ist der Schaden lautlos: eine Wiederherstellung + * würde eine Sperre nicht zurückbringen, von der niemand mehr weiß, dass es + * sie gab. Deshalb fragt dieser Test die migrierte Datenbank, statt der + * Liste zu glauben. + */ + it("nennt keine Sperre, die es in der Datenbank nicht gibt", async ({ skip }) => { + const url = process.env.DIRECT_DATABASE_URL; + if (!url) return skip(); + + const { PrismaClient } = await import("@prisma/client"); + const prisma = new PrismaClient({ datasources: { db: { url } } }); + + try { + const tabellen = await prisma.$queryRawUnsafe>( + `SELECT table_name FROM information_schema.tables + WHERE table_schema = 'public' AND table_type = 'BASE TABLE'`, + ); + const vorhanden = new Set(tabellen.map((zeile) => zeile.table_name)); + + const unbekannt = SPERREN.map((eintrag) => eintrag.tabelle).filter( + (tabelle) => !vorhanden.has(tabelle), + ); + + expect(unbekannt, `Diese Tabellen gibt es nicht (mehr): ${unbekannt.join(", ")}`).toEqual( + [], + ); + } finally { + await prisma.$disconnect(); + } + }); + + it("übersieht keine Sperre, die die Migrationen gesetzt haben", async ({ skip }) => { + const url = process.env.DIRECT_DATABASE_URL; + if (!url) return skip(); + + const { PrismaClient } = await import("@prisma/client"); + const prisma = new PrismaClient({ datasources: { db: { url } } }); + + try { + const rolle = await prisma.$queryRawUnsafe>( + "SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'florian_app') AS da", + ); + if (!rolle[0]?.da) return skip(); + + // Welche Rechte hat die Anwendungsrolle heute wirklich? + const zeilen = await prisma.$queryRawUnsafe< + Array<{ table_name: string; privilege_type: string }> + >( + `SELECT table_name, privilege_type FROM information_schema.role_table_grants + WHERE grantee = 'florian_app'`, + ); + const hat = new Set(zeilen.map((z) => `${z.table_name}:${z.privilege_type}`)); + + // Jede Tabelle, der ein Schreibrecht fehlt, muss in der Liste stehen — + // sonst käme sie nach einer Wiederherstellung ungesperrt zurück. + const alleTabellen = [...new Set(zeilen.map((z) => z.table_name))]; + const notiert = new Map(SPERREN.map((e) => [e.tabelle, new Set(e.rechte)])); + + const fehlend: string[] = []; + for (const tabelle of alleTabellen) { + for (const recht of ["INSERT", "UPDATE", "DELETE"]) { + if (!hat.has(`${tabelle}:${recht}`) && !notiert.get(tabelle)?.has(recht)) { + fehlend.push(`${tabelle}: ${recht}`); + } + } + } + + expect( + fehlend, + "Diese Sperren stehen in der Datenbank, aber nicht in scripts/sicherung-sperren.ts — " + + `nach einer Wiederherstellung wären sie weg: ${fehlend.join(", ")}`, + ).toEqual([]); + } finally { + await prisma.$disconnect(); + } + }); +});