L10b Betrieb & Aufräumen: Deploy – craftvia-worker, CI-Testjob, DEPLOY.md, Certvia-Doku archiviert
- docker-compose.coolify(.prebuilt).yml: Service craftvia-worker (Target worker, Chromium, shm_size 1gb, gleiche Härtung), Craftvia-Variablen für app und worker. - Dockerfile: worker-Stage mit HOME=/home/app (Chromium-Profil für non-root); lokaler docker build der Targets runner und worker erfolgreich, PDF-Erzeugung im Image geprüft. - .env.example/.env.prod.example/.env.coolify.example: alle Craftvia-Variablen inkl. RLS, KI-Provider, PDF_CHROMIUM_PATH, OFFLINE_MAX_DAYS, API_RATE_LIMIT_*, AI_GENERATION_RETENTION_DAYS, AI_MONTHLY_TOKEN_LIMIT. - CI (.github, .gitea): Job gate mit Postgres (pgvector) und Redis als Service: migrate deploy, seed, Passwort für craftvia_app, tsc, lint, build, npm run test. - docs/craftvia/DEPLOY.md (aus den Certvia-Deploy-Docs abgeleitet): Architektur, Domains, Secrets, Worker, Migrationen, RLS-Aktivierung, Backup/Restore, KI, Rate Limits, Aufbewahrung, Smoke, Update/Rollback. build-and-push-images.sh baut craftvia-worker. - Certvia-/ISMS-Dokumente aus docs/ nach docs/_certvia-archiv/ (mit README); Verweise in README.md und Skript-Kommentaren angepasst. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,485 @@
|
||||
# Deployment: Produktivserver (Contabo-VPS via Coolify)
|
||||
|
||||
> Zielumgebung: öffentlicher Contabo-VPS, eigene Coolify-Instanz, Git (Gitea) auf
|
||||
> demselben VPS, Domain **app.certvia.de** mit echtem Let's-Encrypt-TLS.
|
||||
> Ergänzt `docs/DEPLOY-COOLIFY.md` (Testserver). Deploy-Datei: `docker-compose.coolify.yml`.
|
||||
|
||||
## Unterschiede zum Testserver
|
||||
|
||||
| Aspekt | Test (intern) | Produktiv (Contabo) |
|
||||
|--------|---------------|---------------------|
|
||||
| Erreichbarkeit | intern, HTTP, sslip.io | öffentlich, **HTTPS**, `app.certvia.de` |
|
||||
| Git-Quelle | internes Gitea | **Gitea auf dem VPS** (Release-Push) |
|
||||
| Erst-Admin | Demo-Seed (`RUN_DEMO_SEED=true`) | **Bootstrap-Admin** (`BOOTSTRAP_ADMIN=true`), **kein** Demo-Seed |
|
||||
| Secrets | Testwerte | **frische, starke** Secrets |
|
||||
| Backups | optional | **Pflicht** (pg_dump + Restore-Test) |
|
||||
| Branch | `dev` | `main` |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — VPS-Grundlage
|
||||
|
||||
1. **Contabo-VPS** bereitstellen — **≥ 8 GB RAM** empfohlen (Next-Build ~1 GB + Stack). Bei weniger RAM zwingend Swap:
|
||||
```bash
|
||||
fallocate -l 4G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
|
||||
echo '/swapfile none swap sw 0 0' >> /etc/fstab
|
||||
free -h
|
||||
```
|
||||
2. **Grundhärtung**: System aktualisieren, SSH-Key-Login (Passwort-Login aus), unattended-upgrades.
|
||||
3. **Firewall** (ufw): nur benötigte Ports offen — `80`, `443`, SSH, sowie der Gitea-SSH-Port (s. Phase 2).
|
||||
4. **Coolify installieren** (offizielles Installskript) — eigene, unabhängige Instanz.
|
||||
|
||||
## Phase 2 — Gitea auf dem VPS
|
||||
|
||||
5. Gitea als **Coolify-Ressource** (One-Click/Compose) aufsetzen, eigene Subdomain (z. B. `git.certvia.de`), TLS via Coolify.
|
||||
6. Repo `ISMS-Tool` in diesem Gitea anlegen.
|
||||
7. Lokal ein **zweites Remote** hinzufügen und beim Release `main` dorthin pushen:
|
||||
```bash
|
||||
git remote add prod https://git.certvia.de/<org>/ISMS-Tool.git
|
||||
git push prod main
|
||||
```
|
||||
(Das ist der bewusste „Release-Push" — Prod deployt nur, was hier landet.)
|
||||
|
||||
## Phase 3 — DNS & Domain
|
||||
|
||||
8. Bei eurem DNS für `certvia.de` einen **A-Record** `app.certvia.de` → Contabo-IP anlegen (ebenso `git.certvia.de`).
|
||||
9. Coolify vergibt/prüft danach automatisch das Let's-Encrypt-Zertifikat (VPS ist öffentlich erreichbar).
|
||||
|
||||
## Phase 4 — App-Ressource (Prod) in Coolify
|
||||
|
||||
10. **+ New Resource** → Git-Quelle = das **VPS-Gitea** (Deploy-Key), Repo `ISMS-Tool`, **Branch `main`**.
|
||||
11. **Build Pack: Docker Compose**, **Compose Location: `docker-compose.coolify.yml`**.
|
||||
12. **Domain** beim Service `app` = `https://app.certvia.de:3000` (Port 3000), Schema **https**.
|
||||
13. **Environment-Variablen** aus `.env.prod.example` setzen (Runtime-Variablen). Kritisch:
|
||||
- Alle Secrets **frisch** (`AUTH_SECRET` neu: `openssl rand -base64 32`; neue DB-/MinIO-Passwörter).
|
||||
- `AUTH_URL=https://app.certvia.de` (exakt die App-Domain).
|
||||
- `RUN_DEMO_SEED=false` (bzw. weglassen).
|
||||
- **Bootstrap** (erster Deploy): `BOOTSTRAP_ADMIN=true` + `BOOTSTRAP_ADMIN_EMAIL/PASSWORD/NAME` + `BOOTSTRAP_TENANT_NAME/SLUG`.
|
||||
14. **Persistent Storage** prüfen — v. a. `pgdata` (sonst Datenverlust bei Redeploy).
|
||||
|
||||
## Phase 5 — Erster Deploy & Bootstrap
|
||||
|
||||
15. **Deploy**. Ablauf: `postgres` (healthy) → `migrate` (migriert **und** legt via `BOOTSTRAP_ADMIN=true` den Erst-Admin an) → `app`.
|
||||
16. In den **`migrate`-Logs** prüfen: `>> Bootstrap-Admin läuft…` → `✔ Bootstrap fertig: Mandant … + Plattform-Admin … angelegt.`
|
||||
17. **Login** unter `https://app.certvia.de` mit den `BOOTSTRAP_ADMIN_*`-Zugangsdaten → **Passwort sofort ändern**.
|
||||
18. Optional danach `BOOTSTRAP_ADMIN` auf `false` (das Skript ist idempotent, es schadet aber nicht, es an zu lassen).
|
||||
|
||||
## Phase 6 — Backups & Betrieb (vor „echtem" Go-Live)
|
||||
|
||||
19. **Postgres-Backups**: regelmäßiger `pg_dump` (oder Coolify-DB-Backup nach S3) + **Restore-Test** dokumentieren. `pgdata` ist das kritische Volume.
|
||||
20. **MinIO-Backup**, sobald Datei-/Logo-Upload aktiv ist.
|
||||
21. **Monitoring/Alerting**: App-Healthcheck ist im Compose vorhanden; zusätzlich Uptime-Check auf `https://app.certvia.de` und Log-Aggregation einrichten.
|
||||
22. **Updates**: Betriebssystem/Coolify/Images regelmäßig aktualisieren.
|
||||
|
||||
## Phase 7 — Auto-Deploy (optional)
|
||||
|
||||
23. Gitea-Webhook (VPS-Gitea → Coolify) mit **Branch-Filter `main`**. Da Gitea und Coolify auf demselben Host sind, ggf. `GITEA__webhook__ALLOWED_HOST_LIST` weit genug setzen (vgl. Testserver-Erfahrung).
|
||||
|
||||
---
|
||||
|
||||
## Release-Fluss (Zusammenfassung)
|
||||
|
||||
```
|
||||
Feature-Branch → dev # Entwicklung, Auto-Deploy Testserver
|
||||
dev → main # Release-Merge (nach Test-Freigabe)
|
||||
git push prod main # Release-Push ins VPS-Gitea → Prod-Deploy
|
||||
```
|
||||
|
||||
## Row Level Security scharfschalten (F-04)
|
||||
|
||||
Postgres RLS ist als zweite Verteidigungslinie definiert (Policy `tenant_isolation`
|
||||
je Mandanten-Tabelle) und mit der Migration `rls_enforce` unter
|
||||
`FORCE ROW LEVEL SECURITY` + `WITH CHECK` scharf gestellt. Sie wird **env-gesteuert**
|
||||
aktiviert (`RLS_ENFORCED`), Default ist **aus** (Owner-Betrieb wie bisher).
|
||||
|
||||
**Wie es funktioniert:** Bei `RLS_ENFORCED=true` verbindet sich der App-Prozess als
|
||||
eingeschränkte Rolle `isms_app` (NOBYPASSRLS) über `RLS_DATABASE_URL` und setzt
|
||||
`app.tenant_id` transaktionslokal pro Operation. Für diese Rolle greifen die
|
||||
Policies. Migrationen, Seed/Bootstrap und der mandantenübergreifende Login-Lookup
|
||||
laufen weiter über die **Owner-`DATABASE_URL`**.
|
||||
|
||||
**Scharfschalten (einmalig):**
|
||||
|
||||
1. App-Rolle mit LOGIN + starkem Passwort versehen (nur einmal, kein Secret ins Repo):
|
||||
```bash
|
||||
docker exec -it <postgres-container> psql -U isms -d isms \
|
||||
-c "ALTER ROLE isms_app WITH LOGIN PASSWORD '<STARKES_PASSWORT>';"
|
||||
```
|
||||
2. In Coolify die Env-Variablen setzen:
|
||||
- `RLS_ENFORCED=true`
|
||||
- `RLS_DATABASE_URL=postgresql://isms_app:<STARKES_PASSWORT>@postgres:5432/isms?schema=public`
|
||||
3. App-Service neu deployen. Fehlt `RLS_DATABASE_URL` bei aktivem Flag, bricht die
|
||||
App bewusst beim Start ab (fail secure).
|
||||
|
||||
> **Warnung — Owner-Rolle muss BYPASSRLS/Superuser sein.** Die in `DATABASE_URL`
|
||||
> genutzte Owner-/Migrate-Rolle (`isms`) MUSS `BYPASSRLS` bzw. Superuser sein,
|
||||
> sonst sähe der Login (mandantenübergreifend) **keine** Nutzer und Migrationen
|
||||
> könnten fehlschlagen. FORCE ist für diese Rolle unschädlich, weil BYPASSRLS die
|
||||
> RLS auch unter FORCE umgeht.
|
||||
>
|
||||
> **Warnung — ohne Kontext = null Zeilen.** `RLS_ENFORCED=true` NIE ohne korrektes
|
||||
> `RLS_DATABASE_URL` setzen: eine RLS-Rolle ohne gesetzten `app.tenant_id` sieht
|
||||
> gar keine Zeilen. Das Setzen des Kontexts übernimmt `dbForTenant` automatisch.
|
||||
|
||||
Nachweis der Korrektheit lokal: `npx tsx scripts/test-rls-enforcement.ts`.
|
||||
|
||||
## Host-Encryption at-rest — Daten-Volume (`pgdata` + MinIO)
|
||||
|
||||
> Umsetzung der Härtung §2 (`docs/KONZEPT-haertung.md`). **Ops-Runbook, kein App-Code.**
|
||||
> Schützt gegen **physischen Plattendiebstahl / VPS-Decommission** — die Volumes liegen
|
||||
> sonst im Klartext auf der Contabo-Platte (`pgvector`-Community-Postgres hat **kein TDE**).
|
||||
> **Kein** Schutz gegen Live-Kompromittierung des laufenden Servers (dafür greifen
|
||||
> App-Feld-Encryption, RLS, Zugriffskontrollen) — im Betrieb ist das Volume im RAM entschlüsselt.
|
||||
|
||||
**Prinzip:** Ein **LUKS/dm-crypt**-Container (dm-crypt/`cryptsetup`) bzw. ein
|
||||
**provider-verschlüsseltes Block-Volume** trägt das gesamte Daten-Volume. Coolifys
|
||||
Persistent-Storage (`pgdata`, MinIO-Daten, ggf. Redis-AOF) wird auf dieses
|
||||
verschlüsselte Volume gelegt — damit sind `pgdata` **und** MinIO in einem Rutsch gedeckt.
|
||||
|
||||
**Einrichtung (einmalig, vor dem ersten Deploy mit echten Daten):**
|
||||
|
||||
1. Separates Block-Volume/Partition bereitstellen (z. B. `/dev/sdb`) — **nicht** das Root-FS.
|
||||
2. LUKS-Container anlegen und öffnen:
|
||||
```bash
|
||||
cryptsetup luksFormat --type luks2 /dev/sdb # Passphrase vergeben (s. u.)
|
||||
cryptsetup open /dev/sdb cryptdata # -> /dev/mapper/cryptdata
|
||||
mkfs.ext4 /dev/mapper/cryptdata
|
||||
mkdir -p /srv/cryptdata && mount /dev/mapper/cryptdata /srv/cryptdata
|
||||
```
|
||||
3. Coolifys Datenwurzel (bzw. die betroffenen Named-Volumes/Bind-Mounts für `pgdata`
|
||||
und MinIO) auf `/srv/cryptdata/...` legen und die Compose-`volumes` dorthin zeigen lassen.
|
||||
4. **Passphrase / Key-File** ausschließlich im **Org-Passwortmanager** hinterlegen
|
||||
(Eintrag im `docs/SECRETS-REGISTER.md` führen) + versiegelte Offline-Kopie. **Nie** ins Repo,
|
||||
nie ins Backup-Bucket.
|
||||
|
||||
**Boot-Unlock (dokumentierte Entscheidung nötig — Betreiber wählt):**
|
||||
|
||||
- **A) Manuelles Unlock (empfohlen für Einzel-VPS, höchster Schutz):** Nach jedem Reboot
|
||||
meldet sich ein Operator per SSH an und führt `cryptsetup open` + `mount` (bzw. ein
|
||||
`systemd`-Gate vor den Docker-/Coolify-Start) aus. Kein Key auf der Platte → Diebstahl
|
||||
der Platte nützt nichts. Nachteil: **kein unbeaufsichtigter Reboot** (Downtime bis Unlock).
|
||||
- **B) Key-File auf getrenntem Medium / Netz-Unlock:** `crypttab`-Key-File auf einem separaten,
|
||||
nicht mit der Platte gestohlenen Träger, oder **dracut/clevis + Tang** (Network-Bound Disk
|
||||
Encryption) für auto-unlock, solange der Tang-Server erreichbar ist. Komfortabler,
|
||||
aber der Angreifer, der Platte **und** Key/Tang-Zugang hat, gewinnt.
|
||||
|
||||
> **Betreiber-Entscheidung dokumentieren:** A oder B, wer die Passphrase hält, und wie der
|
||||
> Reboot-Ablauf aussieht (Runbook-Schritt „Nach Reboot: Volume entsperren, dann Coolify starten").
|
||||
> Ohne Unlock startet Postgres/MinIO nicht — das ist beabsichtigt (fail-secure).
|
||||
|
||||
## Verschlüsselte Backups — pgBackRest / age / restic
|
||||
|
||||
> Umsetzung der Härtung §3 + Backup-Konzept §9 (`docs/KONZEPT-backup-restore.md`).
|
||||
> **Entscheidung:** **client-seitige AES-256-Verschlüsselung mit einem Schlüssel pro Umgebung**
|
||||
> — das Artefakt ist verschlüsselt, **bevor** es S3/MinIO erreicht (at-rest + in-transit +
|
||||
> zero-knowledge vom Speicher). **Nicht** allein auf Storage-SSE verlassen (SSE gern zusätzlich).
|
||||
> Konsistent zur bestehenden App-Verschlüsselung (TOTP AES-256-GCM, Backup-Export AES-256-GCM).
|
||||
|
||||
**Schichten (je Umgebung getrennte Schlüssel):**
|
||||
|
||||
| Schutzobjekt | Werkzeug | Schlüssel |
|
||||
|---|---|---|
|
||||
| Cluster-Backup (Postgres, WAL/Base) | **pgBackRest** `repo-cipher-type=aes-256-cbc` + `repo-cipher-pass` → S3/MinIO | pgBackRest-Repo-Key je Umgebung |
|
||||
| Per-Tenant-Export + DSGVO-Pakete | **`age`** (X25519, encrypt-then-upload) | `age`-Keypair je Umgebung |
|
||||
| MinIO-Dateien (Uploads/Logos) | **restic** (eingebaute AES-256-Verschlüsselung) | restic-Repo-Passwort je Umgebung |
|
||||
|
||||
**pgBackRest (Beispiel `pgbackrest.conf`, Repo auf MinIO/S3):**
|
||||
```ini
|
||||
[global]
|
||||
repo1-type=s3
|
||||
repo1-s3-endpoint=<minio-oder-s3-endpoint>
|
||||
repo1-s3-bucket=certvia-pgbackrest-prod # eigener Bucket, NICHT der App-Bucket
|
||||
repo1-s3-key=<zugang> # nur Backup-Zugang, getrennt vom App-MinIO-User
|
||||
repo1-s3-key-secret=<zugang-secret>
|
||||
repo1-cipher-type=aes-256-cbc
|
||||
repo1-cipher-pass=<PGBACKREST_REPO_KEY> # aus Passwortmanager, NIE im Bucket
|
||||
repo1-retention-full=4 # Retention: s. u.
|
||||
[certvia]
|
||||
pg1-path=/srv/cryptdata/pgdata
|
||||
```
|
||||
|
||||
**age (Per-Tenant-/DSGVO-Artefakte):**
|
||||
```bash
|
||||
age-keygen -o /root/age-prod.key # Private Key -> Passwortmanager + offline
|
||||
# Public Recipient (age1...) als Env/Config für die Backup-Jobs:
|
||||
age -r age1<recipient-prod> -o export.tenant.age export.tenant.tar
|
||||
```
|
||||
|
||||
**restic (MinIO-Objektdaten):**
|
||||
```bash
|
||||
export RESTIC_REPOSITORY=s3:<endpoint>/certvia-restic-prod
|
||||
export RESTIC_PASSWORD=<RESTIC_REPO_KEY> # aus Passwortmanager
|
||||
restic backup /srv/cryptdata/minio
|
||||
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune
|
||||
```
|
||||
|
||||
**Verdrahtung in Coolify / Betrieb:**
|
||||
|
||||
- **Scheduling:** Coolify-**Scheduled Task / Cron** je Job (pgBackRest full+incr, `age`-Export,
|
||||
restic) — Zugangsdaten als Coolify-Env-Secrets, **nicht** im Compose committen.
|
||||
- **Retention je Schicht dokumentieren** (DR-WAL/Base, Per-Tenant-Snapshots, DSGVO-Exporte)
|
||||
und dem **DSB** vorlegen — Löschfristen ergeben sich aus der DSGVO-Aufbewahrung.
|
||||
- **Getrennte Buckets/Zugänge:** Backup-Repos liegen in **eigenen** Buckets mit **eigenem**
|
||||
Zugang, nie im App-MinIO-Bucket und nie zusammen mit den Schlüsseln.
|
||||
- **Restore-Test regelmäßig + dokumentiert** (der Prod-Runbook fordert das bereits für `pgdata`,
|
||||
Phase 6): mindestens quartalsweise eine Wiederherstellung in eine Wegwerf-Umgebung, Ergebnis
|
||||
protokollieren. Ein ungetestetes Backup gilt als kein Backup.
|
||||
|
||||
**Bezug zur App-Backup-Engine:** Der in `dev` integrierte App-Export/Restore verschlüsselt
|
||||
Artefakte bereits **client-seitig mit AES-256-GCM** über `BACKUP_ENC_KEY` (Fallback `AUTH_SECRET`),
|
||||
**ein Key pro Umgebung** (Backup-Konzept §9). Umgebungs-Secrets (Pepper/`MFA_ENC_KEY`/`BACKUP_ENC_KEY`)
|
||||
liegen **nicht** im Artefakt; der Restore setzt Owner=Superuser voraus (`session_replication_role`).
|
||||
pgBackRest/`age`/restic sind die **Ops-Ebene darunter** (Cluster/Host/Objektspeicher) —
|
||||
dieselbe Grundregel „ein Schlüssel pro Umgebung, getrennt vom Artefakt" gilt durchgängig.
|
||||
|
||||
## Schicht A — Cluster-Disaster-Recovery mit PITR (Go-Live-Runbook)
|
||||
|
||||
> Umsetzung Backup-Konzept §2 (`docs/KONZEPT-backup-restore.md`). **Ops-Runbook, kein App-Code.**
|
||||
> Schicht A deckt den **gesamten** Cluster ab — auch die **globalen** Tabellen (`Identity`/
|
||||
> Credentials, Kataloge, `PlatformAdmin`), die die mandanten-scoped App-Engine (Schicht B)
|
||||
> bewusst NICHT anfasst. Zweck: Totalausfall / Ransomware / menschlicher Massenfehler →
|
||||
> „DB auf Zeitpunkt T" (Point-in-Time-Recovery).
|
||||
|
||||
**Bausteine:** **pgBackRest** (empfohlen) oder **wal-g** — periodisches **Voll-/Inkrement-Backup**
|
||||
plus kontinuierliches **WAL-Archiving** nach S3/MinIO, verschlüsselt (`repo-cipher`). Erst das
|
||||
WAL-Archiv macht PITR möglich (Replay bis zu einem Zeitpunkt zwischen zwei Base-Backups).
|
||||
|
||||
### A.1 Repo-Config (Ergänzung zu `pgbackrest.conf` oben)
|
||||
|
||||
```ini
|
||||
[global]
|
||||
# ... (repo1-* wie im Abschnitt „Verschlüsselte Backups" oben) ...
|
||||
repo1-retention-full=4 # 4 Voll-Backups vorhalten (s. Retention-Tabelle)
|
||||
repo1-retention-archive=8 # WAL für so viele Full-Sets behalten (PITR-Fenster)
|
||||
repo1-retention-archive-type=full
|
||||
start-fast=y
|
||||
archive-async=y # asynchrones Archiving (Durchsatz/latenztolerant)
|
||||
spool-path=/var/spool/pgbackrest
|
||||
|
||||
[certvia]
|
||||
pg1-path=/srv/cryptdata/pgdata
|
||||
```
|
||||
|
||||
### A.2 WAL-Archiving in Postgres aktivieren (`postgresql.conf`)
|
||||
|
||||
```ini
|
||||
archive_mode = on
|
||||
archive_command = 'pgbackrest --stanza=certvia archive-push %p'
|
||||
wal_level = replica
|
||||
max_wal_senders = 3
|
||||
```
|
||||
|
||||
```bash
|
||||
# Stanza einmalig anlegen + prüfen, dann erstes Voll-Backup:
|
||||
pgbackrest --stanza=certvia stanza-create
|
||||
pgbackrest --stanza=certvia check
|
||||
pgbackrest --stanza=certvia --type=full backup
|
||||
# Zeitplan (Coolify Scheduled Task / cron):
|
||||
# full wöchentlich pgbackrest --stanza=certvia --type=full backup
|
||||
# diff/inc täglich pgbackrest --stanza=certvia --type=incr backup
|
||||
```
|
||||
|
||||
### A.3 Restore / PITR-Prozedur (dokumentierter Ablauf)
|
||||
|
||||
> **Achtung:** Cluster-weit — ersetzt den GESAMTEN Datenbestand aller Mandanten. Für einen
|
||||
> **einzelnen** Kunden ist der **Portal-Restore** (Schicht B, `/admin/[id]?restore=1`) das
|
||||
> richtige Werkzeug, nicht PITR.
|
||||
|
||||
```bash
|
||||
# 1. App + Postgres stoppen (kein Schreibzugriff während des Restore).
|
||||
docker compose stop app && systemctl stop postgresql # bzw. Coolify-Dienst anhalten
|
||||
|
||||
# 2a. Vollständiger Restore auf den neuesten konsistenten Stand:
|
||||
pgbackrest --stanza=certvia --delta restore
|
||||
|
||||
# 2b. ODER Point-in-Time-Recovery auf einen Zeitpunkt T:
|
||||
pgbackrest --stanza=certvia --delta \
|
||||
--type=time --target="2026-08-11 14:30:00+02" \
|
||||
--target-action=promote restore
|
||||
|
||||
# 3. Postgres starten — spielt WAL bis zum Target und promotet dann.
|
||||
systemctl start postgresql
|
||||
# Recovery-Fortschritt in den Postgres-Logs verfolgen (…"recovery stopping before"…).
|
||||
|
||||
# 4. Kohärenz prüfen (s. Restore-Kohärenz unten): Secrets der Zielumgebung müssen
|
||||
# zu den Daten passen (Pepper/MFA_ENC_KEY), sonst Login/MFA planmäßig zurücksetzen.
|
||||
# 5. App wieder starten, Smoke-Test (Login, ein Mandant, ein Upload).
|
||||
```
|
||||
|
||||
- **Quartalsweiser Restore-Test PFLICHT:** PITR in eine **Wegwerf-Umgebung** wiederherstellen,
|
||||
Ergebnis protokollieren (der Go-Live fordert das ohnehin für `pgdata`). Ungetestetes Backup = kein Backup.
|
||||
- **Retention/PITR-Fenster** dem DSB vorlegen (s. Retention-Tabelle unten).
|
||||
|
||||
## MinIO-Objekt-Backup mit restic (vollwertig, Go-Live-Runbook)
|
||||
|
||||
> Ersetzt das frühere „Best-Effort-Key-Kopieren" durch ein **vollwertiges, versioniertes,
|
||||
> verschlüsseltes** Objekt-Backup. MinIO hält Uploads/Logos **und** die App-Backup-Artefakte
|
||||
> (`<tenantId>/backups/…`) sowie die DSGVO-Pakete (`<tenantId>/dsgvo-exports/…`); ein
|
||||
> DB-Backup allein genügt nicht (Backup-Konzept §1/§3).
|
||||
|
||||
```bash
|
||||
export RESTIC_REPOSITORY=s3:<endpoint>/certvia-restic-prod # eigener Bucket, eigener Zugang
|
||||
export RESTIC_PASSWORD=<RESTIC_REPO_KEY> # aus Passwortmanager, NIE ins Repo/Bucket
|
||||
export AWS_ACCESS_KEY_ID=<restic-zugang> AWS_SECRET_ACCESS_KEY=<restic-secret>
|
||||
|
||||
restic snapshots || restic init # einmalig initialisieren
|
||||
# Zeitplan (Coolify Scheduled Task / cron), täglich:
|
||||
restic backup /srv/cryptdata/minio --tag minio --host certvia-prod
|
||||
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune
|
||||
restic check --read-data-subset=5% # Integrität stichprobenweise prüfen
|
||||
|
||||
# Restore (ganzes Repo oder gezielt ein Prefix):
|
||||
restic restore latest --target /srv/cryptdata/minio
|
||||
restic restore latest --target /tmp/recover --include '/srv/cryptdata/minio/<tenantId>/uploads'
|
||||
```
|
||||
|
||||
- **Getrennte Buckets/Zugänge:** restic-Repo **nicht** im App-MinIO-Bucket, Schlüssel **nie**
|
||||
zusammen mit den Artefakten.
|
||||
- **Konsistenz:** MinIO-Snapshot und DB-Backup laufen zeitnah; die App-Restore-Engine (Schicht B)
|
||||
stellt DB **und** den Mandanten-Prefix wieder her — beide Ebenen müssen vorhanden sein.
|
||||
|
||||
### Retention je Schicht (dem DSB vorzulegen — Platzhalter, DSB bestätigt)
|
||||
|
||||
| Schicht | Objekt | Vorschlag |
|
||||
|---|---|---|
|
||||
| A (pgBackRest) | Voll-Backups | 4 Wochen (`repo1-retention-full=4`) |
|
||||
| A (pgBackRest) | WAL-Archiv / PITR-Fenster | bis zu 8 Full-Sets (`repo1-retention-archive`) |
|
||||
| B (App-Export) | Per-Tenant-Snapshots | 30–90 Tage (DSB) |
|
||||
| DSGVO | Zustellpakete (`dsgvo-exports/`) | Download-Link **1 h TTL**; ZIP kurzlebig, danach löschen |
|
||||
|
||||
### ⚠ Restore-Kohärenz (Vorbedingung im Restore-Runbook)
|
||||
|
||||
> **`PASSWORD_PEPPER`, `MFA_ENC_KEY` und `BACKUP_ENC_KEY` sind Umgebungs-Secrets und stehen
|
||||
> NICHT im Backup-Artefakt.** Ein Restore in eine Umgebung mit **anderen** Secrets bricht:
|
||||
> - **anderer `PASSWORD_PEPPER`** → **alle** Passwort-Prüfungen schlagen fehl (kein Login).
|
||||
> - **anderes `MFA_ENC_KEY`** → TOTP-Secrets nicht entschlüsselbar → MFA-Prüfung bricht.
|
||||
> - **anderes `BACKUP_ENC_KEY`** → das AES-256-GCM-Artefakt lässt sich gar nicht entschlüsseln.
|
||||
>
|
||||
> **Vorbedingung vor jedem Restore:** Zielumgebung hält **exakt die Secrets, mit denen das
|
||||
> Backup erzeugt wurde**, oder es wird ein **Passwort-/MFA-Reset** (bzw. Neuverschlüsselung)
|
||||
> eingeplant. Deshalb: Secrets je Umgebung getrennt führen (`docs/SECRETS-REGISTER.md`) und die
|
||||
> passende Kopie **vor** dem Restore bereitstellen. Cross-Environment-Restore (prod→staging) ist
|
||||
> nur mit den prod-Secrets **oder** anschließendem Reset lauffähig.
|
||||
|
||||
## Sicherheits-Checkliste Go-Live
|
||||
|
||||
- [ ] Frische, starke Secrets (nicht aus Test übernommen)
|
||||
- [ ] `RUN_DEMO_SEED` aus, **keine** Demo-Daten in Prod
|
||||
- [ ] Bootstrap-Admin-Passwort nach erstem Login geändert
|
||||
- [ ] HTTPS erzwungen, gültiges Zertifikat
|
||||
- [ ] `pgdata` persistent + Backup + Restore-Test
|
||||
- [ ] Firewall aktiv, SSH gehärtet
|
||||
- [ ] Gitea-Repo privat, Zugriff nur für das Team
|
||||
- [ ] `REDIS_PASSWORD` gesetzt und in `REDIS_URL` eingetragen (F-18)
|
||||
- [ ] Alle Images auf konkrete Tags gepinnt (kein `:latest`), CI grün (F-11)
|
||||
- [ ] **Host-Encryption** aktiv: Daten-Volume (`pgdata` + MinIO) auf LUKS/verschlüsseltem Volume, Boot-Unlock-Verfahren dokumentiert (A oder B)
|
||||
- [ ] **Verschlüsselte Backups**: pgBackRest (`aes-256-cbc`) + `age` + restic eingerichtet, Retention dem DSB vorgelegt, **Restore-Test** dokumentiert
|
||||
- [ ] **Secrets-Register** (`docs/SECRETS-REGISTER.md`) gepflegt: Ownership + Rotationsregel je Umgebung, Keys nie im Artefakt-Bucket, versiegelte Offline-Kopie
|
||||
- [ ] **Restore-Kohärenz** geprüft: Zielumgebung hält die zum Backup passenden `PASSWORD_PEPPER`/`MFA_ENC_KEY`/`BACKUP_ENC_KEY` (sonst Reset einplanen)
|
||||
|
||||
---
|
||||
|
||||
## Supply-Chain & Container-Härtung (F-11 / F-18)
|
||||
|
||||
Diese Maßnahmen sind in `Dockerfile`, `docker-compose*.yml`, `.gitea/workflows/ci.yml`
|
||||
und `renovate.json` umgesetzt.
|
||||
|
||||
### F-11 — Supply Chain
|
||||
|
||||
- **`npm ci` statt `npm install`** (Lockfile bindend, reproduzierbar). Base-Image ist
|
||||
**`node:22-slim`** (Debian/glibc) statt Alpine/musl — damit greifen die im Lockfile
|
||||
hinterlegten glibc-Optional-Deps (lightningcss/oxide/argon2/sharp) zuverlässig.
|
||||
- **npm 11 im Build gepinnt.** Das committete `package-lock.json` wurde mit npm 11
|
||||
erzeugt; das in `node:22.14.0` gebündelte npm 10.9.2 löst `next@16.2.12 → @swc/helpers`
|
||||
anders auf und würde `npm ci` mit *„out of sync"* abbrechen. `RUN npm i -g npm@11.19.0`
|
||||
stellt den Resolver-Gleichstand her — **ohne** Lockfile-Änderung.
|
||||
> **Offene Folgeänderung (Dependency-Lane / F-12):** Sauberer wäre, das Lockfile mit
|
||||
> npm 10 neu zu erzeugen (`rm -rf node_modules package-lock.json && npm install` unter
|
||||
> Node 22), damit die npm-Pinnung entfallen kann. Das ist eine `package-lock.json`-Änderung
|
||||
> und gehört **nicht** in diese Infra-Lane.
|
||||
- **Image-Pins** (keine rollenden Tags):
|
||||
`node:22.14.0-slim`, `pgvector/pgvector:0.8.0-pg16`, `redis:7.4.2-alpine`,
|
||||
`minio/minio:RELEASE.2025-04-22T22-12-26Z`, `mailhog/mailhog:v1.0.1`.
|
||||
- **Schlanke `migrate`-Stage** statt `builder` für den Migrations-/Seed-Job: kein
|
||||
`next build`, kein Build-Cache. Sie enthält weiterhin devDeps (tsx/Prisma-CLI) + `src`,
|
||||
weil `prisma/seed.ts` und `scripts/bootstrap-admin.ts` aus `../src`/`@/server` importieren.
|
||||
Ein reiner Prisma-CLI-Container würde diese Jobs brechen — die Entkopplung von Seed/Bootstrap
|
||||
vom App-Code ist als App-seitige Folgeänderung empfohlen.
|
||||
|
||||
### SBOM
|
||||
|
||||
Zwei Wege (der CI-Weg ist optional/nicht-blockierend hinterlegt):
|
||||
|
||||
```bash
|
||||
# a) aus dem npm-Baum (CycloneDX) — braucht ein valides Lockfile (s. F-12-Hinweis):
|
||||
npm ci --include=optional && npm sbom --sbom-format cyclonedx --omit dev > sbom.cyclonedx.json
|
||||
|
||||
# b) aus dem gebauten Image (unabhängig vom npm-Baum), z. B. mit Syft:
|
||||
docker build -t isms:sbom .
|
||||
syft isms:sbom -o cyclonedx-json > sbom.image.cyclonedx.json
|
||||
```
|
||||
|
||||
### F-18 — Härtung & Netzsegmentierung
|
||||
|
||||
- **Redis mit `requirepass`** (`REDIS_PASSWORD`, in `REDIS_URL` eingesetzt).
|
||||
- **Internes Netz** (`internal: true`) für `postgres`/`redis`/`minio`/`migrate` — kein
|
||||
Egress, kein Host-Exposure; nur `app` zusätzlich im default-Netz (Coolify-Proxy).
|
||||
- **`security_opt: no-new-privileges`, `cap_drop: [ALL]`** (gezielte `cap_add` nur für die
|
||||
Entrypoints von postgres/redis, die intern per gosu den Benutzer wechseln), sowie
|
||||
**`deploy.resources.limits`** (CPU/RAM) je Dienst.
|
||||
- **Dev-Compose**: Host-Ports nur noch auf `127.0.0.1` gebunden (Postgres/Redis/MinIO/Mailhog).
|
||||
|
||||
### CI & Renovate
|
||||
|
||||
- **CI**: `.gitea/workflows/ci.yml` (Spiegel `.github/workflows/ci.yml`) fährt
|
||||
`npm ci → tsc --noEmit → lint → build`, dazu ein **`npm audit`-Gate**
|
||||
(`--omit=dev --audit-level=high`, hart) plus einen informativen Volllauf.
|
||||
> **Braucht einen aktivierten Gitea-Actions-Runner** — ohne registrierten Runner läuft
|
||||
> die Pipeline nicht an.
|
||||
- **Renovate** (`renovate.json`): wöchentliche, gruppierte Updates; Sicherheits-/OSV-Alerts
|
||||
sofort und priorisiert. **`next-auth`/`@auth/core` (Beta 5.x)** sind bewusst auf
|
||||
manuelles Review gesetzt (kein Auto-Merge) — Auth-Regressionen können „fail open" bedeuten (F-01).
|
||||
|
||||
### IM-D — E-Mail-Eingang für Vorfälle (Inbound / E-Mail-to-Ticket)
|
||||
|
||||
Vorfälle können per E-Mail gemeldet werden. Kunden leiten Meldungen an eine
|
||||
kundenindividuelle **Intake-Adresse** `vorfall-<token>@in.certvia.de` weiter; der
|
||||
`incident-inbound-worker` holt die Mails per IMAP ab und legt daraus Vorfälle an
|
||||
(bzw. reiht unklare Mails in die Betreiber-Review). Der Token trägt die
|
||||
Mandantenzuordnung (global eindeutig, nicht erratbar) — es gibt **kein Postfach je
|
||||
Kunde**, sondern ein Catch-all.
|
||||
|
||||
**Globaler Setup (einmalig, DNS + Postfach):**
|
||||
|
||||
1. **Subdomain `in.certvia.de`** anlegen und als Mail-Domain einrichten.
|
||||
2. **Catch-all-Postfach** für `*@in.certvia.de` → ein einzelnes IMAP-Postfach
|
||||
(alle `vorfall-<token>@…` landen darin). MX-Record auf den Mailhost setzen.
|
||||
3. **DKIM/SPF/DMARC** für `in.certvia.de` veröffentlichen (Empfang; die
|
||||
Absender-Authentizität der weitergeleiteten Kundenmails wird über deren DKIM +
|
||||
die pro Kunde gepflegte Absender-Allowlist geprüft — SPF bricht bei Weiterleitung
|
||||
und ist bewusst nur ein Signal).
|
||||
4. **IMAP-Zugang** des Catch-all-Postfachs als Secrets hinterlegen (siehe unten).
|
||||
|
||||
**Env (Dienst `incident-inbound-worker` in `docker-compose.coolify.yml`):**
|
||||
|
||||
| Variable | Zweck |
|
||||
| --- | --- |
|
||||
| `INCIDENT_IMAP_HOST` / `INCIDENT_IMAP_PORT` | IMAP-Server des Catch-all-Postfachs (Default-Port 993) |
|
||||
| `INCIDENT_IMAP_USER` / `INCIDENT_IMAP_PASSWORD` | Zugangsdaten des Catch-all-Postfachs |
|
||||
| `INCIDENT_IMAP_TLS` | `true` (Default) für IMAPS; `false` nur für STARTTLS/Plain |
|
||||
| `INCIDENT_IMAP_MAILBOX` | Postfach/Ordner (Default `INBOX`) |
|
||||
| `INCIDENT_IMAP_POLL_MS` | Abholintervall in ms (Default 60000, min. 15000) |
|
||||
| `INCIDENT_INTAKE_DOMAIN` | Muss zur Catch-all-Subdomain passen (Default `in.certvia.de`) |
|
||||
|
||||
Ohne `INCIDENT_IMAP_*` beendet sich der Worker **sauber** mit einer Meldung
|
||||
(kein Crash) — der übrige Betrieb ist davon unberührt. Verarbeitete Mails werden
|
||||
als `\Seen` markiert; zusätzlich schützt die **Message-ID-Dedupe** gegen
|
||||
Doppel-Tickets beim erneuten Abholen.
|
||||
|
||||
**Provisionierung je Kunde (Betreiber-Portal):** Beim Onboarding (oder später) im
|
||||
Mandanten unter **E-Mail-Eingang** die Absender-Domänen (Allowlist) hinterlegen —
|
||||
die Intake-Adresse wird automatisch erzeugt und angezeigt. Der Kunde richtet die
|
||||
Weiterleitung ein; sobald die **erste Test-Mail** als Vorfall ankommt, springt der
|
||||
Status von *Weiterleitung ausstehend* auf *verifiziert* (manueller Override im
|
||||
Portal möglich). Offene Verifizierungen und zu prüfende Eingänge (kein/unbekannter
|
||||
Token, Allowlist-/DKIM-Fehlschlag) listet das Betreiber-Dashboard.
|
||||
Reference in New Issue
Block a user