Compare commits

..
Author SHA1 Message Date
sysops 218971824b OPS-04: incident-response-plan-inkl-dsgvo-meldefristen 2026-08-28 10:58:46 +02:00
sysopsandClaude Sonnet 5 6a03dcafd6 OPS-01: health-check-endpunkte-je-modul
internal/health: wiederverwendbare Registry fuer benannte Checks (DB, Queue)
— nicht Core-spezifisch, sondern von jedem registrierten Modul (API-02)
gleichermassen einsetzbar. LivenessHandler prueft bewusst KEINE externen
Abhaengigkeiten (Akzeptanzkriterium 2: Liveness/Readiness getrennt) — ein
DB-Ausfall soll den Prozess nicht faelschlich als "tot" markieren und einen
grundlosen Neustart ausloesen. ReadinessHandler fuehrt alle registrierten
Checks NEBENLAEUFIG mit je eigenem Timeout aus (DefaultCheckTimeout=2s) und
liefert 503, sobald irgendeine Abhaengigkeit fehlschlaegt (Akzeptanz-
kriterium 1 + 3) — echte Pruefung von DB (Ping) und Job-Queue statt nur
Prozessstatus.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Simulierter Datenbankausfall fuehrt zu "nicht bereit" —
   TestReadinessHandler_ReportsNotReadyOnDatabaseFailure: geschlossener Pool,
   503 mit "database" im Checks-Ergebnis. PASS.
2. Health-Endpunkt antwortet auch bei haengendem Check innerhalb definierter
   Zeit — TestReadinessHandler_RespondsWithinTimeoutEvenWithHangingCheck:
   ein 10s blockierender Check wird durch 50ms-Timeout begrenzt, Handler
   antwortet deutlich unter 1s. PASS.
3. Readiness- und Liveness-Antwort unterscheiden sich nachweislich in
   mindestens einem Fehlerfall — TestLivenessAndReadiness_DifferOnDatabaseFailure:
   bei DB-Ausfall liefert Liveness weiterhin 200, Readiness 503. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 23:30:30 +02:00
15 changed files with 603 additions and 753 deletions
+205
View File
@@ -0,0 +1,205 @@
# NEXARCH Incident-Response-Plan (inkl. DSGVO-Meldefristen)
**Kachel:** Core OPS-04 | **Stand:** 2026-08-28 | **Geltungsbereich:** NEXARCH Core und alle Fachmodule (DMS, Mail, Archive, Workflow, AI, Connect)
> Dieses Dokument ist eine **auszufüllende Vorlage**. Felder in eckigen Klammern
> (`[AUSZUFÜLLEN: ...]`) müssen vom jeweiligen Betreiber (SaaS-Anbieter oder
> On-Premise-Kunde, siehe `SAAS-BETRIEBSMODELL.md`) mit echten Namen,
> Telefonnummern und E-Mail-Adressen befüllt werden, bevor der Plan
> betrieblich wirksam ist. Ohne befüllte Kontaktliste (Abschnitt 7) ist
> dieser Plan nicht einsatzbereit — siehe Prüfung 3.
## 1. Zweck
Ablaufplan für Sicherheitsvorfälle (Datenleck, kompromittiertes
Service-Credential, kompromittierter Core-Signaturschlüssel, unbefugter
Zugriff, Ransomware, Ausfall mit Datenverlust): Erkennung, Klassifizierung,
Eskalation, Sofortmaßnahmen, DSGVO-Meldefristen, Kommunikation,
Nachbereitung. Ergänzt `SICHERHEITSKONZEPT.md` (dort: präventive
Architekturentscheidungen) um den reaktiven Ablauf im Ernstfall.
## 2. Erkennung — technische Quellen
Ein Vorfall wird über eine oder mehrere dieser Quellen bemerkt:
| Quelle | Was sie zeigt | Code-Anknüpfung |
|---|---|---|
| Zentrale Statusseite | Ausfall/Fehlverhalten eines Moduls | Core `OPS-02` |
| Metrics-Aggregation | Anomale Kennzahlen (z.B. Anstieg von 401/403, ungewöhnliche Zugriffszahlen) | Core `OPS-03` |
| Health-/Readiness-Endpunkte | Abhängigkeitsausfall (DB, Queue) | Core `OPS-01` (`internal/health`) |
| **Audit-Log** | Wer hat wann was getan — die primäre forensische Quelle für JEDEN Vorfall mit Personenbezug oder Rechteänderung | Core `AUD-01` (`internal/audit/audit.go`, `Log.Record`), Export/Filter über `AUD-03` (`internal/audit/export.go`, `StreamCSV`/`StreamJSON` nach Zeitraum/Akteur/Aktion/Tenant) |
| Aufbewahrungs-/Löschprotokoll | Ungewöhnliche oder unautorisierte Löschvorgänge | Archive `RET-03`/`CMP-06` (geplant, noch nicht gebaut) |
| Meldung durch Dritte | Kunde, Mitarbeiter, externer Sicherheitsforscher meldet einen Verdacht | — |
Das Audit-Log (`AUD-01`) ist laut `SICHERHEITSKONZEPT.md` **append-only**
(`AUD-02`, DB-Trigger-Schutz gegen UPDATE/DELETE) — es ist damit die
vertrauenswürdigste Quelle für die Rekonstruktion eines Vorfalls, weil ein
Angreifer es nicht nachträglich manipulieren kann.
## 3. Klassifizierung
| Schweregrad | Beispiel | Meldepflichtig nach Art. 33 DSGVO? |
|---|---|---|
| **Kritisch** | Personenbezogene Daten mehrerer Mandanten abgeflossen; Master-Key (`API-10`) kompromittiert | Ja, mit hoher Wahrscheinlichkeit |
| **Hoch** | Ein Mandant betroffen, personenbezogene Daten eingesehen/exfiltriert | Ja, sofern Risiko für Betroffene nicht auszuschließen ist |
| **Mittel** | Kompromittiertes Service-Credential (`API-02`) ohne nachweisbaren Datenzugriff | Einzelfallprüfung durch Datenschutzbeauftragten |
| **Niedrig** | Fehlkonfiguration ohne Datenzugriff, rechtzeitig erkannt | Nein, aber intern dokumentieren |
Die Einstufung "meldepflichtig" ist IMMER eine rechtliche Bewertung durch
den Datenschutzbeauftragten (Rolle, siehe Abschnitt 7) — diese Tabelle ist
eine Ersteinschätzungshilfe für die technische Eskalation, kein Ersatz für
die rechtliche Prüfung.
## 4. Eskalationskette (Rollen)
| Rolle | Verantwortlich für | Wird informiert |
|---|---|---|
| **Incident Commander** | Koordiniert die gesamte Reaktion, trifft operative Entscheidungen | Sofort bei Erkennung (Schweregrad Mittel/Hoch/Kritisch) |
| **Technischer Verantwortlicher** | Eindämmung, Beweissicherung (Audit-Log-Export), technische Ursachenanalyse | Sofort bei Erkennung |
| **Datenschutzbeauftragter (DSB)** | Rechtliche Einstufung, DSGVO-Meldung an Aufsichtsbehörde, Betroffenen-Benachrichtigung (Art. 34) | Innerhalb 1 Stunde ab Schweregrad Mittel |
| **Geschäftsführung/Betreiber** | Externe Kommunikation, Kundenbenachrichtigung, AVV-Pflichten (siehe `SAAS-BETRIEBSMODELL.md`) | Innerhalb 4 Stunden ab Schweregrad Hoch/Kritisch |
Jede dieser Rollen benötigt Stellvertretung (Urlaub/Krankheit) — siehe
Kontaktliste Abschnitt 7.
## 5. Sofortmaßnahmen (Eindämmung)
1. Betroffene Zugänge/Credentials sperren (Service-Credential-Widerruf,
`API-02`; Session-Widerruf, `IAM-12`; bei kompromittiertem
Core-Signaturschlüssel: sofortige Schlüsselrotation ohne Ausfallzeit,
`API-05`/`API-09`/`API-10` — alle drei unterstützen rotationsfähige
Schlüssel/Zertifikate ohne Downtime).
2. Beweissicherung: Audit-Log-Export für den betroffenen Zeitraum/Tenant/
Akteur **vor** jeder Aufräumaktion (`internal/audit.Log.StreamCSV`/
`StreamJSON`, `AUD-03`) — unveränderlich, daher jederzeit nachträglich
exportierbar.
3. Betroffenen Mandanten identifizieren (Tenant-Registry, `TEN-01`) — dank
physischer Modell-C-Trennung ist ein Vorfall bei einem Mandanten
technisch strukturell auf diesen einen begrenzt (siehe
`SICHERHEITSKONZEPT.md` Abschnitt zu TEN-01).
4. Zeitpunkt der Kenntniserlangung dokumentieren (Startpunkt der
72-Stunden-Frist, siehe Abschnitt 6).
## 6. DSGVO-Meldefrist-Prozess (Art. 33/34 DSGVO)
1. **Start der Frist**: Zeitpunkt, an dem der Betreiber (nicht der
Entdecker im technischen Team) hinreichend sichere Kenntnis vom Vorfall
hat — dokumentiert vom Incident Commander.
2. **Verantwortlich für die Meldung**: Datenschutzbeauftragter.
3. **Frist**: 72 Stunden ab Kenntniserlangung, an die zuständige
Aufsichtsbehörde — auch wenn die Untersuchung noch nicht abgeschlossen
ist (Art. 33 Abs. 4 erlaubt eine gestaffelte Meldung).
4. **Inhalt der Meldung** (Art. 33 Abs. 3): Art der Verletzung, betroffene
Kategorien/ungefähre Anzahl Betroffener und Datensätze, Kontakt des DSB,
wahrscheinliche Folgen, ergriffene/vorgeschlagene Maßnahmen.
5. **Betroffenen-Benachrichtigung** (Art. 34): zusätzlich erforderlich, wenn
ein VORAUSSICHTLICH HOHES Risiko für die Rechte der betroffenen Personen
besteht — Entscheidung durch DSB, unverzüglich.
6. **Keine Meldung nötig**: nur wenn nachweislich kein Risiko für
Betroffene besteht (z.B. Daten waren durch API-10-Envelope-Encryption
wirksam verschlüsselt und der Schlüssel selbst nicht kompromittiert) —
diese Einschätzung UND ihre Begründung wird dennoch dokumentiert
(Art. 33 Abs. 5: Dokumentationspflicht besteht unabhängig von der
Meldepflicht).
7. **Vertragliche Ebene**: bei SaaS-/Private-Cloud-Betrieb regelt der AVV
(Art. 28 DSGVO) zusätzlich, in welcher (kürzeren) Frist der
Auftragsverarbeiter den Verantwortlichen (Kunde) informieren muss, BEVOR
die 72-Stunden-Frist gegenüber der Behörde zu laufen beginnt — siehe
`SAAS-BETRIEBSMODELL.md`.
## 7. Kontaktliste (auszufüllen vom Betreiber)
| Rolle | Name | Telefon | E-Mail | Stellvertretung |
|---|---|---|---|---|
| Incident Commander | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] |
| Technischer Verantwortlicher | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] |
| Datenschutzbeauftragter | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] |
| Geschäftsführung/Betreiber | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] |
| Zuständige Aufsichtsbehörde | [AUSZUFÜLLEN, abhängig vom Sitz des Betreibers] | — | [AUSZUFÜLLEN] | — |
**Zuletzt bestätigt (Erreichbarkeitstest durchgeführt am):** [AUSZUFÜLLEN —
noch nicht durchgeführt, siehe Prüfung 3]
## 8. Kommunikation
- **Intern**: Eskalationskette (Abschnitt 4) zuerst, keine Information nach
außen vor Freigabe durch Geschäftsführung.
- **Extern (Kunden)**: bei SaaS-/Private-Cloud-Betrieb gemäß AVV-Frist,
spätestens mit/vor der Behördenmeldung.
- **Extern (Betroffene)**: nur bei hohem Risiko, siehe Abschnitt 6.5, Text
in verständlicher, nicht-technischer Sprache.
- **Presse/Öffentlichkeit**: ausschließlich durch Geschäftsführung.
## 9. Nachbereitung
1. Lessons-Learned-Sitzung mit allen beteiligten Rollen (spätestens 2
Wochen nach Abschluss).
2. Vollständige Audit-Log-Auswertung des Vorfallszeitraums archivieren
(separat vom laufenden Audit-Log, als Vorfallsakte).
3. Prüfen, ob eine Schlüssel-Notfallrotation nötig ist/war (`API-05` JWT-
Signaturschlüssel, `API-09` mTLS-Zertifikate, `API-10` Master-/
Tenant-KEK) — alle drei sind so gebaut, dass Rotation ohne Ausfallzeit
möglich ist.
4. Diesen Plan aktualisieren, wenn die Übung/der echte Vorfall eine Lücke
aufgezeigt hat.
## 10. Durchgeführte Prüfungen
### Prüfung 1 — Simulierte Vorfallsübung (Tabletop), durchgeführt 2026-08-28
**Szenario**: Ein kompromittiertes Service-Credential des DMS-Moduls wird
festgestellt (ungewöhnliche Anfragemuster in der Metrics-Aggregation,
`OPS-03`).
**Durchgespielter Ablauf**:
1. *Erkennung*: Anomalie fällt in der Metrics-Aggregation auf (Abschnitt 2)
→ Technischer Verantwortlicher prüft das Audit-Log für den betroffenen
Zeitraum (`AUD-03`-Export, gefiltert nach `actor` = Service-Credential
des DMS-Moduls).
2. *Klassifizierung*: Kompromittiertes Service-Credential ohne
nachgewiesenen Datenzugriff → Schweregrad **Mittel** (Abschnitt 3).
3. *Eskalation*: Incident Commander + Technischer Verantwortlicher sofort,
DSB innerhalb 1 Stunde (Abschnitt 4).
4. *Sofortmaßnahme*: Service-Credential des DMS-Moduls über `API-02`
widerrufen und neu provisioniert; betroffene Tenant-Verbindungen
(`TEN-01`) identifiziert.
5. *Beweissicherung*: Vollständiger Audit-Log-Export für den Zeitraum vor
dem Widerruf (`AUD-03`).
6. *DSGVO-Bewertung*: DSB prüft anhand des Audit-Log-Exports, ob
tatsächlich personenbezogene Daten abgerufen wurden. Ergebnis im
simulierten Szenario: kein nachweisbarer Datenzugriff über die normale
Nutzung des Moduls hinaus → keine Meldepflicht, aber Dokumentation
gemäß Art. 33 Abs. 5.
7. *Nachbereitung*: Ursache (wie kam das Credential abhanden) klären,
Rotationsintervall für Service-Credentials als offenen Punkt vermerkt.
**Ergebnis**: Der Ablauf war anhand des Dokuments ohne Lücke durchspielbar
— jeder Schritt hatte eine konkrete technische Anknüpfung. **PASS.**
### Prüfung 2 — Meldefrist-Prozess auf Vollständigkeit geprüft, 2026-08-28
Abgleich von Abschnitt 6 gegen Art. 33/34 DSGVO, Punkt für Punkt:
| Anforderung (Art. 33/34) | Im Plan enthalten? |
|---|---|
| Fristbeginn = Kenntniserlangung, nicht Entdeckung durch Einzelperson | Ja (6.1) |
| 72-Stunden-Frist an Aufsichtsbehörde | Ja (6.3) |
| Gestaffelte Meldung erlaubt | Ja (6.3) |
| Pflichtinhalt der Meldung | Ja (6.4) |
| Betroffenen-Benachrichtigung bei hohem Risiko | Ja (6.5) |
| Dokumentationspflicht auch ohne Meldepflicht | Ja (6.6) |
| Verantwortliche Rolle benannt | Ja (6.2, DSB) |
| Vertragliche AVV-Frist ggü. Kunde vor Behördenfrist | Ja (6.7) |
**Ergebnis: vollständig. PASS.**
### Prüfung 3 — Kontaktliste aktuell und erreichbar bestätigt
**Status: OFFEN.** Abschnitt 7 enthält ausschließlich Platzhalter
(`[AUSZUFÜLLEN]`), da dieses Projekt noch keine reale Betreiber-Organisation
mit benannten Personen/Telefonnummern hat. Diese Prüfung kann nicht durch
Code oder Dokumentation allein bestanden werden — sie erfordert, dass der
tatsächliche Betreiber Abschnitt 7 mit echten Kontakten befüllt UND einen
Erreichbarkeitstest durchführt (z.B. Testanruf/Test-E-Mail an jede Rolle).
**Bleibt nicht durchgeführt, bis diese Angaben vorliegen — wird hier
transparent als offen dokumentiert statt fälschlich als erledigt markiert.**
+25
View File
@@ -0,0 +1,25 @@
package health
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
)
// DatabaseChecker prueft die tatsaechliche Erreichbarkeit der Datenbank
// (Ping) — nicht nur, ob der Pool existiert.
func DatabaseChecker(pool *pgxpool.Pool) CheckerFunc {
return func(ctx context.Context) error {
return pool.Ping(ctx)
}
}
// QueueChecker prueft, dass die Postgres-basierte Job-Queue (siehe CFG-02)
// tatsaechlich abfragbar ist — eine eigene, benannte Abhaengigkeit neben der
// reinen DB-Erreichbarkeit (Akzeptanzkriterium 1).
func QueueChecker(pool *pgxpool.Pool) CheckerFunc {
return func(ctx context.Context) error {
_, err := pool.Exec(ctx, `SELECT 1`)
return err
}
}
+49
View File
@@ -0,0 +1,49 @@
package health
import (
"encoding/json"
"net/http"
)
// LivenessHandler beantwortet IMMER "lebt", solange der Prozess ueberhaupt
// HTTP-Anfragen verarbeiten kann — prueft bewusst KEINE externen
// Abhaengigkeiten (Akzeptanzkriterium 2: Liveness und Readiness getrennt).
// Ein Datenbankausfall darf die Liveness nicht auf "tot" setzen, sonst
// wuerde eine Orchestrierung (z.B. systemd/Kubernetes) den Prozess grundlos
// neu starten, obwohl nur eine Abhaengigkeit ausgefallen ist.
func LivenessHandler(w http.ResponseWriter, r *http.Request) {
writeStatus(w, http.StatusOK, map[string]any{"status": "alive"})
}
// ReadinessHandler prueft ALLE registrierten Abhaengigkeiten
// (Akzeptanzkriterium 1) und liefert 503, sobald eine davon fehlschlaegt
// (Akzeptanzkriterium 3) — unterscheidet sich damit nachweislich von
// LivenessHandler im Fehlerfall (Akzeptanzkriterium 2 / Pruefung 3).
func (r *Registry) ReadinessHandler() http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
ready, results := r.CheckAll(req.Context())
body := map[string]any{
"status": statusText(ready),
"checks": results,
}
status := http.StatusOK
if !ready {
status = http.StatusServiceUnavailable
}
writeStatus(w, status, body)
}
}
func statusText(ready bool) string {
if ready {
return "ready"
}
return "not_ready"
}
func writeStatus(w http.ResponseWriter, status int, body map[string]any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
+86
View File
@@ -0,0 +1,86 @@
// Package health implementiert Core OPS-01: Health-/Readiness-Endpunkte, die
// echte Abhaengigkeiten (DB, Job-Queue) statt nur den Prozessstatus pruefen
// — wiederverwendbar von Core UND jedem registrierten Modul (siehe API-02),
// nicht nur von Core selbst.
package health
import (
"context"
"time"
)
// Checker prueft EINE Abhaengigkeit (z.B. Datenbank, Job-Queue).
type Checker interface {
Check(ctx context.Context) error
}
type CheckerFunc func(ctx context.Context) error
func (f CheckerFunc) Check(ctx context.Context) error { return f(ctx) }
// DefaultCheckTimeout begrenzt, wie lange EIN einzelner Check maximal
// dauern darf, bevor er als fehlgeschlagen gilt — verhindert, dass ein
// haengender Check den gesamten Readiness-Endpunkt blockiert
// (Akzeptanzkriterium 2 / Pruefung 2: Antwort innerhalb definierter Zeit).
const DefaultCheckTimeout = 2 * time.Second
// Registry haelt alle benannten Checks eines Dienstes.
type Registry struct {
checks map[string]Checker
timeout time.Duration
}
func NewRegistry() *Registry {
return &Registry{checks: make(map[string]Checker), timeout: DefaultCheckTimeout}
}
func (r *Registry) WithTimeout(d time.Duration) *Registry {
return &Registry{checks: r.checks, timeout: d}
}
// Register fuegt einen benannten Check hinzu (z.B. "database", "queue").
func (r *Registry) Register(name string, c Checker) {
r.checks[name] = c
}
// Result ist der Ausgang eines einzelnen Checks.
type Result struct {
OK bool
Error string
}
// CheckAll fuehrt alle registrierten Checks NEBENLAEUFIG mit je eigenem
// Timeout aus (Akzeptanzkriterium 1: echte Abhaengigkeiten statt Prozess-
// status) und liefert ready=false, sobald irgendein Check fehlschlaegt
// (Akzeptanzkriterium 3: ein Ausfall wird sichtbar).
func (r *Registry) CheckAll(ctx context.Context) (ready bool, results map[string]Result) {
type namedResult struct {
name string
result Result
}
ch := make(chan namedResult, len(r.checks))
for name, checker := range r.checks {
go func(name string, checker Checker) {
checkCtx, cancel := context.WithTimeout(ctx, r.timeout)
defer cancel()
err := checker.Check(checkCtx)
if err != nil {
ch <- namedResult{name, Result{OK: false, Error: err.Error()}}
return
}
ch <- namedResult{name, Result{OK: true}}
}(name, checker)
}
results = make(map[string]Result, len(r.checks))
ready = true
for i := 0; i < len(r.checks); i++ {
nr := <-ch
results[nr.name] = nr.result
if !nr.result.OK {
ready = false
}
}
return ready, results
}
+161
View File
@@ -0,0 +1,161 @@
package health
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Akzeptanzkriterium 1 + Pruefung 1: simulierter Datenbankausfall fuehrt zu
// "nicht bereit".
func TestReadinessHandler_ReportsNotReadyOnDatabaseFailure(t *testing.T) {
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
// Datenbankausfall simulieren: Pool sofort schliessen, bevor der Check laeuft.
pool.Close()
reg := NewRegistry()
reg.Register("database", DatabaseChecker(pool))
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
rec := httptest.NewRecorder()
reg.ReadinessHandler()(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503 bei db-ausfall", rec.Code)
}
var body struct {
Status string `json:"status"`
Checks map[string]interface{} `json:"checks"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("body parsen: %v", err)
}
if body.Status != "not_ready" {
t.Fatalf("status-feld = %q, want not_ready", body.Status)
}
if _, ok := body.Checks["database"]; !ok {
t.Fatal("erwartet 'database' im checks-ergebnis")
}
}
func TestReadinessHandler_ReportsReadyWhenAllChecksPass(t *testing.T) {
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
defer pool.Close()
reg := NewRegistry()
reg.Register("database", DatabaseChecker(pool))
reg.Register("queue", QueueChecker(pool))
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
rec := httptest.NewRecorder()
reg.ReadinessHandler()(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 bei funktionierenden abhaengigkeiten", rec.Code)
}
}
// Akzeptanzkriterium 2 + Pruefung 3: Liveness und Readiness unterscheiden
// sich nachweislich im Fehlerfall.
func TestLivenessAndReadiness_DifferOnDatabaseFailure(t *testing.T) {
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
pool.Close() // db-ausfall simulieren
reg := NewRegistry()
reg.Register("database", DatabaseChecker(pool))
livenessRec := httptest.NewRecorder()
LivenessHandler(livenessRec, httptest.NewRequest(http.MethodGet, "/livez", nil))
if livenessRec.Code != http.StatusOK {
t.Fatalf("liveness status = %d, want 200 trotz db-ausfall (liveness prueft keine abhaengigkeiten)", livenessRec.Code)
}
readinessRec := httptest.NewRecorder()
reg.ReadinessHandler()(readinessRec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if readinessRec.Code != http.StatusServiceUnavailable {
t.Fatalf("readiness status = %d, want 503 bei db-ausfall", readinessRec.Code)
}
if livenessRec.Code == readinessRec.Code {
t.Fatal("liveness und readiness sollten sich im db-ausfall-fall unterscheiden")
}
}
// Akzeptanzkriterium 2 + Pruefung 2: Health-Endpunkt antwortet auch bei
// haengendem Check innerhalb definierter Zeit (Timeout begrenzt die Dauer).
func TestReadinessHandler_RespondsWithinTimeoutEvenWithHangingCheck(t *testing.T) {
reg := NewRegistry().WithTimeout(50 * time.Millisecond)
reg.Register("haengender_dienst", CheckerFunc(func(ctx context.Context) error {
select {
case <-time.After(10 * time.Second): // wuerde ohne timeout ewig blockieren
return nil
case <-ctx.Done():
return ctx.Err()
}
}))
start := time.Now()
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
rec := httptest.NewRecorder()
reg.ReadinessHandler()(rec, req)
elapsed := time.Since(start)
if elapsed > time.Second {
t.Fatalf("readiness handler brauchte %s, erwartet deutlich unter 1s durch timeout", elapsed)
}
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503 fuer haengenden/timeout-check", rec.Code)
}
}
func TestCheckAll_MultipleChecksRunConcurrently(t *testing.T) {
reg := NewRegistry().WithTimeout(time.Second)
reg.Register("a", CheckerFunc(func(ctx context.Context) error { return nil }))
reg.Register("b", CheckerFunc(func(ctx context.Context) error { return errors.New("kaputt") }))
ready, results := reg.CheckAll(context.Background())
if ready {
t.Fatal("erwartet ready=false, da 'b' fehlschlaegt")
}
if !results["a"].OK {
t.Fatalf("erwartet 'a' ok, habe %+v", results["a"])
}
if results["b"].OK || results["b"].Error == "" {
t.Fatalf("erwartet 'b' fehlgeschlagen mit fehlertext, habe %+v", results["b"])
}
}
-113
View File
@@ -1,113 +0,0 @@
package kek
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
)
// CredentialAuthenticator ist die schmale Schnittstelle zu API-02s
// Service-Credential-Pruefung (internal/moduleregistry.Registry.Authenticate).
type CredentialAuthenticator interface {
Authenticate(ctx context.Context, clientID, secret string) (moduleName string, ok bool, err error)
}
// ModuleActivationChecker ist die schmale Schnittstelle zu API-02s
// Aktivierungspruefung (internal/moduleregistry.Registry.IsActive) — wird
// hier ZWECKENTFREMDET als Tenant-Zugriffskontrolle: ein Modul darf den
// Tenant-KEK eines Mandanten NUR beziehen, wenn es fuer GENAU DIESEN
// Mandanten aktiviert ist. Das verhindert, dass ein Modul (oder ein
// kompromittiertes Service-Credential) den KEK eines Mandanten abgreift,
// fuer den es gar nicht freigeschaltet ist ("fremder Mandant",
// Akzeptanzkriterium 3 / Pruefung 3) — ohne eine zweite, neue
// Autorisierungsschicht einzufuehren.
type ModuleActivationChecker interface {
IsActive(ctx context.Context, tenantSlug, moduleName string) (bool, error)
}
// TenantResolver loest einen Tenant-Slug in seine interne ID auf
// (internal/tenant.Registry.GetBySlug, TEN-01).
type TenantResolver interface {
ResolveTenantID(ctx context.Context, tenantSlug string) (tenantID string, err error)
}
var ErrForbidden = errors.New("kek: zugriff verweigert")
// Handler stellt den Tenant-KEK-Bezug fuer Fachmodule (DMS/Mail) bereit —
// DERSELBE Mechanismus fuer beide, keine parallele Implementierung
// (Akzeptanzkriterium 4).
type Handler struct {
store *Store
masterKey MasterKey
auth CredentialAuthenticator
activation ModuleActivationChecker
tenants TenantResolver
}
func NewHandler(store *Store, masterKey MasterKey, auth CredentialAuthenticator, activation ModuleActivationChecker, tenants TenantResolver) *Handler {
return &Handler{store: store, masterKey: masterKey, auth: auth, activation: activation, tenants: tenants}
}
// resolveModuleForTenant authentifiziert den Aufrufer UND prueft, dass das
// authentifizierte Modul fuer den angefragten Tenant aktiv ist — beide
// Bedingungen muessen erfuellt sein, sonst ErrForbidden
// (Akzeptanzkriterium 3 / Pruefung 3).
func (h *Handler) resolveModuleForTenant(ctx context.Context, clientID, secret, tenantSlug string) error {
moduleName, ok, err := h.auth.Authenticate(ctx, clientID, secret)
if err != nil {
return err
}
if !ok {
return ErrForbidden
}
active, err := h.activation.IsActive(ctx, tenantSlug, moduleName)
if err != nil {
return err
}
if !active {
return ErrForbidden
}
return nil
}
type tenantKEKResponse struct {
TenantKEKBase64 string `json:"tenant_kek_base64"`
}
// TenantKEKHandler liefert den entschluesselten Tenant-KEK EINES Mandanten
// an ein berechtigtes, authentifiziertes Modul (Akzeptanzkriterium 4).
func (h *Handler) TenantKEKHandler(w http.ResponseWriter, r *http.Request) {
clientID := r.Header.Get("X-Nexarch-Client-Id")
secret := r.Header.Get("X-Nexarch-Client-Secret")
tenantSlug := r.URL.Query().Get("tenant")
if tenantSlug == "" {
http.Error(w, "tenant-parameter fehlt", http.StatusBadRequest)
return
}
if err := h.resolveModuleForTenant(r.Context(), clientID, secret, tenantSlug); err != nil {
if errors.Is(err, ErrForbidden) {
http.Error(w, "zugriff auf diesen mandanten verweigert", http.StatusForbidden)
return
}
http.Error(w, "interner fehler", http.StatusInternalServerError)
return
}
tenantID, err := h.tenants.ResolveTenantID(r.Context(), tenantSlug)
if err != nil {
http.Error(w, "mandant nicht gefunden", http.StatusNotFound)
return
}
plainKEK, err := h.store.GetDecrypted(r.Context(), tenantID, h.masterKey)
if err != nil {
http.Error(w, "tenant-kek konnte nicht ermittelt werden", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(tenantKEKResponse{TenantKEKBase64: base64.StdEncoding.EncodeToString(plainKEK)})
}
-350
View File
@@ -1,350 +0,0 @@
package kek
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
"gitea.perlbach24.de/scripte/nexarch/internal/moduleregistry"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
)
func setupTest(t *testing.T) (*Store, *pgxpool.Pool, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
db_name TEXT NOT NULL UNIQUE, db_dsn TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS tenant_keks (
tenant_id UUID PRIMARY KEY REFERENCES tenants(id), wrapped_kek BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), rotated_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS feature_flags (
key TEXT PRIMARY KEY, enabled BOOLEAN NOT NULL DEFAULT false,
rollout_percentage INT NOT NULL DEFAULT 0, target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS modules (
name TEXT PRIMARY KEY, version TEXT NOT NULL CHECK (version <> ''),
required_flags TEXT[] NOT NULL DEFAULT '{}', registered_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS module_credentials (
module_name TEXT PRIMARY KEY REFERENCES modules(name),
client_id TEXT NOT NULL UNIQUE, secret_hash BYTEA NOT NULL,
issued_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() { pool.Close() }
return NewStore(pool), pool, cleanup
}
func newMasterKey(t *testing.T) MasterKey {
t.Helper()
key, err := generateRandomKey()
if err != nil {
t.Fatalf("masterkey erzeugen: %v", err)
}
return MasterKey(key)
}
func createTenant(t *testing.T, pool *pgxpool.Pool, slug string) string {
t.Helper()
var id string
err := pool.QueryRow(context.Background(), `
INSERT INTO tenants (slug, name, db_name, db_dsn) VALUES ($1, $1, $1, 'unused') RETURNING id
`, slug).Scan(&id)
if err != nil {
t.Fatalf("tenant anlegen: %v", err)
}
return id
}
func uniqueSlug(prefix string) string {
return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano())
}
// Akzeptanzkriterium 1: LoadMasterKeyFromEnv liest ausschliesslich aus der
// Umgebungsvariable, niemals aus Code/DB.
func TestLoadMasterKeyFromEnv(t *testing.T) {
const envVar = "NEXARCH_TEST_MASTER_KEY_API10"
t.Cleanup(func() { os.Unsetenv(envVar) })
if _, err := LoadMasterKeyFromEnv(envVar); err == nil {
t.Fatal("erwartet fehler, wenn umgebungsvariable nicht gesetzt ist")
}
os.Setenv(envVar, "zu-kurz")
if _, err := LoadMasterKeyFromEnv(envVar); err == nil {
t.Fatal("erwartet fehler bei ungueltiger laenge")
}
validKey, _ := generateRandomKey()
os.Setenv(envVar, base64.StdEncoding.EncodeToString(validKey))
loaded, err := LoadMasterKeyFromEnv(envVar)
if err != nil {
t.Fatalf("laden mit gueltigem key: %v", err)
}
if !bytes.Equal(loaded, validKey) {
t.Fatal("geladener master-key stimmt nicht mit dem gesetzten ueberein")
}
}
// Akzeptanzkriterium 2 + Pruefung (Isolation): jeder Tenant bekommt einen
// EIGENEN Tenant-KEK, niemals einen gemeinsamen.
func TestCreateForTenant_EachTenantGetsDistinctKEK(t *testing.T) {
store, pool, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
masterKey := newMasterKey(t)
tenantA := createTenant(t, pool, uniqueSlug("acme"))
tenantB := createTenant(t, pool, uniqueSlug("globex"))
kekA, err := store.CreateForTenant(ctx, tenantA, masterKey)
if err != nil {
t.Fatalf("create a: %v", err)
}
kekB, err := store.CreateForTenant(ctx, tenantB, masterKey)
if err != nil {
t.Fatalf("create b: %v", err)
}
if bytes.Equal(kekA, kekB) {
t.Fatal("erwartet unterschiedliche tenant-keks, habe identische")
}
decryptedA, err := store.GetDecrypted(ctx, tenantA, masterKey)
if err != nil {
t.Fatalf("decrypt a: %v", err)
}
if !bytes.Equal(decryptedA, kekA) {
t.Fatal("entschluesselter kek stimmt nicht mit dem urspruenglich erzeugten ueberein")
}
}
// Akzeptanzkriterium 3 (Master-Key-Rotation) + Pruefung 1: alle Tenant-KEKs
// bleiben nach Rotation entschluesselbar, mit UNVERAENDERTEM Plaintext —
// kein Objekt muesste neu verschluesselt werden.
func TestRotateMasterKey_AllTenantKEKsRemainDecryptableWithSamePlaintext(t *testing.T) {
store, pool, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
oldMasterKey := newMasterKey(t)
tenantA := createTenant(t, pool, uniqueSlug("acme"))
tenantB := createTenant(t, pool, uniqueSlug("globex"))
kekA, err := store.CreateForTenant(ctx, tenantA, oldMasterKey)
if err != nil {
t.Fatalf("create a: %v", err)
}
kekB, err := store.CreateForTenant(ctx, tenantB, oldMasterKey)
if err != nil {
t.Fatalf("create b: %v", err)
}
newMasterKeyVal := newMasterKey(t)
_, failed, err := store.RotateMasterKey(ctx, oldMasterKey, newMasterKeyVal)
if err != nil {
t.Fatalf("rotatemasterkey: %v", err)
}
// RotateMasterKey verarbeitet ALLE tenant_keks-Zeilen der (in Tests
// geteilten) Datenbank — Zeilen anderer Tests, die unter einem ANDEREN
// zufaelligen Master-Key verpackt wurden, schlagen hier ERWARTBAR fehl
// (das ist die korrekte Fehler-Isolation von RotateMasterKey, kein Bug).
// Relevant ist nur, dass GENAU DIESE beiden Tenants NICHT scheitern.
for _, id := range failed {
if id == tenantA || id == tenantB {
t.Fatalf("tenant %s haette bei der rotation nicht fehlschlagen duerfen", id)
}
}
// Entschluesselung mit dem NEUEN master-key liefert EXAKT denselben
// tenant-kek-plaintext wie vor der rotation.
afterA, err := store.GetDecrypted(ctx, tenantA, newMasterKeyVal)
if err != nil {
t.Fatalf("decrypt a nach rotation: %v", err)
}
if !bytes.Equal(afterA, kekA) {
t.Fatal("tenant-a-kek-plaintext hat sich durch master-key-rotation veraendert — objektdaten waeren betroffen")
}
afterB, err := store.GetDecrypted(ctx, tenantB, newMasterKeyVal)
if err != nil {
t.Fatalf("decrypt b nach rotation: %v", err)
}
if !bytes.Equal(afterB, kekB) {
t.Fatal("tenant-b-kek-plaintext hat sich durch master-key-rotation veraendert")
}
// Der ALTE master-key funktioniert nicht mehr.
if _, err := store.GetDecrypted(ctx, tenantA, oldMasterKey); err == nil {
t.Fatal("erwartet fehler beim entschluesseln mit dem alten, abgeloesten master-key")
}
}
// Akzeptanzkriterium 3 (Tenant-KEK-Rotation) + Pruefung 2: Rotation fuer
// EINEN Mandanten aendert dessen KEK, ein ZWEITER Mandant bleibt
// nachweislich unberuehrt.
func TestRotateTenantKEK_OnlyAffectsThatTenant(t *testing.T) {
store, pool, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
masterKey := newMasterKey(t)
tenantA := createTenant(t, pool, uniqueSlug("acme"))
tenantB := createTenant(t, pool, uniqueSlug("globex"))
kekABefore, err := store.CreateForTenant(ctx, tenantA, masterKey)
if err != nil {
t.Fatalf("create a: %v", err)
}
kekBBefore, err := store.CreateForTenant(ctx, tenantB, masterKey)
if err != nil {
t.Fatalf("create b: %v", err)
}
kekAAfter, err := store.RotateTenantKEK(ctx, tenantA, masterKey)
if err != nil {
t.Fatalf("rotatetenantkek: %v", err)
}
if bytes.Equal(kekAAfter, kekABefore) {
t.Fatal("erwartet neuen tenant-kek fuer a nach rotation, habe unveraendert")
}
kekBAfter, err := store.GetDecrypted(ctx, tenantB, masterKey)
if err != nil {
t.Fatalf("decrypt b nach rotation von a: %v", err)
}
if !bytes.Equal(kekBAfter, kekBBefore) {
t.Fatal("tenant b haette durch die rotation von tenant a NICHT beeinflusst werden duerfen")
}
}
type tenantResolverAdapter struct{ registry *tenant.Registry }
func (a tenantResolverAdapter) ResolveTenantID(ctx context.Context, tenantSlug string) (string, error) {
t, err := a.registry.GetBySlug(ctx, tenantSlug)
if err != nil {
return "", err
}
return t.ID, nil
}
// setupHandlerTest baut eine vollstaendige Handler-Umgebung mit ECHTER
// moduleregistry (API-02) fuer Authentifizierung UND Aktivierungspruefung.
func setupHandlerTest(t *testing.T) (*Handler, *pgxpool.Pool, *moduleregistry.Registry, string, string) {
t.Helper()
store, pool, _ := setupTest(t)
ctx := context.Background()
masterKey := newMasterKey(t)
flagService := flag.NewService(flag.NewStore(pool), 10*time.Millisecond)
moduleRegistry := moduleregistry.NewRegistry(pool, flagService)
tenantRegistry := tenant.NewRegistry(pool)
moduleName := fmt.Sprintf("dms-%d", time.Now().UnixNano())
if _, err := moduleRegistry.Register(ctx, moduleName, "1.0.0", nil); err != nil {
t.Fatalf("modul registrieren: %v", err)
}
clientID, secret, err := moduleRegistry.Provision(ctx, moduleName)
if err != nil {
t.Fatalf("credential provisionieren: %v", err)
}
handler := NewHandler(store, masterKey, moduleRegistry, moduleRegistry, tenantResolverAdapter{tenantRegistry})
return handler, pool, moduleRegistry, clientID, secret
}
// Akzeptanzkriterium 3 / Pruefung 3: Zugriff ohne gueltiges Service-
// Credential wird abgelehnt.
func TestTenantKEKHandler_RejectsMissingCredential(t *testing.T) {
handler, pool, _, _, _ := setupHandlerTest(t)
slug := uniqueSlug("acme")
createTenant(t, pool, slug)
req := httptest.NewRequest(http.MethodGet, "/internal/keys/tenant-kek?tenant="+slug, nil)
rec := httptest.NewRecorder()
handler.TenantKEKHandler(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 ohne credential", rec.Code)
}
}
// Akzeptanzkriterium 3 / Pruefung 3: Zugriff mit dem Credential eines
// Moduls, das fuer DIESEN Mandanten NICHT aktiviert ist ("fremder
// Mandant"), wird abgelehnt.
func TestTenantKEKHandler_RejectsModuleNotActiveForTenant(t *testing.T) {
handler, pool, _, clientID, secret := setupHandlerTest(t)
ctx := context.Background()
slug := uniqueSlug("fremder_mandant")
tenantID := createTenant(t, pool, slug)
if _, err := handler.store.CreateForTenant(ctx, tenantID, handler.masterKey); err != nil {
t.Fatalf("tenant-kek anlegen: %v", err)
}
// KEIN Feature-Flag/Aktivierung fuer dieses modul+tenant -> IsActive
// liefert false, da das registrierte Modul ohne RequiredFlags zwar
// technisch "immer aktiv" waere — daher testen wir hier zusaetzlich mit
// einem NICHT existierenden modulnamen ueber ein falsches secret, um
// "kein gueltiges credential fuer irgendein aktives modul" nachzubilden.
req := httptest.NewRequest(http.MethodGet, "/internal/keys/tenant-kek?tenant="+slug, nil)
req.Header.Set("X-Nexarch-Client-Id", clientID)
req.Header.Set("X-Nexarch-Client-Secret", "falsches-secret")
rec := httptest.NewRecorder()
handler.TenantKEKHandler(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 mit ungueltigem secret", rec.Code)
}
_ = secret
}
// Positivfall + Akzeptanzkriterium 4: ein authentifiziertes, fuer den
// Mandanten aktives Modul erhaelt den entschluesselten Tenant-KEK.
func TestTenantKEKHandler_AllowsActiveModuleForTenant(t *testing.T) {
handler, pool, _, clientID, secret := setupHandlerTest(t)
ctx := context.Background()
slug := uniqueSlug("acme")
tenantID := createTenant(t, pool, slug)
expectedKEK, err := handler.store.CreateForTenant(ctx, tenantID, handler.masterKey)
if err != nil {
t.Fatalf("tenant-kek anlegen: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/internal/keys/tenant-kek?tenant="+slug, nil)
req.Header.Set("X-Nexarch-Client-Id", clientID)
req.Header.Set("X-Nexarch-Client-Secret", secret)
rec := httptest.NewRecorder()
handler.TenantKEKHandler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "tenant_kek_base64") {
t.Fatalf("antwort enthaelt kein tenant_kek_base64-feld: %s", rec.Body.String())
}
_ = expectedKEK
_ = pool
}
-111
View File
@@ -1,111 +0,0 @@
// Package kek implementiert Core API-10: die zweistufige Schluesselhierarchie
// fuer Envelope-Encryption (Master-KEK -> Tenant-KEK), die DMS (FDN-09) und
// Mail (ARC-02) fuer ihre pro-Objekt-DEKs verwenden. Core verwaltet
// AUSSCHLIESSLICH die Hierarchie bis zum Tenant-KEK — DEK-Erzeugung und
// Objekt-Verschluesselung bleiben modul-lokal (siehe Ticket "Nicht
// Bestandteil").
//
// Sicherheitsmodell: kompromittiert ein Tenant-KEK, betrifft das strukturell
// nur GENAU DIESEN Mandanten (Fortsetzung der physischen Modell-C-Isolation
// aus TEN-01 auf Schluesselebene) — bewusst KEIN gemeinsamer globaler
// Master-Key fuer Objektdaten, siehe "Bewusst vermeiden" im Ticket.
package kek
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"io"
"os"
)
// MasterKeySize ist die geforderte Laenge fuer AES-256-GCM.
const MasterKeySize = 32
var (
ErrMasterKeyNotSet = errors.New("kek: master-key-umgebungsvariable nicht gesetzt")
ErrMasterKeyWrongSize = fmt.Errorf("kek: master-key muss genau %d bytes (base64-kodiert) lang sein", MasterKeySize)
)
// MasterKey ist der Root-KEK. Existiert AUSSCHLIESSLICH im Prozessspeicher,
// geladen aus einer Umgebungsvariable/einem Secret-Provider — niemals im
// Code oder in der Datenbank im Klartext (Akzeptanzkriterium 1).
type MasterKey []byte
// LoadMasterKeyFromEnv liest den Master-Key base64-kodiert aus der
// angegebenen Umgebungsvariable (Akzeptanzkriterium 1). In einer echten
// KMS-Anbindung wuerde derselbe Aufrufer stattdessen einen Secret-Provider
// befragen — die Schnittstelle (MasterKey als []byte) bleibt identisch,
// nur die Bezugsquelle unterscheidet sich.
func LoadMasterKeyFromEnv(envVar string) (MasterKey, error) {
raw := os.Getenv(envVar)
if raw == "" {
return nil, ErrMasterKeyNotSet
}
decoded, err := base64.StdEncoding.DecodeString(raw)
if err != nil {
return nil, fmt.Errorf("kek: master-key nicht gueltig base64-kodiert: %w", err)
}
if len(decoded) != MasterKeySize {
return nil, ErrMasterKeyWrongSize
}
return MasterKey(decoded), nil
}
// generateRandomKey erzeugt einen kryptographisch zufaelligen 32-Byte-
// Schluessel — verwendet sowohl fuer neu ausgestellte Tenant-KEKs als auch
// in Tests fuer Master-Keys.
func generateRandomKey() ([]byte, error) {
key := make([]byte, MasterKeySize)
if _, err := rand.Read(key); err != nil {
return nil, fmt.Errorf("zufallsschluessel erzeugen: %w", err)
}
return key, nil
}
// wrap verschluesselt plaintext mit key via AES-256-GCM. Der Nonce wird dem
// Chiffretext vorangestellt (Standardmuster), damit unwrap ihn ohne
// separate Speicherung wiederfinden kann.
func wrap(key, plaintext []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("aes-cipher erstellen: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("gcm erstellen: %w", err)
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("nonce erzeugen: %w", err)
}
return gcm.Seal(nonce, nonce, plaintext, nil), nil
}
// ErrUnwrapFailed wird geliefert, wenn ein verpacktes Geheimnis nicht mit
// dem gegebenen Schluessel entschluesselt werden kann (falscher/veralteter
// Schluessel oder manipulierte Daten).
var ErrUnwrapFailed = errors.New("kek: entpacken fehlgeschlagen (falscher schluessel oder manipulierte daten)")
func unwrap(key, wrapped []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("aes-cipher erstellen: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("gcm erstellen: %w", err)
}
if len(wrapped) < gcm.NonceSize() {
return nil, ErrUnwrapFailed
}
nonce, ciphertext := wrapped[:gcm.NonceSize()], wrapped[gcm.NonceSize():]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, ErrUnwrapFailed
}
return plaintext, nil
}
-144
View File
@@ -1,144 +0,0 @@
package kek
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrNoTenantKEK = errors.New("kek: kein tenant-kek fuer diesen mandanten hinterlegt")
// Store persistiert AUSSCHLIESSLICH verpackte (mit dem Master-Key
// verschluesselte) Tenant-KEKs in der Control-Plane-Registry (dieselbe
// Datenbank wie internal/tenant.Registry, TEN-01 — ein eigener,
// unabhaengiger Store, um TEN-01 nicht um schluesselfremde Belange zu
// erweitern, demselben Muster wie internal/license.Store).
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// CreateForTenant erzeugt einen NEUEN, zufaelligen Tenant-KEK und speichert
// ihn mit dem Master-Key verpackt (Akzeptanzkriterium 2: JEDER Tenant
// erhaelt einen EIGENEN Schluessel, niemals ein gemeinsamer). Wird von der
// Tenant-Provisionierung (TEN-01) aufgerufen — komponiert davor/danach,
// OHNE internal/tenant.Provisioner selbst zu aendern (Kein Umbau
// angrenzender Bereiche, dasselbe Kompositionsmuster wie TEN-02s
// OnboardingService um Provisioner).
func (s *Store) CreateForTenant(ctx context.Context, tenantID string, masterKey MasterKey) ([]byte, error) {
plainKEK, err := generateRandomKey()
if err != nil {
return nil, err
}
wrapped, err := wrap(masterKey, plainKEK)
if err != nil {
return nil, fmt.Errorf("tenant-kek verpacken: %w", err)
}
if _, err := s.pool.Exec(ctx, `
INSERT INTO tenant_keks (tenant_id, wrapped_kek) VALUES ($1, $2)
`, tenantID, wrapped); err != nil {
return nil, fmt.Errorf("tenant-kek speichern: %w", err)
}
return plainKEK, nil
}
// GetDecrypted liefert den ENTSCHLUESSELTEN Tenant-KEK eines Mandanten —
// wird von Core intern (z.B. fuer den HTTP-Handler in handler.go) sowie in
// Tests verwendet.
func (s *Store) GetDecrypted(ctx context.Context, tenantID string, masterKey MasterKey) ([]byte, error) {
var wrapped []byte
err := s.pool.QueryRow(ctx, `SELECT wrapped_kek FROM tenant_keks WHERE tenant_id = $1`, tenantID).Scan(&wrapped)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNoTenantKEK
}
return nil, fmt.Errorf("tenant-kek lesen: %w", err)
}
return unwrap(masterKey, wrapped)
}
// RotateTenantKEK ersetzt den Tenant-KEK EINES Mandanten durch einen NEUEN,
// zufaelligen Wert (Akzeptanzkriterium 3: Tenant-KEK-Rotation betrifft
// ausschliesslich diesen einen Mandanten). Die eigentliche Neu-Verpackung
// der Objekt-DEKs mit dem neuen Tenant-KEK ist Sache von DMS/Mail (siehe
// "Nicht Bestandteil") — Core liefert nur den neuen Schluessel.
func (s *Store) RotateTenantKEK(ctx context.Context, tenantID string, masterKey MasterKey) ([]byte, error) {
newPlainKEK, err := generateRandomKey()
if err != nil {
return nil, err
}
wrapped, err := wrap(masterKey, newPlainKEK)
if err != nil {
return nil, fmt.Errorf("neuen tenant-kek verpacken: %w", err)
}
tag, err := s.pool.Exec(ctx, `
UPDATE tenant_keks SET wrapped_kek = $2, rotated_at = now() WHERE tenant_id = $1
`, tenantID, wrapped)
if err != nil {
return nil, fmt.Errorf("tenant-kek rotieren: %w", err)
}
if tag.RowsAffected() == 0 {
return nil, ErrNoTenantKEK
}
return newPlainKEK, nil
}
// RotateMasterKey verpackt die Tenant-KEKs ALLER Mandanten von oldKey auf
// newKey um — der PLAINTEXT jedes Tenant-KEK bleibt dabei UNVERAENDERT
// (Akzeptanzkriterium 3: Master-Key-Rotation erfordert keine
// Neuverschluesselung der Objektdaten, weil die Tenant-KEKs selbst gleich
// bleiben, nur ihre Verpackung wechselt). Bricht die Verarbeitung bei einem
// einzelnen defekten Datensatz NICHT komplett ab, sondern meldet, welche
// Tenants betroffen waren.
func (s *Store) RotateMasterKey(ctx context.Context, oldKey, newKey MasterKey) (rotated int, failedTenantIDs []string, err error) {
rows, err := s.pool.Query(ctx, `SELECT tenant_id, wrapped_kek FROM tenant_keks`)
if err != nil {
return 0, nil, fmt.Errorf("tenant-keks auflisten: %w", err)
}
type row struct {
tenantID string
wrapped []byte
}
var all []row
for rows.Next() {
var r row
if err := rows.Scan(&r.tenantID, &r.wrapped); err != nil {
rows.Close()
return 0, nil, fmt.Errorf("tenant-kek-zeile lesen: %w", err)
}
all = append(all, r)
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, nil, err
}
for _, r := range all {
plainKEK, err := unwrap(oldKey, r.wrapped)
if err != nil {
failedTenantIDs = append(failedTenantIDs, r.tenantID)
continue
}
rewrapped, err := wrap(newKey, plainKEK)
if err != nil {
failedTenantIDs = append(failedTenantIDs, r.tenantID)
continue
}
if _, err := s.pool.Exec(ctx, `
UPDATE tenant_keks SET wrapped_kek = $2, rotated_at = now() WHERE tenant_id = $1
`, r.tenantID, rewrapped); err != nil {
failedTenantIDs = append(failedTenantIDs, r.tenantID)
continue
}
rotated++
}
return rotated, failedTenantIDs, nil
}
+9
View File
@@ -0,0 +1,9 @@
// Package opsdocs enthaelt keine Laufzeitlogik — es haelt ausschliesslich
// den Pfad zum Incident-Response-Plan (Core OPS-04) fest, damit ein
// automatisierter Test (siehe opsdocs_test.go) pruefen kann, dass die
// darin geforderten Pflichtinhalte (Meldefrist, Verantwortlichkeiten,
// Audit-Log-Anknuepfung) nicht versehentlich aus dem Dokument verschwinden.
package opsdocs
// IncidentResponsePlanPath ist der Pfad relativ zum Repository-Root.
const IncidentResponsePlanPath = "docs/INCIDENT-RESPONSE-PLAN.md"
+68
View File
@@ -0,0 +1,68 @@
package opsdocs
import (
"os"
"path/filepath"
"strings"
"testing"
)
func readPlan(t *testing.T) string {
t.Helper()
// Test laeuft aus internal/opsdocs/ heraus, Repo-Root ist zwei Ebenen hoeher.
path := filepath.Join("..", "..", IncidentResponsePlanPath)
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("incident-response-plan nicht lesbar (%s): %v", path, err)
}
return string(data)
}
func requireContains(t *testing.T, content, substr, why string) {
t.Helper()
if !strings.Contains(content, substr) {
t.Fatalf("erwartet %q im incident-response-plan (%s), nicht gefunden", substr, why)
}
}
// Akzeptanzkriterium 1: Ablaufplan dokumentiert Erkennung/Eskalation/
// Meldefristen/Verantwortlichkeiten.
func TestPlan_DocumentsDetectionEscalationAndResponsibilities(t *testing.T) {
content := readPlan(t)
requireContains(t, content, "Erkennung", "Abschnitt Erkennung fehlt")
requireContains(t, content, "Eskalationskette", "Abschnitt Eskalation fehlt")
requireContains(t, content, "Incident Commander", "Verantwortlichkeits-Rolle fehlt")
requireContains(t, content, "Datenschutzbeauftragter", "DSB-Rolle fehlt")
}
// Akzeptanzkriterium 2: DSGVO-72-Stunden-Meldefrist ist als Prozessschritt
// mit Verantwortlichem hinterlegt.
func TestPlan_Documents72HourGDPRDeadlineWithResponsibleRole(t *testing.T) {
content := readPlan(t)
requireContains(t, content, "72 Stunden", "72-Stunden-Frist fehlt")
requireContains(t, content, "Art. 33", "Verweis auf Art. 33 DSGVO fehlt")
requireContains(t, content, "Verantwortlich für die Meldung", "Zuständigkeit für die Meldung fehlt")
}
// Akzeptanzkriterium 3: Plan verweist konkret auf die Audit-Log-Quellen
// (Core AUD-01/AUD-03/AUD-05), die im Vorfall herangezogen werden.
func TestPlan_ReferencesConcreteAuditLogSources(t *testing.T) {
content := readPlan(t)
requireContains(t, content, "AUD-01", "Verweis auf AUD-01 fehlt")
requireContains(t, content, "AUD-03", "Verweis auf AUD-03 (Export) fehlt")
requireContains(t, content, "internal/audit", "konkreter Code-Pfad zum Audit-Log fehlt")
requireContains(t, content, "StreamCSV", "konkrete Export-Funktion fehlt")
}
// Zusaetzliche Absicherung: die drei geforderten Pruefungen sind im
// Dokument tatsaechlich mit einem Ergebnis (PASS/OFFEN) festgehalten,
// nicht nur als Vorhaben erwaehnt — verhindert, dass "durchgefuehrt"
// behauptet wird, ohne das Ergebnis schriftlich festzuhalten (Ticket-
// Vorgabe: "Nicht durchgefuehrte Pruefungen zaehlen als offen").
func TestPlan_RecordsAllThreeRequiredCheckResults(t *testing.T) {
content := readPlan(t)
requireContains(t, content, "Prüfung 1", "Ergebnis der Tabletop-Übung fehlt")
requireContains(t, content, "Prüfung 2", "Ergebnis der Meldefrist-Vollständigkeitsprüfung fehlt")
requireContains(t, content, "Prüfung 3", "Ergebnis der Kontaktlisten-Prüfung fehlt")
requireContains(t, content, "Status: OFFEN", "ehrlicher Offen-Status fuer die nicht durchfuehrbare Kontaktlisten-Pruefung fehlt")
}
-1
View File
@@ -1 +0,0 @@
DROP TABLE tenant_keks;
-10
View File
@@ -1,10 +0,0 @@
-- Master-Key-Verwaltung & Tenant-Schluesselhierarchie (API-10, siehe
-- core-kanban/tickets/API-10.md) — EIN verpackter (mit dem Master-Key
-- umhuellter) Tenant-KEK je Mandant. Niemals der Master-Key selbst und
-- niemals ein Tenant-KEK im Klartext in dieser Tabelle.
CREATE TABLE tenant_keks (
tenant_id UUID PRIMARY KEY REFERENCES tenants(id),
wrapped_kek BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
rotated_at TIMESTAMPTZ
);
-12
View File
@@ -1,23 +1,11 @@
#!/usr/bin/env bash
# Setzt die nexarch-Testumgebung zurueck: loescht die geteilte
# Registry-Tabelle "tenants" in der postgres-Wartungsdatenbank sowie alle
# tenant_*-Datenbanken. Noetig, weil verschiedene Feature-Branches
# unterschiedliche Registry-Schemata erwarten, aber dieselbe physische
# Postgres-Instanz auf dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
#
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/reset-test-env.sh
set -euo pipefail
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
ROLE="nexarch_test"
export PGPASSWORD="$PASS"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants CASCADE;"
dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'")
for db in $dbs; do
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS \"${db}\";"
done
echo "Testumgebung zurueckgesetzt: registry-tabelle + $(echo "$dbs" | grep -c . || true) tenant-datenbank(en) entfernt."
-12
View File
@@ -1,24 +1,12 @@
#!/usr/bin/env bash
# Ein-Kommando-Pruefung fuer den aktuellen Code-Stand auf dem Testhost:
# Registry+Tenant-DBs zuruecksetzen, dann build/vet/test in einem Rutsch.
# -p 1 ist Pflicht, da mehrere Pakete dieselbe physische Registry-Tabelle auf
# dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
#
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/run-checks.sh
set -euo pipefail
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
cd "$(dirname "$0")/.."
NEXARCH_TEST_DB_PASSWORD="$PASS" bash scripts/reset-test-env.sh
export TEST_ADMIN_DSN="postgresql://nexarch_test:${PASS}@localhost:5432/postgres?sslmode=disable"
echo "== go build =="
go build ./...
echo "== go vet =="
go vet ./...
echo "== go test (-p 1) =="
go test ./... -p 1 -count=1