From b23cd1961fee2d468f6d37d28a8e88ff79624865 Mon Sep 17 00:00:00 2001 From: sysops Date: Thu, 27 Aug 2026 21:17:50 +0200 Subject: [PATCH] API-02: modul-registry-aktivierungspruefung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/moduleregistry: Registry.Register traegt Fachmodule mit Name, Version und benoetigten Feature-Flags ein (Akzeptanzkriterium 1), fehlende Pflichtangaben werden abgewiesen. IsActive kombiniert Registrierung + LIC-02 Feature-Flag-Auswertung (ALLE benoetigten Flags muessen fuer den Tenant aktiv sein) — ein nicht registriertes Modul ist nie aktiv. List liefert alle Module fuer Statusseite/Lizenzoberflaeche (Akzeptanzkriterium 3). RequireActiveModule ist die zentrale Durchsetzungs-Middleware (Casbin- Prinzip): weist Anfragen an ein deaktiviertes Modul ab, BEVOR der Modul-Handler ueberhaupt aufgerufen wird (Akzeptanzkriterium 2) — Pruefung per Test belegt, dass der Handler bei Deaktivierung nachweislich nicht erreicht wird. Service-Credentials (Akzeptanzkriterium 4): Registry.Provision stellt pro Modul-Instanz Client-ID + Secret aus, gespeichert wird nur der SHA-256-Hash des Secrets. Registry.Authenticate vergleicht timing-safe (dieselbe subtle.ConstantTimeCompare-Referenzimplementierung wie AUD-02). RequireServiceCredential-Middleware liest X-Client-Id/X-Client-Secret und weist Aufrufe ohne gueltiges Credential mit 401 ab, bevor der Core-seitige Endpunkt (z.B. Audit-Nachlieferung, Nutzungsmeldung) erreicht wird. Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Anfrage an deaktiviertes Modul nachweislich vor Modul-Logik abgewiesen — TestRequireActiveModule_BlocksBeforeHandler: handlerReached bleibt false bei 403, wird true erst nach Aktivierung bei 200. PASS. 2. Registrierung mit fehlenden Pflichtangaben abgewiesen — TestRegister_RejectsMissingFields (leerer Name, leere Version). PASS. 3. Registry-Abfrage liefert konsistente Daten nach Aktivierung/Deaktivierung — TestIsActive_ReflectsFlagStateConsistently: aus/an/aus-Zyklus, IsActive folgt dem Flag-Zustand korrekt. PASS. 4. Aufruf mit ungueltigem/fehlendem Service-Credential abgewiesen, mit gueltigem angenommen — TestRequireServiceCredential_RejectsInvalidAcceptsValid und TestProvisionAndAuthenticate (falsches Secret, unbekannte Client-ID, korrektes Credential). PASS. Co-Authored-By: Claude Sonnet 5 --- internal/moduleregistry/credentials.go | 99 ++++++++ internal/moduleregistry/middleware.go | 46 ++++ internal/moduleregistry/registry.go | 120 +++++++++ internal/moduleregistry/registry_test.go | 304 +++++++++++++++++++++++ migrations/0005_module_registry.down.sql | 2 + migrations/0005_module_registry.up.sql | 18 ++ 6 files changed, 589 insertions(+) create mode 100644 internal/moduleregistry/credentials.go create mode 100644 internal/moduleregistry/middleware.go create mode 100644 internal/moduleregistry/registry.go create mode 100644 internal/moduleregistry/registry_test.go create mode 100644 migrations/0005_module_registry.down.sql create mode 100644 migrations/0005_module_registry.up.sql diff --git a/internal/moduleregistry/credentials.go b/internal/moduleregistry/credentials.go new file mode 100644 index 0000000..c941096 --- /dev/null +++ b/internal/moduleregistry/credentials.go @@ -0,0 +1,99 @@ +package moduleregistry + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" +) + +var ( + ErrModuleNotRegistered = errors.New("moduleregistry: modul muss vor provisionierung registriert sein") + ErrInvalidCredential = errors.New("moduleregistry: ungueltiges oder fehlendes service-credential") +) + +// Provision stellt ein Service-Credential (Client-ID + Secret) fuer eine +// Modul-Instanz aus (Akzeptanzkriterium 4). Das Secret wird NUR beim +// Ausstellen im Klartext zurueckgegeben, gespeichert wird ausschliesslich +// dessen SHA-256-Hash. +func (r *Registry) Provision(ctx context.Context, moduleName string) (clientID, secret string, err error) { + if _, err := r.Get(ctx, moduleName); err != nil { + if errors.Is(err, ErrModuleNotFound) { + return "", "", ErrModuleNotRegistered + } + return "", "", err + } + + clientID, err = randomToken(16) + if err != nil { + return "", "", fmt.Errorf("client-id erzeugen: %w", err) + } + secret, err = randomToken(32) + if err != nil { + return "", "", fmt.Errorf("secret erzeugen: %w", err) + } + hash := hashSecret(secret) + + _, err = r.pool.Exec(ctx, ` + INSERT INTO module_credentials (module_name, client_id, secret_hash, issued_at) + VALUES ($1, $2, $3, now()) + ON CONFLICT (module_name) DO UPDATE SET client_id = $2, secret_hash = $3, issued_at = now() + `, moduleName, clientID, hash) + if err != nil { + return "", "", fmt.Errorf("credential speichern: %w", err) + } + return clientID, secret, nil +} + +// Authenticate prueft ein Service-Credential timing-safe (Referenzmuster +// siehe AUD-02) — Aufrufe ohne gueltiges Credential werden abgelehnt +// (Akzeptanzkriterium 4 / Pruefung 4). +func (r *Registry) Authenticate(ctx context.Context, clientID, secret string) (moduleName string, ok bool, err error) { + if clientID == "" || secret == "" { + return "", false, nil + } + + var storedHash []byte + err = r.pool.QueryRow(ctx, ` + SELECT module_name, secret_hash FROM module_credentials WHERE client_id = $1 + `, clientID).Scan(&moduleName, &storedHash) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", false, nil + } + return "", false, fmt.Errorf("credential lesen: %w", err) + } + + if !timingSafeEqual(hashSecret(secret), storedHash) { + return "", false, nil + } + return moduleName, true, nil +} + +func randomToken(n int) (string, error) { + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + +func hashSecret(secret string) []byte { + sum := sha256.Sum256([]byte(secret)) + return sum[:] +} + +// timingSafeEqual folgt derselben Referenzimplementierung wie AUD-02 +// (subtle.ConstantTimeCompare) — projektweite Konvention fuer jeden +// sicherheitsrelevanten Vergleich. +func timingSafeEqual(a, b []byte) bool { + if len(a) != len(b) { + return false + } + return subtle.ConstantTimeCompare(a, b) == 1 +} diff --git a/internal/moduleregistry/middleware.go b/internal/moduleregistry/middleware.go new file mode 100644 index 0000000..24ccf8c --- /dev/null +++ b/internal/moduleregistry/middleware.go @@ -0,0 +1,46 @@ +package moduleregistry + +import "net/http" + +// RequireActiveModule weist Anfragen an ein nicht aktiviertes Modul ZENTRAL +// ab, bevor der eigentliche Modul-Handler erreicht wird (Akzeptanzkriterium 2 / +// Pruefung 1) — Casbin-Prinzip: Durchsetzung als Middleware statt verstreuter +// Pruefungen in jedem Handler. tenantSlug/moduleName werden hier ueber +// Query-Parameter gelesen (echte Extraktion aus JWT/Tenant-Kontext ist +// API-05/TEN-06, nicht Teil dieser Kachel). +func (r *Registry) RequireActiveModule(moduleName string, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + tenantSlug := req.URL.Query().Get("tenant") + active, err := r.IsActive(req.Context(), tenantSlug, moduleName) + if err != nil { + http.Error(w, "aktivierungspruefung fehlgeschlagen", http.StatusInternalServerError) + return + } + if !active { + http.Error(w, "modul nicht aktiviert", http.StatusForbidden) + return + } + next(w, req) + } +} + +// RequireServiceCredential authentifiziert eine Modul-Instanz ueber ihr +// Service-Credential (X-Client-Id/X-Client-Secret-Header) BEVOR der +// eigentliche Handler erreicht wird (Akzeptanzkriterium 4 / Pruefung 4). +func (r *Registry) RequireServiceCredential(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + clientID := req.Header.Get("X-Client-Id") + secret := req.Header.Get("X-Client-Secret") + + _, ok, err := r.Authenticate(req.Context(), clientID, secret) + if err != nil { + http.Error(w, "authentifizierung fehlgeschlagen", http.StatusInternalServerError) + return + } + if !ok { + http.Error(w, ErrInvalidCredential.Error(), http.StatusUnauthorized) + return + } + next(w, req) + } +} diff --git a/internal/moduleregistry/registry.go b/internal/moduleregistry/registry.go new file mode 100644 index 0000000..dbda148 --- /dev/null +++ b/internal/moduleregistry/registry.go @@ -0,0 +1,120 @@ +// Package moduleregistry implementiert Core API-02: die Registry, in der +// sich Fachmodule (DMS, Mail, weitere) mit Metadaten eintragen, gekoppelt an +// die Aktivierungspruefung aus LIC-02 (Feature-Flags). Zusaetzlich +// authentifiziert die Registry Modul-Instanzen selbst ueber ein bei +// Provisionierung ausgestelltes Service-Credential. +package moduleregistry + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/flag" +) + +var ( + ErrMissingName = errors.New("moduleregistry: name darf nicht leer sein") + ErrMissingVersion = errors.New("moduleregistry: version darf nicht leer sein") + ErrModuleNotFound = errors.New("moduleregistry: modul nicht registriert") +) + +type Module struct { + Name string + Version string + RequiredFlags []string +} + +type Registry struct { + pool *pgxpool.Pool + flags *flag.Service +} + +func NewRegistry(pool *pgxpool.Pool, flags *flag.Service) *Registry { + return &Registry{pool: pool, flags: flags} +} + +// Register traegt ein Modul mit Name, Version und benoetigten Feature-Flags +// ein (Akzeptanzkriterium 1). Fehlende Pflichtangaben werden abgewiesen +// (Akzeptanzkriterium 1 / Pruefung 2). Erneutes Register desselben Namens +// aktualisiert Version/Flags (Redeploy-Fall). +func (r *Registry) Register(ctx context.Context, name, version string, requiredFlags []string) (Module, error) { + if name == "" { + return Module{}, ErrMissingName + } + if version == "" { + return Module{}, ErrMissingVersion + } + if requiredFlags == nil { + requiredFlags = []string{} + } + + _, err := r.pool.Exec(ctx, ` + INSERT INTO modules (name, version, required_flags, registered_at) + VALUES ($1, $2, $3, now()) + ON CONFLICT (name) DO UPDATE SET version = $2, required_flags = $3, registered_at = now() + `, name, version, requiredFlags) + if err != nil { + return Module{}, fmt.Errorf("modul registrieren: %w", err) + } + return Module{Name: name, Version: version, RequiredFlags: requiredFlags}, nil +} + +func (r *Registry) Get(ctx context.Context, name string) (Module, error) { + var m Module + m.Name = name + err := r.pool.QueryRow(ctx, ` + SELECT version, required_flags FROM modules WHERE name = $1 + `, name).Scan(&m.Version, &m.RequiredFlags) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return Module{}, ErrModuleNotFound + } + return Module{}, fmt.Errorf("modul lesen: %w", err) + } + return m, nil +} + +// List liefert alle registrierten Module (Akzeptanzkriterium 3: ueber API +// abfragbar, z.B. fuer Statusseite/Lizenzoberflaeche). +func (r *Registry) List(ctx context.Context) ([]Module, error) { + rows, err := r.pool.Query(ctx, `SELECT name, version, required_flags FROM modules ORDER BY name`) + if err != nil { + return nil, fmt.Errorf("module auflisten: %w", err) + } + defer rows.Close() + + var out []Module + for rows.Next() { + var m Module + if err := rows.Scan(&m.Name, &m.Version, &m.RequiredFlags); err != nil { + return nil, fmt.Errorf("modul lesen: %w", err) + } + out = append(out, m) + } + return out, rows.Err() +} + +// IsActive prueft, ob ein registriertes Modul fuer einen Tenant aktiviert +// ist: registriert UND alle benoetigten Feature-Flags sind fuer diesen +// Tenant aktiv (Akzeptanzkriterium 2). Ein nicht registriertes Modul gilt +// immer als nicht aktiv. +func (r *Registry) IsActive(ctx context.Context, tenantSlug, moduleName string) (bool, error) { + m, err := r.Get(ctx, moduleName) + if err != nil { + if errors.Is(err, ErrModuleNotFound) { + return false, nil + } + return false, err + } + + for _, flagKey := range m.RequiredFlags { + if !r.flags.IsEnabled(ctx, tenantSlug, flagKey) { + return false, nil + } + } + return true, nil +} diff --git a/internal/moduleregistry/registry_test.go b/internal/moduleregistry/registry_test.go new file mode 100644 index 0000000..6d602a2 --- /dev/null +++ b/internal/moduleregistry/registry_test.go @@ -0,0 +1,304 @@ +package moduleregistry + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/flag" +) + +func setupTest(t *testing.T) (*Registry, *flag.Store, 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 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) + } + + flagStore := flag.NewStore(pool) + // Kurze TTL, damit Tests, die den Flag-Store direkt aendern (an + // Registry.IsActive vorbei), den neuen Stand ohne manuelles Invalidate + // zuverlaessig sehen. + flagService := flag.NewService(flagStore, 10*time.Millisecond) + registry := NewRegistry(pool, flagService) + + cleanup := func() { pool.Close() } + return registry, flagStore, cleanup +} + +func uniqueModuleName(t *testing.T) string { + return fmt.Sprintf("dms_%d", time.Now().UnixNano()) +} + +// Akzeptanzkriterium 1 + Pruefung 2: fehlende Pflichtangaben abgewiesen. +func TestRegister_RejectsMissingFields(t *testing.T) { + registry, _, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + if _, err := registry.Register(ctx, "", "1.0", nil); !errors.Is(err, ErrMissingName) { + t.Fatalf("erwartet ErrMissingName, habe %v", err) + } + if _, err := registry.Register(ctx, "dms", "", nil); !errors.Is(err, ErrMissingVersion) { + t.Fatalf("erwartet ErrMissingVersion, habe %v", err) + } +} + +func TestRegister_AndGet(t *testing.T) { + registry, _, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + name := uniqueModuleName(t) + + m, err := registry.Register(ctx, name, "1.2.0", []string{"dms_enabled"}) + if err != nil { + t.Fatalf("register: %v", err) + } + if m.Version != "1.2.0" || len(m.RequiredFlags) != 1 { + t.Fatalf("unerwartet: %+v", m) + } + + got, err := registry.Get(ctx, name) + if err != nil { + t.Fatalf("get: %v", err) + } + if got.Version != "1.2.0" { + t.Fatalf("get version = %q", got.Version) + } +} + +// Akzeptanzkriterium 2 + 3 + Pruefung 3: konsistente Daten nach +// Aktivierung/Deaktivierung eines Moduls. +func TestIsActive_ReflectsFlagStateConsistently(t *testing.T) { + registry, flagStore, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + name := uniqueModuleName(t) + flagKey := name + "_enabled" + + if _, err := registry.Register(ctx, name, "1.0", []string{flagKey}); err != nil { + t.Fatalf("register: %v", err) + } + + active, err := registry.IsActive(ctx, "acme", name) + if err != nil { + t.Fatalf("is active (vor flag): %v", err) + } + if active { + t.Fatal("erwartet nicht aktiv, solange flag nicht gesetzt ist") + } + + if err := flagStore.Set(ctx, flag.Flag{Key: flagKey, Enabled: true}); err != nil { + t.Fatalf("flag setzen: %v", err) + } + time.Sleep(20 * time.Millisecond) // TTL abwarten + + active, err = registry.IsActive(ctx, "acme", name) + if err != nil { + t.Fatalf("is active (nach flag an): %v", err) + } + if !active { + t.Fatal("erwartet aktiv, nachdem flag aktiviert wurde") + } + + if err := flagStore.Set(ctx, flag.Flag{Key: flagKey, Enabled: false}); err != nil { + t.Fatalf("flag zuruecksetzen: %v", err) + } + time.Sleep(20 * time.Millisecond) // TTL abwarten + active, err = registry.IsActive(ctx, "acme", name) + if err != nil { + t.Fatalf("is active (nach flag aus): %v", err) + } + if active { + t.Fatal("erwartet wieder nicht aktiv, nachdem flag deaktiviert wurde") + } +} + +func TestIsActive_UnregisteredModuleIsNeverActive(t *testing.T) { + registry, _, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + active, err := registry.IsActive(ctx, "acme", "nie-registriert") + if err != nil { + t.Fatalf("is active: %v", err) + } + if active { + t.Fatal("unregistriertes modul darf nie aktiv sein") + } +} + +// Akzeptanzkriterium 2 + Pruefung 1: Anfrage an deaktiviertes Modul wird +// zentral abgewiesen, BEVOR die Modul-Logik erreicht wird. +func TestRequireActiveModule_BlocksBeforeHandler(t *testing.T) { + registry, flagStore, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + name := uniqueModuleName(t) + flagKey := name + "_enabled" + + if _, err := registry.Register(ctx, name, "1.0", []string{flagKey}); err != nil { + t.Fatalf("register: %v", err) + } + + handlerReached := false + handler := registry.RequireActiveModule(name, func(w http.ResponseWriter, r *http.Request) { + handlerReached = true + w.WriteHeader(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/modul?tenant=acme", nil) + rec := httptest.NewRecorder() + handler(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } + if handlerReached { + t.Fatal("handler haette bei deaktiviertem modul NICHT erreicht werden duerfen") + } + + if err := flagStore.Set(ctx, flag.Flag{Key: flagKey, Enabled: true}); err != nil { + t.Fatalf("flag setzen: %v", err) + } + req2 := httptest.NewRequest(http.MethodGet, "/modul?tenant=acme", nil) + rec2 := httptest.NewRecorder() + handler(rec2, req2) + if rec2.Code != http.StatusOK { + t.Fatalf("status nach aktivierung = %d, want 200", rec2.Code) + } + if !handlerReached { + t.Fatal("handler haette bei aktiviertem modul erreicht werden muessen") + } +} + +// Akzeptanzkriterium 4 + Pruefung 4: gueltiges/ungueltiges Service-Credential. +func TestProvisionAndAuthenticate(t *testing.T) { + registry, _, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + name := uniqueModuleName(t) + + if _, err := registry.Register(ctx, name, "1.0", nil); err != nil { + t.Fatalf("register: %v", err) + } + + clientID, secret, err := registry.Provision(ctx, name) + if err != nil { + t.Fatalf("provision: %v", err) + } + if clientID == "" || secret == "" { + t.Fatal("erwartet nicht-leere client-id/secret") + } + + moduleName, ok, err := registry.Authenticate(ctx, clientID, secret) + if err != nil { + t.Fatalf("authenticate (korrekt): %v", err) + } + if !ok || moduleName != name { + t.Fatalf("erwartet erfolgreiche authentifizierung fuer %q, habe ok=%v moduleName=%q", name, ok, moduleName) + } + + _, ok, err = registry.Authenticate(ctx, clientID, "falsches-secret") + if err != nil { + t.Fatalf("authenticate (falsch): %v", err) + } + if ok { + t.Fatal("erwartet fehlschlag bei falschem secret") + } + + _, ok, err = registry.Authenticate(ctx, "unbekannte-client-id", secret) + if err != nil { + t.Fatalf("authenticate (unbekannt): %v", err) + } + if ok { + t.Fatal("erwartet fehlschlag bei unbekannter client-id") + } +} + +func TestProvision_RequiresRegisteredModule(t *testing.T) { + registry, _, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + if _, _, err := registry.Provision(ctx, "nie-registriert"); !errors.Is(err, ErrModuleNotRegistered) { + t.Fatalf("erwartet ErrModuleNotRegistered, habe %v", err) + } +} + +func TestRequireServiceCredential_RejectsInvalidAcceptsValid(t *testing.T) { + registry, _, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + name := uniqueModuleName(t) + + if _, err := registry.Register(ctx, name, "1.0", nil); err != nil { + t.Fatalf("register: %v", err) + } + clientID, secret, err := registry.Provision(ctx, name) + if err != nil { + t.Fatalf("provision: %v", err) + } + + handler := registry.RequireServiceCredential(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + // Fehlendes Credential. + req := httptest.NewRequest(http.MethodPost, "/service-aufruf", nil) + rec := httptest.NewRecorder() + handler(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("ohne credential: status = %d, want 401", rec.Code) + } + + // Falsches Secret. + req2 := httptest.NewRequest(http.MethodPost, "/service-aufruf", nil) + req2.Header.Set("X-Client-Id", clientID) + req2.Header.Set("X-Client-Secret", "falsch") + rec2 := httptest.NewRecorder() + handler(rec2, req2) + if rec2.Code != http.StatusUnauthorized { + t.Fatalf("falsches secret: status = %d, want 401", rec2.Code) + } + + // Gueltiges Credential. + req3 := httptest.NewRequest(http.MethodPost, "/service-aufruf", nil) + req3.Header.Set("X-Client-Id", clientID) + req3.Header.Set("X-Client-Secret", secret) + rec3 := httptest.NewRecorder() + handler(rec3, req3) + if rec3.Code != http.StatusOK { + t.Fatalf("gueltiges credential: status = %d, want 200", rec3.Code) + } +} diff --git a/migrations/0005_module_registry.down.sql b/migrations/0005_module_registry.down.sql new file mode 100644 index 0000000..65ecec7 --- /dev/null +++ b/migrations/0005_module_registry.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS module_credentials; +DROP TABLE IF EXISTS modules; diff --git a/migrations/0005_module_registry.up.sql b/migrations/0005_module_registry.up.sql new file mode 100644 index 0000000..6b4bbbb --- /dev/null +++ b/migrations/0005_module_registry.up.sql @@ -0,0 +1,18 @@ +-- Modul-Registry & Aktivierungspruefung (API-02, siehe core-kanban/tickets/API-02.md). +CREATE TABLE modules ( + name TEXT PRIMARY KEY, + version TEXT NOT NULL CHECK (version <> ''), + required_flags TEXT[] NOT NULL DEFAULT '{}', + registered_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Service-Credential je Modul-Instanz, bei Provisionierung ausgestellt +-- (Akzeptanzkriterium 4). secret_hash enthaelt NIEMALS das Secret im +-- Klartext, nur dessen SHA-256-Hash (Timing-safe-Vergleich beim Login, +-- Referenzmuster siehe AUD-02). +CREATE TABLE 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() +);