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,123 @@
|
||||
# Deployment: Testserver via Coolify (intern)
|
||||
|
||||
> Zielumgebung: interner Coolify-Host (`192.168.1.207`), Docker-Compose-Stack,
|
||||
> HTTP über eine `*.sslip.io`-Domain (kein öffentliches TLS).
|
||||
> Ergänzt `docs/HANDOVER-DEVOPS.md`. Deploy-Datei: `docker-compose.coolify.yml`.
|
||||
|
||||
## Architekturüberblick (Coolify)
|
||||
|
||||
- Coolify deployt den **Compose-Stack** aus diesem Repo und übernimmt **Reverse-Proxy + Routing** — die App wird **nicht** per Host-Port exponiert.
|
||||
- Services: `migrate` (Init-Job) → `app` (Next.js standalone) + `postgres` (pgvector), `redis`, `garage` (Objektspeicher) + `garage-provision` (Init-Job).
|
||||
- **Migrationen** laufen als eigener Init-Job `migrate` (`prisma migrate deploy`, schlanke **`migrate`-Stage** ohne `next build`) **vor** `app` — der App-Container migriert selbst nicht.
|
||||
- **Objektspeicher Garage** (ersetzt MinIO, Community EOL): S3-kompatibel, Single-Node. Buckets/Keys legt NICHT die S3-API an, sondern der Init-Job `garage-provision` (Admin-API, idempotent) **vor** `app`. Details/Cutover: eigener Abschnitt unten.
|
||||
- **Redis** läuft mit `requirepass` (F-18): `REDIS_PASSWORD` als Coolify-Env setzen und in die `REDIS_URL` einsetzen (`redis://:<pw>@redis:6379`).
|
||||
- **Netzsegmentierung** (F-18): `postgres`/`redis`/`garage`/`migrate` liegen im internen Netz (`internal: true`, kein Egress); nur `app` ist zusätzlich im default-Netz (Coolify-Proxy). Garage wird nicht per Traefik/Host-Port exponiert.
|
||||
- **Secrets/Config** kommen aus den **Coolify-Env-Variablen** (Referenz: `.env.coolify.example`), nicht aus einer committeten `.env`.
|
||||
|
||||
## 1. Gitea mit Coolify verbinden (Deploy Key)
|
||||
|
||||
SSH ist erreichbar (`git@…sslip.io:22`), daher Deploy-Key:
|
||||
|
||||
1. **Coolify → Keys & Tokens → Private Keys**: SSH-Key generieren (oder beim Anlegen der Ressource „Private Repository (deploy key)“ automatisch). Öffentlichen Key kopieren.
|
||||
2. **Gitea → Repo `msolarczek/ISMS-Tool` → Settings → Deploy Keys → Add Deploy Key**: Key einfügen, nur Lesezugriff.
|
||||
3. Repo-URL in Coolify (SSH):
|
||||
`git@gitea-vkbhbn2qdkz5ppk9q4qgb0tn.192.168.1.207.sslip.io:msolarczek/ISMS-Tool.git`
|
||||
|
||||
## 2. Ressource anlegen
|
||||
|
||||
1. Projekt/Environment wählen → **+ New → Private Repository (deploy key)**.
|
||||
2. Repo `ISMS-Tool`, **Branch `devops/coolify-testserver`** (nach erfolgreichem Test → `main`).
|
||||
3. **Build Pack: Docker Compose**, **Compose Location: `docker-compose.coolify.yml`**.
|
||||
|
||||
## 3. Environment-Variablen
|
||||
|
||||
Werte aus `.env.coolify.example` in Coolify eintragen. Kritisch:
|
||||
|
||||
- Hostnamen = Compose-Service-Namen: `postgres`, `redis`, `garage` (nicht `localhost`); `S3_ENDPOINT=http://garage:3900`.
|
||||
- `AUTH_SECRET` frisch: `openssl rand -base64 32`.
|
||||
- `AUTH_URL` = exakt die in Schritt 4 vergebene App-Domain (`http://…`).
|
||||
- **Garage** (siehe Abschnitt unten): `GARAGE_RPC_SECRET` + `GARAGE_ADMIN_TOKEN` (je `openssl rand -hex 32`), sowie `S3_ACCESS_KEY` = `GK`+24 Hex (`echo "GK$(openssl rand -hex 12)"`) und `S3_SECRET_KEY` = `openssl rand -hex 32`. Alle **literal** setzen (nicht via `${…}` — Coolify-Interpolationsfalle).
|
||||
|
||||
## 4. Domain & HTTP
|
||||
|
||||
- Beim Service `app` unter **Domains** die `*.sslip.io`-Domain übernehmen, Schema **`http://`**.
|
||||
- Dieselbe Domain als `AUTH_URL` setzen (sonst schlägt der NextAuth-Login fehl).
|
||||
|
||||
## 5. Persistenz
|
||||
|
||||
Named Volumes müssen als Persistent Storage erkannt sein — v. a. **`pgdata`** (sonst Datenverlust bei Redeploy) und **`garage_meta`** (Bucket-/Key-/Layout-Definitionen — ohne meta sind die Objektdaten in `garage_data` nicht adressierbar). Ebenso `garage_data`, `redisdata`, `backups`. **`garage_meta` in die Host-Backup-Strategie aufnehmen** (analog `pgdata`).
|
||||
|
||||
## 6. Deploy
|
||||
|
||||
**Deploy** starten. Reihenfolge: `postgres` (healthy) → `migrate` (läuft einmalig durch) → `app` (startet erst nach erfolgreichem Migrate). Logs von `migrate`/`app` bei Fehlern prüfen.
|
||||
|
||||
## 7. Demo-Admin anlegen (einmalig, Testserver)
|
||||
|
||||
Der Demo-Seed läuft nicht automatisch. Im Coolify-**Terminal** des `migrate`-Containers (builder-Image, hat `tsx`):
|
||||
|
||||
```
|
||||
npx tsx prisma/seed.ts
|
||||
```
|
||||
|
||||
Login danach: `admin@demo.example` / `Demo1234!`.
|
||||
|
||||
> Produktiv: **kein** Demo-Seed. Stattdessen einmaliger Bootstrap des Plattform-Admins
|
||||
> (`provisionTenant({ admin.isPlatformAdmin: true })`) — Skript noch zu erstellen
|
||||
> (siehe `docs/HANDOVER-DEVOPS.md` §4).
|
||||
|
||||
## 8. Smoke-Test
|
||||
|
||||
Über die App-Domain: Login, Admin-Konsole, eine Modul-Seite.
|
||||
|
||||
## Redeploy / Migrationen-Nachziehen
|
||||
|
||||
Jeder Coolify-Redeploy baut neu und lässt `migrate` erneut laufen (`migrate deploy` ist idempotent — nur neue Migrationen werden angewandt). App startet erst nach erfolgreichem Migrate.
|
||||
|
||||
## Von Test zu Produktiv (Delta)
|
||||
|
||||
- `AUTH_SECRET`, DB-Passwörter und die **Garage-Secrets** (`GARAGE_RPC_SECRET`, `GARAGE_ADMIN_TOKEN`, `S3_ACCESS_KEY`/`S3_SECRET_KEY`) neu/aus Secret-Store; eigene Domain + gültiges TLS.
|
||||
- **Kein** Demo-Seed; Erst-Superadmin per Bootstrap-Skript.
|
||||
- Backups (`pg_dump`/Volume-Snapshots inkl. **`garage_meta`**) + Restore-Test, Monitoring/Alerting.
|
||||
- Ggf. ≥ 2 `app`-Replicas hinter dem Coolify-Proxy.
|
||||
|
||||
## Objektspeicher (Garage) — Cutover, Provisioning & Rollback
|
||||
|
||||
Ablösung von MinIO (Community EOL) durch **Garage** (S3-kompatibel, aktiv gepflegt,
|
||||
Single-Node). Konzept: `docs/KONZEPT-garage-migration.md`. Es gibt **keine produktiven
|
||||
Daten** → **Neu-Deploy statt Datenmigration** (kein rclone, kein Wartungsfenster).
|
||||
|
||||
**Konfig-Bausteine**
|
||||
- `deploy/garage.toml` (eingecheckt, **secret-frei**): `replication_factor=1`,
|
||||
`s3_region=us-east-1`, S3-API `:3900`, Admin-API `:3903`. `rpc_secret`/`admin_token`
|
||||
liest der Daemon aus der Env (`GARAGE_RPC_SECRET`/`GARAGE_ADMIN_TOKEN`).
|
||||
- Volumes: `garage_meta` (**kritisch**, ins Host-Backup) + `garage_data`.
|
||||
- Init-Job `garage-provision` (`scripts/garage-provision.ts`, Admin-API, **idempotent**):
|
||||
Layout → Bucket `isms-documents` → Access-Key **importieren** (aus `S3_ACCESS_KEY/…`)
|
||||
→ Rechte read/write. Läuft bei jedem Deploy; „already exists“ = Erfolg. Ohne
|
||||
`GARAGE_ADMIN_TOKEN` No-op. Loggt **keine** Secrets.
|
||||
|
||||
**Cutover-Runbook (Test-Instanz)**
|
||||
1. Compose enthält bereits `garage` + `garage-provision`; der alte `minio`-Service ist
|
||||
auskommentiert (Rollback-Netz) und wird erst nach Abnahme entfernt.
|
||||
2. Coolify-Env **literal** setzen (nicht `${…}`): `S3_ENDPOINT=http://garage:3900`,
|
||||
`S3_REGION=us-east-1`, `S3_BUCKET=isms-documents`, `GARAGE_RPC_SECRET`,
|
||||
`GARAGE_ADMIN_TOKEN`, `S3_ACCESS_KEY` (`GK`+24 Hex), `S3_SECRET_KEY` (64 Hex).
|
||||
3. Deploy. Reihenfolge: `garage` (healthy: `/garage status`) → `garage-provision`
|
||||
(legt Layout/Bucket/Key/Rechte an) → `app`. Optionaler DB-Reset nur, falls alte
|
||||
Objekt-Referenzen stören (bei frischer/geseedeter Instanz unnötig).
|
||||
4. Abnahme (§ KONZEPT §9): Upload → Download (`/files/[...key]`) → Backup-Export `.cvb`
|
||||
+ Download → DSGVO-ZIP → Restore mit Mandanten-Isolation → Backup-Historie List/
|
||||
Aufräumen → Negativfall „fehlender Bucket“ = sprechender Fehler.
|
||||
5. **`garage_meta`** in der Host-Backup-Strategie bestätigen. Erst danach den
|
||||
`minio`-Block **und** das Volume `miniodata` aus `docker-compose.coolify.yml` entfernen.
|
||||
|
||||
**Rollback** (unkritisch, keine Prod-Daten): `garage`/`garage-provision` wieder aus-,
|
||||
den auskommentierten `minio`-Block wieder einkommentieren und die alten `S3_*`/
|
||||
`MINIO_*`-Env setzen; neu deployen. Solange `minio` + `miniodata` noch existieren, ist
|
||||
das ein reiner Compose-/Env-Wechsel.
|
||||
|
||||
**Provisioning erneut anstoßen / debuggen**: Im Coolify-Terminal eines Containers mit
|
||||
`tsx` (`migrate`-Image) `npx tsx scripts/garage-provision.ts` erneut laufen lassen
|
||||
(idempotent). „Container weg ohne Logs“ (Coolify entfernt fehlgeschlagene Container
|
||||
sofort): Provisioning-Ausgabe zusätzlich in eine Datei spiegeln, z. B.
|
||||
`npx tsx scripts/garage-provision.ts 2>&1 | tee /tmp/garage-provision.log`.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Prod-Deploy via vorgebaute Images (Plan B) — Runbook
|
||||
|
||||
> **Warum:** Der direkte Build auf dem Prod-Coolify läuft in den Deployment-Timeout
|
||||
> (~1 h; Ursache ist Coolifys Build-Pfad — u. a. die ~265-ARG-Injektion, die den
|
||||
> Layer-Cache bei jedem Deploy bricht → Kaltbau). Lösung: Images **einmal auf einem
|
||||
> leistungsfähigen Host bauen**, in die Registry pushen, und der Prod-Host **zieht nur**.
|
||||
> Ergebnis: Prod-Deploy = Pull + Migrate = Minuten statt Stunde.
|
||||
|
||||
## Topologie (Stand 2026-09-10)
|
||||
|
||||
| Rolle | Host |
|
||||
|---|---|
|
||||
| **Build-Host** (baut + pusht) | `192.168.1.155` — lokales Coolify (= local-gitea-Host), amd64, Docker |
|
||||
| **Registry** | **git.certvia.de** (Gitea Container Registry), Namespace `msolarczek` |
|
||||
| **Prod** (`certvia_prod`, GEFIM live) | **coolify.certvia.de** (eigener Server, ≠ 192.168) |
|
||||
| Quelle | git.certvia.de/msolarczek/certvia, Branch `main` |
|
||||
|
||||
Beteiligte Dateien im Repo:
|
||||
- `docker-compose.coolify.prebuilt.yml` — wie `docker-compose.coolify.yml`, aber die App-Services ziehen `${REGISTRY:-git.certvia.de/msolarczek}/certvia-*:${IMAGE_TAG:-main}` statt zu bauen (`postgres`/`redis` unverändert).
|
||||
- `scripts/build-and-push-images.sh` — baut + pusht die drei Images.
|
||||
|
||||
Service → Image (Dockerfile-Target):
|
||||
- `app` → **certvia-app** (`runner`)
|
||||
- `migrate`, `worker`, `backup-worker`, `incident-inbound-worker`, `garage-provision` → **certvia-migrate** (`migrate`)
|
||||
- `garage` → **certvia-garage** (`garage`)
|
||||
|
||||
## Voraussetzung: Gitea-Token
|
||||
Token in git.certvia.de → Settings → Applications mit Scopes:
|
||||
`read:repository` (Klonen) + `read:package` + `write:package` (Image Push/Pull).
|
||||
|
||||
## Ablauf je Release
|
||||
|
||||
### 1. Auf dem Build-Host (192.168.1.155)
|
||||
```bash
|
||||
docker login git.certvia.de -u msolarczek # Passwort = Token
|
||||
git clone https://msolarczek:<TOKEN>@git.certvia.de/msolarczek/certvia.git cv-build # oder: cd cv-build && git fetch && git pull
|
||||
cd cv-build && git checkout main && git pull
|
||||
ALSO_MAIN=true ./scripts/build-and-push-images.sh # baut+pusht Tag <SHA> UND :main
|
||||
```
|
||||
> **Wichtig:** `ALSO_MAIN=true` setzen → es wird zusätzlich das bewegliche Tag `:main`
|
||||
> gepusht. Coolify zieht per Default `:main` (siehe Gotcha IMAGE_TAG unten).
|
||||
|
||||
### 2. Prod-Host (coolify.certvia.de) einmalig am Registry anmelden
|
||||
```bash
|
||||
docker login git.certvia.de -u msolarczek # Passwort = Token
|
||||
```
|
||||
(oder in der Coolify-UI unter Settings → Docker Registries hinterlegen). Nur beim
|
||||
ersten Mal / nach Token-Wechsel nötig.
|
||||
|
||||
### 3. Coolify (certvia_prod, coolify.certvia.de)
|
||||
- **Configuration → „Docker Compose Location"** = `docker-compose.coolify.prebuilt.yml` (einmalig).
|
||||
- Env unverändert: `RUN_DEMO_SEED=false`, `BOOTSTRAP_ADMIN=false`.
|
||||
- **Redeploy.** Log zeigt Pull der `certvia-*:main` → `migrate` (achte auf neue Migration) → `garage`/`garage-provision` → `app` healthy.
|
||||
|
||||
### 4. Nach dem Deploy — Bestandsmandanten-Migrationen der Fachdaten
|
||||
Nur nötig, wenn eine Änderung Bestandsmandanten betrifft (z. B. der 5×5-Backfill). In
|
||||
Coolify → certvia_prod → Service `backup-worker`/`worker` → Terminal (oder per SSH
|
||||
`docker exec`):
|
||||
```bash
|
||||
npx tsx scripts/backfill-risk-5x5.ts # idempotent, alle Mandanten
|
||||
```
|
||||
|
||||
### 5. Smoke-Test
|
||||
Login → betroffene Modulseiten prüfen (z. B. `/processes`, `/dependencies`).
|
||||
|
||||
## Gotchas (aus dem ersten Live-Lauf gelernt)
|
||||
|
||||
- **`IMAGE_TAG` wird von Coolify NICHT in die Compose-Interpolation gereicht** → die
|
||||
Datei fällt auf `:main` zurück. Deshalb `:main` immer mitpushen (`ALSO_MAIN=true`).
|
||||
SHA-genaues Pinning müsste erst geklärt werden (dann `IMAGE_TAG=<sha>` in Coolify-Env).
|
||||
- **Coolify entfernt die alten Container VOR dem Pull.** Fehlt der Ziel-Tag in der
|
||||
Registry, schlägt der Pull fehl und **prod ist unten**. Also *immer erst* Images
|
||||
(inkl. `:main`) pushen, *dann* Redeploy.
|
||||
- **Klon-403:** Ein reiner Package-Token darf nicht klonen — `read:repository` fehlt.
|
||||
- **Migration-Drift ungefährlich:** `prisma migrate deploy` läuft auch dann sauber (Exit 0),
|
||||
wenn die DB eine angewandte Migration hat, die lokal nicht mehr existiert (empirisch geprüft).
|
||||
- Nach Nutzung Token, der im Klartext (URL/History) auftauchte, **widerrufen**.
|
||||
|
||||
## Rollback
|
||||
Alten Stand deployen: früheres Image-Tag als `:main` retaggen+pushen (auf dem Build-Host)
|
||||
und Redeploy — oder Compose-Location zurück auf `docker-compose.coolify.yml` (Host-Build,
|
||||
langsam) als Notnagel.
|
||||
@@ -0,0 +1,119 @@
|
||||
# DevOps-Integrations-Runbook — 2-Lane-Entwicklung (Onboarding-Wizard)
|
||||
|
||||
> Zweck: **DevOps übernimmt das Integrieren** der Feature-Branches nach `dev` (Merge,
|
||||
> Validierung, Doku-Pflege, Push). **Entwickler committen nur auf ihren Feature-Branches**
|
||||
> und müssen sich um Merge/Konsolidierung nicht mehr kümmern.
|
||||
> Basis-Doku: `docs/STAND-dev-branch.md` (Single Source of Truth), `docs/HANDOVER-DEV.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Setup (Ist-Zustand)
|
||||
|
||||
- **Zwei Git-Worktrees** desselben Repos, die sich **eine** lokale Postgres-DB teilen:
|
||||
- `~/Projects/ISMS-Tool` → Integrations-Branch **`dev`** (hier arbeitet DevOps).
|
||||
- `~/Projects/ISMS-Tool-devB` → Dev-B-Worktree (Feature-Branch).
|
||||
- **Branch-Modell:** Integrationsbranch **`dev`**; Feature-Branches mit **Bindestrich**
|
||||
(`dev-a1-wizard-shell`, `dev-b1-tasks-erweiterung`, `dev-b2-regel-engine`, …).
|
||||
⚠️ **Nicht** `dev/x` verwenden: Git kann nicht gleichzeitig Branch `dev` **und** `dev/x`
|
||||
führen (D/F-Konflikt) — deshalb Bindestrich.
|
||||
- **`main`** ist das spätere Merge-Ziel (hier nicht angefasst).
|
||||
- **Gitea (Remote)** ist aktuell zeitweise offline → Pushes erst, wenn erreichbar.
|
||||
Durch das geteilte Repo sehen alle Worktrees ein aktualisiertes `dev` sofort (ohne Push).
|
||||
|
||||
## 2. Verantwortungs-Split (neu)
|
||||
|
||||
| Rolle | macht | macht **nicht** |
|
||||
|-------|-------|------------------|
|
||||
| **Entwickler (A/B)** | Feature-Branch, kleine Commits je Story, Rebase auf `dev` **vor** dem Erzeugen einer Migration | Merge nach `dev`, Doku-Pflege, Push |
|
||||
| **DevOps** | Merge Feature-Branch → `dev`, Validierungs-Gate, `STAND-dev-branch.md` pflegen, Push (wenn Gitea da) | Fachlogik implementieren |
|
||||
|
||||
## 3. Integrations-Ablauf (pro fertigem Feature-Branch)
|
||||
|
||||
```bash
|
||||
# 0) Im Entwickler-Worktree: sauber & auf dev rebased?
|
||||
git -C ~/Projects/ISMS-Tool-devB status --porcelain # leer = sauber
|
||||
|
||||
# 1) Ins Integrations-Worktree, auf dev, sauberer Tree
|
||||
cd ~/Projects/ISMS-Tool
|
||||
git checkout dev
|
||||
git status --porcelain # leer = sauber
|
||||
|
||||
# 2) Konfliktvorschau (optional)
|
||||
git merge-tree $(git merge-base dev <feature-branch>) dev <feature-branch> | grep -i "changed in both" || echo "keine Konflikte"
|
||||
|
||||
# 3) Nicht-destruktiv mergen (Entwickler-Branch bleibt unangetastet)
|
||||
git merge --no-ff <feature-branch> -m "Merge <lane>: <Stories> in dev"
|
||||
```
|
||||
|
||||
**Konflikte** treten fast nur in den **Naht-Dateien** auf — **additiv** auflösen, nie die
|
||||
Ergänzungen der anderen Lane löschen:
|
||||
`prisma/schema.prisma` · `scripts/check-module-guards.ts` · `messages/de.json`/`en.json` ·
|
||||
`seed/isms-vorlagenpaket-v2/variables.schema.json`.
|
||||
|
||||
## 4. Validierungs-Gate (muss komplett grün sein)
|
||||
|
||||
```bash
|
||||
npx prisma generate \
|
||||
&& npx prisma migrate status \
|
||||
&& npx tsc --noEmit \
|
||||
&& npm run lint \
|
||||
&& npm run build \
|
||||
&& python3 seed/isms-vorlagenpaket-v2/_verify.py # nur nötig, wenn seed/variables geändert
|
||||
```
|
||||
- `migrate status` muss **„Database schema is up to date!"** melden (sonst → §6).
|
||||
- `build` führt den **Modul-Guard-Vollständigkeitscheck** als `prebuild` aus.
|
||||
|
||||
## 5. Doku + Branch-Sync + Push
|
||||
|
||||
```bash
|
||||
# STAND aktualisieren (Executive-Summary-Lane-Zeilen, Migrations-Liste, Commit-Übersicht)
|
||||
$EDITOR docs/STAND-dev-branch.md
|
||||
git add docs/STAND-dev-branch.md
|
||||
git commit -m "Doku: STAND — <Story> integriert"
|
||||
|
||||
# Aktive Feature-Branch-Zeiger auf dev nachziehen (optional, Komfort)
|
||||
git branch -f dev-a1-wizard-shell dev
|
||||
|
||||
# Push NUR wenn Gitea erreichbar:
|
||||
git fetch # prüfen, ob origin/dev jemand vorausgezogen hat
|
||||
git push origin dev # bei Divergenz NICHT force-pushen — abstimmen
|
||||
```
|
||||
Commit-Konvention: deutsch, granular, Trailer `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
|
||||
|
||||
## 6. Bekannte Stolpersteine (durch die geteilte DB)
|
||||
|
||||
- **Migrations-Timestamp-Diskrepanz nach Rebase:** Wird eine Migration im Branch neu erzeugt
|
||||
(neuer Timestamp) und die geteilte DB hat noch die alte angewandt, meldet `migrate status`
|
||||
„applied ≠ lokal". **Fix ohne Datenverlust** (kein SQL läuft neu):
|
||||
```bash
|
||||
# stalen DB-Eintrag entfernen …
|
||||
npx prisma db execute --schema prisma/schema.prisma --stdin <<< \
|
||||
"DELETE FROM \"_prisma_migrations\" WHERE migration_name='<ALT_TIMESTAMP>_<name>';"
|
||||
# … und die committete Migration als angewandt markieren
|
||||
npx prisma migrate resolve --applied <NEU_TIMESTAMP>_<name>
|
||||
```
|
||||
- **`migrate diff --from-config-datasource` zeigt Fremd-Änderungen:** Da beide Lanes dieselbe
|
||||
DB teilen, will der Diff die Spalten/Objekte der **anderen** Lane droppen. Beim Erzeugen einer
|
||||
Migration nur die **eigenen** DDL-Blöcke übernehmen (Entwickler-Aufgabe; DevOps sollte es kennen).
|
||||
- **Neue RBAC-Rechte/Rollen wirken erst nach DB-Grant:** Permissions werden beim **Login** aus der
|
||||
DB aufgelöst. Neue Einträge in `ROLE_DEFS` brauchen für **bestehende** Mandanten einen Grant
|
||||
(Re-Seed/Re-Provisioning oder gezieltes SQL). Neue Mandanten bekommen sie automatisch.
|
||||
- **Veraltetes JWT nach Re-Seed:** Nach einem Re-Seed zeigen offene Sessions auf alte User-IDs →
|
||||
Screen „Konto deaktiviert". **Fix:** ab-/neu anmelden (`/api/auth/signout`) — das JWT wird neu aufgelöst.
|
||||
- **Turbopack-Cache:** Nach Rewrites von Server-Actions kann die Browser-Konsole veraltete
|
||||
HMR-Fehler zeigen. Maßgeblich sind `tsc`/`build` + `preview_logs` (Server) — bei Zweifel Dev-Server neu starten.
|
||||
|
||||
## 7. Nicht-destruktive Regeln
|
||||
|
||||
- Feature-Branches **in `dev` mergen** (`--no-ff`) — **nie** die Entwickler-Branches rebasen/umschreiben.
|
||||
- Einen in einem Worktree ausgecheckten Branch **nicht** löschen.
|
||||
- Vollständig gemergte Branches (z. B. `dev-b1`, sobald `dev-b2` gelandet ist) dürfen aufgeräumt
|
||||
(gelöscht) werden — nur wenn nicht ausgecheckt.
|
||||
- Bei `origin/dev`-Divergenz **kein** `--force` — abstimmen und mergen.
|
||||
|
||||
## 8. Aktueller Stand (zum Zeitpunkt der Runbook-Erstellung)
|
||||
|
||||
- `dev` enthält beide Lanes bis: **A1-1, F2, A1-2** (Dev A) und **F1, B1, F4** (Dev B), konsolidiert.
|
||||
- Feature-Branches: `dev-a1-wizard-shell` (= `dev`), `dev-b2-regel-engine` (Dev B, offene Stories).
|
||||
- Offene Stories: A2 (Scoping + AL zentral), A3, F3+B2 (Regel-Engine), B3 (Fragebogen), B4 (Richtlinien).
|
||||
- Migrationen auf `dev`: siehe `docs/STAND-dev-branch.md` (Liste + Reihenfolge).
|
||||
@@ -0,0 +1,219 @@
|
||||
# Feindesign & Implementierungsplan — Zentrale Identität + Mandanten-Mitgliedschaften (certvia)
|
||||
|
||||
> Produkt: **certvia** (ISMS-Tool). Grundlage: [KONZEPT-identity-mandanten.md](KONZEPT-identity-mandanten.md) (Option C, Entscheidungen A–E + Two-Step-Login + Passphrasen). Dieses Dokument ist der **umsetzbare Bauplan** für ein mehrköpfiges, noch zu onboardendes Entwicklerteam inkl. PM. Alle Datei:Zeile-Angaben beziehen sich auf Branch `dev`.
|
||||
|
||||
## 0. Wie dieses Dokument zu lesen ist
|
||||
- **PM:** §7 (Meilensteine), §8 (Risiken), §9 (DoD), §11 (Rollen/Cadence), §12 (Aufwand).
|
||||
- **Entwickler:** §1–§6 (Feindesign), §10 (Onboarding + „Goldene Regeln").
|
||||
- **Alle:** §1 ist die eine Leitentscheidung, aus der alles Weitere folgt.
|
||||
|
||||
---
|
||||
|
||||
## 1. Leitentscheidung (das Sicherheitsnetz): `User.id` bleibt stabil
|
||||
Wir führen **nicht** eine große Umverkabelung durch. Stattdessen:
|
||||
- **`User` bleibt** die per-Mandant-Zeile (jetzt gedanklich „Mitgliedschaft") mit **derselben `id`** und **derselben `tenantId`**.
|
||||
- Wir **schneiden nur die Auth-Felder heraus** in eine neue globale **`Identity`** und hängen `User.identityId → Identity.id` an.
|
||||
- In der Session bleibt **`session.user.tenantId`** erhalten — es zeigt künftig auf den **aktiven** Mandanten. Dadurch bleiben **~356 `dbForTenant(session.user.tenantId)`-Stellen, alle 6 Owner-FKs (`assets/processes/risks/measures.owner_id`, `user_roles`, `webauthn`) und das RLS-Modell unangetastet.**
|
||||
|
||||
**Folge:** Der Umbau konzentriert sich auf **Login, Session-Aufbau, Mandantenauswahl, Nutzer-Lifecycle** — nicht auf die 356 Fachstellen. Das ist die risikoärmste Schneise.
|
||||
|
||||
> **Zusatz-Vereinfachung (Entscheidung D):** Es gibt **keinen Produktivdatenbestand** (nur Testdaten). Deshalb **kein Backfill/Merge**, sondern **Schema-Neuschnitt + Reseed**. Der aufwändigste und riskanteste Teil eines solchen Umbaus entfällt komplett.
|
||||
|
||||
---
|
||||
|
||||
## 2. Zieldatenmodell (konkret)
|
||||
|
||||
**Neu — `Identity` (global, KEIN `tenantId`, NICHT in `TENANT_MODELS`):**
|
||||
`id, email @unique, passwordHash, mustChangePassword, mfaSecret, mfaEnrolledAt, recoveryCodes, lastTotpStep, failedLogins, lockedUntil, sessionsValidAfter, status, createdAt, updatedAt`.
|
||||
→ Übernimmt exakt die Felder, die heute in `User` (`schema.prisma:98-139`) und `PlatformAdmin` doppelt liegen.
|
||||
|
||||
**Geändert — `User` = Mitgliedschaft (bleibt in `TENANT_MODELS`, behält `tenantId`+`id`):**
|
||||
- **entfernt** (wandern zu Identity): `passwordHash, mfaSecret, mfaEnrolledAt, recoveryCodes, lastTotpStep, failedLogins, lockedUntil, mustChangePassword, sessionsValidAfter, isPlatformAdmin`.
|
||||
- **neu:** `identityId → Identity`.
|
||||
- **bleibt:** `tenantId, name, status: UserStatus, userRoles`, alle Ownership-Relationen. `@@unique([tenantId, email])` → ersetzt durch `@@unique([tenantId, identityId])` (eine Person max. 1 Mitgliedschaft je Mandant); `email` bleibt als Denormalisierung optional oder entfällt (Quelle = Identity).
|
||||
|
||||
**`WebAuthnCredential`** (`schema.prisma:143-160`): `userId → identityId`, `tenantId` entfällt → **raus aus `TENANT_MODELS`**, RLS-Policy der Tabelle entfernen (Passkeys sind identitäts-, nicht mandantengebunden).
|
||||
|
||||
**`AuthToken`** (`schema.prisma:1679-1700`): `principalType/principalId` → auf `identity` umstellen; neuer `TokenType: "invitation"` (heute wird `password_reset` mit 7-Tage-TTL zweckentfremdet).
|
||||
|
||||
**`PlatformAdmin`** (`schema.prisma:211-234`): **bleibt getrennt** (Entscheidung E). *Option (empfohlen, klein):* eine Identity kann zusätzlich Plattform-Admin sein → `PlatformAdmin.identityId` als Verweis, Store aber getrennt. Nicht zwingend für Phase 1.
|
||||
|
||||
**`TENANT_MODELS` (`db.ts:81-148`):** `Identity` **nicht** aufnehmen (globaler Lookup über Owner-`prisma`, wie heute der Login). `WebAuthnCredential` **entfernen**. `User` **bleibt** drin.
|
||||
|
||||
---
|
||||
|
||||
## 3. Session-/Token-Shape (neu)
|
||||
|
||||
**JWT/Session (`next-auth.d.ts` erweitern):**
|
||||
```
|
||||
identityId
|
||||
activeTenantId → gespiegelt als session.user.tenantId (⇒ 356 Call-Sites unverändert!)
|
||||
activeMembershipId (= User.id des aktiven Mandanten)
|
||||
memberships[] [{ tenantId, tenantSlug, membershipId, tenantName }] (schlanke Liste für den Switcher)
|
||||
permissions[] (des AKTIVEN Mandanten)
|
||||
tenantSlug (des aktiven Mandanten)
|
||||
isPlatformAdmin, mfaEnrolled, tokenIssuedAt
|
||||
```
|
||||
|
||||
**Kritische Regel:** `session.user.tenantId === activeTenantId`, immer **genau ein** aktiver Mandant. Kippt das (leer/mehrdeutig), kippt der RLS-Kontext → Fail-closed.
|
||||
|
||||
**Konsumenten anpassen (aber minimal):**
|
||||
- `auth.ts` jwt/session-Callbacks (`:276-299`): neue Felder setzen, `tenantId` = aktiver Mandant.
|
||||
- `action-guard.ts:29-84` und `(app)/layout.tsx:37-120`: `sessionsValidAfter` künftig aus **Identity** lesen; Permissions bei **Mandantenwechsel** neu auflösen (heute beim Login eingefroren).
|
||||
- `rbac.ts` (`hasPermission/requirePermission`, 43 Konsumenten): unverändert — liest weiter `session.user.permissions`, die aber pro aktivem Mandant befüllt werden.
|
||||
- `proxy.ts`: neuer Pfad `/select-tenant` in Post-Login-Gate; „angemeldet, aber kein aktiver Mandant → `/select-tenant`".
|
||||
|
||||
---
|
||||
|
||||
## 4. Die Flows (Feindesign)
|
||||
|
||||
### 4.1 Login (Two-Step, MFA beim Login) — `login/page.tsx`, `auth.ts`
|
||||
1. **Seite 1: E-Mail + Passwort** (Feld „Organisation" **entfällt**). `signIn` gegen **Identity** (`auth.ts:96` von `user.findMany` auf `identity.findUnique({email})` umstellen; Lockout/Dummy-Verify bleiben).
|
||||
2. **MFA-pending-Zustand:** kurzlebiger, signierter, einzweckiger Server-State (kein voller Session-Cookie). Realisierung: eigener NextAuth-Step oder ein `mfa_pending`-JWT mit 5 min TTL, das nur `/login/mfa` bedient.
|
||||
3. **Seite 2: MFA** — angezeigt, wenn Identity MFA hat **oder** ≥ 1 Mitgliedschaft in MFA-Pflicht-Mandant. TOTP/Passkey verifizieren (`mfa.ts:47`, Replay via Identity.`lastTotpStep`) oder Enroll erzwingen.
|
||||
4. **Volle Session** ausstellen (Identity + MFA erfüllt), `memberships[]` laden.
|
||||
5. **Weiterleitung:** 1 Mitgliedschaft → direkt `/dashboard` (activeTenant gesetzt); mehrere → `/select-tenant`; keine → Hinweisseite.
|
||||
|
||||
### 4.2 Mandantenauswahl & -Wechsel — neu: `/select-tenant` + Action `setActiveTenant`
|
||||
- Server Action `setActiveTenant(membershipId)`: **(1)** Membership gehört zur Session-Identity? **(2)** Mitgliedschaft+Mandant ACTIVE? **(3)** `sessionsValidAfter` ok? **(4)** MFA-Netz: verlangt Zielmandant MFA und nicht erfüllt → Enroll. Dann Token neu prägen: `activeTenantId/activeMembershipId/permissions/tenantSlug`.
|
||||
- **Sicherheitskontrollen (Pflicht):** aktiver `tenantId` **server-autoritativ** (nie Client-Input); Re-Validierung bei jedem Wechsel; **Audit** „Identity → Mandant". (Siehe §8.)
|
||||
- **Tenant-Switcher** in `(app)/layout.tsx` (Kopf/Sidebar) → ruft `setActiveTenant`.
|
||||
|
||||
### 4.3 Einladung (Standard-Anlageweg) — `auth-selfservice.ts`, `auth-token.ts`, neue Seite `/invite`
|
||||
- Neuer **`TokenType: "invitation"`** + Zielseite **`/invite?token=`** (Erst-Passwort + optional MFA **auf der Identity** setzen), statt Zweckentfremdung von `/reset`.
|
||||
- **Neutralität:** Der einladende Admin sieht **immer** „Einladung gesendet" — unabhängig davon, ob die Identity schon existierte (kein Cross-Tenant-Leak).
|
||||
- Existiert die Identity → Mail „Sie wurden zu Mandant X hinzugefügt" + **Bestätigungslink** (Beitritt annehmen). Existiert nicht → „Konto einrichten + beitreten".
|
||||
|
||||
### 4.4 Nutzeranlage
|
||||
- **Plattform** (`platform-users.ts:89`): `createTenantUser` → Identity finden/erstellen + Membership + Rollen; **`pwMode "set"` + Fallback-Passwort entfernen**; Betreiber darf direkt verknüpfen.
|
||||
- **Kunde** (`tenant-users.ts:139`): `createUser` → **immer Einladung**; „Passwort setzen"-Zweig raus.
|
||||
- **Onboarding-Wizard** (`onboarding-team.ts:166`): `inviteFunctionHolder` → Identity-Einladung statt User+Passwort.
|
||||
- **UI** (`user-forms.tsx`): pwMode-Auswahl entfällt → reines Einladungsformular (Name, E-Mail, Rollen).
|
||||
|
||||
### 4.5 MFA-Pflicht-Durchsetzung (Mandanten-Policy trifft Identity-MFA)
|
||||
- Gates umstellen: `(app)/layout.tsx:73-79`, `enroll-mfa/page.tsx:24-32`, `(platform)/layout.tsx:27` → prüfen **Identity.mfaEnrolledAt** statt `User`.
|
||||
- `mfa-policy.ts:7` (`resolveMfaRequired`, mandantenweit) bleibt. Regel: **strengster betretener Mandant gewinnt**; da MFA an der Identity hängt, deckt eine Einrichtung alle ab.
|
||||
|
||||
### 4.6 Passwort/MFA-Reset → Identity-Ebene, raus aus Mandantenverwaltung
|
||||
- **Entfernen:** `platform-users.ts:152` (`resetTenantUserPassword`), `tenant-users.ts:207` (`resetUserPassword`) + UI-Bindungen (`admin/[id]/page.tsx:205`, `settings/users/page.tsx:105`). Ersatz: Identity-Self-Service (Recovery-Codes) + Plattform-Ebene.
|
||||
- **Umleiten auf Identity:** `auth-recovery.ts` (gesamt), `account.ts:66` (`changeOwnPassword`), `sessions.ts:34/92` (`sessionsValidAfter` an Identity), `webauthn.ts:73` (Passkey an Identity), `secret-crypto.ts`-Nutzung unverändert (Schlüssel bleibt).
|
||||
|
||||
---
|
||||
|
||||
## 5. Migrationsstrategie (DB)
|
||||
Dank Entscheidung D **kein Backfill**. Ablauf:
|
||||
1. **Schema-Recut** in `schema.prisma`: `Identity` neu, `User` verschlanken (+`identityId`), `WebAuthnCredential`/`AuthToken` umhängen.
|
||||
2. **Forward-Migration(en):** `identities` (ohne RLS/Policy!), `users`-Spalten entfernen + `identity_id` FK, `webauthn_credentials` `tenant_id`/Policy entfernen + `identity_id`, `auth_tokens` principal-Umbau. RLS-Policies der betroffenen Tabellen anpassen (analog `rls_enforce`-Muster, `20260730160000`).
|
||||
3. **`db.ts`:** `TENANT_MODELS` anpassen (Identity raus lassen, WebAuthn entfernen).
|
||||
4. **Reseed:** `prisma/seed.ts`, `provision.ts`, `bootstrap-admin.ts`, `sync-role-permissions.ts` auf Identity+Membership. Testumgebung frisch aufsetzen.
|
||||
|
||||
> Für die Test-/Coolify-Instanz: einmalig **DB leeren** (nur Testdaten) → `migrate deploy` → Seed. Kein Sonderpfad nötig.
|
||||
|
||||
---
|
||||
|
||||
## 6. Arbeitspakete (Workstreams) für das Team
|
||||
|
||||
| WS | Inhalt | Kernfiles | Abhängig von |
|
||||
|---|---|---|---|
|
||||
| **WS0 Fundament** | `Identity`-Modell, Schema-Recut, Migrationen, `TENANT_MODELS`, RLS-Anpassung WebAuthn | `schema.prisma`, `prisma/migrations/*`, `db.ts` | — (Blocker) |
|
||||
| **WS1 Auth-Kern** | Login gegen Identity, Two-Step + MFA-pending, Token/Session-Shape, jwt/session-Callbacks | `auth.ts`, `next-auth.d.ts` | WS0 |
|
||||
| **WS2 Mandantenkontext** | `/select-tenant`, `setActiveTenant`, Tenant-Switcher, Guards + Permissions bei Wechsel, `proxy.ts` | `action-guard.ts`, `(app)/layout.tsx`, `proxy.ts`, `rbac.ts` | WS1 |
|
||||
| **WS3 Einladungs-Lifecycle** | `invitation`-Token, `/invite`-Seite, Anlage überall auf Einladung, „Passwort setzen" raus | `auth-token.ts`, `auth-selfservice.ts`, `platform-users.ts`, `tenant-users.ts`, `onboarding-team.ts`, `user-forms.tsx` | WS0 |
|
||||
| **WS4 Passwort/MFA an Identity** | Reset/Change/Email-Change, MFA/Passkey/Recovery, Sessions-Invalidierung, Reset raus aus Mgmt | `auth-recovery.ts`, `account.ts`, `sessions.ts`, `webauthn.ts`, `platform.ts` | WS0 |
|
||||
| **WS5 UI** | Login-Seiten (2-stufig), `/select-tenant`, Switcher, Einladungsformulare | `login/*`, neue Seiten, `(app)/layout.tsx` | WS1, WS2 |
|
||||
| **WS6 Seed/Provision/Bootstrap** | Reseed auf Identity, `provision.ts`, `bootstrap-admin.ts`, `sync-role-permissions.ts` | genannte | WS0 |
|
||||
| **WS7 Tests & Gate** | neue `scripts/test-*`: Identity-Login, Tenant-Switch-Isolation, MFA-Enforcement multi-tenant, Einladung, Two-Step-Bypass | `scripts/test-*.ts` | fortlaufend |
|
||||
|
||||
**Parallelisierung:** Nach **WS0** laufen **WS1, WS3, WS4, WS6** parallel. **WS2** setzt auf WS1, **WS5** auf WS1+WS2. **WS7** durchgehend (jede Story bringt ihren Test mit).
|
||||
|
||||
---
|
||||
|
||||
## 7. Meilensteine (PM-Sicht)
|
||||
| M | Ergebnis (Demo-fähig) | umfasst |
|
||||
|---|---|---|
|
||||
| **M0** | Onboarding abgeschlossen, Dev-Umgebungen laufen, Gate grün | §10 |
|
||||
| **M1** | Fundament: Schema+Migration+Reseed grün, RLS-Tests grün | WS0, WS6, WS7-Basis |
|
||||
| **M2** | Login gegen Identity (Two-Step) + Single-Membership-Nutzer landet im Dashboard | WS1, WS5-Login |
|
||||
| **M3** | Multi-Membership: Auswahl + Wechsel + MFA-Netz + Isolation nachgewiesen | WS2, WS5-Switcher, WS7-Isolation |
|
||||
| **M4** | Einladungs-Lifecycle (Plattform+Kunde+Wizard), „Passwort setzen" entfernt | WS3 |
|
||||
| **M5** | Passwort/MFA-Reset auf Identity, Reset raus aus Mandantenverwaltung | WS4 |
|
||||
| **M6** | Härtung, vollständige Test-Suite, Doku, STAND aktualisiert, Deploy | WS7, Doku |
|
||||
|
||||
---
|
||||
|
||||
## 8. Risiken & Gegenmaßnahmen
|
||||
| Risiko | Gegenmaßnahme |
|
||||
|---|---|
|
||||
| **RLS kippt**, wenn kein/mehrdeutiger aktiver Mandant | `session.user.tenantId` immer = genau **eine** validierte Membership; fail-closed; Guard wirft bei leer |
|
||||
| **356 Call-Sites** anfassen | Leitentscheidung §1: `User.id` + `session.user.tenantId`-Semantik erhalten → Call-Sites unverändert |
|
||||
| **Two-Step „halb angemeldet"-Bypass** | MFA-pending-State ist einzweckig + kurzlebig, **keine** App-Session vor MFA |
|
||||
| **Rechte veraltet** bei Wechsel ohne Re-Login | `setActiveTenant` löst Permissions **neu** auf (nicht Token-eingefroren) |
|
||||
| **Breiterer Blast-Radius** eines Credentials | starke Passphrase-Policy + MFA + kurze Session + globaler Kill-Switch (`Identity.sessionsValidAfter`) |
|
||||
| **Plattform/Tenant-Cookie-Kollision** | bereits gelöst (getrennte Cookie-Namen, `platform-auth.ts`) — beibehalten |
|
||||
| **Team nicht eingearbeitet** | M0-Onboarding als eigener Meilenstein, „Goldene Regeln" §10, Pair auf WS0 |
|
||||
|
||||
---
|
||||
|
||||
## 9. Definition of Done
|
||||
**Pro Story:** tsc + lint + build grün · zugehöriges `scripts/test-*.ts` grün · keine neuen `dbForTenant`-Regressionen · Doku-Schnipsel im PR.
|
||||
**Gesamt (M6):** alle `scripts/test-*` grün (inkl. **neuer** Isolations-/Enforcement-Tests) · Login/Auswahl/Wechsel/Einladung/Reset im Browser verifiziert · RLS-Test mit Multi-Membership-Nutzer nachweist, dass Wechsel **keine** Fremddaten sichtbar macht · `docs/STAND-dev-branch.md` + dieses Doc aktualisiert · sauber nach `dev` (beide Remotes) integriert.
|
||||
|
||||
---
|
||||
|
||||
## 10. Onboarding der neuen Entwickler (M0)
|
||||
**Pflichtlektüre (in dieser Reihenfolge):** `docs/HANDOVER-DEV.md` → `docs/SPEC.md` → `docs/STAND-dev-branch.md` → dieses Doc → [KONZEPT-identity-mandanten.md](KONZEPT-identity-mandanten.md).
|
||||
|
||||
**Dev-Setup:** Node + Postgres (pgvector-Image), `.env` aus `.env.example`, `npx prisma migrate deploy`, Demo-Seed (`RUN_DEMO_SEED`), `npm run dev`. Demo-Logins in `prisma/seed.ts`.
|
||||
|
||||
**Validierungs-Gate (vor jedem PR):** `npx tsc --noEmit` · `npm run lint` · `npm run build` · **alle 17 `scripts/test-*.ts`**.
|
||||
|
||||
**RLS-Mentalmodell (Pflichtverständnis):** `dbForTenant(tenantId)` setzt pro Transaktion `app.tenant_id`; `TENANT_MODELS` sagt, welche Modelle mandantengefiltert sind; globale Modelle (Kataloge, künftig `Identity`) laufen über den Owner-`prisma`. **Nie** ein globales Modell in `TENANT_MODELS` aufnehmen und **nie** ein tenant-Modell ohne Kontext lesen.
|
||||
|
||||
**DevOps-Workflow:** Feature-Branch → `git merge --no-ff` nach `dev` → Gate grün → Push auf **beide** Remotes (`origin` = git.certvia.de, `local-gitea`) → `docs/STAND-dev-branch.md` pflegen. (certvia ist strikt getrennt von anderen Produkten — nichts vermischen.)
|
||||
|
||||
**Goldene Regeln dieses Umbaus:**
|
||||
1. `User.id` = Mitgliedschaft, **stabil lassen**. Auth-Felder leben auf `Identity`.
|
||||
2. `Identity` ist **global**, **nicht** in `TENANT_MODELS`, kein `tenant_id`, keine RLS-Policy.
|
||||
3. Genau **ein** aktiver Mandant pro Session; server-autoritativ; bei Wechsel re-validieren.
|
||||
4. **Keine** „Passwort direkt setzen"-Anlage mehr — nur Einladung.
|
||||
5. MFA/Passwort gehören der Identity — **kein** Mandanten-Admin-Reset.
|
||||
|
||||
---
|
||||
|
||||
## 11. Rollen & Cadence (Beispielbesetzung: 1 Lead + 2 Devs + PM)
|
||||
| Rolle | Verantwortung |
|
||||
|---|---|
|
||||
| **Tech-Lead / Senior** | WS0 + WS1 (Fundament + Auth-Kern), Review aller Auth-/RLS-PRs, Sicherheitskontrollen §8 |
|
||||
| **Dev A** | WS2 (Mandantenkontext/Guards) + WS5 (UI) |
|
||||
| **Dev B** | WS3 (Einladung) + WS4 (Passwort/MFA an Identity) + WS6 (Seed) |
|
||||
| **alle** | WS7 (jede Story bringt ihren Test) |
|
||||
| **PM** | Meilenstein-Tracking (§7), Risiken (§8), DoD-Abnahme (§9), Entscheidungs-Eskalation (Phase-2-Punkte), Cadence |
|
||||
|
||||
**Cadence-Vorschlag:** WS0 als **Pairing** (Lead + je 1 Dev) — dient zugleich als Onboarding-Vehikel; danach 2-Wochen-Iterationen entlang M1–M6, Demo je Meilenstein, PR-Review verpflichtend für Auth/RLS.
|
||||
|
||||
---
|
||||
|
||||
## 12. Aufwandsschätzung (grob, Personentage)
|
||||
> Annahme: 3 Devs, noch nicht eingearbeitet → Ramp-up eingepreist. Ohne Produktivdaten-Migration (Entscheidung D).
|
||||
|
||||
| Block | PT (Bereich) |
|
||||
|---|---|
|
||||
| M0 Onboarding (3 Personen) | 6–9 |
|
||||
| WS0 Fundament + Migration + Reseed | 5–8 |
|
||||
| WS1 Auth-Kern (Two-Step, Session-Shape) | 8–12 |
|
||||
| WS2 Mandantenkontext + Guards + Switcher | 6–9 |
|
||||
| WS3 Einladungs-Lifecycle | 5–8 |
|
||||
| WS4 Passwort/MFA an Identity | 6–9 |
|
||||
| WS5 UI | 4–6 |
|
||||
| WS6 Seed/Provision/Bootstrap | 2–4 |
|
||||
| WS7 Tests & Härtung | 5–8 |
|
||||
| **Summe** | **≈ 47–73 PT** |
|
||||
|
||||
**Kalenderdauer** bei 3 Devs mit Parallelisierung (§6) + PM-Overhead: **≈ 5–7 Wochen** bis M6 (inkl. Onboarding-Woche). Kritischer Pfad: **WS0 → WS1 → WS2 → WS5**. WS3/WS4/WS6 laufen daneben.
|
||||
|
||||
---
|
||||
|
||||
## 13. Phase 2 (bewusst später, kein Blocker)
|
||||
- Per-Mandant-Schalter „bei jedem Betreten Step-up erzwingen" (Hochsicherheits-Kunden).
|
||||
- E-Mail-Änderung als Identity-Operation (Sonderfälle/Merge).
|
||||
- Optionale Konsolidierung `PlatformAdmin` in die `Identity` (Store getrennt lassen, nur Verweis).
|
||||
@@ -0,0 +1,122 @@
|
||||
# DevOps-Übergabe — ISMS-Tool (Deployment Test → Produktiv)
|
||||
|
||||
> Stand: 2026-07-20 · Branch `main` · Commit `081c000`
|
||||
> Zweck: Betrieb/Deployment. Gemeinsame Arbeit an Dockerfile, Einrichtung Testserver (remote), danach Produktivserver.
|
||||
> Ergänzt: `docs/HANDOVER-DEV.md` (App-Architektur/Setup) und `docs/HANDOVER-PM.md` (fachlicher Status).
|
||||
|
||||
---
|
||||
|
||||
## 1. Überblick der Runtime
|
||||
|
||||
Die App ist eine **Next.js-16-Anwendung im Standalone-Modus** (`output: "standalone"` in `next.config`, Start via `node server.js`), zustandslos horizontal skalierbar. Zustand liegt ausschließlich in den Backing-Services.
|
||||
|
||||
| Service | Image / Herkunft | Port | Zweck | Persistenz |
|
||||
|--------|------------------|------|-------|-----------|
|
||||
| **app** | Multi-Stage-Build (`Dockerfile`) | 3000 | Web-App (Next.js standalone) | zustandslos |
|
||||
| **postgres** | `pgvector/pgvector:0.8.0-pg16` | 5432 | Primärdatenbank (inkl. `vector`-Extension) | Volume `pgdata` |
|
||||
| **redis** | `redis:7.4.2-alpine` (mit `requirepass`) | 6379 | Cache/Queue (für späteren BullMQ-Worker) | Volume `redisdata` |
|
||||
| **minio** | `minio/minio:RELEASE.2025-04-22T22-12-26Z` | 9000/9001 | Objektspeicher (noch **ungenutzt**; für kommenden Logo-/Datei-Upload) | Volume `miniodata` |
|
||||
| **mailhog** | `mailhog/mailhog:v1.0.1` (Profil `dev`) | 1025/8025 | SMTP-Fang für Entwicklung | — |
|
||||
| **worker** | s. Dockerfile (Profil `worker`) | — | **noch nicht lauffähig** (kein `worker`-npm-Script vorhanden) | — |
|
||||
|
||||
Alles bereits als **`docker-compose.yml`** im Repo-Root beschrieben; **`Dockerfile`** ist ein 4-Stage-Build (`deps` mit `npm ci` → `builder` mit `prisma generate` + `next build` → schlanke `migrate`-Stage für den Migrations-/Seed-Job → schlanker `runner` als non-root `app`-User). Alle Images sind auf konkrete Tags gepinnt (F-11), Base ist `node:22-slim` (glibc).
|
||||
|
||||
---
|
||||
|
||||
## 2. Umgebungsvariablen (`.env`)
|
||||
|
||||
Vorlage: **`.env.example`**. Vollständige Liste:
|
||||
|
||||
| Variable | Zweck | Prod-Hinweis |
|
||||
|----------|-------|-------------|
|
||||
| `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | Compose-Init von Postgres | **Secret** (Passwort) |
|
||||
| `DATABASE_URL` | Prisma-Verbindungsstring | **Secret**; muss auf denselben DB-Nutzer/-Namen zeigen |
|
||||
| `REDIS_URL` | Redis-Verbindung | — |
|
||||
| `AUTH_SECRET` | NextAuth-Signaturschlüssel (JWT) | **Secret**, zwingend gesetzt & stark |
|
||||
| `AUTH_URL` | Öffentliche Basis-URL der App | pro Umgebung (Test/Prod) unterschiedlich, inkl. `https://` |
|
||||
| `AI_PROVIDER` / `AI_API_KEY` | KI-Chat / KI-Formulierungshilfe (noch ungenutzt) | Secret, optional |
|
||||
| `SMTP_HOST/PORT/USER/PASSWORD/FROM` | E-Mail (noch ungenutzt; Einladungs-/Benachrichtigungsflow offen) | Secret, optional |
|
||||
| `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` | Objektspeicher | **fehlen in `.env.example`** → für Prod ergänzen (sonst Compose-Defaults `isms`/`isms-secret`) |
|
||||
|
||||
> **Secrets** gehören nicht ins Git. Für Prod: Secret-Management (Docker Secrets / Env aus Vault / CI-Variablen). `.env.example` enthält nur Namen/Defaults.
|
||||
|
||||
---
|
||||
|
||||
## 3. Build & Start
|
||||
|
||||
```bash
|
||||
# Image bauen
|
||||
docker compose build app
|
||||
|
||||
# Test/lokal hochfahren (ohne dev-only mailhog)
|
||||
docker compose up -d postgres redis minio app
|
||||
|
||||
# mit Mailhog (Dev): docker compose --profile dev up -d
|
||||
# Worker (aktuell NICHT lauffähig, s. §6): docker compose --profile worker up -d worker
|
||||
```
|
||||
|
||||
App danach unter `AUTH_URL` (Port 3000) erreichbar.
|
||||
|
||||
---
|
||||
|
||||
## 4. ⚠️ Datenbank-Migrationen & Bootstrap (kritisch)
|
||||
|
||||
**Der App-Container führt KEINE Migrationen beim Start aus** (CMD ist `node server.js`). Migrationen und das erste Provisioning sind ein **separater Deploy-Schritt**:
|
||||
|
||||
1. **Migrationen anwenden:** `npx prisma migrate deploy` gegen die Ziel-DB.
|
||||
- Die `runner`-Stage enthält Prisma-CLI/`tsx` **nicht** (nur Runtime-Trace). Migrationen daher aus der **`builder`-Stage** (voller `node_modules`) oder aus einem eigenen **Migrations-Job/CI-Step** fahren. → **Designentscheidung mit DevOps** (z. B. Init-Container `command: npx prisma migrate deploy` mit builder-Image, `restart: "no"`).
|
||||
- Die Init-Migration legt `CREATE EXTENSION IF NOT EXISTS "vector"` an (pgvector) — das Postgres-Image bringt die Extension mit.
|
||||
2. **Erster Mandant / Superadmin (Prod):** Der Demo-Seed (`prisma/seed.ts`) ist **nur für Entwicklung** (Demo-Mandant + Testnutzer) — **nicht in Produktion ausführen**. In Prod erfolgt das Anlegen von Kunden über die **Admin-Konsole** (`provisionTenant`). **Offen:** Es gibt aktuell **kein Bootstrap-Skript** für den allerersten Plattform-Admin — dieser muss initial angelegt werden (Skript/One-Off gegen `provisionTenant` mit `isPlatformAdmin: true`, oder manuell). → **Mit App-Entwickler abstimmen** (kleiner Task).
|
||||
|
||||
---
|
||||
|
||||
## 5. Persistenz, Backup & Restore
|
||||
|
||||
- **Volumes:** `pgdata` (kritisch), `miniodata` (künftig Uploads), `redisdata` (unkritisch, Cache).
|
||||
- **Backups Prod:** regelmäßiger `pg_dump` (logisch) oder Volume-/Snapshot-Backup; MinIO-Bucket-Backup, sobald Uploads aktiv. Aufbewahrung + Restore-Test einplanen (DSGVO-Löschkonzept ist app-seitig noch offen).
|
||||
- Mandantentrennung ist **logisch** (eine DB, `tenant_id` + Postgres-RLS + App-Guard) — Backups umfassen alle Mandanten gemeinsam.
|
||||
|
||||
---
|
||||
|
||||
## 6. Bekannte Lücken / To-dos für DevOps
|
||||
|
||||
1. **`worker`-Service nicht lauffähig:** Es existiert **kein `npm run worker`**-Script (BullMQ-Scheduler ist app-seitig noch nicht implementiert). Profil `worker` bis dahin **nicht** starten.
|
||||
2. **Migrations-/Bootstrap-Strategie** festlegen (§4) — Migrations-Job + Erst-Superadmin.
|
||||
3. **MinIO-Env** in `.env` für Prod ergänzen; MinIO wird erst mit dem kommenden Logo-/Datei-Upload wirklich genutzt (Bucket-Präfix je Mandant vorgesehen).
|
||||
4. **Reverse-Proxy + TLS** vor der App (Port 3000 nicht direkt exponieren): Traefik/Caddy/nginx mit HTTPS; `AUTH_URL` auf die öffentliche `https://`-Adresse setzen. Rate-Limiting/IP-Härtung für Login/Admin einplanen.
|
||||
5. **Ressourcen/Skalierung:** `app` ist zustandslos → mehrere Replicas möglich; Sticky-Sessions nicht nötig (JWT). Postgres/Redis als Singleton bzw. Managed-Service.
|
||||
6. **Health/Observability:** Postgres-Healthcheck vorhanden; für `app` einen HTTP-Healthcheck/Readiness ergänzen (derzeit keiner definiert). Log-Aggregation + Alerting einrichten.
|
||||
7. **`AUTH_SECRET`** je Umgebung eindeutig & stark; bei Rotation invalidieren alle Sessions.
|
||||
|
||||
---
|
||||
|
||||
## 7. Test- vs. Produktivumgebung — Unterschiede
|
||||
|
||||
| Aspekt | Test (Remote) | Produktiv |
|
||||
|--------|---------------|-----------|
|
||||
| Daten | ggf. Demo-Seed erlaubt | **kein Demo-Seed**; nur echtes Provisioning |
|
||||
| Secrets | Test-Werte | echte Secrets aus Secret-Store |
|
||||
| Mail | Mailhog (Profil `dev`) | echtes SMTP-Relay |
|
||||
| TLS | optional/self-signed | verpflichtend, gültiges Zertifikat |
|
||||
| Backups | optional | verpflichtend + Restore-Test |
|
||||
| Skalierung | 1 Replica | ≥ 2 App-Replicas hinter Proxy |
|
||||
|
||||
---
|
||||
|
||||
## 8. Deploy-Checkliste (Kurz)
|
||||
|
||||
**Testserver:**
|
||||
1. Repo klonen/pullen (Gitea, Branch `main`).
|
||||
2. `.env` aus `.env.example` mit Test-Werten (inkl. `MINIO_*`).
|
||||
3. `docker compose build app`.
|
||||
4. `docker compose up -d postgres redis minio`.
|
||||
5. **Migrationen:** `prisma migrate deploy` (builder-Image/Job).
|
||||
6. **Erst-Superadmin** anlegen (One-Off, s. §4) — für Test ggf. Demo-Seed ok.
|
||||
7. `docker compose up -d app`; Reverse-Proxy/TLS davor; Smoke-Test (Login, Admin-Konsole, Modul-Seite).
|
||||
|
||||
**Produktivserver:** wie oben, aber Secret-Store, **kein** Demo-Seed, TLS verpflichtend, Backups + Monitoring aktiv, ≥ 2 App-Replicas, Migrations-/Bootstrap-Schritt in die Deploy-Pipeline integriert.
|
||||
|
||||
---
|
||||
|
||||
## 9. Referenzen im Repo
|
||||
`Dockerfile`, `docker-compose.yml`, `.env.example`, `next.config.*` (`output: standalone`), `prisma/schema.prisma` + `prisma/migrations/**` (inkl. RLS-Policies & pgvector), `prisma/seed.ts` (nur Dev), `src/server/provision.ts` (Provisioning-Logik für Prod-Onboarding).
|
||||
@@ -0,0 +1,135 @@
|
||||
# Konzept — Datensicherung, Wiederherstellung, DSGVO-Export & Löschung (certvia)
|
||||
|
||||
> Status: **Konzept/Entscheidungsvorlage** (kein Code). Produkt: **certvia** (ISMS-Tool, Single-DB + Postgres-RLS, alle Mandanten in denselben Tabellen mit `tenant_id`). Kernidee: **eine mandanten-scoped Traversierungs-Engine** bedient vier Zwecke — Per-Tenant-Backup, gezielten Restore, DSGVO-Auskunft/-Export und DSGVO-Löschung/Offboarding.
|
||||
|
||||
## 0. Zielbild
|
||||
1. **Disaster-Recovery** der gesamten DB (Totalausfall, Ransomware, „Zeitpunkt-T-Wiederherstellung").
|
||||
2. **Gezielter Einzelkunden-Restore** (Kunde A zurück, Kunde B unangetastet) — über das **Betreiber-Portal**.
|
||||
3. **DSGVO-Auskunft/-Datenportabilität** (Art. 15/20) — je Mandant und je betroffener Person.
|
||||
4. **DSGVO-Löschung** (Art. 17) — ganzer Mandant (Offboarding) und einzelne Person, mit ISMS-konformer Aufbewahrung.
|
||||
|
||||
Alle vier teilen sich **einen** Baustein: das Durchlaufen aller mandantengescopten Tabellen in FK-Reihenfolge, gefiltert auf `tenant_id` (bzw. auf eine Person).
|
||||
|
||||
## 1. Architektur-Grundlage (bestehend, wird genutzt)
|
||||
- **`TENANT_MODELS`** (`src/server/db.ts`) = autoritative Menge aller mandantengescopten Tabellen. Plus deren FK-Topologie ⇒ die verbindliche Reihenfolge für Export (parent→child) und Löschung (child→parent).
|
||||
- **`tenant_id`-Spalte + RLS** ⇒ jede Operation `WHERE tenant_id = A` ist **beweisbar** auf einen Mandanten begrenzt.
|
||||
- **cuid-Primärschlüssel** (kein Serial) ⇒ Reinsert kollisionsfrei, IDs bleiben erhalten, keine Sequenz-Konflikte.
|
||||
- **Owner-`prisma`-Client** (BYPASSRLS) für Backup/Restore/Löschung; **nie** der RLS-Client.
|
||||
- **Globale Tabellen** (Kataloge, `Permission`, `Tenant`-Stammsatz, künftig **`Identity`**) sind **nicht** tenant-scoped → über Schicht A gesichert, nicht Teil des Tenant-Artefakts.
|
||||
- **MinIO/S3** hält Dateien/Logos je Mandant (Prefix) — DB-Backup allein reicht nicht.
|
||||
|
||||
## 2. Schicht A — Cluster-Disaster-Recovery (ganze DB)
|
||||
- **pgBackRest** oder **wal-g**: periodisches Vollbackup + kontinuierliches **WAL-Archiving** nach S3/offsite ⇒ **PITR** (Point-in-Time-Recovery).
|
||||
- Zweck: Totalausfall/Ransomware/menschlicher Massenfehler → „DB auf Zeitpunkt T".
|
||||
- **Verschlüsselt** (SSE-KMS oder client-seitig) — dockt an die (vorerst geparkte) DB-Härtung an.
|
||||
- Deckt auch die **globalen** Tabellen inkl. `Identity`/Credentials ab, die Schicht B bewusst nicht anfasst.
|
||||
|
||||
## 3. Schicht B — Mandanten-scoped Logical Export/Restore
|
||||
### Export (Backup-Artefakt je Mandant)
|
||||
- Aus **einem konsistenten Snapshot** (`REPEATABLE READ`-Transaktion), damit die FK-Integrität innerhalb des Mandanten stimmt.
|
||||
- Je Tabelle aus `TENANT_MODELS`: `COPY (SELECT * FROM t WHERE tenant_id = 'A') TO …` → ein Artefakt (z. B. NDJSON/COPY) pro Mandant → gzip → verschlüsselt nach S3.
|
||||
- Enthält **zusätzlich** den MinIO-Prefix des Mandanten (Datei-Snapshot) und ein **Manifest** (Schema-/Migrationsversion, Zeitstempel, Zeilenzahlen je Tabelle, Prüfsummen).
|
||||
- Zeitplan: nächtlich + **on-demand vor riskanten Operationen** (Restore, Massen-Import).
|
||||
|
||||
### Restore (gezielt Kunde A)
|
||||
- Owner-Client, **eine Transaktion**, FK-Reihenfolge, alles `WHERE tenant_id = 'A'`:
|
||||
1. Mandant **sperren** (`status = SUSPENDED`) → keine parallelen Schreibzugriffe.
|
||||
2. **Pre-Restore-Sicherheitsschnappschuss** des aktuellen Standes (Restore ist damit reversibel).
|
||||
3. **Replace**: `DELETE … WHERE tenant_id='A'` (child→parent) + Reinsert aus dem Artefakt (parent→child). Constraints ggf. `DEFERRED`.
|
||||
4. MinIO-Prefix des Mandanten wiederherstellen.
|
||||
5. Mandant reaktivieren, **Audit** schreiben.
|
||||
- **Beweisbar sicher** für andere Mandanten: keine Operation verlässt `tenant_id='A'`.
|
||||
- Schema-Version im Manifest gegen aktuelle Migration prüfen; bei Differenz Artefakt vor Reinsert migrieren/abweisen.
|
||||
|
||||
## 4. Betreiber-Portal — Restore-Flow
|
||||
Restore ist eine **Betreiber**-Fähigkeit (kein Mandanten-Admin) → Plattform-Portal (`/admin`, `platformAuth`).
|
||||
- **Auslöser:** Aktion „Wiederherstellen" auf `/admin/[id]`, gegated durch **`requirePlatformFullAdmin` + frischer MFA-Step-up** (`assertPlatformStepUp`).
|
||||
- **Auswahl:** Mandant + Sicherungspunkt (Liste der Per-Tenant-Snapshots / PITR-Zeitpunkt) + **Vorschau/Dry-run** (Zeilenzahlen, Snapshot-Zeit) + **getippte Bestätigung** („RESTORE kunde-a").
|
||||
- **Ausführung als Hintergrund-Job im Worker** (BullMQ, vorhanden) — **nicht** inline in der Server-Action (Timeouts/Progress/Audit). Die Action **enqueued** nur.
|
||||
- **Kontrollen:** Sperre während Restore · Pre-Restore-Schnappschuss · `tenant_id`-Scope · Plattform-Audit (wer/Mandant/Snapshot/wann) · MinIO mit.
|
||||
|
||||
## 5. DSGVO-Export (Art. 15 Auskunft / Art. 20 Portabilität)
|
||||
**Rollen (wichtig):** certvia ist **Auftragsverarbeiter**, der Mandant ist **Verantwortlicher**. Anfragen richten sich an den Mandanten; certvia liefert das **Werkzeug**. Deshalb existiert der Export an **zwei** Stellen:
|
||||
- **Mandanten-Self-Service** (Mandanten-Admin, für die eigenen Betroffenen),
|
||||
- **Betreiber-Portal** (AVV-Unterstützung / Ausfallhilfe).
|
||||
|
||||
**Zwei Granularitäten:**
|
||||
1. **Per-Mandant** — der gesamte Kundendatensatz (= das Backup-Artefakt aus §3, maschinenlesbar/JSON). Nutzen: Portabilität beim Anbieterwechsel, Offboarding-Kopie.
|
||||
2. **Per-Betroffener** (eine natürliche Person) — alle personenbezogenen Zeilen dieser Person: `User` (Mitgliedschaft), Auth-Daten aus **`Identity`** (Existenz/E-Mail/MFA-Status — **keine** Secrets), sowie alle Referenzen (`owner_id`, `assigneeId`, `createdBy`, `AuditLog.actorId`, `TaskParticipant`, `Audit.*UserId`, …) und Freitext mit Personenbezug.
|
||||
|
||||
**Format/Zustellung:** ZIP (JSON + zugehörige Dateien aus MinIO), erzeugt vom **Worker-Job**, Zustellung über **zeitlich begrenzten signierten Link** (nicht per Mail). Export wird **auditiert**.
|
||||
|
||||
## 6. DSGVO-Löschung (Art. 17 „Recht auf Vergessenwerden")
|
||||
**Zwei Scopes:**
|
||||
1. **Mandanten-Löschung / Offboarding** — alle `tenant_id='A'`-Zeilen (child→parent, eine Transaktion) + MinIO-Prefix. Danach **globale Aufräumung**: eine `Identity` **ohne verbleibende Mitgliedschaft** wird gelöscht/anonymisiert.
|
||||
2. **Einzelne Person (Betroffener)** — Kernspannung im ISMS: **Löschung vs. Nachweis-/Aufbewahrungspflicht**.
|
||||
|
||||
**Löschen vs. Anonymisieren (die zentrale Design-Regel):**
|
||||
- Datensätze, die aus **ISMS-/Nachweisgründen** oder wegen **rechtlicher Pflicht** (Art. 17 Abs. 3) erhalten bleiben müssen (v. a. **Audit-Trail**, Freigaben, Nachweise), werden **anonymisiert/pseudonymisiert** (Name/E-Mail → Tombstone, referenzielle Struktur bleibt), **nicht** hart gelöscht — sonst bricht die Nachvollziehbarkeit.
|
||||
- Wo keine Aufbewahrungspflicht greift: **Hard-Delete**.
|
||||
- Ergebnis: **Löschnachweis/„Deletion Certificate"** (wer/wann/Scope/was gelöscht vs. anonymisiert), auditiert.
|
||||
|
||||
**Personen über mehrere Mandanten (Auth-Umbau!):** Jeder Mandant ist ein **eigener Verantwortlicher**. Löschung erfolgt **pro Mandant-Scope** (Mitgliedschaft + PII in dessen Datensätzen) — **nicht** global über alle Mandanten. Erst wenn **keine** Mitgliedschaft der Person mehr existiert, wird die **globale `Identity`** entfernt/anonymisiert.
|
||||
|
||||
**Backups vs. Löschung (bekannte Spannung):** Unveränderliche Backups lassen sich nicht punktuell „aufbohren". Standard: Löschung wirkt auf **Live-Daten** + wird über eine **Tombstone-/Löschliste beim Restore erneut angewandt** (ein alter Snapshot bringt gelöschte PII nicht zurück); Backups laufen über **Retention** aus. Diese Regel muss dokumentiert und im Restore-Job erzwungen werden.
|
||||
|
||||
## 7. Gemeinsame Engine (der Architektur-Gewinn)
|
||||
Backup-Export, DSGVO-Export, Offboarding und Löschung teilen **eine** tenant-scoped Traversierung über `TENANT_MODELS` + FK-Topologie:
|
||||
- Export = SELECT je Tabelle, `tenant_id`- oder personen-gefiltert.
|
||||
- Restore = DELETE+INSERT, `tenant_id`-gescopt.
|
||||
- Löschung = DELETE (child→parent) bzw. UPDATE-Anonymisierung, `tenant_id`- oder personen-gescopt.
|
||||
Ein Baustein, vier Anwendungsfälle → geringe Redundanz, konsistentes Verhalten, ein Testfokus (Isolation).
|
||||
|
||||
## 8. Schnittstelle zum Auth-Umbau (Identity)
|
||||
- **Export einer Person** vereint globale `Identity` (Existenz/E-Mail/MFA-Status, **keine** Secrets) + alle Mitgliedschaften + zugewiesene/erstellte Objekte.
|
||||
- **Restore eines Mandanten** holt **Mitgliedschaften** (`User`) zurück, **nicht** den globalen Credential-Store (der liegt in Schicht A). Fehlt beim Reinsert die referenzierte `Identity` → sauber behandeln (neu verknüpfen/Einladung).
|
||||
- **Löschung** ist controller-scoped (pro Mandant); globale `Identity` erst bei 0 Mitgliedschaften.
|
||||
⇒ Diese Punkte **jetzt** mitdesignen, **umsetzen nach** WS0 (Identity-Fundament).
|
||||
|
||||
## 9. Verschlüsselung & Schlüssel (Entscheidung)
|
||||
**Entscheidung:** **Client-seitige AES-256-Verschlüsselung mit einem Schlüssel pro Umgebung** — **nicht** allein auf Storage-SSE verlassen. Grund: das Artefakt ist verschlüsselt, **bevor** es S3/MinIO erreicht → der Speicher-Betreiber sieht nie Klartext (at-rest **und** in-transit **und** zero-knowledge vom Speicher). Beide Backup-Tools können das nativ (keine Zusatzkomponente). Dies ist das **gemeinsame Primitiv**, auf dem die Backup-Lane und die Härtungs-Lane bauen.
|
||||
|
||||
**Stack (durchgängig AES-256, konsistent zur bestehenden TOTP-Verschlüsselung AES-256-GCM):**
|
||||
| Schutzobjekt | Mechanismus |
|
||||
|---|---|
|
||||
| Host at-rest (`pgdata`, MinIO) | **LUKS / provider-verschlüsseltes Volume** (deckt physischen Plattendiebstahl/Decommission; transparent im Betrieb) |
|
||||
| Cluster-Backup (Schicht A) | **pgBackRest** mit nativer **AES-256-Repo-Verschlüsselung** (`repo-cipher-type=aes-256-cbc` + `repo-cipher-pass`) → S3/MinIO |
|
||||
| Per-Tenant-Export + DSGVO-Pakete (Schicht B) | **`age`** je Artefakt (X25519, encrypt-then-upload) |
|
||||
| MinIO-Dateien | **restic** (eingebaute Verschlüsselung) oder MinIO-SSE als Defense-in-Depth |
|
||||
|
||||
**Warum nicht SSE-only:** SSE (AWS SSE-KMS / MinIO KES+KMS) ist transparent, aber der Speicher-Betreiber hält die Schlüssel, und self-hosted MinIO-SSE bräuchte KES+KMS als Extra-Komponente. SSE gern **zusätzlich**, nicht als alleinige Zusicherung.
|
||||
|
||||
**Schlüsselverwaltung:**
|
||||
- **Jetzt (self-hosted VPS):** **ein** AES-256-Schlüssel (pgBackRest) + **ein** `age`-Keypair **pro Umgebung** (test/dev/prod getrennt), als **Coolify-Env-Secret** + **Offline-Kopie im Org-Passwortmanager** (versiegelt).
|
||||
- **Register „restore-kritische Secrets":** führt zusammen — **Backup-Keys**, **Pepper**, **`MFA_ENC_KEY`**, `AUTH_SECRET`. Regeln: **niemals** im selben Bucket wie die verschlüsselten Artefakte; **Verlust des Keys = Verlust der Wiederherstellbarkeit**.
|
||||
- ⚠ **Umgebungs-Secret-Kohärenz beim Restore:** Pepper und `MFA_ENC_KEY` stehen **nicht** im per-Mandant-Artefakt. Ein Restore in eine Umgebung mit **anderem** Pepper/`MFA_ENC_KEY` bricht **alle** Passwort-/MFA-Prüfungen. Restore daher nur in eine Umgebung mit **passenden** Secrets (oder Passwort-/MFA-Reset einplanen).
|
||||
- **Später (Phase 2):** Upgrade-Pfad auf **HashiCorp Vault** oder Provider-KMS (Rotation/Audit/Trennung) — bewusst offen, nicht jetzt bauen.
|
||||
|
||||
**Aufgabenteilung der Lanes:** die **Backup-Lane** ruft die Verschlüsselung auf (pgBackRest-Repo-Key bzw. `age`-Recipient); die **Härtungs-Lane** stellt Host-Encryption (LUKS) bereit und verwaltet/rotiert die Keys + das Secrets-Register.
|
||||
|
||||
**Aufbewahrung & Test:**
|
||||
- **Retention** je Schicht dokumentieren (DR-WAL/Base, Per-Tenant-Snapshots, DSGVO-Exporte) — dem DSB vorzulegen.
|
||||
- **Restore-Test** regelmäßig + dokumentiert (der Prod-Runbook fordert das bereits für `pgdata`).
|
||||
|
||||
## 10. Governance-/Sicherheitskontrollen (Zusammenfassung)
|
||||
| Operation | Wer | Zusatzschutz |
|
||||
|---|---|---|
|
||||
| Cluster-PITR | Betreiber/Ops | Offsite-Zugriff, 4-Augen empfohlen |
|
||||
| Tenant-Restore | Plattform-Full-Admin | **MFA-Step-up**, getippte Bestätigung, Mandant-Sperre, Pre-Restore-Snapshot, Audit |
|
||||
| DSGVO-Export | Mandanten-Admin **oder** Betreiber | signierter Link, Audit |
|
||||
| Löschung Person | Mandanten-Admin (Verantwortlicher) | Anonymisierungs-Regeln, Löschnachweis, Audit |
|
||||
| Mandanten-Löschung | Plattform-Full-Admin | **MFA-Step-up**, getippte Bestätigung, Löschnachweis, MinIO + globale Identity-Aufräumung |
|
||||
|
||||
## 11. Phasen & offene Entscheidungen
|
||||
**Phasen:**
|
||||
1. Schicht A (pgBackRest/wal-g + PITR + verschlüsselt) — unabhängig, **sofort** möglich.
|
||||
2. Traversierungs-Engine über `TENANT_MODELS` (Export/Restore-Kern) — **nach WS0**.
|
||||
3. Betreiber-Portal-Restore (Worker-Job + Kontrollen).
|
||||
4. DSGVO-Export (per-Mandant + per-Person).
|
||||
5. DSGVO-Löschung (Anonymisierung vs. Hard-Delete + Löschnachweis + Tombstone-on-Restore).
|
||||
|
||||
**Offene Entscheidungen (für PM/DSB):**
|
||||
- Retention-Fristen je Schicht (DSB-Vorgabe).
|
||||
- Welche Tabellen/Felder bei Personen-Löschung **anonymisiert** (Nachweispflicht) vs. **hart gelöscht** werden — eine explizite **Feld-Klassifikation** ist nötig.
|
||||
- Aufbewahrung/Weg der DSGVO-Export-Pakete (Ablauf des signierten Links).
|
||||
- Ob per-Mandant-Snapshots als DSGVO-Portabilitätsformat genügen oder ein zusätzliches „menschenlesbares" Format nötig ist.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Konzept — Konfigurierbarer Backup-Zielspeicher (Backend + Lokal) (certvia)
|
||||
|
||||
> Status: **Konzept/Entscheidungsvorlage** (kein Code). Produkt: **certvia**. Folge-Feature der Backup-Lane. Ziel: den Zielspeicher für Backup-/DSGVO-Artefakte **im Betreiber-Portal konfigurierbar** machen und **lokale (persistente) Speicherung** als vollwertige Option anbieten — nicht nur über Env.
|
||||
|
||||
## 1. Ist-Zustand (`src/server/storage/backup-store.ts`)
|
||||
- Es gibt bereits eine **`BackupStore`-Abstraktion** (`put/get/list/remove`) mit zwei echten Implementierungen: **`S3BackupStore`** (MinIO/S3) und **`LocalBackupStore`** (echte Byte-Persistenz).
|
||||
- **Aber:** Die Wahl trifft `createBackupStore()` **einmalig beim Prozessstart, rein Env-basiert**: alle `S3_*` gesetzt → S3, sonst lokaler Ordner (`BACKUP_LOCAL_DIR` bzw. `<cwd>/.backups`). Exportiert als **statisches Singleton** `backupStore`.
|
||||
- **Nur 3 Nutzer:** `src/server/backup/export.ts`, `restore.ts`, `ops.ts`.
|
||||
- **Schwächen:** (a) nicht im Backend wählbar; (b) der lokale Ordner liegt im Container → beim Redeploy **flüchtig**; (c) S3-Config nur als Env, nicht pro Betreiber pflegbar.
|
||||
|
||||
## 2. Zielbild
|
||||
- **Betreiber wählt im Portal** das Ziel: **Lokal** oder **S3/MinIO**, inkl. Config, mit „Verbindung testen".
|
||||
- **Lokal ist persistent** (gemountetes Volume), nicht flüchtig.
|
||||
- Env bleibt als **Fallback** funktionsfähig (Rückwärtskompatibilität).
|
||||
|
||||
## 3. Datenmodell (`PlatformSetting`, Singleton erweitern)
|
||||
Aktuell nur `mfaRequired`. Ergänzen:
|
||||
- `backupTarget String @default("local")` — `local` | `s3`
|
||||
- `backupLocalDir String?` — Pfad des lokalen Ziels (muss auf ein **gemountetes** Volume zeigen)
|
||||
- `backupS3Endpoint / backupS3Bucket / backupS3Region / backupS3AccessKey String?`
|
||||
- `backupS3SecretKeyEnc String?` — S3-Secret **verschlüsselt at-rest** über `src/server/secret-crypto.ts` (`encryptSecret`/`decryptSecret`, Schlüssel `MFA_ENC_KEY`/`AUTH_SECRET`) — **nie** Klartext in der DB.
|
||||
|
||||
Migration additiv (nullable, Default `local`). Kein Backfill nötig.
|
||||
|
||||
## 4. Store-Factory umbauen
|
||||
- `backupStore`-Singleton → **`getBackupStore(): Promise<BackupStore>`**: liest `PlatformSetting`, baut den passenden Store, entschlüsselt den S3-Key.
|
||||
- **Präzedenz:** DB-Config (wenn `backupTarget` gesetzt/vollständig) → **sonst** Env (`S3_*` / `BACKUP_LOCAL_DIR`) → **sonst** lokaler Default `.backups`. So bleibt bestehendes Env-Deployment lauffähig.
|
||||
- **Caching + Invalidierung:** Store memoisieren, bei Änderung der Backup-Settings invalidieren (Version/Timestamp aus `PlatformSetting.updatedAt`).
|
||||
- Die **3 Call-Sites** (`export.ts`, `restore.ts`, `ops.ts`) von `backupStore` auf `await getBackupStore()` umstellen.
|
||||
- **Fail-secure:** unvollständige S3-Config → klarer Fehler (nicht still auf lokal fallen, wenn `backupTarget=s3` gewählt wurde).
|
||||
|
||||
## 5. Betreiber-UI
|
||||
- Neue Seite im Plattform-Portal, z. B. **`/admin/backup`** (oder Abschnitt in den Plattform-Einstellungen).
|
||||
- Gated: **`requirePlatformFullAdmin` + MFA-Step-up** (`assertPlatformStepUp`) — Betreiber-Config mit Credentials.
|
||||
- Felder: Ziel-Radio (Lokal/S3), je nach Wahl die Config; **„Verbindung testen"** (Probe-`put`+`get`+`remove` eines winzigen Test-Keys) mit klarer Rückmeldung; Speichern über eine Action analog `setPlatformMfaRequired` (`platformSetting.upsert`, S3-Secret vor dem Schreiben verschlüsseln).
|
||||
|
||||
## 6. Persistenz für „Lokal" (Compose)
|
||||
- In `docker-compose.coolify.yml` ein **persistentes Volume** ergänzen (analog `pgdata`/`miniodata`): `backups:` und in **app + worker** unter dem Pfad aus `backupLocalDir` mounten (z. B. `/app/.backups`). Ohne Mount bleibt Lokal flüchtig.
|
||||
- Doku-Hinweis: `backupLocalDir` muss innerhalb des gemounteten Pfads liegen.
|
||||
|
||||
## 7. Sicherheit
|
||||
- S3-Secret **nur verschlüsselt** in der DB (`secret-crypto`).
|
||||
- Config-Bearbeitung nur **Full-Admin + Step-up**, auditiert.
|
||||
- **`BACKUP_ENC_KEY`** (Artefakt-Verschlüsselung, `src/server/backup/crypto.ts`) bleibt **getrennt** vom Zielspeicher — Verschlüsselung des Inhalts ≠ Wahl des Speicherorts.
|
||||
- Umgebungs-Secret-Kohärenz (Restore) unverändert: `BACKUP_ENC_KEY`/`PASSWORD_PEPPER`/`MFA_ENC_KEY` sind Env, nicht Artefakt (siehe `KONZEPT-backup-restore.md` §9).
|
||||
|
||||
## 8. Tests
|
||||
- `scripts/test-backup-*` erweitern: Store-Auflösung aus DB-Config (local & s3), Präzedenz DB→Env→Default, „Verbindung testen"-Pfad, Fail-secure bei unvollständiger S3-Config.
|
||||
- Export→Restore end-to-end gegen **beide** Backends.
|
||||
|
||||
## 9. Sofort testbar (ohne Umbau)
|
||||
Schon heute: ohne `S3_*` fällt der Store auf **lokal** zurück → Export/Restore/DSGVO laufen (Artefakte im Container-`.backups`, **flüchtig**). Für einen schnellen Funktionstest genügt: **backup-worker + Redis** (vorhanden) + `BACKUP_ENC_KEY` (Fallback `AUTH_SECRET`). Der Umbau macht das Ziel **wählbar** und **persistent**.
|
||||
|
||||
## 10. Offene Entscheidungen
|
||||
- Eigene Seite `/admin/backup` vs. Abschnitt in bestehenden Plattform-Einstellungen.
|
||||
- Mehrere Ziele/Profile (Primär + Offsite) — jetzt 1 Ziel, Mehrfachziele als Phase 2.
|
||||
- Ob der lokale Pfad frei wählbar ist oder auf den gemounteten Volume-Pfad festgelegt wird (empfohlen: fest, um Fehlkonfiguration zu vermeiden).
|
||||
@@ -0,0 +1,217 @@
|
||||
# KONZEPT: Objektspeicher-Migration MinIO → Garage
|
||||
|
||||
**Stand:** 2026-08-20 · **Zielgruppe:** mehrköpfiges Entwicklerteam + PM · **Status:** Entwurf zur Abnahme
|
||||
|
||||
> **Randbedingung (2026-08-20):** Es existieren **nur Test-Instanzen** — **keine produktiven Daten**. Daher **keine Datenmigration** (kein rclone-Sync), sondern ein **kompletter Neu-Deploy** mit frischer Garage. Das eliminiert die frühere Migrations-Lane und das Wartungsfenster; Cutover = MinIO-Service durch Garage ersetzen, provisionieren, neu deployen (optional DB-Reset wie gehabt).
|
||||
|
||||
---
|
||||
|
||||
## 1. Ausgangslage & Motivation
|
||||
|
||||
certvia nutzt aktuell **MinIO** als S3-kompatiblen Objektspeicher (Dokument-Uploads + Backup-Artefakte). Anlass für die Prüfung: der Hinweis, MinIO werde „nicht mehr weiterentwickelt".
|
||||
|
||||
**Faktenlage (verifiziert 2026-08-20):**
|
||||
|
||||
- **Mai 2025** — MinIO entfernt die Admin-Konsole/Verwaltungs-GUI aus der Community Edition (nur noch rudimentärer Object-Browser; Bucket-/User-/Policy-Verwaltung nur noch im kommerziellen AIStor).
|
||||
- **Okt 2025** — MinIO publiziert keine Container-Images mehr auf Docker Hub/Quay (auch nicht für einen kritischen CVE-Fix).
|
||||
- **Dez 2025** — Community-Repo im **Maintenance-Mode**: keine neuen Features, keine PRs, Security-Fixes nur „case-by-case"; Repo als **„no longer maintained"** markiert. Fokus liegt auf **AIStor** (Abo-Produkt). Der Code bleibt AGPLv3 verfügbar.
|
||||
|
||||
**Bewertung:** MinIO ist nicht „von heute auf morgen tot", aber die **Community Edition ist faktisch im End-of-Life-/Wartungsmodus**. Für ein selbstgehostetes ISMS-/Compliance-Produkt (das genau Betriebs-, Wartungs- und Supply-Chain-Sicherheit demonstrieren soll) ist der proaktive Wechsel auf einen aktiv gepflegten Store gerechtfertigt und gut begründbar (u. a. relevant für die eigene Lieferanten-/Komponentenbewertung).
|
||||
|
||||
**Warum Garage:** aktiv entwickelt (Deuxfleurs, AGPLv3, Rust), bewusst **minimalistisch & leichtgewichtig**, S3-kompatibel, für Selbsthosting/kleine bis mittlere Deployments und geo-verteilte Replikation ausgelegt. Passt zum internen Coolify-Testserver **und** zum Contabo-Prod-VPS.
|
||||
|
||||
---
|
||||
|
||||
## 2. Zielbild
|
||||
|
||||
Ein **API-kompatibler Austausch** des Storage-Backends: Der Anwendungscode spricht weiterhin S3 (AWS SDK v3, `forcePathStyle`), lediglich der Container-Dienst, die Provisionierung von Bucket/Key und die Daten werden migriert. **Keine Änderung an Fachlogik, UI oder Datenmodell.**
|
||||
|
||||
Was gleich bleibt:
|
||||
- S3-Protokoll, AWS SDK v3, `forcePathStyle: true`, die Env-Kontrakte `S3_ENDPOINT / S3_ACCESS_KEY / S3_SECRET_KEY / S3_BUCKET / S3_REGION`.
|
||||
- Die Storage-Abstraktionen `src/server/storage/adapter.ts` (Uploads) und `src/server/storage/backup-store.ts` (Backups) inkl. Local-/Stub-Fallback.
|
||||
- Key-Schema (`<tenantId>/uploads/<uuid>-<name>`), Mandanten-Isolation über Key-Präfix.
|
||||
|
||||
---
|
||||
|
||||
## 3. Ist-Analyse certvia (Code-Stand dev)
|
||||
|
||||
Zwei S3-Konsumenten, beide identischer S3-Dialekt:
|
||||
|
||||
| Konsument | Datei | Zweck | Ops |
|
||||
|-----------|-------|-------|-----|
|
||||
| Upload-Storage | `src/server/storage/adapter.ts` | hochgeladene Richtlinien + Audit-Nachweise | Put/Get, HeadBucket→CreateBucket |
|
||||
| Backup-Store | `src/server/storage/backup-store.ts` | verschlüsselte Backup-Artefakte (`.cvb`), DSGVO-ZIP | Put/Get/List/Delete, HeadBucket→CreateBucket |
|
||||
|
||||
Gemeinsam:
|
||||
- `new S3Client({ endpoint, region, forcePathStyle: true, credentials })` — **path-style**, exakt was Garage erwartet.
|
||||
- Verwendete S3-Operationen: `PutObject`, `GetObject`, `HeadBucket`, `CreateBucket`, `ListObjectsV2`, `DeleteObjects`. **Alle außer `CreateBucket` sind Garage-Standard.**
|
||||
- Region-Default `us-east-1`.
|
||||
- Backend-Auswahl rein über Env → sauberer Graceful-Fallback (Stub/Local), kein Hardcoding auf MinIO.
|
||||
|
||||
**Zu provisionierende Buckets (Neu-Deploy, keine Altbestände zu übernehmen):**
|
||||
- `S3_BUCKET` = `isms-documents` (Uploads).
|
||||
- Backup-Bucket: nur falls der Backup-Store auf S3 statt lokal (`BACKUP_LOCAL_DIR`) betrieben wird — dann eigenen Bucket provisionieren.
|
||||
|
||||
### Der einzige echte Reibungspunkt: Bucket-/Key-Anlage
|
||||
|
||||
Garage verwaltet **Buckets und Access-Keys sowie deren Rechte** über die **Garage-Admin-API / `garage`-CLI**, **nicht** über die S3-Operation `CreateBucket`. Konsequenz:
|
||||
- `HeadBucket`, `PutObject`, `GetObject`, `ListObjectsV2`, `DeleteObjects` → funktionieren gegen Garage unverändert.
|
||||
- `CreateBucket` (unsere Selbstheilung „Bucket fehlt → anlegen") → **wird von Garage über die S3-API nicht bedient**. Buckets/Keys müssen **vorab out-of-band** provisioniert werden.
|
||||
|
||||
> Hinweis: `backup-store.ts` **schluckt** einen `CreateBucket`-Fehler bereits (Bucket gilt als „vorhanden angenommen"), `adapter.ts` **wirft** dagegen bei nicht-„already exists"-Fehlern. Nach Vorab-Provisionierung greift ohnehin nur der `HeadBucket`-Erfolgspfad — trotzdem soll `ensureBucket()` explizit Garage-tauglich gemacht werden (siehe Lane C), damit wir uns nicht auf verschlucktes Fehlerverhalten verlassen.
|
||||
|
||||
---
|
||||
|
||||
## 4. Entscheidungen (D) — vom Team/PO zu bestätigen
|
||||
|
||||
| # | Entscheidung | Empfehlung | Begründung |
|
||||
|---|--------------|-----------|------------|
|
||||
| **D1** | Zielspeicher | **Garage, self-hosted — ENTSCHIEDEN 2026-08-20** | Aktiv gepflegt, S3-kompatibel, leichtgewichtig, DSGVO/Datenresidenz in eigener Hand. Alternativen (SeaweedFS, Ceph/RGW, extern R2/Hetzner) in §12 abgewogen. |
|
||||
| **D2** | Region-Handhabung | Garage-`s3_region` = **`us-east-1`** setzen | App-Env bleibt unverändert (Default `us-east-1`) → keine Code-/Config-Drift. |
|
||||
| **D3** | `ensureBucket()` | **Vorab-Provisionierung** + `ensureBucket` prüft nur (HeadBucket), kein S3-CreateBucket | Deterministisch; klare Fehlermeldung „Bucket nicht provisioniert" statt stiller Selbstheilung. |
|
||||
| **D4** | Cutover-Strategie | **Neu-Deploy, kein Wartungsfenster** (MinIO→Garage im Compose ersetzen, provisionieren, deployen; optional DB-Reset) | **Keine produktiven Daten** → keine rclone-Migration nötig. |
|
||||
| **D5** | Topologie | **Single-Node** Garage pro Environment | Passt zur aktuellen 1-Host-Topologie; Multi-Node/Replikation als spätere Ausbaustufe dokumentieren. |
|
||||
| **D6** | Deployment | Garage als **Compose-Service** in `docker-compose.coolify.yml` (ersetzt `minio`) | Gleiches Betriebsmodell wie bisher (Coolify/Traefik). |
|
||||
| **D7** | Reihenfolge | Aktuell nur **Test-Instanz(en)** — dort umsetzen; Prod später nach gleichem Muster | Es gibt derzeit keine Prod-Instanz; Runbook bleibt für spätere Prod gültig. |
|
||||
|
||||
Strategische Vorfrage (self-hosted vs. externer managed S3): **entschieden am 2026-08-20 zugunsten self-hosted Garage** — Datenresidenz + Betriebshoheit für ein DSGVO-/ISMS-Produkt. Externe Optionen (Hetzner OS/Cloudflare R2) bleiben nur als dokumentierte Alternative in §12.
|
||||
|
||||
---
|
||||
|
||||
## 5. Zielarchitektur
|
||||
|
||||
```
|
||||
┌───────────────────────── Coolify-Stack (pro Environment) ─────────────────────────┐
|
||||
app ──S3──► │ garage (Container) │
|
||||
worker ─S3► │ ├─ garage.toml (rpc_secret, s3_api.s3_region=us-east-1, api_bind_addr :3900, │
|
||||
backup- ─S3► │ │ admin_token, metadata_dir, data_dir) │
|
||||
worker │ ├─ Volume: garage_meta → /var/lib/garage/meta (KRITISCH: Metadaten/Layout) │
|
||||
│ └─ Volume: garage_data → /var/lib/garage/data (Objekt-Bytes) │
|
||||
│ garage-provision (Init-Job, restart:no): Layout + Bucket + Key + Rechte via CLI │
|
||||
└────────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Endpoint:** `S3_ENDPOINT=http://garage:3900` (S3-API-Port; Standard 3900). Admin-API auf 3903 (nur intern).
|
||||
- **Zwei Ports beachten:** 3900 = S3, 3902 = Web (optional), 3903 = Admin. Nur S3 wird von der App genutzt; Admin bleibt clusterintern.
|
||||
- **Secrets (pro Environment via Coolify-Env, echte Werte NIE im Chat/Repo):** `GARAGE_RPC_SECRET` (32-byte hex), `GARAGE_ADMIN_TOKEN`, plus der erzeugte Access-Key/Secret, die als `S3_ACCESS_KEY/S3_SECRET_KEY` in die App gehen.
|
||||
- **Backup der Garage-Metadaten:** `garage_meta` enthält Bucket-/Key-/Layout-Definitionen — muss in die Host-Backup-Strategie (analog `pgdata`). Ohne Meta sind die Daten nicht adressierbar.
|
||||
|
||||
---
|
||||
|
||||
## 6. Workstreams / Lanes für das Team
|
||||
|
||||
Vier parallelisierbare Lanes + PM-Koordination (die frühere rclone-Migrations-Lane entfällt, da Neu-Deploy). Abhängigkeiten in Klammern.
|
||||
|
||||
### Lane A — Infra & Deployment (Owner: DevOps)
|
||||
- Garage-Service in `docker-compose.coolify.yml` ergänzen (Image pinnen, Ports, 2 Volumes, `security_opt`/`cap_drop` analog bestehender Services, Healthcheck auf Admin-API `/health`).
|
||||
- `garage.toml` als Config (über Env/Coolify-Mount): `rpc_secret`, `s3_region=us-east-1`, `metadata_dir`, `data_dir`, `admin_token`.
|
||||
- Single-Node-Layout initial (Node einer Zone mit Kapazität zuweisen — ohne Layout kein Schreibzugriff).
|
||||
- `minio`-Service erst **nach** abgenommenem Garage-Betrieb entfernen (bis dahin als Rollback-Sicherheitsnetz stehen lassen).
|
||||
- Garage-`meta`-Volume in Host-Backup aufnehmen.
|
||||
- **Liefergegenstand:** lauffähiger Garage-Container auf der Test-Instanz, Admin-API erreichbar, Layout „ready".
|
||||
|
||||
### Lane B — Provisioning-Automatisierung (Owner: DevOps/Backend) — *(braucht A)*
|
||||
- Init-Job `garage-provision` (restart:no, analog `migrate`): idempotent
|
||||
1. Layout anwenden (falls noch nicht),
|
||||
2. Bucket(s) anlegen (`isms-documents`, ggf. Backup-Bucket),
|
||||
3. Access-Key erzeugen **oder** vorhandenen importieren,
|
||||
4. Key→Bucket-Rechte (read/write/owner) setzen.
|
||||
- Umsetzung über `garage`-CLI **oder** Admin-API (HTTP) — Entscheidung dokumentieren; idempotent (mehrfach ausführbar ohne Fehler).
|
||||
- Ausgabe des Access-Key/Secret **nur** in Coolify-Secrets, nicht in Logs.
|
||||
- **Liefergegenstand:** ein Skript/Job, der aus „leerer Garage" reproduzierbar den betriebsbereiten Zustand herstellt (dokumentiert in `docs/DEPLOY-*`).
|
||||
|
||||
### Lane C — App-Code-Anpassung (Owner: Backend) — *(unabhängig, klein)*
|
||||
- `ensureBucket()` in **beiden** Stores Garage-tauglich: `HeadBucket` zur Verifikation; bei „missing" **kein** S3-`CreateBucket`, sondern klarer Konfigurationsfehler „Bucket nicht provisioniert — Provisioning-Job ausführen". (Provisionierung liegt bei Lane B.)
|
||||
- Optional: gemeinsame S3-Client-Factory extrahieren (DRY über `adapter.ts`/`backup-store.ts`) — nur wenn ohne Risiko.
|
||||
- Region/Endpoint-Doku in `.env.coolify.example` + `.env.example` aktualisieren (MinIO-Kommentare → Garage; `forcePathStyle`-Begründung bleibt gültig).
|
||||
- **Tests:** bestehende `scripts/test-backup-*.ts` + Upload/Download-Pfad gegen einen lokalen Garage-Container grün; neuer Smoke-Test „Bucket fehlt → sprechender Fehler".
|
||||
- **Liefergegenstand:** PR mit Code + aktualisierten Tests, `tsc/lint/build` grün.
|
||||
|
||||
### Lane D — Test, Cutover & Abnahme (Owner: QA/DevOps + PM) — *(integriert A–C)*
|
||||
- End-to-End-Durchstich auf der **Test-Instanz** (Neu-Deploy): Upload, Download, Backup-Export (`.cvb`), DSGVO-ZIP, Restore, Mandanten-Isolation.
|
||||
- **Cutover-Runbook** (siehe §7) an der Test-Instanz **einmal durchspielen** (MinIO raus, Garage rein, provisionieren, deployen, ggf. DB-Reset).
|
||||
- **Rollback-Runbook** (siehe §8).
|
||||
- Abnahmekriterien (§9) abhaken.
|
||||
- **Liefergegenstand:** abgenommene Test-Instanz auf Garage + freigegebenes Runbook (auch für spätere Prod).
|
||||
|
||||
**PM:** Reihenfolge/Abhängigkeiten (A→B, C parallel, D integriert), Abnahme je Lane, Freigabe des Runbooks für spätere Prod (D7).
|
||||
|
||||
---
|
||||
|
||||
## 7. Cutover-Runbook (Neu-Deploy, kein Wartungsfenster nötig)
|
||||
|
||||
1. Im Compose `minio`-Service durch `garage` + `garage-provision` (Init-Job) ersetzen; Volumes `garage_meta`/`garage_data` anlegen.
|
||||
2. Coolify-Env setzen: `S3_ENDPOINT=http://garage:3900`, `S3_REGION=us-east-1`, `GARAGE_RPC_SECRET`, `GARAGE_ADMIN_TOKEN` (literal, echte Werte NICHT aus dem Chat). `S3_ACCESS_KEY/S3_SECRET_KEY` = der beim Provisioning erzeugte Garage-Key.
|
||||
3. Deploy: Garage startet, Layout „ready", `garage-provision` legt Bucket(s) + Key + Rechte an.
|
||||
4. Optional **DB-Reset** (wie in bisherigen Deploys), falls alte Objekt-Referenzen im Datenbestand stören — bei frischer/geseedeter Test-Instanz meist unnötig.
|
||||
5. **Smoke-Tests** (§9) aktiv durchführen.
|
||||
6. `minio`-Service + Volumes entfernen; Garage-`meta`-Volume in Host-Backup bestätigen.
|
||||
|
||||
## 8. Rollback
|
||||
|
||||
- Da es **keine produktiven Daten** gibt, ist Rollback unkritisch: Compose zurück auf `minio` (oder frischer Re-Deploy). Optional den alten `minio`-Service während der ersten Testphase noch nicht löschen (Schritt 6 verzögern), bis Garage abgenommen ist.
|
||||
|
||||
---
|
||||
|
||||
## 9. Validierung / Abnahmekriterien
|
||||
|
||||
Gegen Garage müssen grün sein:
|
||||
- **Upload:** Richtlinie hochladen → Objekt in Garage, `<tenantId>/uploads/...`-Präfix korrekt.
|
||||
- **Download:** Datei abrufen (`/files/[...key]`), Content-Disposition/Filename-Metadatum stimmt.
|
||||
- **Backup-Export:** `.cvb`-Artefakt erzeugt (CVB1-Header), Persistenz + Download.
|
||||
- **DSGVO-ZIP:** erzeugt und lesbar.
|
||||
- **Restore:** Backup → Wiederherstellung, Datenintegrität, **Mandanten-Isolation** gewahrt.
|
||||
- **List/Delete:** Backup-Historie listet, Aufräumen entfernt Prefix.
|
||||
- **Fehlerfall:** fehlender Bucket → sprechender Konfigfehler (kein stiller CreateBucket-Versuch).
|
||||
- **Automatisiert:** `scripts/test-backup-*.ts` grün gegen Garage; `tsc/lint/build` grün.
|
||||
|
||||
---
|
||||
|
||||
## 10. Risiken & Gegenmaßnahmen
|
||||
|
||||
| Risiko | Gegenmaßnahme |
|
||||
|--------|---------------|
|
||||
| `CreateBucket` (S3) gegen Garage nicht verfügbar | Vorab-Provisionierung (Lane B) + `ensureBucket` nur prüfend (Lane C). |
|
||||
| Garage-`meta`-Volume nicht gesichert → Buckets/Keys „weg" | Meta-Volume in Host-Backup; im Runbook explizit verifiziert. |
|
||||
| Region-/Endpoint-Mismatch (Coolify-Env vs. `garage.toml`) | D2 fixiert `us-east-1` beidseitig; in Abnahme geprüft. |
|
||||
| Access-Key/Secret landet in Logs | Provisioning schreibt nur in Coolify-Secrets; Log-Redaction. |
|
||||
| Layout nicht angewendet → Schreibfehler „no capacity" | Provisioning-Job setzt Layout idempotent; Healthcheck. |
|
||||
| Coolify-Interpolations-/„managed"-Fallen (bekannt) | Neue Env (`GARAGE_*`) literal setzen, nicht über `${…}` referenzieren; im Container-Env verifizieren. |
|
||||
| Presigned URLs / spezielle S3-Features | certvia nutzt aktuell keine Presigned-URLs (Downloads laufen serverseitig) — vor Ausbau prüfen. |
|
||||
|
||||
---
|
||||
|
||||
## 11. Grobe Aufwandsschätzung
|
||||
|
||||
| Lane | Aufwand (Personentage, grob) |
|
||||
|------|------------------------------|
|
||||
| A Infra/Deployment | 2–3 |
|
||||
| B Provisioning | 2–3 |
|
||||
| C App-Code | 1–2 |
|
||||
| D Test/Cutover/Abnahme | 1–2 |
|
||||
| PM/Koordination | durchgehend |
|
||||
| **Summe** | **~6–10 PT** (ohne Datenmigration), gut parallelisierbar |
|
||||
|
||||
---
|
||||
|
||||
## 12. Alternativen (zur Vollständigkeit für D1)
|
||||
|
||||
| Option | Pro | Contra |
|
||||
|--------|-----|--------|
|
||||
| **Garage** (empfohlen) | aktiv, leicht, S3, self-hosted, DSGVO in eigener Hand | Bucket/Key via Admin-API (einmaliger Provisioning-Aufwand); kein Erasure-Coding (Replikation) |
|
||||
| SeaweedFS | performant, S3-Gateway, aktiv | größerer Funktionsumfang/komplexer als nötig |
|
||||
| Ceph/RGW | Enterprise-Standard, sehr robust | schwergewichtig, hoher Betriebsaufwand — für 1-Host überdimensioniert |
|
||||
| Externer managed S3 (Hetzner OS / Cloudflare R2) | kein Storage-Ops, hohe Verfügbarkeit | Datenresidenz/Abhängigkeit extern; Kosten; DSGVO-AV nötig |
|
||||
|
||||
---
|
||||
|
||||
## 13. Quellen (MinIO-Status / Garage-Provisioning)
|
||||
|
||||
- MinIO users complain after admin UI removed from Community Edition — blocksandfiles.com (2025-06)
|
||||
- MinIO Faces Fallout for Stripping Functions from Open Source Version — futuriom.com (2025-06)
|
||||
- MinIO Ends Community Development, Positions AIStor as the Future — faun.dev / devopslinks
|
||||
- MinIO in Maintenance Mode: Open Source Alternatives — bizety.com (2025-12)
|
||||
- minio/minio Discussion #21326 „It's not a feature issue, it's a trust one" — github.com
|
||||
- Garage — Administration API / Features / Bucket & Key Operations — garagehq.deuxfleurs.fr, deepwiki.com
|
||||
|
||||
*(Quellen-Status zeitkritisch; vor Projektstart kurz gegenprüfen.)*
|
||||
@@ -0,0 +1,50 @@
|
||||
# Konzept — Sicherheitshärtung (Lane H)
|
||||
|
||||
> Status: **Entscheidungsvorlage / Backlog** (kein Code). Produkt: **certvia** (ISMS-Tool). Freigegeben, weil der Auth-Umbau (Option C, WS0–WS5 + Contract) abgeschlossen ist. **Parallel** zur Backup-Lane baubar. Trifft die Backup-Lane genau am **gemeinsamen Verschlüsselungs-Primitiv** (§9 in `docs/KONZEPT-backup-restore.md`).
|
||||
|
||||
## 0. Umfang
|
||||
Alles unter „DB-/Plattform-Härtung", das **nicht** ins Backup-Konzept gehört: **Pepper**, **Host-Encryption at-rest**, **verschlüsselte Backups** (operativer Bezug zu §9), **DB-Connection-TLS**, **Secrets-Register**. Argon2-Parameter sind **erledigt**.
|
||||
|
||||
## 1. Pepper (Passwort-Hash) — App-Härtung
|
||||
**Ist:** `src/server/password.ts` nutzt Argon2id mit fixierten Parametern (`ARGON2_OPTIONS`), **Salt automatisch**, **kein Pepper**.
|
||||
**Soll:** globaler **Pepper** über die Argon2-`secret`-Option — bei `hash()` **und** jeder `verify()`-Stelle.
|
||||
- **Schlüsselquelle:** neues Umgebungs-Secret `PASSWORD_PEPPER` (32-Byte hex), analog `MFA_ENC_KEY`.
|
||||
- **Umsetzung schlank dank Option C:** der verify-Pfad ist nach WS1/WS4 zentralisiert (Login gegen Identity) → `secret` an genau den zentralen hash/verify-Helfer geben statt an 6 verstreute Stellen. Bevorzugt einen `verifyPassword(hash, pw)`-Wrapper einführen (falls noch nicht), damit Pepper **eine** Stelle ist.
|
||||
- ⚠ **Nicht rotierbar** ohne Passwort-Reset für alle (kein Rehash-on-Login) — wie `MFA_ENC_KEY`. Deshalb **jetzt setzen, solange test/dev-DBs frisch/leer sind**.
|
||||
- **Restore-Kohärenz:** Pepper ist ein **Umgebungs-Secret**, steht nicht in Backup-Artefakten → Restore nur in Umgebung mit passendem Pepper (siehe Backup-Konzept §9).
|
||||
**Dateien:** `src/server/password.ts` (+ zentraler verify-Wrapper), alle `verify()`-Aufrufer, `.env*`-Beispiele, Deploy-Env.
|
||||
|
||||
## 2. Host-Encryption at-rest (Live-DB + Objektspeicher)
|
||||
**Ist:** `pgvector/pgvector` (Community-Postgres, **kein TDE**); `pgdata`/MinIO-Volumes liegen im Klartext auf der VPS-Platte.
|
||||
**Soll:** **LUKS/dm-crypt** bzw. **provider-verschlüsseltes Volume** für das Daten-Volume (deckt `pgdata` **und** MinIO).
|
||||
- Schützt gegen physischen Plattendiebstahl/Decommission; transparent im Betrieb (im RAM entschlüsselt) — **kein** Schutz gegen Live-Server-Kompromittierung (dafür App-Feld-Encryption + Zugriffskontrollen).
|
||||
- Operativer Runbook-Punkt (nicht App-Code): einmalig einrichten, in `docs/DEPLOY-PROD-CONTABO.md` als Go-Live-Checkliste ergänzen.
|
||||
|
||||
## 3. Verschlüsselte Backups (operativer Bezug zu §9)
|
||||
Setzt die Entscheidung aus `docs/KONZEPT-backup-restore.md` §9 **operativ** um:
|
||||
- **pgBackRest** mit `repo-cipher-type=aes-256-cbc` + `repo-cipher-pass` → S3/MinIO.
|
||||
- **`age`** für Per-Tenant-/DSGVO-Artefakte; **restic** für MinIO-Dateien.
|
||||
- Verdrahtung in Coolify (Cron/Job + Zugangsdaten), Retention, **Restore-Test** dokumentieren.
|
||||
|
||||
## 4. DB-Connection-TLS
|
||||
**Ist:** `DATABASE_URL`/`RLS_DATABASE_URL` ohne `sslmode` → App↔Postgres **ohne** TLS, aber netz-isoliert (internes Docker-Netz `backend`, `internal: true`).
|
||||
**Soll:** `sslmode=require` (bzw. `verify-full` mit CA) **sobald** die DB je den Host/eine Vertrauensgrenze verlässt (managed DB, eigener DB-Host). Aktuell durch die Netz-Isolation gemindert → **Phase 2 / bedingt**, nicht dringlich.
|
||||
|
||||
## 5. Secrets-Register „restore-/betriebs-kritisch"
|
||||
Ein gepflegtes Register (Ort: Org-Passwortmanager) mit Ownership + Rotationsregel:
|
||||
| Secret | Zweck | Rotierbar? |
|
||||
|---|---|---|
|
||||
| `AUTH_SECRET` | Session-/JWT-Signatur | ja (invalidiert Sessions) |
|
||||
| `MFA_ENC_KEY` | TOTP-Secret-Verschlüsselung (AES-256-GCM) | **nein** ohne MFA-Neueinrichtung |
|
||||
| `PASSWORD_PEPPER` | Passwort-Pepper (neu, §1) | **nein** ohne Passwort-Reset |
|
||||
| pgBackRest-Repo-Key | Cluster-Backup-Verschlüsselung | ja (mit Repo-Rekey) |
|
||||
| `age`-Keypair | Per-Tenant-/DSGVO-Artefakte | ja (neue Artefakte) |
|
||||
Regeln: **je Umgebung getrennt** (test/dev/prod); **niemals** im selben Bucket wie die Artefakte; Offline-Kopie versiegelt.
|
||||
|
||||
## 6. Erledigt (nur Verweis)
|
||||
- **Argon2id-Parameter** fixiert/dokumentiert (`src/server/password.ts`, `ARGON2_OPTIONS`) — gemerged.
|
||||
- **TOTP-Secrets** AES-256-GCM at-rest (`src/server/secret-crypto.ts`, SEC3-d) — bestand bereits.
|
||||
|
||||
## 7. Phasen & offene Entscheidungen
|
||||
**Phasen:** (1) Pepper (App, jetzt — leere DBs). (2) Host-Encryption + verschlüsselte Backups (Ops, mit Backup-Lane). (3) Secrets-Register formalisieren. (4) DB-TLS + Vault/KMS = Phase 2.
|
||||
**Offen (PM/ISB/DSB):** Pepper-Env-Name bestätigen + „nicht rotierbar" akzeptieren · Vault/KMS-Zeitpunkt · optional Spalten-Verschlüsselung (pgcrypto) für besonders sensible PII.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Umsetzungskonzept — Zentrale Identität mit Mandanten-Mitgliedschaften (Option C)
|
||||
|
||||
> Status: **Entscheidungsvorlage** (ohne Code). Ziel: eine Person meldet sich mit **einem** Login/Passwort und **einer** MFA an und wählt anschließend, in **welchem Mandanten** sie arbeitet — mit je Mandant unterschiedlichen Rollen. Die mandantengetrennte Datenhaltung (RLS, Ownership) bleibt **unangetastet**.
|
||||
|
||||
## 1. Grundmodell in einem Satz
|
||||
Eine **globale `Identity`** (E-Mail + ein Passwort + eine MFA) verweist auf **N per-Mandant-`User`-Zeilen** („Mitgliedschaften", jede mit eigenen Rollen). Zentralisiert werden nur **Anmeldung + MFA + Mandantenwechsel**; alles Fachliche bleibt pro Mandant.
|
||||
|
||||
## 2. Getroffene Entscheidungen
|
||||
| # | Entscheidung | Festlegung |
|
||||
|---|---|---|
|
||||
| A | MFA-Zeitpunkt | **Beim Login**, als getrennter zweiter Schritt (Two-Step, „identifier-first") |
|
||||
| B | Reset-Hoheit | Mandanten-Admin verliert MFA-/Passwort-Reset; Reset via **Self-Service (Recovery-Codes) + Plattform-Ebene**. Mandanten-Admin entzieht nur die **Mitgliedschaft**. |
|
||||
| C | Nutzeranlage | **Einladung/Bestätigung** als Standard (Kunden-Dashboard zwingend); Plattform-Admin darf zusätzlich direkt verknüpfen |
|
||||
| D | Migration Altbestand | **Entfällt** — bisher nur Testdaten. Neuanlage/Seed statt Zusammenführung. |
|
||||
| E | Plattform-Admins | **Getrennter Store** (`platform_admins`) bleibt — eigene Sicherheitsdomäne |
|
||||
| + | Login-Fluss | MFA-Abfrage **erst nach** Benutzername + Passwort (siehe §5) |
|
||||
| + | Passwort-Policy | **Eine globale Baseline**, passphrasen-freundlich (NIST 800-63B), mind. so streng wie der strengste Mandant |
|
||||
|
||||
**Phase 2 (bewusst zurückgestellt):** per-Mandant-Schalter „bei jedem Betreten Step-up erzwingen"; E-Mail-Änderung als Identity-Operation.
|
||||
|
||||
## 3. Datenmodell-Skizze (konzeptuell)
|
||||
**Neu: `Identity` (global)** — die Anmelde-Identität einer Person.
|
||||
- `email` (global eindeutig, Verknüpfungsschlüssel) · `passwordHash` · MFA (`mfaSecret`, `mfaEnrolledAt`, `recoveryCodes`) · `status` · `sessionsValidAfter` (globaler Kill-Switch) · Lockout-Felder.
|
||||
|
||||
**`User` (bestehend, bleibt pro Mandant) = „Mitgliedschaft"** — bekommt nur ein neues Feld `identityId → Identity`.
|
||||
- Behält: `tenantId`, `name`, `status`, **Rollen** (`user_roles`), **alle Ownership-FKs** (Asset-/Risk-/Prozess-Eigentümer …).
|
||||
- Gibt ab (wandert auf `Identity`): `passwordHash`, MFA-Felder, Recovery-Codes → künftig **nicht mehr** für Auth genutzt.
|
||||
|
||||
**Verknüpfung:** `Identity 1 —— N User(=Membership)`. Eine Person mit drei Mandanten = **eine** Identity + **drei** User-Zeilen.
|
||||
|
||||
**Unverändert:** `Role`/`UserRole`/`Permission` (pro Mandant), `Tenant`, `TenantSettings` (inkl. MFA-Pflicht-Flag), `platform_admins` (separat).
|
||||
|
||||
## 4. Was liegt wo?
|
||||
| Aspekt | Identity (global) | Membership = User (pro Mandant) |
|
||||
|---|---|---|
|
||||
| Passwort / Passphrase | ✅ (eins) | — |
|
||||
| MFA / Recovery-Codes | ✅ (eine Einrichtung) | — |
|
||||
| Rollen & Rechte | — | ✅ (je Mandant frei verschieden) |
|
||||
| Ownership (Assets, Risiken …) | — | ✅ |
|
||||
| Sperre der Person (global) | ✅ `status`/`sessionsValidAfter` | — |
|
||||
| Entzug des Zugangs zu **einem** Mandanten | — | ✅ Mitgliedschaft deaktivieren/löschen |
|
||||
|
||||
## 5. Login-Fluss (Two-Step, MFA beim Login)
|
||||
1. **Schritt 1 — E-Mail + Passwort** → Credential der `Identity` prüfen (Lockout + konstante Laufzeit gegen Enumeration wie heute).
|
||||
2. **Zwischenzustand:** kurzlebiger, einzweckiger **„MFA-pending"-Token** serverseitig — erlaubt **ausschließlich** den MFA-Abschluss, **keine** App-Session. (Verhindert „halb angemeldet"-Bypass.)
|
||||
3. **Schritt 2 — MFA** (eigene Seite), angezeigt wenn: Identity hat MFA **oder** ≥ 1 Mitgliedschaft in einem MFA-Pflicht-Mandanten.
|
||||
- MFA vorhanden → TOTP/Passkey verifizieren.
|
||||
- MFA nötig, aber nicht eingerichtet → **jetzt** einrichten (Enroll-Gate).
|
||||
- Keine MFA nötig → überspringen.
|
||||
4. **Volle Session** ausstellen (Identity authentifiziert + MFA erfüllt).
|
||||
5. **Mandanten-Auswahl:** aktive Mitgliedschaften in aktiven Mandanten. Auswahl → Server setzt `tenantId` + Rollen. **Nur eine** Mitgliedschaft → Auswahl überspringen.
|
||||
- **Sicherheitsnetz:** verlangt der gewählte Mandant MFA und ist sie (noch) nicht erfüllt → MFA vor Betreten nachziehen.
|
||||
|
||||
## 6. Mandantenwechsel (ohne erneuten Login) — Sicherheitskontrollen
|
||||
Zulässig und Standard, **aber nur mit diesen drei Pflicht-Kontrollen:**
|
||||
1. **Server-autoritativer Kontext:** aktiver `tenantId` wird bei **jedem** Wechsel aus einer **frisch validierten Mitgliedschaft** neu abgeleitet — nie aus Client-Input. RLS `app.tenant_id` pro Transaktion daraus.
|
||||
2. **Re-Validierung bei jedem Wechsel/Request:** Mitgliedschaft + Mandant + Nutzerstatus + `sessionsValidAfter` prüfen → Entzug/Sperre wirkt sofort.
|
||||
3. **MFA-Netz beim Betreten** eines Pflicht-Mandanten (Backstop zu §5.5).
|
||||
- Zusätzlich: **Wechsel wird auditiert** („Identity X → Mandant Y"), Folgeaktionen werden der aktiven Mitgliedschaft/Mandant zugeordnet.
|
||||
- Restrisiko bewusst: breiterer Blast-Radius eines kompromittierten Credentials → beherrscht über starke Identität + MFA + kurze Session-Laufzeit + globalen Kill-Switch.
|
||||
|
||||
## 7. MFA-Matrix (Person × Mandant)
|
||||
| Identity hat MFA? | Zielmandant verlangt MFA? | Ergebnis beim Login/Betreten |
|
||||
|---|---|---|
|
||||
| ja | ja | Faktor verifizieren |
|
||||
| ja | nein | kein Zwang (Faktor liegt vor, wird nicht abgefragt) |
|
||||
| nein | ja | **Einrichtung erzwingen** |
|
||||
| nein | nein | keine MFA |
|
||||
|
||||
> Grenze: Ein Mandant kann **kein** eigenes, isoliertes MFA-Gerät erzwingen — es gibt **einen** Faktor pro Person. (Preis der zentralen Identität.)
|
||||
|
||||
## 8. Nutzeranlage
|
||||
**Über das Plattform-(Admin-)Dashboard:**
|
||||
- E-Mail unbekannt → neue `Identity` (Initialpasswort/Einladung, `mustChangePassword`) + Mitgliedschaft im Zielmandanten mit Rollen.
|
||||
- E-Mail bekannt → **kein** neues Passwort/MFA, **nur** Mitgliedschaft ergänzen. Person sieht den Mandanten künftig in der Auswahl. (Betreiber darf direkt verknüpfen.)
|
||||
|
||||
**Über das Kunden-(Mandanten-)Dashboard — immer per Einladung:**
|
||||
- Mandanten-Admin sieht **neutral** „Einladung an *E-Mail* gesendet" — unabhängig davon, ob die Identity schon existierte (**kein** Cross-Tenant-Leak / keine Konten-Enumeration).
|
||||
- Identity existiert → Person **bestätigt** den Beitritt in ihrem bestehenden Konto (Einwilligung durch den Menschen, nicht durch den fremden Admin).
|
||||
- Identity existiert nicht → normaler „Konto einrichten + beitreten"-Flow.
|
||||
- Rollen werden bei der Einladung je Mandant vergeben (frei verschieden pro Mandant).
|
||||
|
||||
## 9. Passwort-/Passphrasen-Policy
|
||||
- **Eine globale Baseline** (weil ein Credential), mind. so streng wie der strengste Mandant.
|
||||
- Passphrasen voll unterstützt: lange Obergrenze (≥ 64 Zeichen), Leer-/Unicode-Zeichen erlaubt, **keine** erzwungene Zusammensetzung, **kein** Zwangswechsel, **Abgleich gegen Leak-Listen**. Hashing wie bisher Argon2id.
|
||||
|
||||
## 10. Auswirkungen auf Bestehendes
|
||||
- **Zwei Auth-Instanzen bleiben** (Mandant vs. Plattform). Die Mandanten-Instanz authentifiziert künftig gegen **`Identity`** statt `User`; die Session trägt zusätzlich die **Mitgliedschaftsliste** und den **aktiven** `tenantId`.
|
||||
- **RLS/Ownership/Audit unverändert** — hängen weiter an der per-Mandant-`User`-Zeile.
|
||||
- **Login-UI** wird zweistufig (Passwort-Seite → MFA-Seite → Mandanten-Auswahl); die heutige „alles in einem Formular"-Maske entfällt.
|
||||
- **MFA-/Passwort-Reset** in der Mandanten-Nutzerverwaltung entfällt (→ Self-Service/Plattform); dort bleibt „Mitgliedschaft deaktivieren/Rollen ändern".
|
||||
- **Bootstrap/Seed** legt künftig `Identity` + Mitgliedschaft(en) an.
|
||||
|
||||
## 11. Offene Detailpunkte fürs Implementierungs-Feindesign (kein Blocker)
|
||||
- Genaue Lebensdauer/Signierung des „MFA-pending"-Zustands.
|
||||
- Darstellung & Default der Mandanten-Auswahl (zuletzt genutzt merken?).
|
||||
- Wortlaut der neutralen Einladungs-Rückmeldung + Ablauf/Frist der Einladung.
|
||||
- Recovery-Code-Fluss als alleiniger Selbst-Reset-Weg (Anzahl, Nachgenerierung).
|
||||
@@ -0,0 +1,50 @@
|
||||
# Konzept — Betreiber-Konsole-UX + vollständige i18n (parallele Lane)
|
||||
|
||||
> Status: **Backlog/Feindesign-Skizze** (kein Code). Produkt: **certvia** (ISMS-Tool). Unabhängig von Identity/Backup/Härtung → **parallel** abarbeitbar. Enthält die zwei gemeldeten „Bugfixes" + zwei kleine Ergänzungen.
|
||||
|
||||
## Bugfix 1 — Betreiber-Konsole: Module & Benutzer als Popup, Stammdaten sichtbar
|
||||
**Betrifft:** `src/app/(platform)/admin/[id]/page.tsx` (Mandanten-Detailseite im Betreiber-Portal).
|
||||
|
||||
**Ist:** Module-Liste und Benutzerverwaltung liegen **inline direkt auf dem Kundenprofil**; die eigentlichen Stammdaten des Kunden sind dort **nicht** prominent.
|
||||
|
||||
**Soll:**
|
||||
- **Module → Popup:** nicht mehr inline, sondern per Button „Module verwalten" → Popup (Muster wie gehabt über searchParam, z. B. `?modules=1`, `<Modal>`). Toggle/Import bleiben im Popup.
|
||||
- **Benutzer → Popup:** die Benutzer-Tabelle/-Verwaltung nicht mehr im Hauptfenster, sondern Button „Benutzer verwalten" → Popup (`?users=1`). Anlage/Bearbeiten laufen wie bisher als verschachtelte Modals (`?new`/`?edit`).
|
||||
- **Hauptfenster stattdessen:** **Stammdaten** (aus `TenantSettings`: Unternehmensname/Kurzname, Adresse, Sektor, D-U-N-S, TISAX-Level, Status) **+ Hauptkontakt** prominent sichtbar. „Hauptkontakt" = neues/abgeleitetes Feld (z. B. designierter Mandanten-Admin oder ein Stammdaten-Kontaktfeld) — **offene Kleinentscheidung:** eigenes Feld in `TenantSettings` (`mainContactName/Email/Phone`) vs. Ableitung aus dem `tenant-admin`.
|
||||
|
||||
**Muster ist vorhanden:** die Seite nutzt bereits `<Modal>` + searchParams (`?new`/`?edit`/`?audit`) → Module/Benutzer analog kapseln, keine neue Infrastruktur.
|
||||
|
||||
**Dateien:** `src/app/(platform)/admin/[id]/page.tsx`, ggf. `src/components/*` (Auslagerung Module-/Benutzer-Block), optional Migration für `mainContact*` in `TenantSettings`.
|
||||
|
||||
## Bugfix 2 — Vollständige UI-Sprache (Menü + Beschreibungen) + per-Mitarbeiter-Umschaltung
|
||||
**Wichtiger Befund:** Der **englische UI-Katalog existiert bereits** (`messages/en.json`, ~95 % vs. `messages/de.json`). Bisher ist **nur der Richtlinien-Inhalt** EN (über `TenantSettings.locale`, das die **Import-Sprache der Vorlagen** steuert). Das **Menü/die Beschreibungen** sind EN im Katalog vorhanden, werden aber **nie angezeigt**, weil:
|
||||
```
|
||||
src/i18n/request.ts:8 → const locale = "de"; // hart verdrahtet
|
||||
// Kommentar: "MVP is fixed to de; per-user locale (en) is prepared — switch here later"
|
||||
```
|
||||
|
||||
**Soll:** die UI-Sprache **per Mitarbeiter individuell** umschaltbar.
|
||||
- **Speicherort (Option-C-konform):** persönliche Präferenz gehört an die **`Identity`** (folgt der Person über alle Mandanten) — neues Feld `Identity.uiLocale` (`de`|`en`, Default `de`). *(Alternative: pro Mitgliedschaft — verworfen, weil Menüsprache eine Personen-, keine Mandantenpräferenz ist.)*
|
||||
- **Aktivierung:** `src/i18n/request.ts` liest statt der Konstante die `uiLocale` der aktiven Session-Identity (Fallback `de`).
|
||||
- **Umschalter:** im **Nutzer-Menü** (Header/Profil) ein Sprachauswahl-Control → Server-Action `setUiLocale` → `Identity.uiLocale` speichern, Seite neu laden.
|
||||
- **Restsprache:** `messages/en.json` auf 100 % prüfen/auffüllen (Lücken, neue Strings aus TISAX/Identity-Umbau). Sicherstellen, dass **alle** sichtbaren Strings über den Katalog laufen (keine hartkodierten deutschen Texte in Komponenten).
|
||||
|
||||
**Abgrenzung:** `TenantSettings.locale` bleibt für die **Vorlagen-Import-Sprache** (Inhalt); **neu** ist die **UI-Sprache je Person** (`Identity.uiLocale`) — zwei getrennte Achsen.
|
||||
|
||||
**Dateien:** `prisma/schema.prisma` (`Identity.uiLocale` + Migration), `src/i18n/request.ts`, Nutzer-Menü-Komponente (Header/Profil) + `setUiLocale`-Action, `messages/en.json` (Lückenschluss), Audit der hartkodierten Strings.
|
||||
|
||||
## Ergänzung A — Login-Maske: Organisationsfeld entfällt
|
||||
**Ist:** `src/app/login/page.tsx` zeigt weiterhin ein optionales Feld **„Organisation"** (`tenant`, als Hinweis behalten).
|
||||
**Soll:** Feld **entfernen** — nach Option C ist es überflüssig (Login gegen globale Identity; Mandant wird **nach** dem Login über `/select-tenant` gewählt). Die `tenant`-Durchreichung in `login`-Action / `signMfaPending` / `signLoginTicket` kann entfallen bzw. leer bleiben.
|
||||
**Dateien:** `src/app/login/page.tsx` (Feld raus), ggf. `src/server/login-ticket.ts`-Signaturen entschlacken.
|
||||
|
||||
## Ergänzung B — Konsistenz-Check „Stammdaten"
|
||||
Beim Sichtbarmachen der Stammdaten (Bugfix 1) prüfen, dass die Quelle **eine** bleibt (`TenantSettings` speist die ISMS-Variablen) — keine Doppelpflege einführen.
|
||||
|
||||
## Reihenfolge / Parallelität
|
||||
- **Bugfix 1** (Betreiber-Konsole) und **Bugfix 2** (i18n) sind unabhängig → parallel.
|
||||
- **Ergänzung A** (Login) ist ein 10-Minuten-Fix, gern zusammen mit Bugfix 2 (beide Auth-/UI-nah).
|
||||
- Keine Abhängigkeit zu Backup/Härtung; Bezug zu Option C nur konzeptuell (`Identity.uiLocale`, `/select-tenant` existiert bereits).
|
||||
|
||||
## Gate (wie immer)
|
||||
tsc/lint/build + `scripts/test-*.ts` grün; UI-Änderungen im Browser verifizieren (Betreiber-Popups, Sprachumschaltung DE↔EN, Login ohne Organisationsfeld).
|
||||
@@ -0,0 +1,41 @@
|
||||
# Certvia-Archiv
|
||||
|
||||
Craftvia ist aus dem Fundament des ISMS-Produkts **Certvia** hervorgegangen (Auth.js mit
|
||||
Identity/Mitgliedschaften, RLS, Mail-/Backup-Worker, Garage, Härtung). Dieser Ordner hält
|
||||
die dabei übernommenen Dokumente **unverändert** vor. Sie sind **nicht maßgeblich** für Craftvia.
|
||||
|
||||
## Warum archiviert
|
||||
|
||||
- Produkt-, Domain- und Personenbezug auf Certvia/ISMS (`app.certvia.de`, Gitea-/Coolify-Hosts,
|
||||
Rollen ISB/DSB, Vorfall-Mail-Eingang, Risiko-Backfill), veraltete Namen (`isms_app`,
|
||||
`isms-documents`, MinIO).
|
||||
- Konzepte und Umsetzungs-Prompts sind umgesetzt. Der Ist-Stand steht im Code und in der
|
||||
Craftvia-Doku.
|
||||
- Der betriebsrelevante Inhalt (Coolify-Deploy, Prebuilt-Images, RLS-Aktivierung, Secrets,
|
||||
Backup/Restore, Garage) ist in **[docs/craftvia/DEPLOY.md](../craftvia/DEPLOY.md)**
|
||||
zusammengeführt und auf Craftvia umgeschrieben.
|
||||
|
||||
Maßgeblich sind [AGENTS.md](../../AGENTS.md), [docs/craftvia/SPEC-CRAFTVIA.md](../craftvia/SPEC-CRAFTVIA.md),
|
||||
[docs/craftvia/ARCHITEKTUR.md](../craftvia/ARCHITEKTUR.md) und [docs/craftvia/DEPLOY.md](../craftvia/DEPLOY.md).
|
||||
|
||||
## Inhalt
|
||||
|
||||
| Datei | Thema | Noch als Hintergrund nützlich für |
|
||||
|---|---|---|
|
||||
| `DEPLOY-COOLIFY.md` | Testserver via Coolify (Certvia) | – (ersetzt durch DEPLOY.md) |
|
||||
| `DEPLOY-PROD-CONTABO.md` | Prod-VPS, LUKS, pgBackRest/age/restic, PITR, Vorfall-Mail-Eingang | Host-Encryption- und PITR-Details |
|
||||
| `DEPLOY-PROD-PREBUILT.md` | Prebuilt-Images über die Registry | – (ersetzt durch DEPLOY.md) |
|
||||
| `HANDOVER-DEVOPS.md` | frühe DevOps-Übergabe (Stand Juli 2026) | – |
|
||||
| `DEVOPS-INTEGRATION-RUNBOOK.md` | Branch-Integration im Certvia-Team | – |
|
||||
| `SECRETS-REGISTER.md` | Secrets-Register (Certvia) | Rotationsregeln (in DEPLOY.md übernommen) |
|
||||
| `KONZEPT-backup-restore.md` | Backup-/Restore-/DSGVO-Engine | Designbegründung von `src/server/backup/**` |
|
||||
| `KONZEPT-backup-target.md` | konfigurierbarer Backup-Zielspeicher | Designbegründung `/admin/backup` |
|
||||
| `KONZEPT-garage-migration.md` | MinIO → Garage | Designbegründung Garage/`garage-provision` |
|
||||
| `KONZEPT-haertung.md` | Pepper, Host-Encryption, Secrets | Designbegründung `PASSWORD_PEPPER` |
|
||||
| `KONZEPT-identity-mandanten.md`, `FEINDESIGN-identity-mandanten.md`, `UEBERGABE-identity-mandanten.md` | zentrale Identity + Mandanten-Mitgliedschaften | Designbegründung Two-Step-Login/Mandantenwechsel |
|
||||
| `KONZEPT-ui-i18n.md` | Betreiber-Konsole-UX, i18n | – |
|
||||
| `SEC1-MAIL.md`, `SEC2-AUTH-SELFSERVICE.md` | Mail-Fundament, Passwort-Self-Service | Hintergrund zu `src/server/mail/**`, `scripts/test-mail.ts`, `scripts/test-auth-selfservice.ts` |
|
||||
| `sicherheit/` | PO-Konzept und Claude-Code-Prompts SEC1–SEC6 (Certvia) | – |
|
||||
|
||||
Die Querverweise **innerhalb** dieser Dokumente (`docs/…`) zeigen noch auf die alten Pfade.
|
||||
Sie werden bewusst nicht nachgezogen.
|
||||
@@ -0,0 +1,177 @@
|
||||
# SEC1 — SMTP-Mail-Fundament
|
||||
|
||||
> Branch `dev-sec1-mail-smtp` (Basis `dev`) · Grundlage: `docs/sicherheit/SEC1-Mail-Fundament-Detail.md`
|
||||
> Fundament für Passwort-Reset (SEC2), Einladung (SEC4), MFA-Hinweise (SEC3) und
|
||||
> Ticket-/Freigabe-/Fristen-Benachrichtigungen.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architektur
|
||||
|
||||
```
|
||||
Aufrufer (Server-Action / Event)
|
||||
│ enqueueMail({template,to,vars,tenantId,locale,dedupeKey})
|
||||
▼
|
||||
service.ts ──► MailLog(pending) Worker-Prozess (npm run worker:mail)
|
||||
│ (dedupeKey = Sperre) ┌──────────────────────────────────┐
|
||||
├─ Queue erreichbar? ──ja──► Redis ──►│ BullMQ "mail" │
|
||||
│ │ → renderTemplate(locale) │
|
||||
└─ nein ─────────────────────────────►│ → provider.send() (deliver.ts) │
|
||||
(inline, gleicher deliver-Pfad) │ → MailLog(sent|failed) │
|
||||
│ → Retry/Backoff → mail-dead-letter
|
||||
└──────────────────────────────────┘
|
||||
Provider-Interface ← SMTP (nodemailer, Pool + TLS)
|
||||
```
|
||||
|
||||
Vier Schichten, wie im Aufgabenpaket vorgesehen: **Provider** (`provider.ts`,
|
||||
`provider-smtp.ts`), **Templates** (`templates.ts` + `src/lib/email-brand.ts`),
|
||||
**Queue/Worker** (`queue.ts`, `worker.ts`), **Service/Trigger** (`service.ts`,
|
||||
`notifications.ts`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Betriebsmodus (Entscheidung)
|
||||
|
||||
Das Aufgabenpaket verlangt eine Festlegung — hier ist sie:
|
||||
|
||||
| `REDIS_URL` | Modus | Verhalten |
|
||||
|---|---|---|
|
||||
| gesetzt **und** erreichbar | **Queue** (Produktivmodus) | Asynchron über BullMQ; ein **eigener Worker-Prozess** (`npm run worker:mail`, eigener Container in Coolify) stellt zu. Retry mit exponentiellem Backoff (5 Versuche), Dead-Letter-Queue, Limiter (20 Mails/10 s, Concurrency 5), Graceful Shutdown. |
|
||||
| gesetzt, aber **nicht erreichbar** | Inline (degradiert) | Der Versand fällt auf den direkten Pfad zurück, damit bei einem Redis-Ausfall keine Mail verloren geht — **ohne** Retry/DLQ. Wird im Log vermerkt. |
|
||||
| nicht gesetzt | Inline | Lokale Entwicklung und Demo laufen ohne Redis. |
|
||||
|
||||
Die Erreichbarkeit wird **vor** dem Einstellen geprüft (`isQueueReady()`). Ein
|
||||
Fallback *nach* einem fehlgeschlagenen `add()` wäre riskant: der Job könnte
|
||||
angekommen sein und nur die Bestätigung verloren gegangen — das ergäbe einen
|
||||
Doppelversand. Deshalb wird ein Fehler beim Einstellen nur protokolliert.
|
||||
|
||||
Der Worker registriert zusätzlich den **Fristen-Job** (täglich 07:00
|
||||
Europe/Berlin) auf einer **eigenen** Queue `mail-scheduler`. Grund: ein
|
||||
BullMQ-Worker konsumiert alle Jobs *seiner* Queue unabhängig vom Job-Namen —
|
||||
auf einer gemeinsamen Queue könnte der Zustell-Worker den Fristen-Job abgreifen.
|
||||
|
||||
---
|
||||
|
||||
## 3. Konfiguration
|
||||
|
||||
Führend sind die bereits im Repo etablierten Namen; die im Aufgabenpaket
|
||||
genannten Aliasse werden zusätzlich akzeptiert.
|
||||
|
||||
| Variable | Alias | Zweck |
|
||||
|---|---|---|
|
||||
| `SMTP_HOST`, `SMTP_PORT` | — | Relay |
|
||||
| `SMTP_SECURE` | — | `true` = implizites TLS (465), sonst STARTTLS. Ohne Angabe aus dem Port abgeleitet. |
|
||||
| `SMTP_USER`, `SMTP_PASSWORD` | `SMTP_PASS` | Authentifizierung (optional) |
|
||||
| `SMTP_FROM` | `MAIL_FROM` | Absenderadresse |
|
||||
| `MAIL_FROM_NAME` | — | Anzeigename (Default `Certvia`) |
|
||||
| `MAIL_REPLY_TO` | — | optionale Antwortadresse |
|
||||
| `APP_BASE_URL` | `AUTH_URL` | Basis für absolute Links in Mails |
|
||||
|
||||
**Fehlt die Konfiguration, wird nicht still versendet:** `getMailConfig()`
|
||||
liefert `null` samt Begründung, das MailLog bleibt `pending` mit Fehlertext, und
|
||||
die Admin-Konsole zeigt „Nicht konfiguriert". TLS wird erzwungen — gelockert nur
|
||||
gegen `localhost`/`mailpit`/`mailhog`, weil der lokale Test-SMTP kein gültiges
|
||||
Zertifikat hat.
|
||||
|
||||
---
|
||||
|
||||
## 4. Datenmodelle
|
||||
|
||||
- **`MailLog`** — Versandprotokoll: Empfänger, Template, Locale, Status
|
||||
(`pending|sent|failed|bounced|suppressed`), `providerMessageId`, Fehlertext,
|
||||
Versuchszähler, `dedupeKey` (unique). `tenantId` nullable für Plattform-Mails
|
||||
(`scope=platform`, analog `AuditLog`).
|
||||
**Bewusst nicht gespeichert:** Mail-Inhalt und jegliche Tokens.
|
||||
- **`NotificationPreference`** — je Nutzer und Ereignistyp. **Default opt-in:**
|
||||
fehlt die Zeile, wird versendet; erst ein ausdrückliches `email = false`
|
||||
unterdrückt. Die Pflege-UI folgt später — Modell und Auflösung stehen.
|
||||
|
||||
Beide in `TENANT_MODELS` und unter RLS (ENABLE + FORCE + `USING`/`WITH CHECK`,
|
||||
Migration `20260730180000_mail_fundament`, inkl. `GRANT` für `isms_app`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Templates
|
||||
|
||||
Acht Templates, jeweils **de und en**, **HTML und Text**:
|
||||
`invitation`, `password_reset`, `password_changed`, `email_change_verify`,
|
||||
`email_changed_notice`, `mfa_changed`, `notification`, `test`.
|
||||
|
||||
Layout, Farben und die Fußzeile „Certvia — ein Produkt von GEFIM" kommen aus
|
||||
`src/lib/email-brand.ts` (Inline-Styles, Tabellenlayout für Outlook, absolute
|
||||
Asset-URLs).
|
||||
|
||||
**i18n-Abweichung mit Begründung:** die Sprachen liegen in einem eigenen
|
||||
Katalog (`src/server/mail/templates.ts`), nicht in `messages/*.json`. Die Mails
|
||||
werden im **Worker** gerendert — außerhalb eines Requests; die
|
||||
next-intl-Server-APIs (`getTranslations`) setzen einen Request-Scope voraus und
|
||||
stehen dort nicht zur Verfügung.
|
||||
|
||||
**Tokens:** Templates bekommen fertige `actionUrl`s. Erzeugt werden die Tokens
|
||||
in SEC2/SEC3/SEC4 — sie erscheinen weder im MailLog noch im Server-Log.
|
||||
|
||||
---
|
||||
|
||||
## 6. Benachrichtigungen
|
||||
|
||||
| Ereignis | Auslöser | Empfänger |
|
||||
|---|---|---|
|
||||
| `task_assigned` | `createTask`, `updateTask` (nur bei **Wechsel** des Zuständigen) | neuer Zuständiger |
|
||||
| `task_approval_requested` | `submitForApproval` (Richtlinien) | ausgewählter Freigeber |
|
||||
| `task_decided` | `approveTask` / `rejectTask` | Einreicher |
|
||||
| `task_due` | Fristen-Job (täglich) | Zuständiger |
|
||||
|
||||
Regeln: strikt mandantenisoliert, Sprache je Empfänger (Präferenz → Mandanten-
|
||||
Locale → `de`), **kein Selbstversand** über die eigene Handlung, Idempotenz über
|
||||
`dedupeKey` (der Fristen-Job hängt das Datum an → höchstens eine Erinnerung je
|
||||
Aufgabe und Tag). Fehler beim Versand werden geloggt, kippen aber nie die
|
||||
auslösende Fachaktion.
|
||||
|
||||
Benachrichtigungen tragen einen Präferenzhinweis in der Fußzeile;
|
||||
sicherheitsrelevante Transaktionsmails (Reset, Passwortwechsel) bewusst **nicht**
|
||||
— sie sind nicht abbestellbar.
|
||||
|
||||
---
|
||||
|
||||
## 7. Admin-Testversand
|
||||
|
||||
`/admin` zeigt Konfigurationszustand, Modus (Queue/inline) und die letzten zehn
|
||||
MailLog-Zeilen; der Button „Test-Mail senden" schickt an die **eigene** Adresse
|
||||
des angemeldeten Plattform-Admins (kein Empfängerfeld — die Funktion ist eine
|
||||
Zustellprüfung, kein Relay). Autorisierung über `requirePlatformSession()`;
|
||||
`mail.ts` ist in `scripts/check-module-guards.ts` als `EXEMPT` registriert.
|
||||
|
||||
---
|
||||
|
||||
## 8. Verifikation
|
||||
|
||||
```bash
|
||||
docker compose up -d mailhog # SMTP :1025, Weboberfläche :8025
|
||||
npx tsx scripts/test-mail.ts # Abnahmetest
|
||||
```
|
||||
|
||||
Der Test prüft: Rendering aller Templates in de/en (HTML + Text, Branding +
|
||||
Dachmarken-Fußzeile), Präferenzhinweis nur bei Benachrichtigungen, echten
|
||||
Versand mit `MailLog=sent` + `providerMessageId`, Idempotenz über `dedupeKey`
|
||||
und das Verhalten bei fehlender SMTP-Konfiguration.
|
||||
|
||||
Nachgewiesen am 30.07.2026 gegen Mailhog: alle Prüfungen grün, Nachricht kommt
|
||||
als `multipart/alternative` (HTML + Text) mit Absender `Certvia <…>`,
|
||||
`Auto-Submitted: auto-generated` und korrekter Fußzeile an.
|
||||
|
||||
Zusätzlich: `npx tsc --noEmit` → `npm run lint` → `npm run build` (Guard-Check).
|
||||
|
||||
---
|
||||
|
||||
## 9. Offene Punkte / Vorbedingungen
|
||||
|
||||
- **DNS:** SPF, DKIM und DMARC für die Absenderdomain müssen **vor** dem ersten
|
||||
Produktivversand aktiv sein (Ops, kein Code).
|
||||
- **Bounce-Verarbeitung:** `MailLog.status` kennt `bounced`, es gibt aber noch
|
||||
keinen Rückkanal (Webhook/IMAP). Erst mit einem Provider sinnvoll, der
|
||||
Bounces meldet.
|
||||
- **Präferenz-UI:** `NotificationPreference` ist modelliert und wird ausgewertet;
|
||||
die Pflegeoberfläche im Profil fehlt noch.
|
||||
- **Worker-Deployment:** `npm run worker:mail` muss in
|
||||
`docker-compose.coolify.yml` als eigener Service ergänzt werden (der
|
||||
vorhandene `worker`-Service im lokalen Compose zeigt noch auf keinen Befehl).
|
||||
@@ -0,0 +1,154 @@
|
||||
# SEC2 — Passwort-Self-Service, E-Mail-Änderung, Sessions
|
||||
|
||||
> Branch `dev-sec2-auth-selfservice` (Basis `dev-sec1-mail-smtp`) · Grundlage:
|
||||
> `docs/sicherheit/SEC2-Auth-SelfService-Detail.md` · **Abhängigkeit: SEC1**
|
||||
> (Templates `password_reset`, `password_changed`, `email_change_verify`).
|
||||
|
||||
Gilt durchgängig für **beide** Auth-Domänen: Mandanten-Nutzer (`/login`) und
|
||||
Plattform-Admins (`/platform/login`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Token (`AuthToken`)
|
||||
|
||||
| Eigenschaft | Umsetzung |
|
||||
|---|---|
|
||||
| Erzeugung | 32 Byte CSPRNG, base64url |
|
||||
| Speicherung | **nur** SHA-256-Hash (`token_hash`, unique) — das Rohtoken existiert ausschließlich im Link |
|
||||
| Vergleich | konstante Zeit (`timingSafeEqual`) |
|
||||
| Gültigkeit | 60 Minuten (Reset und E-Mail-Verify) |
|
||||
| Single-use | `usedAt` per bedingtem `updateMany` (`usedAt: null`) → zwei parallele Einlösungen können nicht beide gewinnen |
|
||||
| Neuanforderung | entwertet offene Tokens desselben Typs |
|
||||
| Domänen | `principalType` = `tenant_user` \| `platform_admin`; `tenantId` nur bei Mandanten-Nutzern (trägt die RLS) |
|
||||
|
||||
Der Lookup beim Einlösen läuft über den **rohen** `prisma`-Client: Der Nutzer ist
|
||||
zu diesem Zeitpunkt nicht angemeldet, es gibt also keinen Mandantenkontext. Die
|
||||
RLS-Policy bleibt als zweite Verteidigungslinie bestehen (Migration
|
||||
`20260730190000_auth_tokens_sessions`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Session-Invalidierung (`sessionsValidAfter`)
|
||||
|
||||
NextAuth v5 arbeitet hier mit **JWT (stateless)** — es gibt keine Session-Tabelle
|
||||
zum Leeren. Stattdessen trägt jedes Konto eine Versionsmarke:
|
||||
|
||||
- `sessionsValidAfter` wird gesetzt → jedes JWT mit älterem `iat` gilt als ungültig.
|
||||
- Geprüft dort, wo der Kontostatus **ohnehin** aus der DB gelesen wird — ohne
|
||||
zusätzliche Abfrage:
|
||||
`(app)/layout.tsx` (Seitenaufrufe), `moduleGuard` (Mutationen),
|
||||
`requirePlatformSession()` (Plattform).
|
||||
- Ohne `iat` wird **fail-closed** entschieden.
|
||||
|
||||
| Funktion | Wirkung |
|
||||
|---|---|
|
||||
| `invalidateSessions()` | alle Sitzungen — Passwort-Reset, E-Mail-Änderung, Deaktivierung |
|
||||
| `invalidateOtherSessions()` | Marke auf *eine Sekunde vor jetzt* → alle älteren Tokens fallen, die laufende Sitzung überlebt (Selbständerung) |
|
||||
|
||||
Der Sekundenversatz ist kein Zufall: `iat` hat Sekundenauflösung. Ohne ihn würde
|
||||
sich ein Nutzer bei der eigenen Passwortänderung selbst aussperren.
|
||||
|
||||
*Offen (P1, bewusst nicht in SEC2):* „aktive Sitzungen anzeigen und einzeln
|
||||
abmelden" — dafür bräuchte es Session-Records statt reiner JWTs.
|
||||
|
||||
---
|
||||
|
||||
## 3. Abläufe
|
||||
|
||||
### 3.1 Passwort vergessen (`/forgot-password`, `?domain=platform`)
|
||||
|
||||
- Antwort **immer identisch**, unabhängig davon, ob das Konto existiert.
|
||||
- Rate-Limit: 5 pro Stunde, je **IP** und je **Konto** getrennt gezählt.
|
||||
- Reset nur bei aktivem, nicht gesperrtem Konto. Deaktivierte Konten bekommen
|
||||
weder Mail noch abweichende Meldung.
|
||||
- Mandantenseite: existiert die Adresse in **mehreren** Mandanten, ist sie nicht
|
||||
eindeutig auflösbar → kein Reset (nach außen nicht unterscheidbar).
|
||||
|
||||
### 3.2 Einlösung (`/reset?token=…`)
|
||||
|
||||
- Die Seite **prüft** den Token nur; verbraucht wird er erst beim Absenden —
|
||||
sonst würde ein Link-Scanner im Mailserver den Link entwerten.
|
||||
- Ein **Policy-Verstoß entwertet den Link nicht**: geprüft wird vor dem
|
||||
Verbrauch, sonst wäre der Nutzer nach einem Tippfehler ausgesperrt.
|
||||
- Danach: Argon2id-Hash setzen, `mustChangePassword` löschen, Fehlversuchszähler
|
||||
und Sperre zurücksetzen, **alle Sessions invalidieren**, Bestätigungsmail,
|
||||
Audit (ohne Token).
|
||||
- **Kein Auto-Login** — der Nutzer meldet sich neu an.
|
||||
|
||||
### 3.3 Passwort selbst ändern (`/account`, `/platform/profile`)
|
||||
|
||||
Alt-Passwort verifizieren (generische Fehlermeldung, Rate-Limit), Policy prüfen,
|
||||
danach **andere** Sitzungen abmelden — die aktuelle bleibt. Bestätigungsmail + Audit.
|
||||
|
||||
### 3.4 E-Mail-Änderung (Double-Opt-in)
|
||||
|
||||
Alt-Passwort bestätigen → Verifizierungslink an die **neue** Adresse → Klick
|
||||
setzt die Adresse, entwertet alle Sessions (die Adresse ist die Login-Identität)
|
||||
und informiert die **alte** Adresse. Die Kollisionsprüfung läuft zweimal (bei
|
||||
Anforderung und bei Bestätigung), weil die Adresse zwischenzeitlich vergeben
|
||||
worden sein kann. Ob sie frei ist, wird nach außen nie gemeldet.
|
||||
|
||||
---
|
||||
|
||||
## 4. Rate-Limiting
|
||||
|
||||
`src/server/rate-limit.ts`, je Aktion getrennte Fenster, Schlüssel gehasht
|
||||
abgelegt (kein Klartext von Adressen oder IPs im Speicher):
|
||||
|
||||
| Aktion | Limit |
|
||||
|---|---|
|
||||
| Reset-Anfrage | 5 / Stunde |
|
||||
| Reset-Einlösung | 10 / 15 Min |
|
||||
| Alt-Passwort-Prüfung | 10 / 15 Min |
|
||||
| E-Mail-Änderung anfordern | 5 / Stunde |
|
||||
|
||||
**Bewusste Einschränkung:** Die Zähler liegen im Prozessspeicher, sind bei
|
||||
mehreren App-Instanzen also pro Instanz. Das ist vertretbar, weil der Limiter
|
||||
hier nur eine erste Bremse ist — die eigentlichen Garantien (single-use-Tokens,
|
||||
Enumeration-Neutralität, Konto-Lockout aus F-05) hängen nicht daran. Ein
|
||||
geteilter Redis-Zähler gehört zu **SEC5**.
|
||||
|
||||
---
|
||||
|
||||
## 5. Erreichbarkeit
|
||||
|
||||
`/forgot-password`, `/reset` und `/verify-email` sind in `src/proxy.ts` als
|
||||
öffentliche Pfade eingetragen — der Nutzer ist dort per Definition nicht
|
||||
angemeldet. Ihre Absicherung sind Rate-Limit, Enumeration-Neutralität und
|
||||
single-use-Tokens, nicht das Route-Gate.
|
||||
|
||||
---
|
||||
|
||||
## 6. Verifikation
|
||||
|
||||
```bash
|
||||
npx tsx scripts/test-auth-selfservice.ts # Token-, Limit- und Session-Eigenschaften
|
||||
npx tsx scripts/test-reset-flow.ts # End-to-End gegen ein Wegwerf-Konto
|
||||
```
|
||||
|
||||
Nachgewiesen am 30.07.2026, alle Prüfungen grün:
|
||||
|
||||
- Token liegt nur gehasht in der DB (SHA-256, 64 Hex), Rohtoken nie.
|
||||
- Single-use, Ablauf, Manipulation, Typ-Verwechslung und Neuanforderung greifen.
|
||||
- Rate-Limit blockt ab dem 6. Versuch, auch von wechselnder IP bei gleichem Konto.
|
||||
- Session-Marke: älteres JWT ungültig, neueres gültig, ohne `iat` fail-closed.
|
||||
- Kompletter Reset-Zyklus: Passwort gewechselt, altes ungültig, Sessions
|
||||
entwertet, Bestätigungsmail versendet, Audit ohne Token, zweite Einlösung
|
||||
abgewiesen, deaktiviertes Konto ohne Token.
|
||||
- Browser: `/reset` mit gültigem Link zeigt das Formular samt Mandanten-Policy,
|
||||
mit manipuliertem Link die generische Ablehnung.
|
||||
|
||||
Dazu `npx tsc --noEmit` → `npm run lint` → `npm run build` (Guard-Check).
|
||||
|
||||
---
|
||||
|
||||
## 7. Offene Punkte
|
||||
|
||||
- **Step-up-Re-Auth** für die E-Mail-Änderung und weitere kritische Aktionen →
|
||||
**SEC3** (aktuell reicht die Alt-Passwort-Bestätigung).
|
||||
- **Breach-Check** (HaveIBeenPwned) beim Setzen eines Passworts → **SEC5**; der
|
||||
Aufrufpunkt liegt in `redeemPasswordReset`/`changePasswordSelf` bereit.
|
||||
- **Geteiltes Rate-Limit** über Redis → SEC5.
|
||||
- **Aktive Sitzungen anzeigen/einzeln abmelden** (braucht Session-Records) → P1.
|
||||
- **Wartungsjob** für `purgeExpiredTokens()` ist implementiert, aber noch nicht
|
||||
eingeplant (passt in den SEC1-Scheduler).
|
||||
@@ -0,0 +1,78 @@
|
||||
# Secrets-Register — restore-/betriebs-kritische Schlüssel (certvia)
|
||||
|
||||
> Produkt: **certvia** (ISMS-Tool). Umsetzung der Härtung §5 (`docs/KONZEPT-haertung.md`)
|
||||
> und Backup-Konzept §9 (`docs/KONZEPT-backup-restore.md`). **Ops-/Governance-Dokument, kein Code.**
|
||||
> Strikt getrennt von jedem anderen Produkt (insb. „visitvia") — eigene Secrets, eigener Tresor-Bereich.
|
||||
|
||||
Dieses Register ist die **einzige verbindliche Übersicht**, welche Secrets es gibt, wer sie besitzt,
|
||||
wie (und ob) sie rotiert werden und wo sie liegen. **Führung: ISB.** Ablageort der Werte:
|
||||
**Org-Passwortmanager** (je Umgebung getrennter Ordner) **+ versiegelte Offline-Kopie**.
|
||||
|
||||
## Grundregeln (gelten für ALLE Einträge)
|
||||
|
||||
1. **Je Umgebung getrennt** — `test` / `dev` / `prod` haben **eigene, unterschiedliche** Werte.
|
||||
Niemals einen prod-Wert in test/dev wiederverwenden (oder umgekehrt).
|
||||
2. **Nie im Artefakt-Bucket** — Secrets liegen **niemals** im selben Bucket/Speicher wie die
|
||||
verschlüsselten Backups oder DSGVO-Exporte. **Verlust des Keys = Verlust der Wiederherstellbarkeit.**
|
||||
3. **Nie im Repo / nie im Backup-Artefakt** — kein Secret ins Git, keins ins Backup-Payload.
|
||||
Verteilung nur als Coolify-Env-Secret + Passwortmanager.
|
||||
4. **Versiegelte Offline-Kopie** — pro Umgebung eine versiegelte Offline-Kopie (z. B. verschlossener
|
||||
Umschlag / getrennter Offline-Tresor) für den Totalausfall des Passwortmanagers.
|
||||
5. **Rotation protokollieren** — jede Rotation mit Datum, Auslöser und ausführender Person im
|
||||
Passwortmanager-Eintrag vermerken.
|
||||
|
||||
## Register
|
||||
|
||||
| Secret | Zweck | Eigentümer (Owner) | Rotierbar? | Rotationsregel je Umgebung |
|
||||
|---|---|---|---|---|
|
||||
| `AUTH_SECRET` | Session-/JWT-Signatur (Auth.js) | ISB / Betrieb | **Ja** | Rotation invalidiert alle aktiven Sessions (Nutzer müssen neu einloggen). Bei Verdacht auf Kompromittierung sofort, sonst periodisch (z. B. jährlich). Je Umgebung eigener Wert. |
|
||||
| `MFA_ENC_KEY` | Verschlüsselung der TOTP-Secrets at-rest (AES-256-GCM) | ISB | **NEIN**¹ | **Nicht rotierbar ohne MFA-Neueinrichtung.** „Rotation" = Reset: alle Nutzer müssen MFA neu einrichten (alte TOTP-Secrets werden unlesbar). Nur bei Kompromittierung mit geplantem Reset. |
|
||||
| `PASSWORD_PEPPER` | Passwort-Pepper (Argon2-`secret`, Phase 1) | ISB | **NEIN**¹ | **Nicht rotierbar ohne Passwort-Reset für alle** (kein Rehash-on-Login). „Rotation" = Reset: erzwungener Passwort-Neusatz aller Konten. Bewusst **einmalig** gesetzt (leere DBs). |
|
||||
| `BACKUP_ENC_KEY` | Client-seitige Verschlüsselung des App-Backup-Exports (AES-256-GCM); Fallback `AUTH_SECRET` | ISB / Betrieb | **Bedingt**² | Neuer Key gilt nur für **neue** Artefakte; alte Backups bleiben nur mit dem **alten** Key entschlüsselbar → Altschlüssel bis Ablauf der Retention **aufbewahren**. Je Umgebung eigener Wert. |
|
||||
| pgBackRest-Repo-Key (`repo-cipher-pass`) | AES-256-Verschlüsselung des Cluster-Backup-Repos (Postgres WAL/Base) | Betrieb | **Ja**³ | Rotierbar mit Repo-Rekey; alte Repos/Artefakte brauchen den Altschlüssel → bis Retention-Ende aufbewahren. Je Umgebung eigenes Repo + eigener Key. |
|
||||
| `age`-Keypair | Verschlüsselung Per-Tenant-Export + DSGVO-Pakete (X25519) | ISB / DSB | **Ja**³ | Neuer Recipient gilt für **neue** Artefakte; Private Key der Alt-Keys bis Retention-Ende aufbewahren (sonst Alt-Exporte nicht entschlüsselbar). Je Umgebung eigenes Keypair. |
|
||||
| restic-Repo-Passwort | Verschlüsselung des MinIO-Objekt-Backups (restic, AES-256) | Betrieb | **Ja**³ | Rotierbar (`restic key add/remove`); Altschlüssel bis Retention-Ende gültig halten. Je Umgebung eigenes Repo + eigenes Passwort. |
|
||||
|
||||
¹ **Nicht-rotierbar markiert:** `PASSWORD_PEPPER` und `MFA_ENC_KEY` sind **keine** rotierbaren
|
||||
Secrets im üblichen Sinn — eine „Rotation" bedeutet einen **Reset/Neuverschlüsselung** mit
|
||||
Benutzer-Impact (Passwort- bzw. MFA-Neueinrichtung für alle). Deshalb **bewusst einmalig** setzen
|
||||
(solange DBs leer/frisch) und wie einen Wiederherstellungsschlüssel behandeln.
|
||||
|
||||
² `BACKUP_ENC_KEY` ist technisch tauschbar, aber jeder alte Backup-Stand bleibt an seinen
|
||||
Erzeugungs-Key gebunden → nicht „rotieren und alten Key wegwerfen".
|
||||
|
||||
³ Backup-Keys (pgBackRest / `age` / restic) sind rotierbar, aber **Altschlüssel müssen bis zum
|
||||
Ende der jeweiligen Retention aufbewahrt** werden, sonst werden ältere Backups unwiederherstellbar.
|
||||
|
||||
## ⚠ Restore-Kohärenz (verbindliche Vorbedingung)
|
||||
|
||||
**`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 möglich).
|
||||
- **anderes `MFA_ENC_KEY`** → TOTP-Secrets nicht entschlüsselbar → MFA-Prüfung bricht.
|
||||
- **anderes `BACKUP_ENC_KEY`** → das AES-256-GCM-Backup-Artefakt lässt sich **gar nicht** entschlüsseln.
|
||||
|
||||
**Vorbedingung vor jedem Restore** (auch im Restore-Runbook `docs/DEPLOY-PROD-CONTABO.md` verankert):
|
||||
Die Zielumgebung hält **exakt die Secrets, mit denen das Backup erzeugt wurde** — oder es wird
|
||||
ein **Passwort-/MFA-Reset bzw. eine Neuverschlüsselung eingeplant**. Ein Cross-Environment-Restore
|
||||
(z. B. prod → staging zum Debuggen) läuft nur mit den **prod-Secrets** oder mit anschließendem Reset.
|
||||
|
||||
## Ablage & Zugriff (Runbook-Kurzform)
|
||||
|
||||
- **Primär:** Org-Passwortmanager, je Umgebung getrennter Ordner, Zugriff rollenbasiert (ISB + Betrieb).
|
||||
- **Sekundär:** versiegelte Offline-Kopie je Umgebung (Notfall/DR).
|
||||
- **Verteilung an die App:** ausschließlich als Coolify-Env-Secret (Runtime), nie ins Repo/Image.
|
||||
- **Host-Encryption-Passphrase** (LUKS-Volume, s. `docs/DEPLOY-PROD-CONTABO.md`): ebenfalls hier
|
||||
führen — dieselben Grundregeln (getrennt je Umgebung, offline versiegelt, nie im Backup-Bucket).
|
||||
- **Bei Personalwechsel:** rotierbare Secrets (`AUTH_SECRET`, Backup-Keys) neu setzen; Zugriff im
|
||||
Passwortmanager entziehen. Nicht-rotierbare (`PASSWORD_PEPPER`/`MFA_ENC_KEY`) nur bei begründetem
|
||||
Kompromittierungsverdacht — dann mit geplantem Reset.
|
||||
|
||||
## Bezug / Weiterführendes
|
||||
|
||||
- `docs/KONZEPT-haertung.md` §5 (Register-Anforderung), §1–§3 (Pepper, Host-Encryption, Backups).
|
||||
- `docs/KONZEPT-backup-restore.md` §9 (gemeinsames Verschlüsselungs-Primitiv, Schlüsselverwaltung).
|
||||
- `docs/DEPLOY-PROD-CONTABO.md` (Host-Encryption + verschlüsselte Backups + Restore-Kohärenz, Ops-Runbook).
|
||||
- **Phase 2 (offen):** Upgrade-Pfad auf **HashiCorp Vault** / Provider-**KMS** (Rotation, Audit,
|
||||
Trennung) sowie **DB-Connection-TLS** (`sslmode`) — bewusst zurückgestellt, nicht Teil dieser Lane.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Übergabe — Umbau „Zentrale Identität + Mandanten-Mitgliedschaften" (certvia)
|
||||
|
||||
> Kurzübergabe für Team-Lead + PM + neue Entwickler. Produkt: **certvia** (ISMS-Tool). Branch: **`feature/identity-mandanten`**.
|
||||
|
||||
## Worum es geht
|
||||
Eine Person soll sich mit **einem** Login/Passwort/MFA anmelden und danach wählen, in **welchem Mandanten** sie arbeitet — mit je Mandant unterschiedlichen Rollen. Heute ist jeder Nutzer fest an genau einen Mandanten gebunden (eigenes Passwort/MFA je Mandant). Umbau nach **Option C**: globale `Identity` (Anmeldung) + bestehende per-Mandant-`User`-Zeilen als „Mitgliedschaften".
|
||||
|
||||
## Die zwei Dokumente
|
||||
1. [KONZEPT-identity-mandanten.md](KONZEPT-identity-mandanten.md) — **Entscheidungsvorlage** (fachlich, warum/was). Enthält die getroffenen Entscheidungen A–E, Two-Step-Login, Passphrasen, MFA-Matrix, die vier Anlage-Flows, Sicherheitsbetrachtung.
|
||||
2. [FEINDESIGN-identity-mandanten.md](FEINDESIGN-identity-mandanten.md) — **umsetzbarer Bauplan** (technisch): Zieldatenmodell, Session-Shape, Flows, 7 Workstreams, 6 Meilensteine, Risiken, DoD, Onboarding, Aufwand (≈ 47–73 PT / ≈ 5–7 Wochen).
|
||||
|
||||
## Getroffene Entscheidungen (fix)
|
||||
| A | B | C | D | E |
|
||||
|---|---|---|---|---|
|
||||
| MFA **beim Login** (2-stufig: erst Passwort, dann MFA) | Reset-Hoheit → Self-Service + Plattform (Mandanten-Admin verliert MFA-/Passwort-Reset) | **Einladung** als Standard-Anlage | **Migration entfällt** (nur Testdaten → Reseed) | Plattform-Admins **getrennt** (`platform_admins`) |
|
||||
|
||||
## Womit das Team startet (M0 → M1)
|
||||
1. **Onboarding:** Pflichtlektüre `docs/HANDOVER-DEV.md` → `docs/SPEC.md` → `docs/STAND-dev-branch.md` → FEINDESIGN → KONZEPT. Dev-Setup + Gate (§10 im FEINDESIGN).
|
||||
2. **WS0 Fundament als Pairing** (Lead + je 1 Dev): `Identity`-Modell, Schema-Recut, Migration, `TENANT_MODELS`, Reseed. Blocker für fast alles Weitere.
|
||||
3. Danach parallel: WS1 (Auth-Kern), WS3 (Einladung), WS4 (Passwort/MFA an Identity), WS6 (Seed).
|
||||
|
||||
## Die 5 „Goldenen Regeln" (nicht verhandelbar)
|
||||
1. `User.id` = Mitgliedschaft, **stabil lassen**; Auth-Felder leben auf `Identity`.
|
||||
2. `Identity` ist **global**, **nicht** in `TENANT_MODELS`, kein `tenant_id`, keine RLS-Policy.
|
||||
3. Genau **ein** aktiver Mandant pro Session; server-autoritativ; bei Wechsel re-validieren.
|
||||
4. **Keine** „Passwort direkt setzen"-Anlage mehr — nur Einladung.
|
||||
5. MFA/Passwort gehören der Identity — **kein** Mandanten-Admin-Reset.
|
||||
|
||||
## Warum der Umbau überschaubar bleibt
|
||||
Weil `User.id` **und** die Semantik von `session.user.tenantId` (= aktiver Mandant) erhalten bleiben, sind **~356 `dbForTenant()`-Stellen, alle 6 Owner-FKs und das RLS-Modell unberührt**. Der Umbau konzentriert sich auf Login, Session-Aufbau, Mandantenauswahl und Nutzer-Lifecycle.
|
||||
|
||||
## Koordination mit laufender Arbeit
|
||||
- **Richtlinien-Upload im Adminportal** (parallel in Arbeit) ist **funktional nicht betroffen**: er hängt nur an `moduleGuard("policies")`, `session.user.tenantId`, `session.user.id`, `dbForTenant()` und `requirePlatformFullAdmin` — alles stabile Flächen. **Ein** Berührungspunkt: die geteilte Datei `src/app/(platform)/admin/[id]/page.tsx` (Policy-Upload an der Module-Karte, Identity-Umbau an der Benutzerverwaltung) → nur Merge-Koordination, kein funktionaler Konflikt. Empfehlung: WS3/WS-UI und der Policy-Upload-Stream stimmen Reihenfolge auf dieser Datei ab.
|
||||
- **DevOps:** Feature-Branch → `git merge --no-ff` nach `dev` → Gate grün (tsc/lint/build + alle `scripts/test-*.ts`) → Push auf **beide** Remotes (`origin` git.certvia.de + `local-gitea`) → `docs/STAND-dev-branch.md` pflegen.
|
||||
|
||||
## Offen für PM-Entscheidung (kein Blocker fürs Feindesign)
|
||||
- Team-Besetzung/Startdatum, Sprint-Zuschnitt entlang M1–M6.
|
||||
- Phase-2-Punkte (per-Mandant „immer Step-up", E-Mail-Änderung als Identity-Op, evtl. PlatformAdmin-Konsolidierung) — bewusst zurückgestellt.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Umsetzungspaket — Sicherheit & Administration (1 Entwickler)
|
||||
|
||||
> Claude-Code-Prompt. Basis-Branch **`dev`**; je Story ein Feature-Branch `dev/sec<n>-…` (PR-Ziel `dev`). Grundlage: `Sicherheit-und-Administration-Konzept.md` + Ist-Stand `STAND-dev-branch.md`.
|
||||
> **Entscheidungen (fix):** Mail via **SMTP** · **1 Entwickler** (sequenziell) · **2FA bleibt optional**, aber **pro Tenant im Adminportal als Pflicht** einstellbar · **Passkeys** dabei · **DSGVO-Funktionen** dabei.
|
||||
|
||||
## Vorhandenes wiederverwenden (nicht neu bauen)
|
||||
- **MFA-Kern** (TOTP + Recovery-Codes) für Plattform- und Mandanten-Nutzer: `src/server/mfa.ts`; Policy-Flag `securityPolicy.mfaRequired` existiert.
|
||||
- **Passwort**: `src/lib/password-policy.ts` (Validierung) + `src/server/password.ts` (Argon2id + Generator); **Force-Change**-Gate im `(app)`-Layout.
|
||||
- **Auth**: zwei NextAuth-Instanzen — Mandant `src/server/auth.ts` (`/login`), Plattform `src/server/platform-auth.ts` (`/platform/login`). **`PlatformAdmin`**-Store getrennt.
|
||||
- **Queue**: BullMQ + Redis im Stack. **Audit-Log** mit `tenantId` nullable (`scope=platform`). **Task/TaskComment** für Ticket-/Freigabe-Ereignisse.
|
||||
- **Modul-Guard**: neue Server-Actions in `scripts/check-module-guards.ts` eintragen; tenant-Modelle in `TENANT_MODELS`+RLS.
|
||||
|
||||
## Betriebs-/DNS-Voraussetzungen (kein Code, aber Vorbedingung)
|
||||
Dedizierte Absenderdomain (`no-reply@certvia.de`) mit **SPF, DKIM, DMARC**; TLS/HSTS; SMTP-Zugangsdaten + alle Secrets aus **Env/Secret-Store** (nicht im Repo).
|
||||
|
||||
---
|
||||
|
||||
## Reihenfolge (sequenziell, 1 Entwickler)
|
||||
SEC1 → SEC2 → SEC3 → SEC4 → SEC5 → SEC6. Einzelne P0-Härtungen (Rate-Limit an Login/Reset/MFA, Token-Hashing) landen **inline** in SEC2/SEC3; die globalen Härtungen bündelt SEC5.
|
||||
|
||||
---
|
||||
|
||||
## SEC1 — SMTP-Mail-Fundament (`dev/sec1-mail-smtp`) [L]
|
||||
**Ziel:** zuverlässiger, gebrandeter, asynchroner Mail-Versand als Fundament für Reset/Einladung/Benachrichtigung.
|
||||
- **Mail-Service** `src/server/mail/**` mit **SMTP-Transport** (nodemailer, TLS) hinter einem Interface `sendMail(template, to, vars, locale)` — Provider später austauschbar.
|
||||
- **Queue/Worker** über BullMQ/Redis: asynchron, **Retry mit Backoff**, Dead-Letter, Rate-Limit; Fehler/Bounce ins Log.
|
||||
- **Templates** (Certvia-gebrandet, **de/en**, HTML + Text-Alternative): Einladung, Passwort-Reset, „Passwort geändert", E-Mail-Änderung bestätigen, „MFA geändert", **Ticket-/Freigabe-/Fristen-Benachrichtigung**.
|
||||
- **Benachrichtigungsregeln**: je Nutzer/Ereignis opt-in/opt-out + Sprache; strikt mandantengetrennt. Trigger aus Task-Ereignissen (Zuweisung, Freigabe-Anfrage, Entscheidung, Fälligkeit).
|
||||
- **Admin-Testversand** (eine Aktion „Test-Mail senden") zur Zustellprüfung.
|
||||
- **AK:** Mail wird asynchron mit Retry versendet; Templates gebrandet + zweisprachig; Bounces/Fehler geloggt; Ticket-Benachrichtigung wird bei Task-Zuweisung/Freigabe ausgelöst; keine Secrets im Repo.
|
||||
|
||||
## SEC2 — Passwort-Self-Service & Sessions (`dev/sec2-auth-selfservice`) [M–L] · Abh. SEC1
|
||||
- **Passwort-Reset**: „Passwort vergessen" → **Single-Use-Token**, kurzlebig (30–60 Min), **serverseitig gehasht** gespeichert, an E-Mail gebunden. **Enumeration-Schutz** (immer gleiche Antwort), **Rate-Limit** je IP/Konto. Nach Reset: **alle Sessions invalidieren** + Bestätigungs-Mail + Audit; deaktivierte/gesperrte Konten erhalten keinen Reset.
|
||||
- **Passwort selbst ändern**: Profilseite mit **Alt-Passwort-Bestätigung**, Policy-Prüfung, danach **andere Sessions abmelden** (aktuelle behalten), Bestätigungs-Mail + Audit.
|
||||
- **E-Mail-Änderung**: **Double-Opt-in** an neue Adresse + Benachrichtigung an alte; Audit.
|
||||
- **Session-Invalidierung** als wiederverwendbarer Baustein (bei Reset/Änderung/Deaktivierung/MFA-Änderung).
|
||||
- **AK:** Reset-Token single-use/gehasht/rate-limited/enumeration-safe; Self-Change funktioniert; E-Mail-Änderung verifiziert; Sessions werden korrekt invalidiert; alle Ereignisse im Audit.
|
||||
|
||||
## SEC3 — MFA pro Tenant erzwingen + Passkeys (`dev/sec3-mfa-passkeys`) [L] · Abh. SEC1
|
||||
- **Optional bleibt Default.** Im **Adminportal** kann pro Mandant `securityPolicy.mfaRequired` gesetzt werden (Superadmin) → **Enrollment-Gate**: Nutzer dieses Mandanten werden beim nächsten Login zur MFA-Einrichtung gezwungen (analog Force-Change-Gate im `(app)`-Layout). Ebenso erzwingbar für **alle Plattform-Admins**.
|
||||
- **Passkeys/WebAuthn** als zusätzlicher 2. Faktor (`@simplewebauthn/server` + Browser-API): Registrierung + Login; Nutzer kann **TOTP oder Passkey** verwenden; Credentials verwaltbar (anlegen/entfernen).
|
||||
- **Step-up-Re-Auth** für sensible Aktionen: MFA deaktivieren, Plattform-Admin anlegen, Datenexport, Mandant löschen.
|
||||
- **TOTP-Secret at rest verschlüsselt** (falls noch Klartext) + **Rate-Limit** auf Code-Eingabe; Recovery-Codes-Flow (Anzeige/Regenerierung, Verbrauch protokolliert) abrunden.
|
||||
- **AK:** Admin setzt `mfaRequired` je Tenant → betroffene Nutzer müssen beim Login enrollen; Passkey-Registrierung + -Login funktionieren; Step-up greift bei sensiblen Aktionen; TOTP-Secret verschlüsselt; MFA global weiterhin optional, wenn Policy es nicht verlangt.
|
||||
|
||||
## SEC4 — Plattform-Admin-Verwaltung (`dev/sec4-platform-admins`) [M] · Abh. SEC1, SEC3
|
||||
- **Verwaltungs-UI** im Adminportal (`src/app/(platform)/admin/**`, Actions `platform*.ts`): weitere **`PlatformAdmin`** anlegen (Einladung via SEC1 oder Initial-Passwort), **Rollen** (z. B. „Voll-Admin" vs. „Support/Read-only"), **sperren/reaktivieren**, Passwort-Reset, **MFA-Pflicht** für Plattform-Admins.
|
||||
- **Selbst-Aussperr-Schutz** auf Plattform-Ebene (letzter Voll-Admin nicht entfernbar/deaktivierbar); kritische Aktionen mit **Step-up** (SEC3).
|
||||
- **Vollständiges Plattform-Audit** (`scope=platform`) jeder Aktion.
|
||||
- **AK:** ein zweiter Voll-Admin ist anlegbar und kann verwalten; Rollen greifen (Read-only kann nichts ändern); Last-Admin-Schutz aktiv; jede Aktion protokolliert.
|
||||
|
||||
## SEC5 — Härtung P0 (quer) (`dev/sec5-hardening`) [M]
|
||||
- **HTTP-Security-Header** (zentrale Middleware): CSP, HSTS, `X-Frame-Options`/`frame-ancestors`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`.
|
||||
- **Rate-Limiting/Brute-Force** global an Login/Reset/MFA/Admin-Aktionen (IP **und** kontobezogen), ergänzt den bestehenden Konto-Lockout.
|
||||
- **Security-Audit erweitern**: Logins (Erfolg/Fehlschlag), Rechte-/Rollenänderungen, MFA-Änderungen, Exporte, Admin-Aktionen; **append-only**/manipulationssicher; Aufbewahrung definiert.
|
||||
- **Passwort-Breach-Check** (HaveIBeenPwned k-Anonymity) beim Setzen/Ändern zusätzlich zur Policy.
|
||||
- **Token-/Secret-Hygiene**: alle Tokens single-use + gehasht (Konsistenz zu SEC2/SEC3); **Secret-Scanning** in CI.
|
||||
- **AK:** Header messbar gesetzt (z. B. securityheaders-Check); Rate-Limits greifen; Audit-Ereignisse vorhanden; bekannte kompromittierte Passwörter werden abgelehnt.
|
||||
|
||||
## SEC6 — DSGVO-Funktionen (`dev/sec6-dsgvo`) [L] · Abh. SEC1, SEC3 (Step-up)
|
||||
- **Mandanten-Datenexport** (vollständig, maschinenlesbar, z. B. JSON/ZIP), Admin-getriggert, **asynchron** via Queue, Download-Link mit Ablauf.
|
||||
- **Löschung/Retention**: Mandanten-Löschung (Soft→Hard mit Karenz), **Aufbewahrungsfristen**/Löschkonzept, RLS-bewusste Kaskaden; Audit; Step-up-Bestätigung.
|
||||
- **Betroffenenrechte**: Export/Löschung nutzerbezogener Daten, soweit anwendbar.
|
||||
- **Doku-Bausteine** (Inhalt, kein Code): AVV/DPA + TOMs — als offener fachlicher Punkt markieren.
|
||||
- **AK:** vollständiger Tenant-Export erzeugbar; Löschung respektiert Retention + Audit + Step-up; DSGVO-Aktionen protokolliert.
|
||||
|
||||
---
|
||||
|
||||
## Definition of Done (jede Story)
|
||||
`npx tsc --noEmit` → `npm run lint` → `npm run build` (Guard-Check) grün · neue Actions in `check-module-guards.ts` · neue tenant-Modelle in `TENANT_MODELS`+RLS+Migration (RLS-DO-Block) · Security-Verifikation (Header/Rate-Limit/Token) · Browser-Test der Flows · Demo-Umgebung lauffähig · **Secrets nur aus Env**.
|
||||
|
||||
## Hot Files (koordiniert, da 1 Entwickler → nur Reihenfolge beachten)
|
||||
`prisma/schema.prisma` + Migrationsreihenfolge · zentrale **Middleware** (Header/Rate-Limit) · `(app)`- und `(platform)`-Layouts (Enrollment-/Force-Gates) · `src/server/auth.ts` / `platform-auth.ts` / `mfa.ts` · `scripts/check-module-guards.ts`.
|
||||
|
||||
## Offene fachliche Punkte
|
||||
- DNS: SPF/DKIM/DMARC vor Produktivversand aktiv.
|
||||
- AVV/DPA-Texte + TOMs (Datenschutz/ISB).
|
||||
- Aufbewahrungsfristen je Datenart festlegen (Grundlage für SEC6-Retention).
|
||||
@@ -0,0 +1,19 @@
|
||||
# Sicherheit & Administration — Übergabepaket
|
||||
|
||||
## Inhalt / Reihenfolge
|
||||
1. **Sicherheit-und-Administration-Konzept.md** — PO-Konzept: Ist-Abgleich, Empfehlungen, Roadmap.
|
||||
2. **Aufgabenpaket-Sicherheit-Administration.md** — Ein-Entwickler-Backlog SEC1–SEC6 (Branches `dev/sec<n>-…`).
|
||||
3. **SEC1-Mail-Fundament-Detail.md** — ausgearbeiteter Prompt: SMTP-Mail (Fundament).
|
||||
4. **SEC2-Auth-SelfService-Detail.md** — ausgearbeiteter Prompt: Passwort-Reset, Passwort ändern, E-Mail-Änderung, Session-Invalidierung.
|
||||
|
||||
## Fixierte Entscheidungen
|
||||
- Mail via **SMTP** (nodemailer) im ersten Schritt.
|
||||
- **1 Entwickler**, sequenziell: SEC1 → SEC2 → SEC3 → SEC4 → SEC5 → SEC6.
|
||||
- **2FA optional**, aber pro Tenant im **Adminportal** als Pflicht (`mfaRequired`) erzwingbar; **Passkeys** dabei.
|
||||
- **DSGVO-Funktionen** enthalten.
|
||||
|
||||
## Naht SEC1 ↔ SEC2
|
||||
SEC1 liefert Versand + Templates; **SEC2 erzeugt die Tokens** (single-use, gehasht) und übergibt SEC1 nur die fertige `actionUrl` — keine Klartext-Secrets im MailLog.
|
||||
|
||||
## Start
|
||||
Mit **SEC1** beginnen, dann **SEC2**. DNS-Vorbedingung: SPF/DKIM/DMARC vor Produktivversand. Secrets nur aus Env/Secret-Store.
|
||||
@@ -0,0 +1,125 @@
|
||||
# SEC1 — SMTP-Mail-Fundament (Detail-Prompt)
|
||||
|
||||
> Claude-Code-Prompt für **einen** Entwickler. Basis `dev` → Branch **`dev/sec1-mail-smtp`** (PR-Ziel `dev`).
|
||||
> Ziel: zuverlässiger, gebrandeter, **asynchroner** Mail-Versand über **SMTP** — Fundament für Passwort-Reset (SEC2), Einladung, MFA-Hinweise (SEC3) und Ticket-/Freigabe-/Fristen-Benachrichtigungen. Stack vorhanden: **BullMQ + Redis**, Next.js/TS, Prisma, i18n (de/en), Task/TaskComment, Audit-Log, `TenantSettings`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architektur (Zielbild)
|
||||
|
||||
```
|
||||
Aufrufer (Action/Event) Worker-Prozess
|
||||
│ enqueueMail({template,to, ┌───────────────────────────┐
|
||||
│ locale,vars,tenantId}) │ BullMQ "mail"-Queue │
|
||||
▼ │ → render(template,locale) │
|
||||
MailService ──push job──► Redis ───────► │ → MailProvider.send() │
|
||||
│ │ → MailLog(status) │
|
||||
└─ MailLog(pending) │ → Retry/Backoff/DLQ │
|
||||
└───────────────────────────┘
|
||||
Provider-Interface ← SMTP-Impl (nodemailer) [später austauschbar]
|
||||
```
|
||||
|
||||
Trennung in vier Schichten: **Provider** (SMTP), **Templates** (Render), **Queue/Worker** (Zustellung + Retry), **Service/Trigger** (Aufruf + Regeln + Log).
|
||||
|
||||
---
|
||||
|
||||
## 2. Datenmodelle (Prisma, mit Migration + RLS)
|
||||
|
||||
- **`MailLog`** (Auditierbarkeit + Idempotenz): `id`, `tenantId` (nullable → Plattform-Mails), `to`, `template`, `locale`, `status` (`pending|sent|failed|bounced|suppressed`), `providerMessageId?`, `error?`, `dedupeKey?` (unique, für Idempotenz), `createdAt`, `sentAt?`. → in `TENANT_MODELS`+RLS (Plattform-Zeilen mit `tenantId=null`, `scope=platform`).
|
||||
- **`NotificationPreference`**: `userId`, `eventType`, `email` (bool, default true), `locale?`. Default = opt-in; UI kommt später — jetzt Modell + Default-Auflösung.
|
||||
- Migrationen im Prisma-7-Flow (RLS-DO-Block manuell anhängen).
|
||||
|
||||
---
|
||||
|
||||
## 3. Konfiguration (Env / Secret-Store — nichts ins Repo)
|
||||
|
||||
`SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE` (true=TLS/465, false=STARTTLS/587), `SMTP_USER`, `SMTP_PASS`, `MAIL_FROM` (`no-reply@certvia.de`), `MAIL_FROM_NAME` (`Certvia`), `APP_BASE_URL`, `MAIL_REPLY_TO?`.
|
||||
- **Boot-Validierung** (z. B. zod): fehlt Konfig → klarer Fehler, Versand deaktiviert (Jobs bleiben `pending`, Warnung im Log). Kein stiller Fehlversand.
|
||||
|
||||
---
|
||||
|
||||
## 4. Provider & Service
|
||||
|
||||
- **Interface** `src/server/mail/provider.ts`: `send(msg: {from,to,replyTo?,subject,html,text,headers?}): Promise<{messageId}>`. So bleibt der Provider austauschbar (heute SMTP, später API).
|
||||
- **SMTP-Impl** `src/server/mail/provider-smtp.ts`: **nodemailer** (Pool, TLS, Timeouts). Verbindung wiederverwenden.
|
||||
- **Service** `src/server/mail/service.ts`:
|
||||
- `enqueueMail(input)` → `dedupeKey` bilden (z. B. `template:to:refId`), `MailLog(pending)` anlegen (unique verhindert Doppelversand), Job in Queue.
|
||||
- `renderMail(template, locale, vars)` → `{subject, html, text}` (Abschnitt 5).
|
||||
- Action-Datei in `scripts/check-module-guards.ts` als **`EXEMPT`** eintragen (Infrastruktur, kein gegatetes Modul).
|
||||
|
||||
---
|
||||
|
||||
## 5. Templates (gebrandet, de/en, HTML + Text)
|
||||
|
||||
- **Basis-Layout** `src/server/mail/templates/_layout.*`: Certvia-Kopf (Logo/Wortmarke), Markenfarben (Violett `#5d52a3` / Magenta-Akzent `#812d80`), **Inline-CSS** (E-Mail-Client-tauglich; MJML oder handgepflegtes Table-Layout), Fußzeile „**Certvia — ein Produkt von GEFIM**" + Impressum/Abmelde-Hinweis. Immer **Text-Alternative** mitliefern.
|
||||
- **i18n:** je Template `de`/`en` über die bestehenden Message-Kataloge; `locale` aus Nutzer/`NotificationPreference`, Fallback `de`.
|
||||
- **Template-Set (Erststufe):**
|
||||
|
||||
| Template-Key | Anlass | Kern-Variablen | Auslöser |
|
||||
|---|---|---|---|
|
||||
| `invitation` | Nutzer-Onboarding | `name, tenantName, actionUrl, expires` | SEC1/SEC4 (Einladung) |
|
||||
| `password_reset` | Reset angefordert | `name, actionUrl, expires` | **SEC2** (Token wird dort erzeugt) |
|
||||
| `password_changed` | Passwort geändert | `name, when, ip?` | SEC2 |
|
||||
| `email_change_verify` | E-Mail-Änderung bestätigen | `name, actionUrl, expires` | SEC2 |
|
||||
| `mfa_changed` | MFA aktiviert/deaktiviert/Recovery neu | `name, change, when` | SEC3 |
|
||||
| `notification` | Ticket/Freigabe/Fälligkeit | `name, subject, body, actionUrl, taskType` | Task-Events (Abschnitt 6) |
|
||||
|
||||
> **Wichtig:** Reset-/Verify-/Einladungs-**Tokens** werden von SEC2/SEC3/SEC4 erzeugt (single-use, gehasht). SEC1 liefert nur Template + Versand und bekommt die fertige `actionUrl` als Variable — **keine** Klartext-Secrets ins MailLog/Log schreiben.
|
||||
|
||||
---
|
||||
|
||||
## 6. Benachrichtigungs-Trigger (Ticket-/Freigabe-/Fristen)
|
||||
|
||||
- **Task-Ereignisse** (bestehendes `Task`/`TaskComment` + `submitForApproval`): bei **Zuweisung**, **Freigabe-Anfrage**, **Freigabe-Entscheidung** (angenommen/abgelehnt) → `notification`-Mail an den jeweils Zuständigen, sofern `NotificationPreference.email` aktiv.
|
||||
- **Fristen-Erinnerung:** **BullMQ Repeatable Job** (z. B. täglich) prüft fällige/überfällige Aufgaben (`dueDate`) und versendet gebündelte Erinnerungen (keine Spam-Schleifen — je Aufgabe max. definierte Frequenz, `dedupeKey`).
|
||||
- Alle Trigger **mandantenisoliert**; Sprache je Empfänger.
|
||||
|
||||
---
|
||||
|
||||
## 7. Queue / Worker / Robustheit
|
||||
|
||||
- **Queue** `mail` (BullMQ). **Worker** `src/server/mail/worker.ts`: `render → provider.send → MailLog(sent|failed)`.
|
||||
- **Retry:** z. B. 5 Versuche, exponentielles Backoff; nach Ausschöpfung `status=failed` + **Dead-Letter** (separate Queue/Flag) + Alarm-Logeintrag.
|
||||
- **Rate-Limit** je Empfänger/Domain (BullMQ Limiter), **Concurrency** begrenzt, **Graceful Shutdown**.
|
||||
- **Idempotenz:** `dedupeKey`-Unique + „bereits gesendet"-Kurzschluss.
|
||||
- **Betriebsmodus dokumentieren:** Worker als eigener Prozess (Coolify) **oder** im App-Container gestartet — entscheiden und im README festhalten.
|
||||
|
||||
---
|
||||
|
||||
## 8. Sicherheit / Datenschutz
|
||||
|
||||
- **TLS erzwingen** (STARTTLS/implicit), Zertifikatsprüfung an. Secrets nur aus Env/Secret-Store.
|
||||
- **Keine sensiblen Inhalte** in Mails über das Nötige hinaus; **keine Tokens** in Logs/MailLog (nur `dedupeKey`/`providerMessageId`).
|
||||
- **Abmelde-/Präferenz-Hinweis** in Benachrichtigungs-Mails (nicht in sicherheitskritischen Transaktionsmails wie Reset).
|
||||
- **Enumeration-Schutz** ist Sache von SEC2 (Reset) — SEC1 versendet nur, was ihm übergeben wird.
|
||||
- **Audit:** Versandereignisse (ohne Inhalt) ins Security-Audit (Erweiterung in SEC5 kompatibel halten).
|
||||
- **DNS-Vorbedingung:** SPF, DKIM, DMARC für die Absenderdomain (Ops; vor Produktivversand).
|
||||
|
||||
---
|
||||
|
||||
## 9. Admin-Testversand
|
||||
- Aktion „Test-Mail senden" im Adminportal (an eigene Adresse), zeigt Ergebnis/MailLog-Status → schnelle Zustell-/DKIM-Prüfung.
|
||||
|
||||
---
|
||||
|
||||
## 10. Akzeptanzkriterien
|
||||
- `enqueueMail(...)` legt `MailLog(pending)` an und stellt asynchron über SMTP zu; bei Erfolg `sent` + `providerMessageId`, bei Fehler Retry→`failed`/DLQ.
|
||||
- Templates rendern **de und en**, HTML **und** Text, mit Certvia-Branding und „ein Produkt von GEFIM".
|
||||
- **Ticket-Benachrichtigung** wird bei Task-Zuweisung/Freigabe ausgelöst und respektiert `NotificationPreference` + Mandantenisolation.
|
||||
- **Fristen-Erinnerung** läuft als wiederkehrender Job ohne Doppelversand.
|
||||
- Fehlende SMTP-Konfig → klarer Fehler, kein stiller Fehlversand; keine Secrets/Tokens im Repo oder Log.
|
||||
- Lokaler Test gegen **Mailpit/Mailhog** dokumentiert; Admin-Testversand funktioniert.
|
||||
|
||||
## 11. Definition of Done
|
||||
`npx tsc --noEmit` → `npm run lint` → `npm run build` (Guard-Check) grün · Mail-Action als `EXEMPT` registriert · `MailLog`/`NotificationPreference` in `TENANT_MODELS`+RLS+Migration (RLS-DO-Block) · Worker-Betriebsmodus im README · lokaler Mailpit-Testnachweis (Screenshots) · Demo-Umgebung lauffähig.
|
||||
|
||||
## 12. Testplan (Kurz)
|
||||
1. **Lokal Mailpit** (`docker run mailpit`) als SMTP-Ziel; Env setzen; Test-Mail senden → in Mailpit sichtbar (HTML+Text, de/en).
|
||||
2. **Retry:** Provider künstlich fehlschlagen lassen → Job retryt, landet nach N Versuchen in DLQ, `MailLog=failed`.
|
||||
3. **Idempotenz:** zweimal gleicher `dedupeKey` → nur eine Mail.
|
||||
4. **Trigger:** Aufgabe zuweisen / Freigabe anfragen → `notification`-Mail; `NotificationPreference.email=false` → keine Mail.
|
||||
5. **Fristen-Job:** überfällige Aufgabe → genau eine Erinnerung je Zyklus.
|
||||
|
||||
## 13. Bibliotheken
|
||||
`nodemailer` (SMTP) · `bullmq` (vorhanden) · optional `mjml` für Template-Rendering · `zod` (Env-Validierung, i. d. R. vorhanden).
|
||||
Nächste Pakete bauen darauf auf: **SEC2** (Reset/Change nutzen `password_reset`/`password_changed`/`email_change_verify`), **SEC3** (`mfa_changed`), **SEC4** (`invitation`).
|
||||
@@ -0,0 +1,100 @@
|
||||
# SEC2 — Passwort-Reset, Passwort ändern, E-Mail-Änderung & Sessions (Detail-Prompt)
|
||||
|
||||
> Claude-Code-Prompt für **einen** Entwickler. Basis `dev` → Branch **`dev/sec2-auth-selfservice`** (PR-Ziel `dev`). **Abhängigkeit: SEC1** (Mail-Templates `password_reset`, `password_changed`, `email_change_verify`).
|
||||
> Ziel: sicherer **Self-Service** — Passwort vergessen/zurücksetzen, Passwort selbst ändern, E-Mail-Adresse ändern (verifiziert) — plus ein wiederverwendbarer **Session-Invalidierungs-Baustein**. Gilt für **Mandanten-Nutzer** (`/login`) **und Plattform-Admins** (`/platform/login`).
|
||||
|
||||
## Vorhandenes wiederverwenden
|
||||
- `src/lib/password-policy.ts` (Validierung, client-safe) + `src/server/password.ts` (**Argon2id** + Generator). **Nicht** neu implementieren.
|
||||
- Zwei NextAuth-Instanzen: `src/server/auth.ts` (Mandant) · `src/server/platform-auth.ts` (Plattform). **Force-Change-Gate** im `(app)`-Layout als Muster.
|
||||
- Konto-**Lockout** (Tenant) + `User.mustChangePassword` vorhanden. Audit-Log (`scope=platform` für Plattform-Ereignisse). SEC1-Mailservice (`enqueueMail`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Token-Modell (single-use, gehasht)
|
||||
|
||||
**`AuthToken`** (eigenes Modell, nicht im JWT):
|
||||
`id`, `principalType` (`tenant_user` | `platform_admin`), `principalId`, `tenantId?` (nur bei tenant_user, für RLS), `type` (`password_reset` | `email_change`), `tokenHash` (SHA-256 des Rohtokens), `newEmail?` (nur bei email_change), `expiresAt`, `usedAt?`, `createdAt`, `requestIp?`.
|
||||
- **Roh-Token** = 32 Byte CSPRNG, base64url; nur im **Link** (nie in DB/Log). In DB nur der **Hash**. Lookup per Hash, **konstante-Zeit**-Vergleich.
|
||||
- **Gültigkeit** kurz (Reset 30–60 Min, E-Mail-Verify 60 Min); **single-use** (`usedAt` setzen); alte offene Tokens desselben Typs beim Neuanfordern invalidieren.
|
||||
- tenant_user-Zeilen unter RLS (`tenantId`); platform_admin-Zeilen `tenantId=null` (`scope=platform`). Migration + RLS-DO-Block.
|
||||
|
||||
---
|
||||
|
||||
## 2. Passwort-Reset (Self-Service)
|
||||
|
||||
**2a Anfrage** — „Passwort vergessen?" auf `/login` **und** `/platform/login`:
|
||||
- Eingabe E-Mail → **immer gleiche Antwort** („Falls ein Konto existiert, wurde eine E-Mail gesendet."). **Kein** Rückschluss auf Existenz (Enumeration-Schutz).
|
||||
- **Rate-Limit**: je IP **und** je Konto (z. B. 5/Stunde); zusätzlich globaler Missbrauchsschutz.
|
||||
- Existiert das Konto **und** ist aktiv/nicht gesperrt: `AuthToken(password_reset)` erzeugen, Link `"{APP_BASE_URL}/reset?token=…"` per SEC1 (`password_reset`) senden. **Deaktivierte/gesperrte** Konten: keine Mail, keine Fehlermeldung.
|
||||
|
||||
**2b Einlösung** — `/reset?token=…`:
|
||||
- Token per Hash validieren (existiert, nicht abgelaufen, nicht benutzt, Konto aktiv). Ungültig → generische Meldung + Angebot „neu anfordern".
|
||||
- Neues Passwort setzen: **Policy-Prüfung** (`password-policy.ts`) + Argon2id (`password.ts`). Bei Wiederverwendung: **Breach-Check-Hook** vorsehen (Implementierung in SEC5).
|
||||
- Danach: `usedAt` setzen, **alle Sessions invalidieren** (Abschnitt 5), `mustChangePassword=false`, **Bestätigungs-Mail** (`password_changed`), **Audit**-Eintrag (ohne Token).
|
||||
- Redirect zum passenden Login (`/login` bzw. `/platform/login`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Passwort selbst ändern
|
||||
|
||||
Profilseite (Mandanten-App **und** Plattform-Profil):
|
||||
- Felder: aktuelles Passwort, neues, Wiederholung. **Alt-Passwort verifizieren** (Argon2id), Policy-Prüfung.
|
||||
- Erfolg: Passwort setzen, **andere Sessions abmelden** (aktuelle behalten — Abschnitt 5), Bestätigungs-Mail (`password_changed`), Audit.
|
||||
- Fehler generisch; Rate-Limit auf Alt-Passwort-Versuche.
|
||||
|
||||
---
|
||||
|
||||
## 4. E-Mail-Adresse ändern (verifiziert, Double-Opt-in)
|
||||
|
||||
- Nutzer gibt neue Adresse ein → **Alt-Passwort bestätigen** (Step-up folgt in SEC3). Prüfen, dass die neue Adresse **frei** ist (mandantenweit bzw. plattformweit), ohne Enumeration nach außen.
|
||||
- `AuthToken(email_change, newEmail)` erzeugen; **Verifizierungslink an die NEUE Adresse** (`email_change_verify`).
|
||||
- Klick auf Link: Token validieren → E-Mail aktualisieren, `usedAt` setzen; **Benachrichtigung an die ALTE Adresse** („Ihre E-Mail wurde geändert"); Audit. Login-Identität konsistent halten (E-Mail ist Login).
|
||||
- Kollisionsfall (Adresse zwischenzeitlich vergeben): sauber ablehnen.
|
||||
|
||||
---
|
||||
|
||||
## 5. Session-Invalidierung (wiederverwendbarer Baustein)
|
||||
|
||||
NextAuth v5 nutzt **JWT (stateless)** → globale Invalidierung über eine **Versionsmarke**:
|
||||
- Feld **`sessionsValidAfter`** (Timestamp) bzw. `tokenVersion` an `User` **und** `PlatformAdmin`.
|
||||
- JWT trägt `iat`/Version; im **Auth-Callback bzw. Layout-Check** (die DB wird ohnehin schon für Kontostatus/`mustChangePassword` geprüft) zusätzlich `sessionsValidAfter` vergleichen → ältere Tokens sind ungültig → Abmeldung.
|
||||
- **Bump-Auslöser:** Passwort-Reset (2b), Passwort ändern (3, andere Sessions), Konto-Deaktivierung, MFA-Änderung (SEC3), E-Mail-Änderung.
|
||||
- **„Aktuelle Session behalten"** (bei Self-Change): nach Bump das **aktuelle** JWT mit neuer Version neu ausstellen.
|
||||
- **AK:** ein Bump macht bestehende Sessions serverseitig ungültig; Self-Change meldet **nur** die anderen ab.
|
||||
- *Optionaler Folgeschritt (nicht SEC2-Pflicht):* „aktive Sitzungen anzeigen + einzeln abmelden" braucht Session-Records — als P1 vermerken.
|
||||
|
||||
---
|
||||
|
||||
## 6. Sicherheit (verbindlich)
|
||||
- Tokens: CSPRNG, **nur gehasht** gespeichert, single-use, kurzlebig, konstante-Zeit-Vergleich; **nie** in Logs/MailLog.
|
||||
- **Enumeration-Schutz** überall (Reset, E-Mail-Änderung): generische, einheitliche Antworten und Timing.
|
||||
- **Rate-Limiting** an Reset-Anfrage, Reset-Einlösung, Alt-Passwort-Prüfung (IP + Konto) — bindet an den globalen Limiter aus SEC5, hier aber schon inline scharf.
|
||||
- Reset/Änderung an **gesperrten/deaktivierten** Konten unmöglich.
|
||||
- Alle Ereignisse ins **Audit** (wer/wann/was, ohne Secrets); kompatibel zur SEC5-Audit-Erweiterung.
|
||||
- Kein Auto-Login direkt nach Reset (Nutzer meldet sich neu an) — reduziert Token-Missbrauch.
|
||||
|
||||
---
|
||||
|
||||
## 7. Akzeptanzkriterien
|
||||
- „Passwort vergessen" auf `/login` **und** `/platform/login` erzeugt (nur bei aktivem Konto) eine Reset-Mail; Antwort ist **immer** enumeration-neutral; Rate-Limit greift.
|
||||
- Reset-Link ist **single-use**, abgelaufen/benutzt → generische Ablehnung; nach Reset sind **alle Sessions ungültig**, Bestätigungs-Mail + Audit vorhanden.
|
||||
- Passwort-Selbständerung mit Alt-Passwort-Prüfung + Policy; **andere** Sessions werden abgemeldet, aktuelle bleibt.
|
||||
- E-Mail-Änderung erst nach **Verifizierung der neuen Adresse** wirksam; **alte Adresse** wird informiert.
|
||||
- Token-Hashes in DB (keine Klartext-Tokens), keine Secrets in Logs.
|
||||
- Funktioniert für Mandanten-Nutzer **und** Plattform-Admins.
|
||||
|
||||
## 8. Definition of Done
|
||||
`tsc` → `lint` → `build` (Guard-Check) grün · neue Actions in `check-module-guards.ts` · `AuthToken` + `sessionsValidAfter`-Felder in Migration (+RLS-DO-Block, `AuthToken` in `TENANT_MODELS` für tenant-Zeilen) · Browser-Test aller Flows (Tenant + Plattform) gegen Mailpit · Demo-Umgebung lauffähig.
|
||||
|
||||
## 9. Testplan (Kurz)
|
||||
1. **Reset happy path** (Tenant + Plattform): Anfrage → Mail (Mailpit) → Link → neues Passwort → alte Sessions abgemeldet → Login neu.
|
||||
2. **Enumeration:** unbekannte E-Mail → identische Antwort/Timing, keine Mail.
|
||||
3. **Token-Missbrauch:** abgelaufen / bereits benutzt / manipuliert → generische Ablehnung.
|
||||
4. **Rate-Limit:** viele Anfragen → gedrosselt.
|
||||
5. **Self-Change:** falsches Alt-Passwort → Ablehnung; korrekt → andere Session (zweiter Browser) wird ungültig, aktuelle bleibt.
|
||||
6. **E-Mail-Änderung:** Verify-Link an neue Adresse nötig; alte Adresse erhält Hinweis; unbestätigt → keine Änderung.
|
||||
7. **Deaktiviertes Konto:** kein Reset möglich.
|
||||
|
||||
## 10. Bibliotheken / Bausteine
|
||||
`crypto` (CSPRNG/SHA-256, konstante-Zeit) · vorhandene `password-policy.ts`/`password.ts` · SEC1-`enqueueMail` · Rate-Limit-Util (mit SEC5 teilen).
|
||||
Folgepaket **SEC3** nutzt den Session-Invalidierungs-Baustein (MFA-Änderung) und ergänzt **Step-up-Re-Auth** für die E-Mail-Änderung/kritische Aktionen.
|
||||
@@ -0,0 +1,105 @@
|
||||
# Sicherheit & Administration — Konzept & Empfehlung (PO)
|
||||
|
||||
Grundlage: `STAND-dev-branch.md` (Ist-Stand `dev`). Ziel: die genannten Punkte umsetzen **und** — weil Certvia selbst ein ISMS-Produkt ist — ein Sicherheitsniveau erreichen, das man dem Kunden vorlebt („eat your own dog food"). Status-Legende: ✅ vorhanden · 🟡 teilweise · 🟥 neu.
|
||||
|
||||
---
|
||||
|
||||
## 1. Eure Punkte im Ist-Abgleich (wichtig: nicht doppelt bauen)
|
||||
|
||||
| Anforderung | Status heute | Was fehlt / zu tun |
|
||||
|---|---|---|
|
||||
| **E-Mail-Versand** (Onboarding, Reset, Ticket-/Fristen-Benachrichtigungen) | 🟥 offen | „Paket 4" bewusst zurückgestellt; Aktivierung ist bereits **gekapselt**. Kompletter SMTP-/Mail-Layer + Templates neu. |
|
||||
| **Passwort-Reset** | 🟥 offen | Hängt am Mail-Versand; aktuell nur Initial-/Einmal-Passwort. Self-Service-Reset via signiertem, kurzlebigem Token neu. |
|
||||
| **Passwort ändern (selbst)** | 🟡 teilweise | **Force-Change** (`/change-password`) + Passwort-Policy (Argon2id) vorhanden. **Freiwillige** Änderung im Profil ergänzen (mit Alt-Passwort-Bestätigung). |
|
||||
| **2FA / MFA** | 🟡 teilweise | **TOTP optional** (Plattform-Admins *und* Mandanten-Nutzer) + Recovery-Codes vorhanden; Policy-Flag `mfaRequired` da. **Fehlt:** Enrollment-**Erzwingung** beim Login (Gate), optional WebAuthn/Passkeys. |
|
||||
| **Weiterer übergreifender Admin im Adminportal** | 🟡 teilweise | Getrennter **`PlatformAdmin`-Store** + eigener Login (`/platform/login`) + MFA existiert. **Fehlt:** CRUD-UI im Adminportal, um **weitere Plattform-Admins** anzulegen/zu verwalten (Rollen, Sperren, Reset, MFA-Pflicht). |
|
||||
|
||||
**Kernbotschaft:** 2FA und der getrennte Superadmin-Store sind **im Kern schon da** — hier geht es um *Erzwingung* bzw. *Verwaltungs-UI*, nicht um Neubau. Der echte Neubau ist der **Mail-Layer** (und alles, was daran hängt: Reset, Einladung, Benachrichtigungen).
|
||||
|
||||
---
|
||||
|
||||
## 2. Bausteine für die genannten Punkte
|
||||
|
||||
### 2.1 E-Mail-Infrastruktur (Fundament — schaltet Reset/Einladung/Benachrichtigung frei) 🟥
|
||||
- **Transaktionaler Mail-Provider** (SMTP oder API): z. B. Postmark/SendGrid/Mailgun/Amazon SES **oder** eigener SMTP. Auswahl = offene Entscheidung (§5).
|
||||
- **Domänen-Authentifizierung**: **SPF, DKIM, DMARC** einrichten (sonst Spam/Spoofing). Dediziert Absender-Domain (z. B. `no-reply@certvia.de`).
|
||||
- **Template-Engine** (gebrandet, Certvia): Layout-Basis + Bausteine für Einladung, Passwort-Reset, Passwort-geändert-Bestätigung, MFA-Änderung, Ticket-/Freigabe-/Fristen-Benachrichtigung.
|
||||
- **Queue + Retry** (BullMQ/Redis ist im Stack) für zuverlässigen, asynchronen Versand; Fehler-/Bounce-Handling; Rate-Limit.
|
||||
- **Benachrichtigungsregeln** je Nutzer/Ereignis (opt-in/opt-out, Sprache de/en), respektiert Mandanten-Isolation.
|
||||
|
||||
### 2.2 Passwort-Reset (Self-Service) 🟥
|
||||
- **Single-Use-Token**, **kurzlebig** (z. B. 30–60 Min), **serverseitig gehasht** gespeichert (nicht im Klartext), an E-Mail gebunden.
|
||||
- **Enumeration-Schutz**: „Falls ein Konto existiert, wurde eine E-Mail gesendet." (immer gleiche Antwort), **Rate-Limit** pro IP/Konto.
|
||||
- Nach Reset: **alle Sessions invalidieren**, Bestätigungs-Mail „Passwort geändert", Audit-Log-Eintrag. Deaktivierte/geblockte Konten: kein Reset.
|
||||
|
||||
### 2.3 Passwort selbst ändern 🟡
|
||||
- Profilseite „Passwort ändern": **Alt-Passwort-Bestätigung**, Policy-Prüfung (bestehende `password-policy.ts`), danach **andere Sessions abmelden** (aktuelle behalten), Bestätigungs-Mail + Audit.
|
||||
|
||||
### 2.4 2FA scharfschalten & härten 🟡→
|
||||
- **MFA-Enrollment-Gate**: bei `securityPolicy.mfaRequired` (tenant-weit) oder für Plattform-Admins Enrollment beim Login **erzwingen** (analog Force-Change-Gate).
|
||||
- **Recovery-Codes**: Anzeige/Regenerierung, Verbrauch protokolliert (vorhanden — Flow abrunden).
|
||||
- **Optional/Ausbau**: **WebAuthn/Passkeys** (phishing-resistent) als 2. Faktor; **Re-Auth** (Step-up) für sensible Aktionen (MFA deaktivieren, Admin anlegen, Export).
|
||||
- **TOTP-Secret** at rest **verschlüsselt** speichern (falls noch Klartext) + Rate-Limit auf Code-Eingabe.
|
||||
|
||||
### 2.5 Weiterer übergreifender Plattform-Admin 🟡→
|
||||
- **Plattform-Admin-Verwaltung** im Adminportal (`/platform/...`): Liste, **anlegen** (Initial-Passwort/Einladung), Rollen/Rechte (z. B. „Voll-Admin" vs. „Support/Read-only"), **sperren/reaktivieren**, Passwort-Reset, **MFA-Pflicht für alle Plattform-Admins**.
|
||||
- **Vier-Augen für kritische Plattform-Aktionen** (mind. 2 Admins, kein Self-Lockout — der Lockout-Schutz existiert bereits für Tenant-Rollen, hier fürs Plattform-Level ergänzen).
|
||||
- **Vollständiges Audit** jeder Plattform-Admin-Aktion (`scope=platform`, ist im Log-Modell vorgesehen).
|
||||
|
||||
---
|
||||
|
||||
## 3. Weitere sinnvolle Sicherheitsmaßnahmen (Empfehlung, priorisiert)
|
||||
|
||||
**P0 — sollte mit diesem Block kommen (hoher Nutzen, moderat):**
|
||||
- **HTTP-Security-Header**: CSP, HSTS, `X-Frame-Options`/`frame-ancestors`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`. (Zentraler Middleware-Layer.)
|
||||
- **Rate-Limiting/Brute-Force** an Login, Reset, MFA, API — ergänzt den vorhandenen Konto-Lockout (IP- **und** kontobezogen).
|
||||
- **Session-Härtung**: kurze Idle-/Absolute-Timeouts, **Session-Invalidierung** bei Passwort-Reset/Deaktivierung/MFA-Änderung, **„aktive Sitzungen" anzeigen + einzeln abmelden**.
|
||||
- **Sichere Token-/Secret-Behandlung**: alle Tokens single-use + gehasht; Secrets nur aus Env/Secret-Store, nie im Repo; **Secret-Scanning** in CI.
|
||||
- **Audit-/Security-Event-Log erweitern**: Logins (Erfolg/Fehlschlag), Rechteänderungen, MFA-Änderungen, Exporte, Admin-Aktionen; **append-only**/manipulationssicher; Aufbewahrung definiert.
|
||||
- **Passwort-Breach-Check** beim Setzen (HaveIBeenPwned k-Anonymity) zusätzlich zur Policy.
|
||||
|
||||
**P1 — kurz danach (Betrieb/Compliance):**
|
||||
- **Tenant-Isolationstests** automatisiert (RLS-Regression) — Kernrisiko bei Multi-Tenant.
|
||||
- **Verschlüsselung at rest** (DB + **Backups**), **TLS/HSTS** überall, Key-Rotation; TOTP-/sensible Felder verschlüsselt.
|
||||
- **Backups + regelmäßige Restore-Tests** (dogfooding: das fordert ihr selbst als Control ein), DR-Konzept, RTO/RPO.
|
||||
- **Dependency-/Container-Scanning** (SCA), **SAST**, `npm audit`/Renovate; Patch-SLA.
|
||||
- **DSGVO-Funktionen** (Admin Phase 2): mandantenvollständiger **Export**, **Löschkonzept/Retention**, AVV/DPA-Bausteine, TOMs dokumentiert.
|
||||
- **Impersonation** (Admin Phase 2) **nur** zeitlich begrenzt, protokolliert, mit „Support-Sitzung aktiv"-Banner (Support-Zugriff sicher machen).
|
||||
- **E-Mail-Change-Verifizierung** (Double-Opt-in bei Adressänderung) + Benachrichtigung an alte Adresse.
|
||||
|
||||
**P2 — mittelfristig / Reifegrad:**
|
||||
- **WebAuthn/Passkeys**, **SSO (OIDC/SAML)** für Enterprise-Kunden (steht im Backlog).
|
||||
- **Datei-Upload-Sicherheit** (sobald Storage kommt): AV-Scan, MIME/Größen-Limits, kein HTML-Serving, signierte URLs.
|
||||
- **WAF/Reverse-Proxy** + DDoS-Schutz vor der App; least-privilege DB-User; Netzsegmentierung.
|
||||
- **Responsible-Disclosure**: `security.txt`, Kontaktpfad, ggf. Bug-Bounty.
|
||||
- **Externer Pen-Test** vor „Go-Live/Skalierung"; Ergebnisse als Maßnahmen ins eigene ISMS.
|
||||
- **Incident-Response-Prozess** für Certvia selbst (passt zum NIS2-/Vorfälle-Modul, das ihr ohnehin baut).
|
||||
|
||||
---
|
||||
|
||||
## 4. Vorschlag: Priorisierte Roadmap (integriert eure Punkte + P0)
|
||||
|
||||
| Reihe | Paket | Inhalt |
|
||||
|---|---|---|
|
||||
| **1** | **Mail-Fundament** | Provider + SPF/DKIM/DMARC, Queue/Retry, Certvia-Templates, Benachrichtigungsregeln |
|
||||
| **2** | **Auth-Self-Service** | Passwort-Reset (Token), Passwort selbst ändern, E-Mail-Change-Verifizierung, Session-Invalidierung |
|
||||
| **3** | **MFA scharf** | Enrollment-Gate erzwingen, Recovery-Codes-Flow, TOTP-Secret verschlüsselt, Step-up für sensible Aktionen |
|
||||
| **4** | **Plattform-Admin-Verwaltung** | Weitere Superadmins anlegen/verwalten, Rollen, Vier-Augen, Plattform-Audit |
|
||||
| **5** | **Härtung P0** | Security-Header, Rate-Limiting, Audit-Erweiterung, Breach-Check, Secret-Scanning |
|
||||
| **6+** | **P1/P2** | RLS-Tests, at-rest-Verschlüsselung, Backups/Restore, DSGVO, Impersonation, WebAuthn/SSO, Pen-Test |
|
||||
|
||||
Pakete 1–4 sind stark verzahnt (alles hängt am Mail-Fundament); 5 läuft quer und sollte **mit** 1–4 kommen, nicht danach.
|
||||
|
||||
---
|
||||
|
||||
## 5. Offene Entscheidungen (bevor wir Tasks schneiden)
|
||||
1. **Mail-Provider**: API-Dienst (Postmark/SendGrid/SES …) **oder** eigener SMTP? (beeinflusst Zustellbarkeit, DSGVO-Ort, Aufwand)
|
||||
2. **2FA-Ausbau**: reicht TOTP scharfschalten, oder direkt **WebAuthn/Passkeys** mitnehmen?
|
||||
3. **Plattform-Admin-Rollen**: nur „Voll-Admin", oder abgestufte Rollen (Support/Read-only)?
|
||||
4. **DSGVO-Umfang** jetzt (Export/Löschung/Retention) oder in eigener Runde?
|
||||
5. **Team-Setup**: 1, 2 oder 3 Entwickler parallel — dann schneide ich analog zum Wizard vertikale Lanes.
|
||||
|
||||
---
|
||||
|
||||
## 6. Nächster Schritt
|
||||
Auf dieser Basis schneide ich — wie beim Wizard — **Entwickler-Aufgabenpakete** (mit Branch-Konvention unter `dev`, Akzeptanzkriterien, DoD). Sag mir die Antworten zu §5 (v. a. Mail-Provider und Team-Größe), dann lege ich los. Reihenfolge-Empfehlung: **Mail-Fundament zuerst**, weil Reset/Einladung/Benachrichtigung daran hängen.
|
||||
Reference in New Issue
Block a user