347 lines
12 KiB
Go
347 lines
12 KiB
Go
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
|
|
}
|