Compare commits

...
8 Commits
Author SHA1 Message Date
florian.wilde ea34294fa7 Merge #43: Fehlende Umgebungsvariablen durchreichen 2026-09-01 14:13:08 +00:00
Florian Wilde 810716e4e4 Fehlende Umgebungsvariablen durchreichen - und dauerhaft absichern
Der Dienst "app" zaehlt seine Umgebungsvariablen einzeln auf; ein
env_file gibt es nicht. Drei Werte, die die Anwendung ausliest, standen
dort nicht: ERSTEINRICHTUNG_GEHEIMNIS, VERTRAUE_PROXY (beide seit v1.11.0)
und S3_SSE (seit v1.11.1).

Wer sie laut .env.example und INSTALLATION.md eintrug, brachte sie damit
nicht in den Behaelter - die Anleitung war an dieser Stelle schlicht
falsch. Besonders unangenehm bei VERTRAUE_PROXY: Der Wert fehlt still,
die Anmeldesperre zaehlt dann nur nach E-Mail-Adresse statt zusaetzlich
nach Anschluss, und im Betrieb faellt nichts auf.

VERTRAUE_PROXY bekommt bewusst KEINE Vorgabe "true". Die strengere
Einstellung bleibt die Voreinstellung; wer einen Proxy davorstellt, sagt
es ausdruecklich. Ohne Proxy ist der weitergereichte Kopf frei erfunden.

Der eigentliche Ertrag ist der Test: Er liest, welche Variablen env.ts
ausliest - ueber process.env ebenso wie ueber pflicht(), zahl(),
schalter() und schluessel32() -, vergleicht das mit dem, was die
Compose-Datei dem Dienst mitgibt, und meldet jede Luecke. Ausnahmen sind
aufgezaehlt und begruendet.

Damit wiederholt sich der Fehler beim naechsten neuen Wert nicht: Wer ihn
einfuehrt und das Durchreichen vergisst, sieht es sofort.

Gegen drei Mutationen geprueft: Wert wieder entfernt, Vorgabe "true"
gesetzt, S3_SSE entfernt - jede wird rot.

Refs #43
2026-09-01 16:12:32 +02:00
florian.wilde 88e9202c2f Merge #42: Serverseitige Verschlüsselung optional machen 2026-09-01 14:09:01 +00:00
Florian Wilde c0cb29bf53 Serverseitige Verschluesselung optional machen
Der S3-Treiber setzte bei jedem PutObject fest
ServerSideEncryption: 'AES256'. Das mitgelieferte MinIO laeuft ohne KMS
und lehnt das mit "NotImplemented" ab - es liess sich kein einziges
Dokument ablegen. Wer der INSTALLATION.md folgte, landete zwangslaeufig
dort; es war kein Konfigurationsfehler des Betreibers, sondern der
Auslieferungszustand.

Gebraucht wird die Zeile ohnehin nicht mehr. Seit v1.11.0 verschluesselt
die Anwendung jede Datei selbst, bevor sie den Server verlaesst. Eine
zweite Lage durch den Anbieter schuetzt vor nichts, was der eigene
Umschlag nicht schon abdeckt.

Jetzt steuert S3_SSE, ob der Kopf mitgeht. Leer bedeutet aus - auch eine
Zeile mit Leerzeichen, sonst schickte eine leergelassene .env-Zeile einen
leeren Kopf und der Anbieter antwortete mit einem Fehler, den niemand
versteht.

Im Kommentar von v1.11.0 stand, die serverseitige Verschluesselung
"kostet nichts". Das war falsch: Auf MinIO ohne KMS kostete sie die
gesamte Funktion. Der Satz ist korrigiert.

Aufgefallen ist das erst im Betrieb, und der Grund ist derselbe wie beim
Ausloeser: In der Entwicklung laeuft der Dateitreiber, der S3-Pfad wurde
von keinem Test ausgefuehrt. Deshalb pruefen die neuen Tests den Befehl
ab, bevor er das Netz sieht - was hineingeschrieben wird, ist jetzt
belegt, nicht angenommen.

Gegen drei Mutationen geprueft: SSE wieder fest verdrahtet, leere Angabe
nicht abgefangen, Inhalt unverschluesselt - jede wird rot.

Refs #42
2026-09-01 16:08:26 +02:00
florian.wilde dbc24d965d Merge #41: Dokumente mitsichern 2026-09-01 14:04:03 +00:00
Florian Wilde 4d826ef55f Dokumente mitsichern - eine Akte ohne ihre Dateien ist wertlos
Der Sicherungsdienst machte taeglich ein pg_dump und sonst nichts. Nach
einem Plattenschaden waere die Akte vollstaendig wiederhergestellt worden
- mit allen Eintraegen, Verweisen und Pruefsummen, aber ohne eine einzige
Datei dahinter. Die Pruefsumme in der Akte haette dann ein Dokument
belegt, das es nicht mehr gibt. Gegenueber dem Betreuungsgericht ist das
der schlechteste denkbare Zustand.

Je Lauf entstehen jetzt zwei verschluesselte Dateien mit demselben
Zeitstempel: der Datenbankstand und ein Archiv des Ablageverzeichnisses.
Beide mit Pruefsumme, beide in der Aufbewahrung, beide im Monatsstand,
beide in der Selbstpruefung.

Schlaegt der Dokumententeil fehl, bricht der Lauf ab und raeumt den
angefangenen Datenbankstand wieder weg. Eine Sicherung, die nur die halbe
Wahrheit enthaelt, aber vollstaendig aussieht, ist schlimmer als gar
keine: Man verlaesst sich darauf und merkt es erst im Ernstfall.

## Abweichung von der urspruenglichen Idee

Im Issue stand "mc mirror statt rohem Verzeichnis". Beim Umsetzen zeigte
sich, dass der MinIO-Client in diesem Behaelter fehlt und bei jedem Lauf
aus dem Netz geholt werden muesste. Eine Internetabhaengigkeit im
Sicherungspfad ist genau das, was man dort nicht will.

Stattdessen wird das Verzeichnis direkt eingepackt. Das ist vertretbar,
weil MinIO ein Objekt erst vollstaendig unter .minio.sys/tmp schreibt und
dann an seinen Platz umbenennt: Ein laufender Upload liegt nie halbfertig
im Archiv, er fehlt hoechstens. Derselbe Weg deckt den Dateitreiber mit
ab.

## Nur lesend

Der Sicherungsdienst bekommt das Ablageverzeichnis mit ":ro" eingehaengt.
Er laeuft unbeaufsichtigt per cron; ein Fehler im Skript oder ein
kompromittierter Behaelter wuerde sonst Sicherung und Bestand auf einmal
treffen - genau der Fall, gegen den eine Sicherung da ist.

Deshalb schreibt die Wiederherstellung die Dateien auch nicht selbst an
ihren Platz, sondern packt sie nach ./backups aus und gibt den letzten
Handgriff als Einzeiler aus. In ein laufendes MinIO hineinzuschreiben
wuerde dessen Datenbestand ohnehin beschaedigen.

## Geprueft

Die Kernlogik einmal von Hand durchgespielt: packen, verschluesseln,
Selbstpruefung, zurueckholen - der Inhalt kam bitgleich zurueck, der
Klartext war im Archiv nicht auffindbar, und die Selbstpruefung schlug
sowohl bei einem beschaedigten Archiv als auch bei falschem Passwort an.

Dazu elf Pruefungen in tests/skripte.test.ts gegen vier Mutationen:
Dokumententeil entfernt, Abbruch entfernt, Aufraeumen halbiert,
Schreibrecht erteilt - jede wird rot.

Refs #41
2026-09-01 16:03:26 +02:00
florian.wilde 6e6f022234 Merge #40: Ausschlussfristen nur bei Fälligkeit hervorheben 2026-09-01 13:50:59 +00:00
Florian Wilde b3823094d6 Ausschlussfristen nur hervorheben, wenn sie etwas von einem wollen
Auf dem Dashboard standen dauerhaft drei rote Kaesten mit Warndreieck und
Knopf "Frist jetzt bearbeiten": Aufwandspauschalen mit Verfall 2027, 2028
und 2029.

Alle anderen Bloecke des Dashboards filtern nach Dringlichkeit. Dieser
fragte nur, OB etwas eine Ausschlussfrist ist, und nie, WANN sie faellig
wird - eine Frist im Mai 2029 landete damit in derselben Aufmachung wie
eine, die morgen ablaeuft.

Die Daten sind richtig: Pflichtfristen werden bewusst drei Jahre im Voraus
angelegt. Sie gehoeren nur nicht in die Warnung, sondern in die Liste.

Neu ist ein Horizont von 90 Tagen. Er ist grosszuegig gewaehlt, weil die
kuerzeste hier vorkommende Ausschlussfrist sechs Monate laesst. Abgelaufene
warnen weiterhin immer - eine versaeumte Aufwandspauschale bleibt eine
schlechte Nachricht, auch wenn sie lange zurueckliegt.

Der Grund ist nicht Bequemlichkeit: Eine Warnung, die monatelang dasteht
und nichts von einem will, stumpft ab. Man uebersieht die vierte, die
etwas will - und bei § 1878 BGB ist das die Jahrespauschale, die ersatzlos
erlischt.

Ein bestehender Test behauptete "unabhaengig vom Faelligkeitstopf" und
belegte das mit einer Frist 134 Tage in der Zukunft. Die Aussage stimmt
weiterhin, das Beispiel nicht mehr - es liegt jetzt innerhalb des
Horizonts, aber ausserhalb aller Faelligkeitstoepfe.

Gegen drei Mutationen geprueft: Filter entfernt, Abgelaufenes
ausgeschlossen, Horizont um einen Tag verschoben - jede wird rot.

Refs #40
2026-09-01 15:50:17 +02:00
12 changed files with 715 additions and 36 deletions
+13
View File
@@ -109,6 +109,19 @@ S3_BUCKET=betreuungs-cockpit
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
# Serverseitige Verschluesselung durch den Anbieter.
#
# Bleibt leer, und das ist die richtige Voreinstellung: Die Anwendung
# verschluesselt jede Datei selbst, bevor sie den Server verlaesst. Eine
# zweite Lage durch den Anbieter schuetzt vor nichts, was der eigene
# Umschlag nicht schon abdeckt.
#
# Das mitgelieferte MinIO kann sie ohnehin nicht - es laeuft ohne KMS und
# lehnt jede Ablage mit "NotImplemented" ab, sobald hier etwas steht.
#
# Nur setzen, wenn ein externer Anbieter sie ausdruecklich verlangt:
# S3_SSE=AES256
# --- E-Mail-Erinnerungen (optional, ab Etappe 2) ----------------------------
SMTP_HOST=
SMTP_PORT=587
+23
View File
@@ -93,6 +93,15 @@ services:
AUTH_SECRET: ${AUTH_SECRET:?AUTH_SECRET fehlt in der .env}
AUTH_URL: ${AUTH_URL:?AUTH_URL fehlt in der .env}
AUTH_TRUST_HOST: 'true'
# Ohne diesen Wert laesst sich der erste Zugang nicht anlegen - siehe
# src/server/actions/ersteinrichtung.ts. Leer heisst: gesperrt.
ERSTEINRICHTUNG_GEHEIMNIS: ${ERSTEINRICHTUNG_GEHEIMNIS:-}
# Bewusst OHNE Vorgabe "true": Die strengere Einstellung bleibt die
# Voreinstellung. Wer einen Reverse Proxy davorstellt - der
# dokumentierte Betrieb tut das -, sagt es in der .env ausdruecklich.
# Ohne den Wert zaehlt die Anmeldesperre nur nach E-Mail-Adresse.
VERTRAUE_PROXY: ${VERTRAUE_PROXY:-}
VERTRAUE_PROXY_STUFEN: ${VERTRAUE_PROXY_STUFEN:-}
ENC_KEY_V1: ${ENC_KEY_V1:?ENC_KEY_V1 fehlt in der .env - ohne ihn sind die verschluesselten Felder nicht lesbar}
# Bleibt leer, solange nicht rotiert wird. Die Anwendung ueberspringt
# leere Schluesselvariablen - siehe src/lib/env.ts.
@@ -110,6 +119,9 @@ services:
S3_BUCKET: ${S3_BUCKET:-betreuungs-cockpit}
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY}
# Leer lassen. Das mitgelieferte MinIO laeuft ohne KMS und lehnt jede
# Ablage ab, sobald hier etwas steht - siehe .env.example.
S3_SSE: ${S3_SSE:-}
UPLOAD_MAX_BYTES: ${UPLOAD_MAX_BYTES:-26214400}
OCR_AKTIV: ${OCR_AKTIV:-true}
SMTP_HOST: ${SMTP_HOST:-}
@@ -155,9 +167,20 @@ services:
PGDATABASE: ${POSTGRES_DB:-betreuungs_cockpit}
SICHERUNG_PASSWORT: ${SICHERUNG_PASSWORT:?SICHERUNG_PASSWORT fehlt in der .env}
SICHERUNG_AUFBEWAHRUNG_TAGE: ${SICHERUNG_AUFBEWAHRUNG_TAGE:-30}
SICHERUNG_DOKUMENTE_QUELLE: /dokumente
TZ: Europe/Berlin
volumes:
- ./backups:/backups
# Die Dokumente, ausdruecklich NUR LESEND.
#
# Der Sicherungsdienst laeuft unbeaufsichtigt per cron. Ein Dienst,
# der die Daten zerstoeren kann, die er sichern soll, ist ein Risiko
# ohne Gegenwert - ein Fehler im Skript oder ein kompromittierter
# Behaelter wuerde beides auf einmal treffen.
#
# Zurueckspielen geschieht deshalb nicht hierher, sondern in ein
# Verzeichnis unter ./backups; siehe scripts/wiederherstellung.sh.
- minio-daten:/dokumente:ro
- ./scripts/sicherung.sh:/sicherung.sh:ro
- ./scripts/wiederherstellung.sh:/wiederherstellung.sh:ro
entrypoint: ['/bin/sh', '-c']
+53 -8
View File
@@ -184,6 +184,13 @@ cp .env.example .env
nano .env
```
> **Die `.env` allein genügt nicht.** Die `docker-compose.yml` reicht die
> Werte einzeln an den Behälter durch — sie hat kein `env_file:`. Ein
> selbst ergänzter Wert, der dort nicht aufgeführt ist, erreicht die
> Anwendung nicht, auch wenn er in der `.env` steht. Alle hier
> beschriebenen Werte sind eingetragen; ein Test wacht darüber
> (`tests/umgebung-durchreichen.test.ts`).
Diese Werte **musst** du setzen:
```bash
@@ -379,6 +386,13 @@ docker compose exec minio mc ls lokal
# [...] betreuungs-cockpit/
```
> **Serverseitige Verschluesselung?** Brauchst du nicht. Die Anwendung
> verschlüsselt jede Datei selbst, bevor sie den Server verlässt — mit
> `ENC_KEY_V1`, der den Anbieter nie erreicht. Das mitgelieferte MinIO
> läuft ohne KMS und würde jede Ablage ablehnen, sobald `S3_SSE` in der
> `.env` steht. Setz den Wert nur, wenn ein externer Anbieter ihn
> ausdrücklich verlangt.
> **Externer Objektspeicher statt MinIO?** Wenn du einen S3-Dienst in der
> EU benutzt (z. B. Hetzner Object Storage, Falkenstein), streich den
> `minio`-Dienst aus der `docker-compose.yml`, entfern ihn aus
@@ -731,13 +745,19 @@ Erwartete Ausgabe, in dieser Reihenfolge:
```
Sicherung beginnt: /backups/cockpit_2026-08-22_1430.dump.enc
Sicherung fertig (1.2M)
Selbstpruefung bestanden.
Datenbank gesichert (1.2M)
Dokumente gesichert (18M)
Selbstpruefung bestanden - Datenbank und Dokumente.
```
Die **Selbstprüfung** ist der eigentliche Punkt: Das Skript entschlüsselt
die eben geschriebene Datei sofort wieder. Schlägt das fehl, endet der
Lauf mit einem Fehler statt mit einem stillen Scheinerfolg.
beide Dateien sofort wieder und liest sie einmal durch. Schlägt das fehl,
endet der Lauf mit einem Fehler statt mit einem stillen Scheinerfolg.
Fehlt die Zeile `Dokumente gesichert`, ist das Ablageverzeichnis nicht in
den Sicherungsdienst eingehängt. Das Skript bricht dann ab und räumt den
angefangenen Datenbankstand wieder weg — eine Sicherung, die nur die halbe
Wahrheit enthält, aber vollständig aussieht, wäre schlimmer als gar keine.
### Und jetzt einmal zurückspielen
@@ -760,6 +780,25 @@ docker compose exec db psql -U cockpit probe \
docker compose exec db dropdb -U cockpit probe
```
Beim Zurückspielen packt das Skript auch das Dokumentenarchiv aus — nach
`backups/dokumente-wiederhergestellt_<datum>/`. An ihren Platz schreibt es
die Dateien **nicht**: Der Sicherungsdienst hat auf das Ablageverzeichnis
bewusst nur Lesezugriff, und in ein laufendes MinIO hineinzuschreiben würde
dessen Datenbestand beschädigen. Wie der letzte Handgriff aussieht, gibt
das Skript am Ende selbst aus.
Für die Übung genügt ein Blick in das ausgepackte Verzeichnis:
```bash
find backups/dokumente-wiederhergestellt_*/ -type f | head
rm -rf backups/dokumente-wiederhergestellt_*
```
> **Im Ernstfall gehört beides zusammen.** Eine Datenbank ohne die
> zugehörigen Dateien ergibt eine Akte, deren Prüfsummen Dokumente
> belegen, die es nicht mehr gibt. Spiel immer das Paar mit demselben
> Zeitstempel zurück.
### Kontrolle
```bash
@@ -767,12 +806,18 @@ ls -lh backups/
# cockpit_....dump.enc und cockpit_....dump.enc.sha256
```
> **Was das Backup nicht enthält:** den Schlüssel `ENC_KEY_V1` und die
> Dateien im Objektspeicher. Für die Dateien:
> **Was das Backup enthält:** die Datenbank *und* die Dokumente. Je Lauf
> entstehen zwei verschlüsselte Dateien mit demselben Zeitstempel:
>
> ```bash
> docker compose exec minio mc mirror lokal/betreuungs-cockpit /backups/dokumente
> ```
> cockpit_2026-09-01_0315.dump.enc <- Akte, Fristen, Buchungen
> dokumente_2026-09-01_0315.tar.gz.enc <- Bescheide, Schreiben, Belege
> ```
>
> **Was es nicht enthält:** den Schlüssel `ENC_KEY_V1`. Ohne ihn bleiben
> auch aus einer vollständig zurückgespielten Sicherung die Diagnosen,
> IBANs, Notizen und Dokumente unlesbar. Er gehört an die zwei Orte aus
> Schritt 11.
>
> Und kopier die Sicherungen regelmäßig **weg vom Server** — ein Backup,
> das neben dem Original liegt, hilft bei einem Serverausfall nicht.
+81 -18
View File
@@ -1,6 +1,6 @@
#!/bin/sh
# =============================================================================
# Taegliche Sicherung der Datenbank
# Taegliche Sicherung: Datenbank UND Dokumente
# =============================================================================
#
# Laeuft im Behaelter "sicherung" per cron um 03:15 Uhr. Von Hand:
@@ -10,17 +10,44 @@
# Was passiert:
# 1. pg_dump im eigenen Format (-Fc), damit einzelne Tabellen
# wiederherstellbar bleiben.
# 2. Verschluesselung mit AES-256 und PBKDF2 aus SICHERUNG_PASSWORT.
# 2. Die abgelegten Dokumente als Archiv.
# 3. Beides verschluesselt mit AES-256 und PBKDF2 aus SICHERUNG_PASSWORT.
# Das Backup liegt oft auf fremdem Speicher - unverschluesselt waere
# es ein Datenleck mit Ansage.
# 3. SHA-256-Pruefsumme daneben.
# 4. Staende, die aelter sind als SICHERUNG_AUFBEWAHRUNG_TAGE, entfernen.
# 4. SHA-256-Pruefsumme je Datei.
# 5. Staende, die aelter sind als SICHERUNG_AUFBEWAHRUNG_TAGE, entfernen.
# Der jeweils erste Stand eines Monats bleibt stehen.
#
# WICHTIG: Dieses Backup enthaelt die verschluesselten Felder, aber NICHT
# den Schluessel ENC_KEY_V1. Ohne ihn sind Diagnosen, IBANs und Notizen
# auch aus einer wiederhergestellten Datenbank nicht lesbar. Den Schluessel
# getrennt sichern - Passwortmanager oder ausgedruckt im Ordner.
# ## Warum die Dokumente dazugehoeren
#
# Bis v1.11.0 sicherte dieses Skript nur die Datenbank. Nach einem
# Plattenschaden waere die Akte vollstaendig wiederhergestellt worden -
# mit allen Eintraegen, Verweisen und Pruefsummen, aber ohne eine einzige
# Datei dahinter. Die Pruefsumme in der Akte haette dann ein Dokument
# belegt, das es nicht mehr gibt. Gegenueber dem Betreuungsgericht ist das
# der schlechteste denkbare Zustand.
#
# ## Warum das Ablageverzeichnis roh eingepackt wird
#
# Der naheliegende Weg waere "mc mirror" aus dem MinIO-Client. Der ist in
# diesem Behaelter aber nicht vorhanden und muesste bei jedem Lauf aus dem
# Netz geholt werden - eine Internetabhaengigkeit im Sicherungspfad ist
# genau das, was man dort nicht will.
#
# Stattdessen wird das Datenverzeichnis direkt eingepackt. Das ist
# vertretbar, weil MinIO ein Objekt erst vollstaendig unter .minio.sys/tmp
# schreibt und dann an seinen Platz umbenennt: Ein laufender Upload liegt
# also nie halbfertig im Archiv, er fehlt hoechstens. Bei einem Lauf um
# 03:15 Uhr auf einem Einzelplatzsystem ist auch das unwahrscheinlich.
#
# Derselbe Weg deckt den Dateitreiber mit ab - dort liegen die Dateien
# ebenfalls einfach im Verzeichnis.
#
# WICHTIG: Dieses Backup enthaelt die verschluesselten Felder und die
# verschluesselten Dateien, aber NICHT den Schluessel ENC_KEY_V1. Ohne ihn
# sind Diagnosen, IBANs, Notizen und Dokumente auch aus einer
# wiederhergestellten Sicherung nicht lesbar. Den Schluessel getrennt
# sichern - Passwortmanager oder ausgedruckt im Ordner.
# =============================================================================
set -eu
@@ -30,6 +57,11 @@ AUFBEWAHRUNG="${SICHERUNG_AUFBEWAHRUNG_TAGE:-30}"
ZEITSTEMPEL="$(date +%Y-%m-%d_%H%M)"
TAG_IM_MONAT="$(date +%d)"
DATEI="$ZIEL/cockpit_${ZEITSTEMPEL}.dump.enc"
DOKUMENTE_DATEI="$ZIEL/dokumente_${ZEITSTEMPEL}.tar.gz.enc"
# Wo die Dokumente liegen. Der Behaelter bekommt das Ablageverzeichnis
# eingehaengt - siehe docker-compose.yml, Dienst "sicherung".
DOKUMENTE_QUELLE="${SICHERUNG_DOKUMENTE_QUELLE:-/dokumente}"
if [ -z "${SICHERUNG_PASSWORT:-}" ]; then
echo "$(date -Iseconds) FEHLER: SICHERUNG_PASSWORT ist nicht gesetzt. Abbruch." >&2
@@ -50,17 +82,39 @@ pg_dump --format=custom --compress=6 --no-owner --no-privileges \
GROESSE="$(du -h "$DATEI" | cut -f1)"
sha256sum "$DATEI" > "$DATEI.sha256"
# Der jeweils erste Stand eines Monats bekommt eine Markierung und bleibt
# beim Aufraeumen verschont.
if [ "$TAG_IM_MONAT" = "01" ]; then
touch "$DATEI.monatsstand"
echo "$(date -Iseconds) Datenbank gesichert ($GROESSE)"
# --- Dokumente ---------------------------------------------------------------
# Schlaegt dieser Teil fehl, bricht der ganze Lauf ab (set -e). Das ist
# Absicht: Eine Sicherung, die nur die halbe Wahrheit enthaelt, aber
# vollstaendig aussieht, ist schlimmer als gar keine - man verlaesst sich
# darauf und merkt es erst im Ernstfall.
if [ ! -d "$DOKUMENTE_QUELLE" ]; then
echo "$(date -Iseconds) FEHLER: Das Ablageverzeichnis $DOKUMENTE_QUELLE ist nicht eingehaengt." >&2
echo " Ohne die Dokumente ist die Sicherung unvollstaendig." >&2
echo " Siehe docker-compose.yml, Dienst \"sicherung\"." >&2
rm -f "$DATEI" "$DATEI.sha256"
exit 1
fi
echo "$(date -Iseconds) Sicherung fertig ($GROESSE)"
# "tar" ohne Leitung an einen Verbraucher, der frueh schliesst - sonst
# SIGPIPE, siehe die Anmerkung zur Selbstpruefung weiter unten.
tar -czf - -C "$DOKUMENTE_QUELLE" . | openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -salt -pass env:SICHERUNG_PASSWORT -out "$DOKUMENTE_DATEI"
DOK_GROESSE="$(du -h "$DOKUMENTE_DATEI" | cut -f1)"
sha256sum "$DOKUMENTE_DATEI" > "$DOKUMENTE_DATEI.sha256"
echo "$(date -Iseconds) Dokumente gesichert ($DOK_GROESSE)"
# Der jeweils erste Stand eines Monats bekommt eine Markierung und bleibt
# beim Aufraeumen verschont. Beide Teile, sonst faellt die Haelfte weg.
if [ "$TAG_IM_MONAT" = "01" ]; then
touch "$DATEI.monatsstand" "$DOKUMENTE_DATEI.monatsstand"
fi
# --- Aufraeumen --------------------------------------------------------------
ENTFERNT=0
for alt in "$ZIEL"/cockpit_*.dump.enc; do
for alt in "$ZIEL"/cockpit_*.dump.enc "$ZIEL"/dokumente_*.tar.gz.enc; do
[ -e "$alt" ] || continue
[ -e "$alt.monatsstand" ] && continue
@@ -87,9 +141,18 @@ KOPF="$(openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
-pass env:SICHERUNG_PASSWORT -in "$DATEI" 2>/dev/null \
| dd bs=1 count=5 2>/dev/null || true)"
if [ "$KOPF" = "PGDMP" ]; then
echo "$(date -Iseconds) Selbstpruefung bestanden."
else
echo "$(date -Iseconds) FEHLER: Die Sicherung laesst sich nicht entschluesseln!" >&2
if [ "$KOPF" != "PGDMP" ]; then
echo "$(date -Iseconds) FEHLER: Der Datenbankstand laesst sich nicht entschluesseln!" >&2
exit 1
fi
# Dasselbe fuer die Dokumente: entschluesseln und das Archiv auflisten
# lassen. "tar -tzf" liest die ganze Datei und meldet jeden Schaden - das
# ist bei den Groessenordnungen hier bezahlbar und sagt mehr aus als ein
# Blick auf die ersten Bytes.
if ! openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 -pass env:SICHERUNG_PASSWORT -in "$DOKUMENTE_DATEI" 2>/dev/null | tar -tzf - >/dev/null 2>&1; then
echo "$(date -Iseconds) FEHLER: Das Dokumentenarchiv laesst sich nicht lesen!" >&2
exit 1
fi
echo "$(date -Iseconds) Selbstpruefung bestanden - Datenbank und Dokumente."
+64 -4
View File
@@ -9,7 +9,7 @@
# ausdruecklich nach, weil dabei der aktuelle Datenbestand ueberschrieben
# wird.
#
# ZWEI DINGE, DIE MAN VORHER WISSEN MUSS:
# DREI DINGE, DIE MAN VORHER WISSEN MUSS:
#
# 1. Die Sicherung enthaelt die anwendungsseitig verschluesselten Felder,
# aber NICHT den Schluessel. Ohne den passenden ENC_KEY_Vn aus der .env
@@ -20,6 +20,14 @@
# das hier einmal im Jahr auf einer Testdatenbank aus, bevor du es im
# Ernstfall zum ersten Mal brauchst. Siehe README, Abschnitt
# "Wiederherstellung ueben".
#
# 3. Die Dokumente werden NICHT selbsttaetig an ihren Platz geschrieben.
# Dieses Skript packt sie nach ./backups aus und sagt, wie es
# weitergeht. Grund: Der Sicherungsdienst hat auf das Ablageverzeichnis
# bewusst nur Lesezugriff (siehe docker-compose.yml), und in ein
# laufendes MinIO hineinzuschreiben wuerde dessen Datenbestand
# beschaedigen. Der letzte Handgriff bleibt beim Menschen - er ist ein
# Einzeiler und im Klartext beschrieben.
# =============================================================================
set -eu
@@ -87,7 +95,44 @@ openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
| pg_restore --clean --if-exists --no-owner --no-privileges \
--dbname="${PGDATABASE:-postgres}" --exit-on-error
echo "$(date -Iseconds) Wiederherstellung abgeschlossen."
echo "$(date -Iseconds) Datenbank wiederhergestellt."
# --- Dokumente ---------------------------------------------------------------
# Zu "cockpit_2026-09-01_0315.dump.enc" gehoert
# "dokumente_2026-09-01_0315.tar.gz.enc" - gleicher Zeitstempel, anderer
# Name. Fehlt sie, stammt die Sicherung aus einer Fassung vor v1.11.1.
ZEITSTEMPEL="$(basename "$DATEI" | sed -n 's/^cockpit_\(.*\)\.dump\.enc$/\1/p')"
DOKUMENTE_DATEI="$(dirname "$DATEI")/dokumente_${ZEITSTEMPEL}.tar.gz.enc"
AUSPACKZIEL="$(dirname "$DATEI")/dokumente-wiederhergestellt_${ZEITSTEMPEL}"
if [ ! -f "$DOKUMENTE_DATEI" ]; then
echo ""
echo "WARNUNG: Zu dieser Sicherung gibt es kein Dokumentenarchiv."
echo " Erwartet wurde: $DOKUMENTE_DATEI"
echo " Sicherungen aus Fassungen vor v1.11.1 enthalten nur die"
echo " Datenbank. Die Akte ist dann vollstaendig, die Dateien"
echo " dahinter fehlen."
else
if [ -f "$DOKUMENTE_DATEI.sha256" ]; then
echo "Pruefsumme des Dokumentenarchivs wird geprueft ..."
if sha256sum -c "$DOKUMENTE_DATEI.sha256" >/dev/null 2>&1; then
echo " in Ordnung."
else
echo "FEHLER: Die Pruefsumme des Dokumentenarchivs stimmt nicht." >&2
exit 1
fi
fi
mkdir -p "$AUSPACKZIEL"
openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
-pass env:SICHERUNG_PASSWORT -in "$DOKUMENTE_DATEI" \
| tar -xzf - -C "$AUSPACKZIEL"
ANZAHL="$(find "$AUSPACKZIEL" -type f | wc -l)"
echo "$(date -Iseconds) Dokumente ausgepackt: $ANZAHL Dateien nach"
echo " $AUSPACKZIEL"
fi
echo ""
echo "Naechste Schritte:"
echo " 1. Die SQL-Skripte erneut ausfuehren (Volltextsuche und Audit-Schutz):"
@@ -95,6 +140,21 @@ echo " docker compose exec app npm run db:sql"
echo " 2. Pruefen, ob ENC_KEY_V1 in der .env zu diesem Bestand passt -"
echo " ohne den richtigen Schluessel bleiben die verschluesselten"
echo " Felder unlesbar."
echo " 3. In der Anwendung eine betreute Person oeffnen und nachsehen, ob"
echo " die Diagnosen im Klartext erscheinen. Erst dann ist die"
echo " 3. Die Dokumente an ihren Platz bringen. Dazu MinIO anhalten, das"
echo " Datenverzeichnis ersetzen und wieder starten - auf dem Host,"
echo " nicht in diesem Behaelter:"
echo ""
echo " docker compose stop minio app"
echo " docker run --rm \\"
echo " -v betreuungs-cockpit_minio-daten:/ziel \\"
echo " -v \"\$PWD/backups:/quelle:ro\" alpine \\"
echo " sh -c 'rm -rf /ziel/* /ziel/..?* && cp -a /quelle/dokumente-wiederhergestellt_*/. /ziel/'"
echo " docker compose up -d"
echo ""
echo " Steht mehr als ein ausgepacktes Verzeichnis unter ./backups,"
echo " vorher das richtige auswaehlen statt des Sternchens."
echo ""
echo " 4. In der Anwendung eine betreute Person oeffnen und nachsehen, ob"
echo " die Diagnosen im Klartext erscheinen. Dann ein Dokument"
echo " herunterladen. Erst wenn beides klappt, ist die"
echo " Wiederherstellung wirklich gelungen."
+37 -1
View File
@@ -24,6 +24,42 @@ export const WARTEZEIT_DRINGEND_TAGE = 21
/** Wie viele Ausschlussfristen zeigt die Warnung hoechstens? */
const AUSSCHLUSSFRISTEN_MAX = 3
/**
* Wie weit im Voraus eine Ausschlussfrist die Warnung ausloest.
*
* Frueher gab es diese Grenze nicht: Der Warnblock fragte nur, OB etwas
* eine Ausschlussfrist ist, nie WANN sie faellig wird. Auf dem Dashboard
* standen deshalb dauerhaft drei rote Kaesten mit Warndreieck - fuer
* Aufwandspauschalen, die 2027, 2028 und 2029 ablaufen. Die Pflichtfristen
* werden bewusst drei Jahre im Voraus angelegt (jahreVoraus in
* pflichtfristen.ts), und alle landeten ungefiltert in der Warnung.
*
* Das ist nicht nur laestig, sondern gefaehrlich: Eine Warnung, die
* monatelang dasteht und nichts von einem will, stumpft ab. Man uebersieht
* die vierte, die etwas will - und bei § 1878 BGB ist das die
* Jahrespauschale, die ersatzlos erlischt.
*
* 90 Tage, weil die kuerzeste hier vorkommende Ausschlussfrist sechs
* Monate Zeit laesst. Drei Monate Vorlauf sind reichlich, und solange
* nichts ansteht, bleibt der Kasten leer - dann bedeutet er etwas.
*
* Verschwinden tut nichts: Was ausserhalb des Horizonts liegt, steht
* weiterhin in der Fristenliste.
*/
const AUSSCHLUSSFRIST_HORIZONT_TAGE = 90
/**
* Gehoert diese Frist in den hervorgehobenen Warnblock?
*
* Abgelaufenes immer - eine versaeumte Ausschlussfrist bleibt eine
* schlechte Nachricht, auch wenn sie lange zurueckliegt. Sonst nur, was
* innerhalb des Horizonts faellig wird.
*/
function warntJetzt(eintrag: { ausschlussfrist: boolean; tage: number }): boolean {
if (!eintrag.ausschlussfrist) return false
return eintrag.tage < 0 || eintrag.tage <= AUSSCHLUSSFRIST_HORIZONT_TAGE
}
/**
* Datumsfelder ohne Uhrzeit (@db.Date) kommen als UTC-Mitternacht aus der
* Datenbank. Wer sie unbesehen mit einem lokalen "heute" vergleicht,
@@ -126,7 +162,7 @@ export function fristenEinordnen(fristen: FristRoh[], heute: Date): FristenAmpel
dieseWoche: eintraege.filter((e) => e.stufe === 'orange'),
diesenMonat: eintraege.filter((e) => e.stufe === 'gelb'),
weitereOffen: eintraege.filter((e) => e.stufe === 'gruen').length,
ausschlussfristen: eintraege.filter((e) => e.ausschlussfrist).slice(0, AUSSCHLUSSFRISTEN_MAX),
ausschlussfristen: eintraege.filter(warntJetzt).slice(0, AUSSCHLUSSFRISTEN_MAX),
gesamtOffen: eintraege.length,
}
}
+20
View File
@@ -163,6 +163,26 @@ export const env = {
bucket: pflicht('S3_BUCKET'),
accessKeyId: pflicht('S3_ACCESS_KEY_ID'),
secretAccessKey: pflicht('S3_SECRET_ACCESS_KEY'),
/**
* Serverseitige Verschluesselung - nur, wenn ausdruecklich gewuenscht.
*
* Frueher stand im Treiber fest ServerSideEncryption: 'AES256'. Das
* mitgelieferte MinIO laeuft ohne KMS und lehnt das mit
* "NotImplemented" ab: Es liess sich kein einziges Dokument ablegen,
* und wer der INSTALLATION.md folgte, landete zwangslaeufig dort.
*
* Gebraucht wird sie ohnehin nicht mehr. Seit v1.11.0 verschluesselt
* die Anwendung jede Datei selbst, bevor sie den Server verlaesst
* (src/lib/speicher/umschlag.ts). Serverseitige Verschluesselung
* schuetzt vor nichts, was der eigene Umschlag nicht schon abdeckt -
* sie ist hoechstens eine zweite Lage fuer Anbieter, die sie
* anbieten.
*
* Leer bedeutet aus. Wer sie will, setzt S3_SSE=AES256 oder den Wert,
* den sein Anbieter erwartet.
*/
serverseitigeVerschluesselung: process.env.S3_SSE?.trim() || null,
}
},
+11 -4
View File
@@ -6,6 +6,7 @@ import {
ListObjectsV2Command,
PutObjectCommand,
S3Client,
type ServerSideEncryption,
} from '@aws-sdk/client-s3'
import { env } from '@/lib/env'
import {
@@ -33,8 +34,13 @@ import { entschluesselnBinaer, verschluesselnBinaer } from '@/lib/speicher/umsch
* Kontoauszuege.
*
* Deshalb liegt jetzt derselbe Umschlag darum wie beim Dateitreiber, mit
* einem Schluessel aus der Umgebung. Die serverseitige Verschluesselung
* bleibt zusaetzlich stehen; sie kostet nichts.
* einem Schluessel aus der Umgebung.
*
* Die serverseitige Verschluesselung ist damit ueberfluessig und wird nur
* noch gesendet, wenn S3_SSE gesetzt ist. Im Kommentar stand hier einmal,
* sie "kostet nichts" - das war falsch: Das mitgelieferte MinIO laeuft
* ohne KMS und lehnte jedes PutObject mit "NotImplemented" ab. Sie kostete
* die gesamte Funktion.
*
* ## Und deshalb keine presigned URLs mehr
*
@@ -80,6 +86,7 @@ export async function schreiben(
_mimeTyp: string,
): Promise<void> {
const geprueft = schluesselErfordern(schluessel)
const sse = env.s3.serverseitigeVerschluesselung
await s3().send(
new PutObjectCommand({
Bucket: eimer(),
@@ -91,8 +98,8 @@ export async function schreiben(
ContentType: 'application/octet-stream',
// Auch beim direkten Abruf niemals im Browser darstellen.
ContentDisposition: 'attachment',
// Serverseitige Verschluesselung, wo der Anbieter sie unterstuetzt.
ServerSideEncryption: 'AES256',
// Nur mitschicken, wenn konfiguriert - siehe env.s3.
...(sse ? { ServerSideEncryption: sse as ServerSideEncryption } : {}),
}),
)
}
+77 -1
View File
@@ -92,9 +92,10 @@ describe('Fristen-Ampel', () => {
[
frist({ id: 'normal', datum: dbTag('2026-08-20') }),
frist({
// Weit genug weg fuer "gruen", nah genug fuer die Warnung.
id: 'pauschale',
titel: 'Aufwandspauschale 2026',
datum: dbTag('2026-12-31'),
datum: dbTag('2026-10-15'),
ausschlussfrist: true,
rechtsgrundlage: '§ 1878 BGB',
}),
@@ -103,6 +104,81 @@ describe('Fristen-Ampel', () => {
)
expect(daten.ausschlussfristen.map((e) => e.id)).toEqual(['pauschale'])
// Nicht in einem der Faelligkeitstoepfe - der Warnblock steht daneben.
expect(daten.weitereOffen).toBe(1)
})
describe('Ausschlussfristen: nur warnen, wenn es etwas zu tun gibt', () => {
/**
* Der Anlass: Auf dem Dashboard standen dauerhaft drei rote Kaesten mit
* Warndreieck - Aufwandspauschalen mit Verfall 2027, 2028 und 2029. Die
* Pflichtfristen werden bewusst drei Jahre im Voraus angelegt, und der
* Warnblock fragte nur, OB etwas eine Ausschlussfrist ist, nie WANN sie
* faellig wird.
*
* Eine Warnung, die monatelang dasteht und nichts von einem will,
* stumpft ab. Genau davor schuetzen diese Tests.
*/
function pauschale(id: string, datum: string) {
return frist({
id,
titel: `Aufwandspauschale ${id}`,
datum: dbTag(datum),
ausschlussfrist: true,
rechtsgrundlage: '§ 1878 BGB',
})
}
it('lässt Fristen weit in der Zukunft aus der Warnung heraus', () => {
const daten = fristenEinordnen(
[
pauschale('2027', '2027-05-06'),
pauschale('2028', '2028-05-06'),
pauschale('2029', '2029-05-06'),
],
HEUTE,
)
expect(daten.ausschlussfristen).toEqual([])
// Verschwunden sind sie deshalb nicht - sie stehen in der Liste.
expect(daten.weitereOffen).toBe(3)
expect(daten.gesamtOffen).toBe(3)
})
it('warnt, sobald eine in den Horizont rückt', () => {
// 90 Tage nach dem 19.08.2026 ist der 17.11.2026.
const daten = fristenEinordnen([pauschale('bald', '2026-11-17')], HEUTE)
expect(daten.ausschlussfristen.map((e) => e.id)).toEqual(['bald'])
})
it('warnt einen Tag davor noch nicht', () => {
const daten = fristenEinordnen([pauschale('knapp-drueber', '2026-11-18')], HEUTE)
expect(daten.ausschlussfristen).toEqual([])
})
it('warnt bei einer versäumten Frist immer, egal wie lange sie zurückliegt', () => {
// Eine verfallene Aufwandspauschale bleibt eine schlechte Nachricht.
// Sie stillschweigend aus der Warnung zu nehmen, waere das Gegenteil
// von dem, was hier gewollt ist.
const daten = fristenEinordnen([pauschale('verpasst', '2024-05-06')], HEUTE)
expect(daten.ausschlussfristen.map((e) => e.id)).toEqual(['verpasst'])
})
it('mischt Fälliges und Fernes richtig', () => {
const daten = fristenEinordnen(
[
pauschale('verpasst', '2025-05-06'),
pauschale('bald', '2026-10-01'),
pauschale('fern', '2029-05-06'),
],
HEUTE,
)
expect(daten.ausschlussfristen.map((e) => e.id)).toEqual(['verpasst', 'bald'])
})
})
it('warnt bei einer Ausschlussfrist ausdrücklich vor dem ersatzlosen Verfall', () => {
+87
View File
@@ -132,3 +132,90 @@ describe('Skripte mit pipefail', () => {
).toEqual([])
})
})
/**
* Die Sicherung muss die Dokumente enthalten - nicht nur die Datenbank.
*
* Bis v1.11.0 sicherte scripts/sicherung.sh ausschliesslich die Datenbank.
* Nach einem Plattenschaden waere die Akte vollstaendig wiederhergestellt
* worden - mit allen Eintraegen, Verweisen und Pruefsummen, aber ohne eine
* einzige Datei dahinter. Die Pruefsumme in der Akte haette dann ein
* Dokument belegt, das es nicht mehr gibt.
*
* Aufgefallen ist das nicht durch einen Test, sondern beim Nachsehen, wo
* die Daten eigentlich liegen. Die folgenden Pruefungen lesen deshalb den
* Skripttext: Sie koennen die Sicherung nicht ausfuehren - dafuer braeuchte
* es Postgres, MinIO und einen Behaelter -, aber sie schlagen an, sobald
* einer der Bausteine wieder herausfaellt.
*/
describe('Sicherung umfasst die Dokumente', () => {
const sicherung = readFileSync('scripts/sicherung.sh', 'utf8')
const wiederherstellung = readFileSync('scripts/wiederherstellung.sh', 'utf8')
it('packt das Ablageverzeichnis ein', () => {
expect(sicherung).toMatch(/tar -czf - -C "\$DOKUMENTE_QUELLE"/)
})
it('verschlüsselt das Archiv mit demselben Verfahren wie den Datenbankstand', () => {
// Zwei Aufrufe: einer fuer den Dump, einer fuer die Dokumente.
const verschluesselt = sicherung.match(/openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -salt/g)
expect(verschluesselt).toHaveLength(2)
})
it('legt zu beiden Teilen eine Prüfsumme', () => {
expect(sicherung).toMatch(/sha256sum "\$DATEI"/)
expect(sicherung).toMatch(/sha256sum "\$DOKUMENTE_DATEI"/)
})
it('bricht ab, wenn das Ablageverzeichnis nicht eingehängt ist', () => {
// Eine Sicherung, die nur die halbe Wahrheit enthaelt, aber
// vollstaendig aussieht, ist schlimmer als gar keine.
expect(sicherung).toMatch(/if \[ ! -d "\$DOKUMENTE_QUELLE" \]/)
expect(sicherung).toMatch(/rm -f "\$DATEI" "\$DATEI\.sha256"/)
})
it('prüft am Ende beide Teile, nicht nur den Datenbankstand', () => {
expect(sicherung).toMatch(/tar -tzf -/)
expect(sicherung).toMatch(/Datenbank und Dokumente/)
})
it('räumt alte Dokumentenarchive genauso auf wie alte Datenbankstände', () => {
expect(sicherung).toMatch(/dokumente_\*\.tar\.gz\.enc/)
})
it('verschont beim Monatsstand beide Teile', () => {
expect(sicherung).toMatch(/touch "\$DATEI\.monatsstand" "\$DOKUMENTE_DATEI\.monatsstand"/)
})
it('holt die Dokumente beim Zurückspielen mit zurück', () => {
expect(wiederherstellung).toMatch(/DOKUMENTE_DATEI=/)
expect(wiederherstellung).toMatch(/tar -xzf - -C "\$AUSPACKZIEL"/)
})
it('sagt beim Zurückspielen deutlich, wenn kein Dokumentenarchiv dabei ist', () => {
// Sonst haelt man eine halbe Wiederherstellung fuer eine ganze.
expect(wiederherstellung).toMatch(/kein Dokumentenarchiv/)
})
it('schreibt die Dokumente nicht selbst an ihren Platz', () => {
// Der Sicherungsdienst hat auf das Ablageverzeichnis nur Lesezugriff.
// In ein laufendes MinIO hineinzuschreiben wuerde dessen Datenbestand
// beschaedigen - der letzte Handgriff bleibt beim Menschen.
expect(wiederherstellung).toMatch(/docker compose stop minio app/)
})
})
/**
* Der Sicherungsdienst darf die Daten, die er sichert, nicht zerstoeren
* koennen.
*
* Er laeuft unbeaufsichtigt per cron. Ein Fehler im Skript oder ein
* kompromittierter Behaelter wuerde sonst Sicherung und Bestand auf einmal
* treffen - genau der Fall, gegen den eine Sicherung da ist.
*/
describe('Sicherungsdienst greift nur lesend auf die Dokumente zu', () => {
it('hängt das Ablageverzeichnis mit :ro ein', () => {
const compose = readFileSync('docker-compose.yml', 'utf8')
expect(compose).toMatch(/minio-daten:\/dokumente:ro/)
})
})
+136
View File
@@ -0,0 +1,136 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
/**
* Was der S3-Treiber tatsächlich an den Objektspeicher schickt.
*
* Anlass: Der Treiber setzte bei jedem PutObject fest
* `ServerSideEncryption: 'AES256'`. Das mitgelieferte MinIO läuft ohne KMS
* und lehnte das mit `NotImplemented` ab — es ließ sich kein einziges
* Dokument ablegen, und wer der INSTALLATION.md folgte, landete
* zwangsläufig dort.
*
* Gemerkt hat das kein Test, weil in der Entwicklung der Dateitreiber
* läuft und der S3-Pfad nie ausgeführt wurde. Diese Datei schließt genau
* diese Lücke: Sie fängt den Befehl ab, bevor er das Netz sieht, und
* schaut hinein.
*/
process.env.S3_ENDPOINT = 'http://minio:9000'
process.env.S3_BUCKET = 'betreuungs-cockpit'
process.env.S3_ACCESS_KEY_ID = 'probe-zugang'
process.env.S3_SECRET_ACCESS_KEY = 'probe-geheimnis'
/** Der letzte Befehl, den der Treiber abgeschickt hat. */
let letzterBefehl: Record<string, unknown> | null = null
vi.mock('@aws-sdk/client-s3', () => {
class PutObjectCommand {
input: Record<string, unknown>
constructor(input: Record<string, unknown>) {
this.input = input
}
}
class GetObjectCommand extends PutObjectCommand {}
class DeleteObjectCommand extends PutObjectCommand {}
class HeadObjectCommand extends PutObjectCommand {}
class ListObjectsV2Command extends PutObjectCommand {}
class S3Client {
async send(befehl: { input: Record<string, unknown> }) {
letzterBefehl = befehl.input
return {}
}
}
return {
S3Client,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
HeadObjectCommand,
ListObjectsV2Command,
}
})
const SCHLUESSEL = 'betreute/cmt4ey0jm0003o001wq4rc3m9/dokumente/ba7a8c1a-d4c5-41c7-8f5f-ebfb4805b0e5.pdf'
const INHALT = Buffer.from('%PDF-1.4 ein Bescheid', 'utf8')
/**
* Der Treiber baut seinen Klienten einmal und merkt ihn sich. Damit die
* Umgebungsvariable je Testfall neu gelesen wird, muss das Modul frisch
* geladen werden.
*/
async function treiberFrisch() {
vi.resetModules()
return import('@/lib/speicher/s3')
}
describe('S3-Treiber: serverseitige Verschlüsselung', () => {
beforeEach(() => {
letzterBefehl = null
delete process.env.S3_SSE
})
afterEach(() => {
delete process.env.S3_SSE
})
it('schickt ohne S3_SSE keinen Verschlüsselungskopf mit', async () => {
const s3 = await treiberFrisch()
await s3.schreiben(SCHLUESSEL, INHALT, 'application/pdf')
expect(letzterBefehl).not.toBeNull()
expect(letzterBefehl).not.toHaveProperty('ServerSideEncryption')
})
it('schickt ihn mit, wenn S3_SSE gesetzt ist', async () => {
process.env.S3_SSE = 'AES256'
const s3 = await treiberFrisch()
await s3.schreiben(SCHLUESSEL, INHALT, 'application/pdf')
expect(letzterBefehl?.ServerSideEncryption).toBe('AES256')
})
it('übernimmt auch einen anderen Wert, den ein Anbieter verlangt', async () => {
process.env.S3_SSE = 'aws:kms'
const s3 = await treiberFrisch()
await s3.schreiben(SCHLUESSEL, INHALT, 'application/pdf')
expect(letzterBefehl?.ServerSideEncryption).toBe('aws:kms')
})
it('behandelt eine leere Angabe wie "aus"', async () => {
// Sonst schickte eine leergelassene Zeile in der .env einen leeren
// Kopf, und der Anbieter antwortete mit einem Fehler, den niemand
// versteht.
process.env.S3_SSE = ' '
const s3 = await treiberFrisch()
await s3.schreiben(SCHLUESSEL, INHALT, 'application/pdf')
expect(letzterBefehl).not.toHaveProperty('ServerSideEncryption')
})
})
describe('S3-Treiber: was sonst noch mitgeht', () => {
beforeEach(() => {
letzterBefehl = null
delete process.env.S3_SSE
})
it('legt den Inhalt verschlüsselt ab, nicht im Klartext', async () => {
const s3 = await treiberFrisch()
await s3.schreiben(SCHLUESSEL, INHALT, 'application/pdf')
const koerper = letzterBefehl?.Body as Buffer
expect(koerper.subarray(0, 4).toString('ascii')).toBe('BCD1')
expect(koerper.includes(Buffer.from('ein Bescheid', 'utf8'))).toBe(false)
})
it('meldet den Inhaltstyp nicht als PDF - dort liegt Geheimtext', async () => {
const s3 = await treiberFrisch()
await s3.schreiben(SCHLUESSEL, INHALT, 'application/pdf')
expect(letzterBefehl?.ContentType).toBe('application/octet-stream')
expect(letzterBefehl?.ContentDisposition).toBe('attachment')
})
})
+113
View File
@@ -0,0 +1,113 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
/**
* Was die Anwendung liest, muss der Behälter auch bekommen.
*
* Die `docker-compose.yml` zählt die Umgebungsvariablen des Dienstes `app`
* einzeln auf — sie hat kein `env_file:`. Wer einen neuen Wert einführt,
* ihn in `.env.example` dokumentiert und in `env.ts` ausliest, hat damit
* noch nichts erreicht: Im Behälter kommt er nicht an.
*
* Genau das ist mit v1.11.0 passiert. `ERSTEINRICHTUNG_GEHEIMNIS` und
* `VERTRAUE_PROXY` standen in der Anleitung, in der Beispieldatei und im
* Code — nur nicht in der Compose-Datei. Die Anleitung war damit schlicht
* falsch, und der Fehler war von außen nicht zu sehen: `VERTRAUE_PROXY`
* fehlt still, die Anmeldesperre zählt dann eine Dimension weniger.
*
* Dieser Test liest beide Seiten und vergleicht sie. Er prüft kein
* Verhalten, sondern die Verpackung — wie `skripte.test.ts`.
*/
const compose = readFileSync('docker-compose.yml', 'utf8')
const envDatei = readFileSync('src/lib/env.ts', 'utf8')
const beispiel = readFileSync('.env.example', 'utf8')
/**
* Alles, was `env.ts` über seine Helfer ausliest.
*
* `process.env.X` direkt wird mitgenommen, ebenso `pflicht('X')`,
* `zahl('X', …)`, `schalter('X', …)` und `schluessel32('X')`.
*/
function gelesen(): Set<string> {
const namen = new Set<string>()
for (const treffer of envDatei.matchAll(/process\.env\.([A-Z_0-9]+)/g)) {
namen.add(treffer[1]!)
}
for (const treffer of envDatei.matchAll(
/(?:pflicht|zahl|schalter|schluessel32)\('([A-Z_0-9]+)'/g,
)) {
namen.add(treffer[1]!)
}
return namen
}
/** Die Variablen, die der Dienst `app` durchreicht. */
function durchgereicht(): Set<string> {
const abschnitt = compose.slice(compose.indexOf('\n app:'), compose.indexOf('\n sicherung:'))
const namen = new Set<string>()
for (const treffer of abschnitt.matchAll(/^ {6}([A-Z_0-9]+):/gm)) {
namen.add(treffer[1]!)
}
return namen
}
/**
* Was absichtlich nicht durchgereicht wird - jeweils mit Grund.
*
* Wer hier etwas ergänzt, nimmt es bewusst aus der Prüfung. Das ist
* erlaubt, aber es soll dabeistehen, warum.
*/
const AUSNAHMEN = new Map<string, string>([
['NODE_ENV', 'wird im Dienst gesetzt, kommt nicht aus der .env'],
['NEXT_PUBLIC_GIT_COMMIT', 'Bauargument, steckt schon im Abbild'],
['NEXT_PUBLIC_BUILD_ZEIT', 'Bauargument, steckt schon im Abbild'],
['DATEI_SPEICHER_PFAD', 'nur fuer den Dateitreiber; im Behaelter laeuft s3'],
['TESSERACT_BEFEHL', 'Standard "tesseract" stimmt im Abbild immer'],
])
describe('Umgebungsvariablen erreichen den Behälter', () => {
it('reicht jede Variable durch, die die Anwendung ausliest', () => {
const fehlend = [...gelesen()]
.filter((name) => !durchgereicht().has(name))
.filter((name) => !AUSNAHMEN.has(name))
// ENC_KEY_V2, V3 ... werden dynamisch gesucht; nur V1 ist Pflicht.
.filter((name) => !/^ENC_KEY_V\d+$/.test(name))
.sort()
expect(
fehlend,
'Diese Werte liest die Anwendung, aber die docker-compose.yml gibt sie ' +
'dem Dienst "app" nicht mit. In der .env stehen sie dann wirkungslos.',
).toEqual([])
})
it('erklärt jede Ausnahme', () => {
for (const [name, grund] of AUSNAHMEN) {
expect(grund.length, `Ausnahme ${name} ohne Begründung`).toBeGreaterThan(10)
}
})
it('dokumentiert die beiden Werte aus v1.11.0 in der Beispieldatei', () => {
// Sie waren der Anlass. Verschwinden sie aus .env.example, merkt es
// sonst niemand, bis jemand eine Neuinstallation versucht.
expect(beispiel).toMatch(/ERSTEINRICHTUNG_GEHEIMNIS=/)
expect(beispiel).toMatch(/VERTRAUE_PROXY=/)
})
it('lässt VERTRAUE_PROXY ohne Vorgabewert', () => {
// Die strengere Einstellung bleibt die Voreinstellung. Ein Vorgabewert
// "true" waere bequem und falsch: Ohne Proxy ist der weitergereichte
// Kopf frei erfunden.
expect(compose).toMatch(/VERTRAUE_PROXY: \$\{VERTRAUE_PROXY:-\}/)
})
it('findet überhaupt etwas - sonst prüft der Test nichts', () => {
// Ein stiller Ausfall der Erkennung waere schlimmer als eine Luecke:
// Der Test staende gruen da und pruefte nichts.
expect(gelesen().size).toBeGreaterThan(15)
expect(durchgereicht().size).toBeGreaterThan(15)
})
})