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:
1 parent
278d26f7bd
commit
99001a5a2d
6 files changed
+525
-17
No files matched your search
+13
-9
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
);
|
||||
Reference in new issue
Block a user