diff --git a/internal/apiserver/context.go b/internal/apiserver/context.go new file mode 100644 index 0000000..9ed7ca9 --- /dev/null +++ b/internal/apiserver/context.go @@ -0,0 +1,20 @@ +package apiserver + +import "context" + +type contextKey int + +const requestContextKey contextKey = iota + +// RequestContext ist der Tenant-/Benutzerkontext, den die Middleware-Kette +// fuer nachgelagerte Handler bereitstellt (Akzeptanzkriterium 3). +type RequestContext struct { + UserID string + TenantSlug string +} + +// FromContext liest den von der Middleware gesetzten Kontext. +func FromContext(ctx context.Context) (RequestContext, bool) { + rc, ok := ctx.Value(requestContextKey).(RequestContext) + return rc, ok +} diff --git a/internal/apiserver/errors.go b/internal/apiserver/errors.go new file mode 100644 index 0000000..2d952bf --- /dev/null +++ b/internal/apiserver/errors.go @@ -0,0 +1,31 @@ +// Package apiserver implementiert Core API-01: das REST-Grundgerüst mit +// URL-Versionierung, einheitlichem Fehlerformat und Middleware-Kette +// (Auth, Tenant-/Benutzerkontext, Logging). +package apiserver + +import ( + "encoding/json" + "net/http" +) + +// errorBody ist das EINE Fehlerschema fuer alle Endpunkte unter /api/{version}/ +// (Akzeptanzkriterium 2). +type errorBody struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +// WriteError schreibt einen Fehler im einheitlichen Schema. code ist ein +// stabiler, maschinenlesbarer Bezeichner (z.B. "unauthenticated"), message +// ein fuer Menschen lesbarer deutscher Text. +func WriteError(w http.ResponseWriter, status int, code, message string) { + var body errorBody + body.Error.Code = code + body.Error.Message = message + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} diff --git a/internal/apiserver/middleware.go b/internal/apiserver/middleware.go new file mode 100644 index 0000000..1587071 --- /dev/null +++ b/internal/apiserver/middleware.go @@ -0,0 +1,56 @@ +package apiserver + +import ( + "context" + "log/slog" + "net/http" + "time" + + "gitea.perlbach24.de/scripte/nexarch/internal/auth" +) + +// authAndTenantContext prueft die Session (wiederverwendet auth.TokenIssuer.Verify +// aus IAM-02 — keine zweite JWT-Implementierung) und setzt bei Erfolg +// RequestContext fuer nachgelagerte Handler (Akzeptanzkriterium 3). Anders +// als auth.RequireAuth (Klartext-Fehler) antwortet diese Middleware im +// einheitlichen API-01-Fehlerschema (Akzeptanzkriterium 2), damit ALLE +// Endpunkte unter /api/{version}/ dasselbe Format liefern, auch bei +// Auth-Fehlern. +func authAndTenantContext(issuer *auth.TokenIssuer, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie(auth.CookieName) + if err != nil { + WriteError(w, http.StatusUnauthorized, "unauthenticated", "nicht angemeldet") + return + } + + claims, err := issuer.Verify(cookie.Value) + if err != nil { + WriteError(w, http.StatusUnauthorized, "unauthenticated", "nicht angemeldet") + return + } + + rc := RequestContext{UserID: claims.UserID, TenantSlug: claims.TenantSlug} + next(w, r.WithContext(context.WithValue(r.Context(), requestContextKey, rc))) + } +} + +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (s *statusRecorder) WriteHeader(code int) { + s.status = code + s.ResponseWriter.WriteHeader(code) +} + +// loggingMiddleware protokolliert jede Anfrage strukturiert. +func loggingMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + start := time.Now() + next(rec, r) + slog.Info("api-anfrage", "method", r.Method, "path", r.URL.Path, "status", rec.status, "dauer", time.Since(start)) + } +} diff --git a/internal/apiserver/server.go b/internal/apiserver/server.go new file mode 100644 index 0000000..bd0c0a7 --- /dev/null +++ b/internal/apiserver/server.go @@ -0,0 +1,37 @@ +package apiserver + +import ( + "net/http" + + "gitea.perlbach24.de/scripte/nexarch/internal/auth" +) + +// Server registriert versionierte API-Routen (Akzeptanzkriterium 1: unter +// /api/{version}/...) und verdrahtet fuer jede Route dieselbe Middleware- +// Kette (Logging -> Auth+Tenantkontext -> Handler). +type Server struct { + mux *http.ServeMux + issuer *auth.TokenIssuer +} + +func NewServer(issuer *auth.TokenIssuer) *Server { + return &Server{mux: http.NewServeMux(), issuer: issuer} +} + +// Handle registriert pattern unter der angegebenen Version, z.B. +// Handle("v1", "/things", h) -> erreichbar unter /api/v1/things. Verschiedene +// Versionen sind unabhaengige Pfade — eine neue Version beeintraechtigt +// bestehende nicht (Akzeptanzkriterium 1 / Pruefung 3). +func (s *Server) Handle(version, pattern string, h http.HandlerFunc) { + full := "/api/" + version + pattern + s.mux.HandleFunc(full, loggingMiddleware(authAndTenantContext(s.issuer, h))) +} + +// HandleV1 ist die Kurzform fuer die aktuelle Hauptversion. +func (s *Server) HandleV1(pattern string, h http.HandlerFunc) { + s.Handle("v1", pattern, h) +} + +func (s *Server) Handler() http.Handler { + return s.mux +} diff --git a/internal/apiserver/server_test.go b/internal/apiserver/server_test.go new file mode 100644 index 0000000..6375976 --- /dev/null +++ b/internal/apiserver/server_test.go @@ -0,0 +1,149 @@ +package apiserver + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/internal/auth" +) + +func newTestServer() (*Server, *auth.TokenIssuer) { + issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests") + return NewServer(issuer), issuer +} + +func withAuthCookie(req *http.Request, token string) *http.Request { + req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: token}) + return req +} + +// Akzeptanzkriterium 1: API unter versioniertem Pfad erreichbar. +func TestHandleV1_RegistersUnderVersionedPath(t *testing.T) { + srv, issuer := newTestServer() + srv.HandleV1("/things", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + token, err := issuer.Issue("user-1", "acme") + if err != nil { + t.Fatalf("issue: %v", err) + } + + req := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/things", nil), token) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } +} + +// Akzeptanzkriterium 2 + Pruefung 1 (Stichprobe): mehrere Endpunkte liefern +// bei fehlerhafter Anfrage dasselbe Fehlerschema. +func TestErrorFormat_ConsistentAcrossEndpoints(t *testing.T) { + srv, _ := newTestServer() + srv.HandleV1("/endpunkt-a", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + srv.HandleV1("/endpunkt-b", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + + for _, path := range []string{"/api/v1/endpunkt-a", "/api/v1/endpunkt-b"} { + req := httptest.NewRequest(http.MethodGet, path, nil) // ohne cookie -> 401 + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("%s: status = %d, want 401", path, rec.Code) + } + var body errorBody + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("%s: fehlerantwort nicht im erwarteten json-schema: %v (body: %s)", path, err, rec.Body.String()) + } + if body.Error.Code == "" || body.Error.Message == "" { + t.Fatalf("%s: erwartet nicht-leeren code/message, habe %+v", path, body) + } + } +} + +// Akzeptanzkriterium 3 + Pruefung 2: Middleware-Kette setzt Tenant-/ +// Benutzerkontext zuverlaessig, nachweislich fuer mehrere Endpunkte. +func TestMiddleware_SetsRequestContextForEveryEndpoint(t *testing.T) { + srv, issuer := newTestServer() + + var gotA, gotB RequestContext + srv.HandleV1("/kontext-a", func(w http.ResponseWriter, r *http.Request) { + gotA, _ = FromContext(r.Context()) + w.WriteHeader(http.StatusOK) + }) + srv.HandleV1("/kontext-b", func(w http.ResponseWriter, r *http.Request) { + gotB, _ = FromContext(r.Context()) + w.WriteHeader(http.StatusOK) + }) + + token, err := issuer.Issue("user-42", "tenant-x") + if err != nil { + t.Fatalf("issue: %v", err) + } + + for path, got := range map[string]*RequestContext{"/api/v1/kontext-a": &gotA, "/api/v1/kontext-b": &gotB} { + req := withAuthCookie(httptest.NewRequest(http.MethodGet, path, nil), token) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("%s: status = %d, want 200", path, rec.Code) + } + if got.UserID != "user-42" || got.TenantSlug != "tenant-x" { + t.Fatalf("%s: request-context unerwartet: %+v", path, *got) + } + } +} + +// Akzeptanzkriterium 1 + Pruefung 3: eine neue v2-Route laesst sich anlegen, +// ohne v1 zu beeintraechtigen. +func TestVersioning_V2DoesNotAffectV1(t *testing.T) { + srv, issuer := newTestServer() + srv.HandleV1("/things", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("v1-antwort")) + }) + srv.Handle("v2", "/things", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("v2-antwort")) + }) + + token, err := issuer.Issue("user-1", "acme") + if err != nil { + t.Fatalf("issue: %v", err) + } + + reqV1 := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/things", nil), token) + recV1 := httptest.NewRecorder() + srv.Handler().ServeHTTP(recV1, reqV1) + if recV1.Body.String() != "v1-antwort" { + t.Fatalf("v1 antwort = %q, want v1-antwort", recV1.Body.String()) + } + + reqV2 := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v2/things", nil), token) + recV2 := httptest.NewRecorder() + srv.Handler().ServeHTTP(recV2, reqV2) + if recV2.Body.String() != "v2-antwort" { + t.Fatalf("v2 antwort = %q, want v2-antwort", recV2.Body.String()) + } + + // v1 nach dem Anlegen von v2 erneut pruefen — unveraendert. + reqV1Again := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/things", nil), token) + recV1Again := httptest.NewRecorder() + srv.Handler().ServeHTTP(recV1Again, reqV1Again) + if recV1Again.Body.String() != "v1-antwort" { + t.Fatalf("v1 antwort nach v2-anlage = %q, want weiterhin v1-antwort", recV1Again.Body.String()) + } +} + +func TestAuthAndTenantContext_RejectsInvalidToken(t *testing.T) { + srv, _ := newTestServer() + srv.HandleV1("/geschuetzt", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + + req := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/geschuetzt", nil), "kaputtes.token.hier") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} diff --git a/internal/kek/handler.go b/internal/kek/handler.go new file mode 100644 index 0000000..22f02bb --- /dev/null +++ b/internal/kek/handler.go @@ -0,0 +1,113 @@ +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)}) +} diff --git a/internal/kek/kek_test.go b/internal/kek/kek_test.go new file mode 100644 index 0000000..160d71e --- /dev/null +++ b/internal/kek/kek_test.go @@ -0,0 +1,346 @@ +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) + rotated, failed, err := store.RotateMasterKey(ctx, oldMasterKey, newMasterKeyVal) + if err != nil { + t.Fatalf("rotatemasterkey: %v", err) + } + if len(failed) != 0 { + t.Fatalf("erwartet keine fehlgeschlagenen tenants, habe: %v", failed) + } + if rotated < 2 { + t.Fatalf("erwartet mindestens 2 rotierte tenant-keks, habe %d", rotated) + } + + // 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 +} diff --git a/internal/kek/master.go b/internal/kek/master.go new file mode 100644 index 0000000..0f9196e --- /dev/null +++ b/internal/kek/master.go @@ -0,0 +1,111 @@ +// 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 +} diff --git a/internal/kek/store.go b/internal/kek/store.go new file mode 100644 index 0000000..2827bf4 --- /dev/null +++ b/internal/kek/store.go @@ -0,0 +1,144 @@ +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 +} diff --git a/migrations/0006_tenant_keks.down.sql b/migrations/0006_tenant_keks.down.sql new file mode 100644 index 0000000..ed032f1 --- /dev/null +++ b/migrations/0006_tenant_keks.down.sql @@ -0,0 +1 @@ +DROP TABLE tenant_keks; diff --git a/migrations/0006_tenant_keks.up.sql b/migrations/0006_tenant_keks.up.sql new file mode 100644 index 0000000..fc3a526 --- /dev/null +++ b/migrations/0006_tenant_keks.up.sql @@ -0,0 +1,10 @@ +-- 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 +);