Basis: Certvia dev@a48c5fb als Fundament für Craftvia
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s

Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation
und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 11:05:39 +02:00
co-authored by Claude Opus 5
commit c8e6f30a27
720 changed files with 140143 additions and 0 deletions
+470
View File
@@ -0,0 +1,470 @@
# Coolify-Deployment (Testserver/Prod, intern) — abgeleitet von docker-compose.yml.
# Unterschiede zum lokalen Dev-Compose:
# - keine host "ports": Coolify-Proxy routet die Domain intern auf app:3000
# - Konfiguration über Coolify-Env-Variablen statt env_file: .env
# - Service "migrate": Init-Job (prisma migrate deploy + Rollen-Rechte-Sync), läuft einmalig VOR app
# - kein mailhog (Dev); Service "worker" = SEC1 Mail-Worker (BullMQ/Redis-Queue)
# In Coolify als "Docker Compose Location" -> docker-compose.coolify.yml setzen.
#
# Härtung (F-11/F-18):
# - F-11: migrate nutzt die schlanke "migrate"-Stage (kein Next-Build), Images gepinnt.
# - F-18: Backend-Dienste (postgres/redis/garage/migrate) im internen Netz ohne Egress;
# nur app zusätzlich im default-Netz (Coolify-Proxy). Redis mit Passwort.
# Überall no-new-privileges, cap_drop ALL (+gezielte cap_add), Ressourcenlimits.
services:
# Einmaliger Migrations-Job. Nutzt die schlanke "migrate"-Stage (Prisma-CLI/tsx + src,
# aber OHNE next build/.next) — kleinere Angriffsfläche als die frühere builder-Stage.
# Nach `migrate deploy` läuft der Rollen-Rechte-Sync (scripts/sync-role-permissions.ts):
# additiv + idempotent, zieht neu eingeführte Rechte für bestehende Mandanten nach
# (F-06 × F-10). Bei 0 Änderungen ein No-op. Betroffene Nutzer danach neu einloggen.
# Optionaler Demo-Seed nur, wenn RUN_DEMO_SEED=true (Testserver). Idempotent (upserts).
# In Produktion die Variable NICHT setzen; stattdessen BOOTSTRAP_ADMIN.
migrate:
build:
context: .
target: migrate
command: sh -c "npx prisma migrate deploy && echo '>> Rollen-Rechte-Sync läuft…' && npx tsx scripts/sync-role-permissions.ts && echo '>> Vorlagen-Sync läuft…' && npx tsx scripts/sync-policy-templates.ts && if [ \"$$RUN_DEMO_SEED\" = \"true\" ]; then echo '>> Demo-Seed läuft…'; npx tsx prisma/seed.ts; fi && if [ \"$$BOOTSTRAP_ADMIN\" = \"true\" ]; then echo '>> Bootstrap-Admin läuft…'; npx tsx scripts/bootstrap-admin.ts; fi"
environment:
DATABASE_URL: ${DATABASE_URL}
# Härtung §1: Passwort-Pepper (Argon2 `secret`). Seed/Bootstrap hashen Passwörter
# → fail-secure, ohne gültigen 32-Byte-Hex wirft hashPassword und der Job scheitert.
# Muss identisch zum app-Service sein (sonst verifizieren die Hashes später nicht).
PASSWORD_PEPPER: ${PASSWORD_PEPPER}
# Test: Demo-Seed. Produktiv: stattdessen BOOTSTRAP_ADMIN (Erst-Superadmin).
RUN_DEMO_SEED: ${RUN_DEMO_SEED:-false}
BOOTSTRAP_ADMIN: ${BOOTSTRAP_ADMIN:-false}
BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-}
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-}
BOOTSTRAP_ADMIN_NAME: ${BOOTSTRAP_ADMIN_NAME:-}
BOOTSTRAP_TENANT_NAME: ${BOOTSTRAP_TENANT_NAME:-}
BOOTSTRAP_TENANT_SLUG: ${BOOTSTRAP_TENANT_SLUG:-}
BOOTSTRAP_TENANT_SHORT: ${BOOTSTRAP_TENANT_SHORT:-}
BOOTSTRAP_TENANT_SECTOR: ${BOOTSTRAP_TENANT_SECTOR:-}
networks:
- backend
security_opt:
- "no-new-privileges:true"
cap_drop:
- ALL
deploy:
resources:
limits:
cpus: "1.0"
memory: 1G
depends_on:
postgres:
condition: service_healthy
restart: "no"
app:
build:
context: .
target: runner
environment:
DATABASE_URL: ${DATABASE_URL}
# F-04: Row Level Security scharf. Der migrate-Job oben läuft bewusst
# weiter mit der Owner-DATABASE_URL (BYPASSRLS). Nur der App-Prozess
# verbindet als eingeschränkte Rolle isms_app (NOBYPASSRLS) über
# RLS_DATABASE_URL und setzt app.tenant_id pro Transaktion.
RLS_ENFORCED: ${RLS_ENFORCED:-false}
RLS_DATABASE_URL: ${RLS_DATABASE_URL}
# F-18: REDIS_URL wird AUS REDIS_PASSWORD abgeleitet (nicht separat setzen!) — so
# bleibt sie immer konsistent zum `--requirepass` des redis-Dienstes und Coolify
# kann sie nicht als „managed" sperren. In Coolify NUR REDIS_PASSWORD setzen.
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
AUTH_SECRET: ${AUTH_SECRET}
# Härtung §1: Passwort-Pepper (Argon2 `secret`). Fail-secure — ohne gültigen
# 32-Byte-Hex wirft jeder Login/hash. Muss IDENTISCH zum migrate-Job sein und
# ⚠ nach dem Setzen nicht mehr ändern (sonst verifizieren bestehende Hashes nicht).
PASSWORD_PEPPER: ${PASSWORD_PEPPER}
# SEC3-d: TOTP-Secrets werden „at rest" mit AES-256-GCM verschlüsselt
# (src/server/secret-crypto.ts). Der Schlüssel leitet sich aus MFA_ENC_KEY
# ab, sonst aus AUTH_SECRET — daher OPTIONAL (leer = Fallback AUTH_SECRET).
# ⚠ Nach dem Setzen nicht mehr ändern: macht bereits verschlüsselte
# TOTP-Secrets unlesbar (kein Umschlüssel-Tool).
MFA_ENC_KEY: ${MFA_ENC_KEY:-}
AUTH_URL: ${AUTH_URL}
# Hinter dem Coolify/Traefik-Proxy zwingend für Auth.js v5 (sonst UntrustedHost).
AUTH_TRUST_HOST: ${AUTH_TRUST_HOST:-true}
S3_ENDPOINT: ${S3_ENDPOINT}
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_BUCKET: ${S3_BUCKET}
# S1: echter S3-Adapter (src/server/storage/adapter.ts). Region ist für MinIO
# beliebig (Default us-east-1); überschreibbar für echtes AWS-S3.
S3_REGION: ${S3_REGION:-us-east-1}
# Lokaler (persistenter) Backup-Zielspeicher, wenn der Betreiber im Portal „Lokal"
# wählt bzw. als Env-Fallback. Muss auf das gemountete `backups`-Volume zeigen,
# sonst sind Sicherungen beim Redeploy flüchtig. DB-Config hat Vorrang vor dieser Var.
BACKUP_LOCAL_DIR: ${BACKUP_LOCAL_DIR:-/app/.backups}
AI_PROVIDER: ${AI_PROVIDER}
AI_API_KEY: ${AI_API_KEY}
SMTP_HOST: ${SMTP_HOST}
SMTP_PORT: ${SMTP_PORT}
SMTP_USER: ${SMTP_USER}
SMTP_PASSWORD: ${SMTP_PASSWORD}
SMTP_FROM: ${SMTP_FROM}
# Persistenter lokaler Backup-Zielspeicher (überlebt Redeploys).
volumes:
- backups:/app/.backups
# app im internen Netz (Backend-Zugriff) UND im default-Netz (Coolify-Proxy-Routing).
networks:
- backend
- default
security_opt:
- "no-new-privileges:true"
cap_drop:
- ALL
deploy:
resources:
limits:
cpus: "1.0"
memory: 1G
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
garage:
condition: service_healthy
garage-provision:
condition: service_completed_successfully
migrate:
condition: service_completed_successfully
healthcheck:
# Dependency-frei (node ist im Image vorhanden); alles < 500 gilt als gesund
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/',r=>process.exit(r.statusCode<500?0:1)).on('error',()=>process.exit(1))"]
interval: 15s
timeout: 5s
retries: 10
start_period: 30s
restart: unless-stopped
# SEC1: Mail-Worker. Eigener Langläufer-Prozess, der die BullMQ/Redis-Queue
# abarbeitet (Zustellung mit Retry/DLQ + täglicher Fristen-/Reminder-Job).
# Ist REDIS_URL gesetzt, reiht die App Jobs ein und DIESER Worker stellt zu;
# ohne REDIS_URL versendet die App inline und der Worker wird nicht gebraucht.
# Nutzt das schlanke "migrate"-Image (tsx + src + Mail-Deps + Prisma-Client).
# Owner-DATABASE_URL (kein RLS): der Worker arbeitet mandantenübergreifend.
# Im default-Netz (Egress), damit der externe SMTP-Server erreichbar ist.
worker:
build:
context: .
target: migrate
command: ["npx", "tsx", "scripts/mail-worker.ts"]
environment:
DATABASE_URL: ${DATABASE_URL}
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
# SMTP: fehlt die Konfiguration, bleibt der Worker aktiv und meldet den
# Zustand (Jobs bleiben pending) — kein Neustart-Rennen beim Nachreichen.
SMTP_HOST: ${SMTP_HOST}
SMTP_PORT: ${SMTP_PORT}
SMTP_SECURE: ${SMTP_SECURE:-}
SMTP_USER: ${SMTP_USER}
SMTP_PASSWORD: ${SMTP_PASSWORD}
SMTP_FROM: ${SMTP_FROM}
# Absolute Links in Mails (Reset etc.): APP_BASE_URL, sonst AUTH_URL.
AUTH_URL: ${AUTH_URL}
networks:
- backend
- default
security_opt:
- "no-new-privileges:true"
cap_drop:
- ALL
deploy:
resources:
limits:
cpus: "0.5"
memory: 512M
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
migrate:
condition: service_completed_successfully
restart: unless-stopped
# Backup-Worker: arbeitet die BullMQ-Queue "backup-ops" ab (Portal-Restore,
# "Export jetzt", DSGVO-Zustellung). OHNE diesen Dienst bleiben angestoßene Jobs
# dauerhaft auf "queued" (die App reiht nur ein, führt NICHT inline aus).
# Owner-DATABASE_URL (kein RLS): Restore/Export laufen mandantenübergreifend und
# brauchen einen Superuser-Owner (session_replication_role beim Restore).
# Braucht das S3/MinIO-Backup-Bucket (S3_*) und den Backup-Schlüssel (BACKUP_ENC_KEY,
# Fallback AUTH_SECRET). concurrency ist im Worker seriell (destruktiv).
backup-worker:
build:
context: .
target: migrate
command: ["npx", "tsx", "scripts/backup-worker.ts"]
environment:
DATABASE_URL: ${DATABASE_URL}
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
AUTH_SECRET: ${AUTH_SECRET}
# Pepper wird von der Fail-Secure-Startprüfung erwartet (assertSecureEnv).
PASSWORD_PEPPER: ${PASSWORD_PEPPER}
# Backup-Artefakt-Verschlüsselung (AES-256-GCM); ohne eigenen Key = AUTH_SECRET.
BACKUP_ENC_KEY: ${BACKUP_ENC_KEY:-}
# Objektspeicher für Backup-Artefakte + DSGVO-Pakete (S3/MinIO). Fehlt es,
# nutzt der Store einen lokalen (ephemeren!) Ordner — für Prod S3_* setzen.
S3_ENDPOINT: ${S3_ENDPOINT:-}
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-}
S3_SECRET_KEY: ${S3_SECRET_KEY:-}
S3_BUCKET: ${S3_BUCKET:-}
S3_REGION: ${S3_REGION:-}
# Lokaler (persistenter) Backup-Zielspeicher — identischer Mount wie beim app-Service,
# damit „Lokal" für Worker (schreibt Artefakte) und app (liest im Portal) dasselbe
# Volume sieht. DB-Config aus dem Betreiber-Portal hat Vorrang vor dieser Var.
BACKUP_LOCAL_DIR: ${BACKUP_LOCAL_DIR:-/app/.backups}
AUTH_URL: ${AUTH_URL}
# Gleiches persistentes Volume wie app (gemeinsamer lokaler Zielspeicher).
volumes:
- backups:/app/.backups
networks:
- backend
- default
security_opt:
- "no-new-privileges:true"
cap_drop:
- ALL
deploy:
resources:
limits:
cpus: "0.5"
memory: 512M
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
migrate:
condition: service_completed_successfully
restart: unless-stopped
# IM-D — Inbound-Mail-Worker (E-Mail-to-Ticket). Holt Mails vom Catch-all-Postfach
# (vorfall-<token>@in.certvia.de) per IMAP ab und legt daraus Vorfälle an bzw. reiht
# unklare Mails in die Betreiber-Review. Kein Redis nötig (IMAP-Poller, keine Queue).
# Ohne INCIDENT_IMAP_* beendet sich der Prozess sauber („nicht konfiguriert").
incident-inbound-worker:
build:
context: .
target: migrate
command: ["npx", "tsx", "scripts/incident-inbound-worker.ts"]
environment:
DATABASE_URL: ${DATABASE_URL}
AUTH_SECRET: ${AUTH_SECRET}
# Pepper wird von der Fail-Secure-Startprüfung erwartet (assertSecureEnv).
PASSWORD_PEPPER: ${PASSWORD_PEPPER}
# IMAP-Zugang des Catch-all-Postfachs. Fehlt es, beendet sich der Worker sauber.
INCIDENT_IMAP_HOST: ${INCIDENT_IMAP_HOST:-}
INCIDENT_IMAP_PORT: ${INCIDENT_IMAP_PORT:-993}
INCIDENT_IMAP_USER: ${INCIDENT_IMAP_USER:-}
INCIDENT_IMAP_PASSWORD: ${INCIDENT_IMAP_PASSWORD:-}
INCIDENT_IMAP_TLS: ${INCIDENT_IMAP_TLS:-true}
INCIDENT_IMAP_MAILBOX: ${INCIDENT_IMAP_MAILBOX:-INBOX}
INCIDENT_IMAP_POLL_MS: ${INCIDENT_IMAP_POLL_MS:-60000}
# Muss zur Catch-all-Subdomain passen (Ableitung der Intake-Adressen).
INCIDENT_INTAKE_DOMAIN: ${INCIDENT_INTAKE_DOMAIN:-in.certvia.de}
AUTH_URL: ${AUTH_URL}
networks:
- backend
- default
security_opt:
- "no-new-privileges:true"
cap_drop:
- ALL
deploy:
resources:
limits:
cpus: "0.5"
memory: 512M
depends_on:
postgres:
condition: service_healthy
migrate:
condition: service_completed_successfully
restart: unless-stopped
postgres:
image: pgvector/pgvector:0.8.0-pg16
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- backend
security_opt:
- "no-new-privileges:true"
cap_drop:
- ALL
# Postgres-Entrypoint chownt das Datadir und wechselt per gosu auf den postgres-User.
cap_add:
- CHOWN
- DAC_OVERRIDE
- FOWNER
- SETGID
- SETUID
deploy:
resources:
limits:
cpus: "1.0"
memory: 1G
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
redis:
image: redis:7.4.2-alpine
# F-18: Redis mit Passwort. REDIS_URL (app) muss dieses Passwort tragen.
command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}"]
volumes:
- redisdata:/data
networks:
- backend
security_opt:
- "no-new-privileges:true"
cap_drop:
- ALL
# redis-alpine-Entrypoint chownt /data und wechselt per gosu auf den redis-User.
cap_add:
- CHOWN
- SETGID
- SETUID
deploy:
resources:
limits:
cpus: "0.5"
memory: 256M
restart: unless-stopped
# Objektspeicher: Garage (S3-kompatibel) — ersetzt den früheren minio-Service
# (MinIO Community EOL/Maintenance-Mode). Konzept: docs/KONZEPT-garage-migration.md.
# Buckets/Keys werden NICHT über die S3-API angelegt, sondern vom Init-Job
# "garage-provision" (Admin-API). Nichts nach außen (kein Traefik/ports:) —
# rein clusterintern, wie minio zuvor. Version gepinnt (kein latest).
garage:
# Image mit eingebackener deploy/garage.toml (Dockerfile-Stage „garage"). KEIN
# Bind-Mount der Config: Coolify legt relative Bind-Quellen sonst als Verzeichnis an
# → Garage bekäme /etc/garage.toml als Ordner („IO error: Is a directory").
build:
context: .
target: garage
# Secrets stehen NICHT in der eingebackenen deploy/garage.toml — der Daemon liest
# rpc_secret/admin_token aus der Env (in Coolify literal setzen, nicht via ${…}).
environment:
GARAGE_RPC_SECRET: ${GARAGE_RPC_SECRET}
GARAGE_ADMIN_TOKEN: ${GARAGE_ADMIN_TOKEN}
volumes:
# KRITISCH: meta enthält Bucket-/Key-/Layout-Definitionen → MUSS ins Host-Backup
# (analog pgdata). Ohne meta sind die Objektdaten nicht adressierbar.
- garage_meta:/var/lib/garage/meta
- garage_data:/var/lib/garage/data
networks:
- backend
security_opt:
- "no-new-privileges:true"
cap_drop:
- ALL
healthcheck:
# Distroless-Image (nur /garage): der CLI-Status dient als Health-Signal.
# Verbindet auf rpc_public_addr (loopback) → unabhängig von Compose-DNS.
test: ["CMD", "/garage", "status"]
interval: 10s
timeout: 5s
retries: 12
start_period: 10s
deploy:
resources:
limits:
cpus: "1.0"
memory: 1G
restart: unless-stopped
# Idempotenter Provisioning-Job (analog "migrate", restart:no): stellt aus einer
# leeren Garage Layout + Bucket + Access-Key + Rechte her (Garage-Admin-API, HTTP).
# Läuft bei JEDEM Deploy; "already exists" = Erfolg. Ohne GARAGE_ADMIN_TOKEN No-op.
# Nutzt das schlanke "migrate"-Image (Node/tsx) — kein Garage-Binary nötig.
garage-provision:
build:
context: .
target: migrate
command: ["npx", "tsx", "scripts/garage-provision.ts"]
environment:
GARAGE_ADMIN_URL: http://garage:3903
GARAGE_ADMIN_TOKEN: ${GARAGE_ADMIN_TOKEN}
# Der zu importierende Garage-Key = App-Key (App-Env und Garage synchron).
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_BUCKET: ${S3_BUCKET:-isms-documents}
# Optionaler separater Backup-Bucket (nur falls Backup-Store auf S3 statt lokal).
BACKUP_S3_BUCKET: ${BACKUP_S3_BUCKET:-}
# Single-Node-Layout (nominale Kapazität/Zone; für Prod ggf. anheben).
GARAGE_ZONE: ${GARAGE_ZONE:-dc1}
GARAGE_CAPACITY_BYTES: ${GARAGE_CAPACITY_BYTES:-100000000000}
networks:
- backend
security_opt:
- "no-new-privileges:true"
cap_drop:
- ALL
deploy:
resources:
limits:
cpus: "0.5"
memory: 256M
depends_on:
garage:
condition: service_healthy
restart: "no"
# ── ROLLBACK-NETZ (bis Garage abgenommen ist; danach entfernen) ───────────────
# Der alte minio-Service bleibt vorübergehend auskommentiert stehen, damit ein
# Rollback ohne Repo-Archäologie möglich ist. NACH erfolgreicher Garage-Abnahme
# diesen Block UND das Volume "miniodata" (unten) entfernen.
# minio:
# image: minio/minio:RELEASE.2025-04-22T22-12-26Z
# command: server /data --console-address ":9001"
# environment:
# MINIO_ROOT_USER: ${MINIO_ROOT_USER}
# MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
# volumes:
# - miniodata:/data
# networks:
# - backend
# security_opt:
# - "no-new-privileges:true"
# cap_drop:
# - ALL
# deploy:
# resources:
# limits:
# cpus: "0.5"
# memory: 512M
# restart: unless-stopped
networks:
# Internes Netz ohne Egress/Host-Exposure für die Backend-Dienste.
backend:
internal: true
# default: von Coolify für das Proxy-Routing zur app genutzt (mit Egress).
default:
volumes:
pgdata:
redisdata:
# Garage-Objektspeicher. garage_meta ist KRITISCH (Bucket-/Key-/Layout-Definitionen)
# und MUSS in die Host-Backup-Strategie (analog pgdata) — ohne meta sind die Daten
# in garage_data nicht adressierbar.
garage_meta:
garage_data:
# ROLLBACK: altes MinIO-Datenvolume. Nach abgenommener Garage-Migration entfernen.
miniodata:
# Persistenter lokaler Backup-Zielspeicher (Lane „Konfigurierbarer Backup-Zielspeicher").
# Von app + backup-worker unter /app/.backups gemountet, damit „Lokal" Redeploys überlebt.
backups: