diff --git a/internal/kek/handler.go b/internal/kek/handler.go index 22f02bbd..56ce631f 100644 --- a/internal/kek/handler.go +++ b/internal/kek/handler.go @@ -53,23 +53,26 @@ func NewHandler(store *Store, masterKey MasterKey, auth CredentialAuthenticator, // 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 { +// (Akzeptanzkriterium 3 / Pruefung 3). Liefert den authentifizierten +// Modulnamen zurueck, damit der Aufrufer ihn als Akteur fuer den +// privilegierten Master-Key-Zugriffs-Audit-Event (API-13, Akzeptanzkriterium +// 2) verwenden kann. +func (h *Handler) resolveModuleForTenant(ctx context.Context, clientID, secret, tenantSlug string) (moduleName string, err error) { moduleName, ok, err := h.auth.Authenticate(ctx, clientID, secret) if err != nil { - return err + return "", err } if !ok { - return ErrForbidden + return "", ErrForbidden } active, err := h.activation.IsActive(ctx, tenantSlug, moduleName) if err != nil { - return err + return "", err } if !active { - return ErrForbidden + return "", ErrForbidden } - return nil + return moduleName, nil } type tenantKEKResponse struct { @@ -87,7 +90,8 @@ func (h *Handler) TenantKEKHandler(w http.ResponseWriter, r *http.Request) { return } - if err := h.resolveModuleForTenant(r.Context(), clientID, secret, tenantSlug); err != nil { + moduleName, err := h.resolveModuleForTenant(r.Context(), clientID, secret, tenantSlug) + if err != nil { if errors.Is(err, ErrForbidden) { http.Error(w, "zugriff auf diesen mandanten verweigert", http.StatusForbidden) return @@ -102,7 +106,7 @@ func (h *Handler) TenantKEKHandler(w http.ResponseWriter, r *http.Request) { return } - plainKEK, err := h.store.GetDecrypted(r.Context(), tenantID, h.masterKey) + plainKEK, err := h.store.GetDecrypted(r.Context(), tenantID, h.masterKey, moduleName) if err != nil { http.Error(w, "tenant-kek konnte nicht ermittelt werden", http.StatusInternalServerError) return diff --git a/internal/kek/kek_test.go b/internal/kek/kek_test.go index 0b996cfc..d70a2a1d 100644 --- a/internal/kek/kek_test.go +++ b/internal/kek/kek_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/base64" + "errors" "fmt" "net/http" "net/http/httptest" @@ -13,9 +14,13 @@ import ( "time" "github.com/jackc/pgx/v5/pgxpool" + "github.com/prometheus/client_golang/prometheus" + "gitea.perlbach24.de/scripte/nexarch/internal/alerting" + "gitea.perlbach24.de/scripte/nexarch/internal/audit" "gitea.perlbach24.de/scripte/nexarch/internal/flag" "gitea.perlbach24.de/scripte/nexarch/internal/moduleregistry" + "gitea.perlbach24.de/scripte/nexarch/internal/notify" "gitea.perlbach24.de/scripte/nexarch/internal/tenant" ) @@ -55,9 +60,39 @@ func setupTest(t *testing.T) (*Store, *pgxpool.Pool, func()) { client_id TEXT NOT NULL UNIQUE, secret_hash BYTEA NOT NULL, issued_at TIMESTAMPTZ NOT NULL DEFAULT now() ); + CREATE TABLE IF NOT EXISTS audit_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(), + tenant_slug TEXT NOT NULL CHECK (tenant_slug <> ''), actor TEXT NOT NULL CHECK (actor <> ''), + action TEXT NOT NULL CHECK (action <> ''), target TEXT NOT NULL, metadata JSONB NOT NULL DEFAULT '{}'::jsonb + ); + CREATE TABLE IF NOT EXISTS kek_master_key_rotation_state ( + singleton_lock BOOLEAN PRIMARY KEY DEFAULT true CHECK (singleton_lock), rotated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE TABLE IF NOT EXISTS alert_rules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), metric_name TEXT NOT NULL, + comparison TEXT NOT NULL CHECK (comparison IN ('gt','lt')), threshold DOUBLE PRECISION NOT NULL, + label_filters JSONB NOT NULL DEFAULT '{}'::jsonb, recipient TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE TABLE IF NOT EXISTS alert_debounce_state ( + rule_key TEXT PRIMARY KEY, last_fired_at TIMESTAMPTZ NOT NULL + ); + CREATE TABLE IF NOT EXISTS notification_jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), channel TEXT NOT NULL, recipient TEXT NOT NULL, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, status TEXT NOT NULL DEFAULT 'pending', attempts INT NOT NULL DEFAULT 0, + max_attempts INT NOT NULL DEFAULT 5, next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); `); err != nil { t.Fatalf("schema: %v", err) } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM audit_events`) + _, _ = pool.Exec(context.Background(), `DELETE FROM kek_master_key_rotation_state`) + _, _ = pool.Exec(context.Background(), `DELETE FROM alert_rules`) + _, _ = pool.Exec(context.Background(), `DELETE FROM alert_debounce_state`) + _, _ = pool.Exec(context.Background(), `DELETE FROM notification_jobs`) + }) // Als t.Cleanup statt nur per defer im Aufrufer zurueckgegeben: falls ein // Aufrufer "defer cleanup()" nutzt, liefe das Schliessen VOR den @@ -153,7 +188,7 @@ func TestCreateForTenant_EachTenantGetsDistinctKEK(t *testing.T) { t.Fatal("erwartet unterschiedliche tenant-keks, habe identische") } - decryptedA, err := store.GetDecrypted(ctx, tenantA, masterKey) + decryptedA, err := store.GetDecrypted(ctx, tenantA, masterKey, "test") if err != nil { t.Fatalf("decrypt a: %v", err) } @@ -200,14 +235,14 @@ func TestRotateMasterKey_AllTenantKEKsRemainDecryptableWithSamePlaintext(t *test // Entschluesselung mit dem NEUEN master-key liefert EXAKT denselben // tenant-kek-plaintext wie vor der rotation. - afterA, err := store.GetDecrypted(ctx, tenantA, newMasterKeyVal) + afterA, err := store.GetDecrypted(ctx, tenantA, newMasterKeyVal, "test") 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) + afterB, err := store.GetDecrypted(ctx, tenantB, newMasterKeyVal, "test") if err != nil { t.Fatalf("decrypt b nach rotation: %v", err) } @@ -216,7 +251,7 @@ func TestRotateMasterKey_AllTenantKEKsRemainDecryptableWithSamePlaintext(t *test } // Der ALTE master-key funktioniert nicht mehr. - if _, err := store.GetDecrypted(ctx, tenantA, oldMasterKey); err == nil { + if _, err := store.GetDecrypted(ctx, tenantA, oldMasterKey, "test"); err == nil { t.Fatal("erwartet fehler beim entschluesseln mit dem alten, abgeloesten master-key") } } @@ -249,7 +284,7 @@ func TestRotateTenantKEK_OnlyAffectsThatTenant(t *testing.T) { t.Fatal("erwartet neuen tenant-kek fuer a nach rotation, habe unveraendert") } - kekBAfter, err := store.GetDecrypted(ctx, tenantB, masterKey) + kekBAfter, err := store.GetDecrypted(ctx, tenantB, masterKey, "test") if err != nil { t.Fatalf("decrypt b nach rotation von a: %v", err) } @@ -364,3 +399,250 @@ func TestTenantKEKHandler_AllowsActiveModuleForTenant(t *testing.T) { _ = expectedKEK _ = pool } + +// Akzeptanzkriterium 2 + Pruefung 2: jede Entschluesselung eines Tenant-KEK +// durch den Master-Key erzeugt einen eigenen Audit-Event mit Akteur +// (Service-Credential), Zeitpunkt und betroffenem Tenant — UEBER den +// vollstaendigen HTTP-Pfad (echtes Service-Credential, echter Handler), nicht +// nur den Store direkt. +func TestTenantKEKHandler_RecordsPrivilegedMasterKeyAccessAuditEvent(t *testing.T) { + handler, pool, _, clientID, secret := setupHandlerTest(t) + ctx := context.Background() + slug := uniqueSlug("acme") + tenantID := createTenant(t, pool, slug) + if _, err := handler.store.CreateForTenant(ctx, tenantID, handler.masterKey); err != nil { + t.Fatalf("tenant-kek anlegen: %v", err) + } + handler.store = handler.store.WithAudit(newTestAuditLog(pool)) + + 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", rec.Code) + } + + var actor, target string + err := pool.QueryRow(ctx, ` + SELECT actor, target FROM audit_events WHERE action = $1 AND target = $2 + `, MasterKeyAccessAction, tenantID).Scan(&actor, &target) + if err != nil { + t.Fatalf("erwarteter master-key-zugriffs-audit-event nicht gefunden: %v", err) + } + if actor == "" { + t.Fatal("erwartet nicht-leeren akteur (service-credential) im audit-event") + } + if target != tenantID { + t.Fatalf("target = %q, want betroffenen tenant %q", target, tenantID) + } +} + +// Akzeptanzkriterium 2: der privilegierte Master-Key-Zugriffs-Event ist +// getrennt von normalen Audit-Events sichtbar — StreamMasterKeyAccessEvents +// liefert AUSSCHLIESSLICH diese, nicht z.B. eine normale Tenant-Rotation. +func TestStreamMasterKeyAccessEvents_OnlyReturnsPrivilegedEvents(t *testing.T) { + store, pool, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + masterKey := newMasterKey(t) + store = store.WithAudit(newTestAuditLog(pool)) + + tenantA := createTenant(t, pool, uniqueSlug("acme")) + if _, err := store.CreateForTenant(ctx, tenantA, masterKey); err != nil { + t.Fatalf("create: %v", err) + } + // Erzeugt einen NORMALEN Audit-Event (kek.tenant_rotated) ... + if _, err := store.RotateTenantKEK(ctx, tenantA, masterKey); err != nil { + t.Fatalf("rotatetenantkek: %v", err) + } + // ... UND einen privilegierten Master-Key-Zugriffs-Event. + if _, err := store.GetDecrypted(ctx, tenantA, masterKey, "dms"); err != nil { + t.Fatalf("getdecrypted: %v", err) + } + + var buf bytes.Buffer + if err := store.StreamMasterKeyAccessEvents(ctx, audit.Filter{}, &buf); err != nil { + t.Fatalf("stream: %v", err) + } + out := buf.String() + if strings.Contains(out, "kek.tenant_rotated") { + t.Fatalf("erwartet KEINE normalen audit-events in der privilegierten ansicht, habe: %s", out) + } + if !strings.Contains(out, MasterKeyAccessAction) { + t.Fatalf("erwartet den privilegierten master-key-zugriffs-event, habe: %s", out) + } + if !strings.Contains(out, `"actor":"dms"`) { + t.Fatalf("erwartet akteur 'dms' im privilegierten event, habe: %s", out) + } +} + +// StreamMasterKeyAccessEvents ohne konfiguriertes Audit-Log liefert einen +// klaren Fehler statt stillschweigend leer zu bleiben. +func TestStreamMasterKeyAccessEvents_RequiresAudit(t *testing.T) { + store, _, cleanup := setupTest(t) + defer cleanup() + var buf bytes.Buffer + if err := store.StreamMasterKeyAccessEvents(context.Background(), audit.Filter{}, &buf); !errors.Is(err, ErrAuditNotConfigured) { + t.Fatalf("erwartet ErrAuditNotConfigured, habe: %v", err) + } +} + +// Akzeptanzkriterium 3 + Pruefung 1 (Rotationsintervall-Konfiguration): +// LoadRotationIntervalFromEnv liest ein konfiguriertes Intervall und faellt +// andernfalls auf einen sinnvollen Standardwert zurueck. +func TestLoadRotationIntervalFromEnv(t *testing.T) { + const envVar = "NEXARCH_TEST_ROTATION_INTERVAL_API13" + t.Cleanup(func() { os.Unsetenv(envVar) }) + + os.Unsetenv(envVar) + interval, err := LoadRotationIntervalFromEnv(envVar) + if err != nil { + t.Fatalf("ohne env-var: %v", err) + } + if interval != DefaultRotationIntervalDays*24*time.Hour { + t.Fatalf("erwartet standardintervall, habe %v", interval) + } + + os.Setenv(envVar, "30") + interval, err = LoadRotationIntervalFromEnv(envVar) + if err != nil { + t.Fatalf("mit gueltigem env-var: %v", err) + } + if interval != 30*24*time.Hour { + t.Fatalf("erwartet 30 tage, habe %v", interval) + } + + os.Setenv(envVar, "nicht-numerisch") + if _, err := LoadRotationIntervalFromEnv(envVar); err == nil { + t.Fatal("erwartet fehler bei ungueltigem wert") + } + + os.Setenv(envVar, "-5") + if _, err := LoadRotationIntervalFromEnv(envVar); err == nil { + t.Fatal("erwartet fehler bei negativem intervall") + } +} + +// Akzeptanzkriterium 3 + Pruefung 1: die Altersmetrik faellt nach einer +// Master-Key-Rotation auf (nahezu) null zurueck. +func TestMasterKeyAgeSeconds_ResetsAfterRotation(t *testing.T) { + store, pool, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + oldKey := newMasterKey(t) + + // Rotationszustand kuenstlich "alt" machen, um einen messbaren Unterschied + // vor/nach der Rotation zu garantieren. + if _, err := pool.Exec(ctx, ` + INSERT INTO kek_master_key_rotation_state (singleton_lock, rotated_at) VALUES (true, now() - interval '200 days') + ON CONFLICT (singleton_lock) DO UPDATE SET rotated_at = now() - interval '200 days' + `); err != nil { + t.Fatalf("rotationszustand vorbereiten: %v", err) + } + + before, err := store.MasterKeyAgeSeconds(ctx) + if err != nil { + t.Fatalf("alter vor rotation: %v", err) + } + if before < 150*24*3600 { + t.Fatalf("erwartet hohes alter vor rotation, habe %v sekunden", before) + } + + newKey := newMasterKey(t) + if _, _, err := store.RotateMasterKey(ctx, oldKey, newKey); err != nil { + t.Fatalf("rotatemasterkey: %v", err) + } + + after, err := store.MasterKeyAgeSeconds(ctx) + if err != nil { + t.Fatalf("alter nach rotation: %v", err) + } + if after > 60 { + t.Fatalf("erwartet alter nahe null direkt nach rotation, habe %v sekunden", after) + } +} + +// Akzeptanzkriterium 3 + Pruefung 3: bei ueberschrittenem Rotationsintervall +// wird eine Warnung im OPS-Monitoring ausgeloest — end-to-end ueber die +// echte Metrik (NewMasterKeyAgeGauge), die echte OPS-05-Regel +// (EnsureRotationAlertRule) und den echten Evaluator. +func TestEnsureRotationAlertRule_FiresWhenIntervalExceeded(t *testing.T) { + store, pool, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + // Master-key seit 200 tagen nicht rotiert, konfiguriertes intervall 90 tage. + if _, err := pool.Exec(ctx, ` + INSERT INTO kek_master_key_rotation_state (singleton_lock, rotated_at) VALUES (true, now() - interval '200 days') + ON CONFLICT (singleton_lock) DO UPDATE SET rotated_at = now() - interval '200 days' + `); err != nil { + t.Fatalf("rotationszustand vorbereiten: %v", err) + } + + rules := alerting.NewRuleStore(pool) + if err := EnsureRotationAlertRule(ctx, rules, 90*24*time.Hour); err != nil { + t.Fatalf("ensurerotationalertrule: %v", err) + } + + dispatcher := notify.NewDispatcher(pool) + evaluator := alerting.NewEvaluator(rules, dispatcher, pool, time.Millisecond) // minimale drosselung fuer den test + + gauge := NewMasterKeyAgeGauge(store) + reg := prometheus.NewRegistry() + if err := reg.Register(gauge); err != nil { + t.Fatalf("gauge registrieren: %v", err) + } + families, err := reg.Gather() + if err != nil { + t.Fatalf("gather: %v", err) + } + + fired, err := evaluator.Evaluate(ctx, families) + if err != nil { + t.Fatalf("evaluate: %v", err) + } + if len(fired) != 1 { + t.Fatalf("erwartet genau einen ausgeloesten alarm, habe %d", len(fired)) + } + if fired[0].Skipped { + t.Fatal("erwartet nicht gedrosselten alarm bei erstmaliger ueberschreitung") + } + if fired[0].MetricName != MasterKeyAgeMetricName { + t.Fatalf("metricname = %q, want %q", fired[0].MetricName, MasterKeyAgeMetricName) + } +} + +// EnsureRotationAlertRule ist idempotent bei gleichbleibendem Intervall. +func TestEnsureRotationAlertRule_IdempotentForSameInterval(t *testing.T) { + _, pool, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + rules := alerting.NewRuleStore(pool) + + if err := EnsureRotationAlertRule(ctx, rules, 90*24*time.Hour); err != nil { + t.Fatalf("erster aufruf: %v", err) + } + if err := EnsureRotationAlertRule(ctx, rules, 90*24*time.Hour); err != nil { + t.Fatalf("zweiter aufruf: %v", err) + } + + all, err := rules.ListRules(ctx) + if err != nil { + t.Fatalf("listrules: %v", err) + } + count := 0 + for _, r := range all { + if r.MetricName == MasterKeyAgeMetricName { + count++ + } + } + if count != 1 { + t.Fatalf("erwartet genau eine regel fuer die rotations-metrik, habe %d", count) + } +} + +func newTestAuditLog(pool *pgxpool.Pool) *audit.Log { + return audit.NewLog(pool) +} diff --git a/internal/kek/rotation_monitoring.go b/internal/kek/rotation_monitoring.go new file mode 100644 index 00000000..d859ee40 --- /dev/null +++ b/internal/kek/rotation_monitoring.go @@ -0,0 +1,111 @@ +// Rotationsintervall-Konfiguration und OPS-Monitoring-Anbindung fuer die +// Master-Key-Rotation (Akzeptanzkriterium 3). Baut bewusst auf den bereits +// bestehenden, generischen Bausteinen OPS-03 (internal/metrics, Kennzahl) +// und OPS-05 (internal/alerting, Schwellwert-Regel) auf, statt einen +// eigenen, parallelen Alarmierungsweg einzufuehren — "Rotation als +// versionierte, automatisierbare API-Operation", siehe Produkt-DNA. +package kek + +import ( + "context" + "errors" + "fmt" + "os" + "strconv" + "time" + + "github.com/prometheus/client_golang/prometheus" + + "gitea.perlbach24.de/scripte/nexarch/internal/alerting" +) + +// DefaultRotationIntervalDays ist der Vorgabewert, falls keine +// Umgebungsvariable gesetzt ist — 90 Tage ist ein in der Branche uebliches +// Maximal-Intervall fuer Root-/Master-Keys. +const DefaultRotationIntervalDays = 90 + +// MasterKeyAgeMetricName ist der Name der Prometheus-Kennzahl, gegen die das +// Rotationsintervall im OPS-Monitoring geprueft wird (Namenskonvention aus +// internal/metrics: "nexarch_core_"). +const MasterKeyAgeMetricName = "nexarch_core_kek_master_key_age_seconds" + +// RotationAlertRecipient ist der Standard-Empfaenger der Warnung bei +// ueberschrittenem Rotationsintervall (CFG-02-Empfaengerbezeichner). +const RotationAlertRecipient = "security-ops" + +// LoadRotationIntervalFromEnv liest das konfigurierbare Rotationsintervall +// (in Tagen) aus einer Umgebungsvariable (Akzeptanzkriterium 3) — leer oder +// nicht gesetzt liefert DefaultRotationIntervalDays, keinen Fehler, damit +// ein Betrieb ohne explizite Konfiguration trotzdem eine sinnvolle +// Vorgabe-Warnschwelle hat. +func LoadRotationIntervalFromEnv(envVar string) (time.Duration, error) { + raw := os.Getenv(envVar) + if raw == "" { + return DefaultRotationIntervalDays * 24 * time.Hour, nil + } + days, err := strconv.Atoi(raw) + if err != nil { + return 0, fmt.Errorf("kek: rotationsintervall %q ist keine gueltige ganzzahl (tage): %w", raw, err) + } + if days <= 0 { + return 0, errors.New("kek: rotationsintervall muss positiv sein") + } + return time.Duration(days) * 24 * time.Hour, nil +} + +// NewMasterKeyAgeGauge liefert einen Prometheus-Collector fuer +// MasterKeyAgeMetricName, der bei jedem Scrape MasterKeyAgeSeconds abfragt +// (dasselbe Muster wie internal/metrics.NewCoreRegistry). Liefert die Abfrage +// einen Fehler (z.B. DB kurzzeitig nicht erreichbar), wird 0 exportiert statt +// den gesamten Scrape scheitern zu lassen — konsistent mit dem uebrigen +// Metrik-Aggregations-Verhalten (internal/metrics.Aggregator: ein +// fehlerhafter Beitrag blockiert nie den Gesamt-Scrape). +func NewMasterKeyAgeGauge(store *Store) prometheus.Collector { + return prometheus.NewGaugeFunc(prometheus.GaugeOpts{ + Name: MasterKeyAgeMetricName, + Help: "Sekunden seit der letzten Master-KEK-Rotation.", + }, func() float64 { + seconds, err := store.MasterKeyAgeSeconds(context.Background()) + if err != nil { + return 0 + } + return seconds + }) +} + +// EnsureRotationAlertRule legt — sofern noch keine existiert — eine +// OPS-05-Schwellwertregel an, die warnt, sobald MasterKeyAgeMetricName das +// konfigurierte Rotationsintervall ueberschreitet (Akzeptanzkriterium 3 / +// Pruefung 3). Idempotent: ein zweiter Aufruf mit demselben Intervall legt +// KEINE doppelte Regel an, ein GEAENDERTES Intervall aktualisiert die +// bestehende Regel (Rotation bleibt "automatisierbare API-Operation", nicht +// manueller Eingriff in die Alert-Konfiguration). +func EnsureRotationAlertRule(ctx context.Context, rules *alerting.RuleStore, interval time.Duration) error { + existing, err := rules.ListRules(ctx) + if err != nil { + return fmt.Errorf("bestehende alert-regeln pruefen: %w", err) + } + thresholdSeconds := interval.Seconds() + for _, r := range existing { + if r.MetricName == MasterKeyAgeMetricName { + if r.Threshold == thresholdSeconds { + return nil // bereits mit dem gewuenschten intervall vorhanden + } + if err := rules.DeleteRule(ctx, r.ID); err != nil { + return fmt.Errorf("veraltete rotations-alert-regel entfernen: %w", err) + } + break + } + } + _, err = rules.CreateRule(ctx, alerting.Rule{ + MetricName: MasterKeyAgeMetricName, + Comparison: alerting.ComparisonGreaterThan, + Threshold: thresholdSeconds, + Recipient: RotationAlertRecipient, + Description: "Master-KEK-Rotationsintervall ueberschritten", + }) + if err != nil { + return fmt.Errorf("rotations-alert-regel anlegen: %w", err) + } + return nil +} diff --git a/internal/kek/store.go b/internal/kek/store.go index 0079bb8e..1244e444 100644 --- a/internal/kek/store.go +++ b/internal/kek/store.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" @@ -13,6 +14,20 @@ import ( var ErrNoTenantKEK = errors.New("kek: kein tenant-kek fuer diesen mandanten hinterlegt") +// MasterKeyAccessAction ist der EIGENE, hochprivilegierte Audit-Aktionsname +// fuer jede Entschluesselung eines Tenant-KEK durch den Master-Key +// (Akzeptanzkriterium 2). Ein eigener, reservierter Aktionsname statt einer +// Metadaten-Markierung auf einem generischen Event, damit die Trennung von +// normalen Audit-Events allein durch Filtern auf diesen Wert moeglich ist +// (siehe StreamMasterKeyAccessEvents) — kein zweites Datenmodell noetig, +// AUD-01 bleibt der EINE zentrale Schreibpfad. +const MasterKeyAccessAction = "kek.master_key_access" + +// ErrAuditNotConfigured wird geliefert, wenn eine Operation, die zwingend +// Audit-Sichtbarkeit voraussetzt (privilegierte Zugriffsereignisse), ohne +// WithAudit(...) aufgerufen wird. +var ErrAuditNotConfigured = errors.New("kek: audit-log nicht konfiguriert") + // 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, @@ -61,8 +76,13 @@ func (s *Store) CreateForTenant(ctx context.Context, tenantID string, masterKey // 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) { +// Tests verwendet. actor bezeichnet den Aufrufer (Service-Credential/ +// Modulname bzw. "system" fuer interne Aufrufe) und wird — sofern ein +// Audit-Log konfiguriert ist (WithAudit) — als eigener, hochprivilegierter +// Audit-Event protokolliert (Akzeptanzkriterium 2 / Pruefung 2): JEDE +// erfolgreiche Entschluesselung eines Tenant-KEK durch den Master-Key +// erzeugt genau EIN Ereignis mit Akteur, Zeitpunkt und betroffenem Tenant. +func (s *Store) GetDecrypted(ctx context.Context, tenantID string, masterKey MasterKey, actor string) ([]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 { @@ -71,7 +91,36 @@ func (s *Store) GetDecrypted(ctx context.Context, tenantID string, masterKey Mas } return nil, fmt.Errorf("tenant-kek lesen: %w", err) } - return unwrap(masterKey, wrapped) + plainKEK, err := unwrap(masterKey, wrapped) + if err != nil { + return nil, err + } + if s.audit != nil { + if actor == "" { + actor = "unbekannt" + } + _ = s.audit.Record(ctx, audit.Event{ + TenantSlug: audit.SystemTenant, + Actor: actor, + Action: MasterKeyAccessAction, + Target: tenantID, + }) + } + return plainKEK, nil +} + +// StreamMasterKeyAccessEvents liefert AUSSCHLIESSLICH die hochprivilegierten +// Master-Key-Zugriffsereignisse (Akzeptanzkriterium 2: getrennt von +// normalen Audit-Events sichtbar) — filter.Action wird dafuer immer auf +// MasterKeyAccessAction erzwungen, ein Aufrufer kann diese Trennung also +// nicht versehentlich durch einen eigenen Filterwert aufheben. Nutzt AUD-03s +// bestehenden Export-Schreibpfad (StreamJSON) — kein zweites Lesemodell. +func (s *Store) StreamMasterKeyAccessEvents(ctx context.Context, filter audit.Filter, w io.Writer) error { + if s.audit == nil { + return ErrAuditNotConfigured + } + filter.Action = MasterKeyAccessAction + return s.audit.StreamJSON(ctx, filter, w) } // RotateTenantKEK ersetzt den Tenant-KEK EINES Mandanten durch einen NEUEN, @@ -167,5 +216,55 @@ func (s *Store) RotateMasterKey(ctx context.Context, oldKey, newKey MasterKey) ( Metadata: map[string]any{"rotated": rotated, "failed": failedTenantIDs}, }) } + if err := s.recordMasterKeyRotationTimestamp(ctx); err != nil { + return rotated, failedTenantIDs, fmt.Errorf("rotationszeitpunkt festhalten: %w", err) + } return rotated, failedTenantIDs, nil } + +// recordMasterKeyRotationTimestamp haelt den Zeitpunkt der letzten +// Master-Key-Rotation fest (Akzeptanzkriterium 3: Grundlage fuer die +// Altersmetrik, gegen die das konfigurierbare Rotationsintervall geprueft +// wird). Upsert auf die Singleton-Zeile aus Migration 0008. +func (s *Store) recordMasterKeyRotationTimestamp(ctx context.Context) error { + _, err := s.pool.Exec(ctx, ` + INSERT INTO kek_master_key_rotation_state (singleton_lock, rotated_at) VALUES (true, now()) + ON CONFLICT (singleton_lock) DO UPDATE SET rotated_at = now() + `) + if err != nil { + return fmt.Errorf("rotationszeitpunkt speichern: %w", err) + } + return nil +} + +// MasterKeyAgeSeconds liefert, wie viele Sekunden seit der letzten +// Master-Key-Rotation vergangen sind (Akzeptanzkriterium 3) — Grundlage der +// im OPS-Monitoring (OPS-03/OPS-05) sichtbaren Kennzahl. Wurde noch nie +// rotiert, liefert das Alter seit dem allerersten Anlegen der Zeile +// (Migrationszeitpunkt bzw. erster Aufruf von EnsureRotationStateRow). +func (s *Store) MasterKeyAgeSeconds(ctx context.Context) (float64, error) { + if err := s.ensureRotationStateRow(ctx); err != nil { + return 0, err + } + var seconds float64 + err := s.pool.QueryRow(ctx, ` + SELECT extract(epoch FROM (now() - rotated_at)) FROM kek_master_key_rotation_state WHERE singleton_lock + `).Scan(&seconds) + if err != nil { + return 0, fmt.Errorf("master-key-alter lesen: %w", err) + } + return seconds, nil +} + +// ensureRotationStateRow legt die Singleton-Zeile an, falls sie (z.B. bei +// einem Master-Key, der noch nie rotiert wurde) noch nicht existiert. +func (s *Store) ensureRotationStateRow(ctx context.Context) error { + _, err := s.pool.Exec(ctx, ` + INSERT INTO kek_master_key_rotation_state (singleton_lock) VALUES (true) + ON CONFLICT (singleton_lock) DO NOTHING + `) + if err != nil { + return fmt.Errorf("rotationszustand initialisieren: %w", err) + } + return nil +} diff --git a/migrations/0008_kek_master_key_rotation_state.down.sql b/migrations/0008_kek_master_key_rotation_state.down.sql new file mode 100644 index 00000000..aa9f6475 --- /dev/null +++ b/migrations/0008_kek_master_key_rotation_state.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS kek_master_key_rotation_state; diff --git a/migrations/0008_kek_master_key_rotation_state.up.sql b/migrations/0008_kek_master_key_rotation_state.up.sql new file mode 100644 index 00000000..946d3a92 --- /dev/null +++ b/migrations/0008_kek_master_key_rotation_state.up.sql @@ -0,0 +1,11 @@ +-- API-13: haelt fest, wann der Master-KEK zuletzt rotiert wurde +-- (Akzeptanzkriterium 3: konfigurierbares Rotationsintervall + Warnung bei +-- Ueberschreitung). EIN einzelner Datensatz (singleton_lock erzwingt das ueber +-- eine CHECK-Constraint) — es gibt genau EINEN Master-Key, also auch nur EINEN +-- "zuletzt rotiert"-Zeitpunkt. rotated_at ist beim allerersten Anlegen mit +-- now() vorbelegt, damit die Altersmetrik ab Betriebsbeginn sinnvoll ist, auch +-- bevor die erste tatsaechliche Rotation stattgefunden hat. +CREATE TABLE kek_master_key_rotation_state ( + singleton_lock BOOLEAN PRIMARY KEY DEFAULT true CHECK (singleton_lock), + rotated_at TIMESTAMPTZ NOT NULL DEFAULT now() +);