API-13: master-key-rotation-zugriffs-audit

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPsyieCYwvbUBsnN598SbP
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-17 02:26:37 +02:00
1 parent 278d26f7bd
commit 99001a5a2d
6 files changed
+525 -17

No files matched your search

+13 -9
View File
@@ -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
+287 -5
View File
@@ -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)
}
+111
View File
@@ -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_<name>").
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
}
+102 -3
View File
@@ -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
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS kek_master_key_rotation_state;
@@ -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()
);