Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4180a26c6e |
@@ -0,0 +1,88 @@
|
|||||||
|
package cfgservice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultCacheTTL ist die dokumentierte Cache-Invalidierungszeit
|
||||||
|
// (Akzeptanzkriterium 2 / Pruefung 2 in diesem Ticket bezieht sich auf die
|
||||||
|
// Aenderungsnachvollziehbarkeit — die Cache-Frist selbst folgt demselben
|
||||||
|
// Muster wie internal/flag.DefaultCacheTTL).
|
||||||
|
const DefaultCacheTTL = 5 * time.Second
|
||||||
|
|
||||||
|
type cacheEntry struct {
|
||||||
|
value Value
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service ist die Leseseite mit Vorrangregel (Akzeptanzkriterium 1:
|
||||||
|
// Tenant-Override vor Global-Default) und lokalem TTL-Cache.
|
||||||
|
type Service struct {
|
||||||
|
store *Store
|
||||||
|
ttl time.Duration
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
cache map[string]cacheEntry // Schluessel: key + "\x00" + tenantSlug
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(store *Store, ttl time.Duration) *Service {
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = DefaultCacheTTL
|
||||||
|
}
|
||||||
|
return &Service{store: store, ttl: ttl, cache: make(map[string]cacheEntry)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cacheKey(key, tenantSlug string) string {
|
||||||
|
return key + "\x00" + tenantSlug
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve liefert den Konfigurationswert fuer einen Tenant: ein
|
||||||
|
// Tenant-spezifischer Override hat Vorrang vor dem globalen Default
|
||||||
|
// (Akzeptanzkriterium 1 / Pruefung 1). tenantSlug == "" wertet nur den
|
||||||
|
// globalen Wert aus.
|
||||||
|
func (s *Service) Resolve(ctx context.Context, tenantSlug, key string) (Value, error) {
|
||||||
|
ck := cacheKey(key, tenantSlug)
|
||||||
|
|
||||||
|
s.mu.RLock()
|
||||||
|
entry, exists := s.cache[ck]
|
||||||
|
fresh := exists && time.Now().Before(entry.expiresAt)
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if fresh {
|
||||||
|
return entry.value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
v, err := s.resolveUncached(ctx, tenantSlug, key)
|
||||||
|
if err != nil {
|
||||||
|
return Value{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.cache[ck] = cacheEntry{value: v, expiresAt: time.Now().Add(s.ttl)}
|
||||||
|
s.mu.Unlock()
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) resolveUncached(ctx context.Context, tenantSlug, key string) (Value, error) {
|
||||||
|
if tenantSlug != "" {
|
||||||
|
v, err := s.store.Get(ctx, key, tenantSlug)
|
||||||
|
if err == nil {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrNotFound) {
|
||||||
|
return Value{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.store.Get(ctx, key, GlobalScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate erzwingt beim naechsten Resolve-Aufruf ein sofortiges Neuladen
|
||||||
|
// fuer einen bestimmten (key, tenantSlug) statt auf den TTL-Ablauf zu warten
|
||||||
|
// — analog internal/flag.Service.Invalidate.
|
||||||
|
func (s *Service) Invalidate(key, tenantSlug string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.cache, cacheKey(key, tenantSlug))
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package cfgservice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + Pruefung 1: Tenant-Override hat Vorrang vor
|
||||||
|
// Global-Default, automatisiert getestet.
|
||||||
|
func TestService_TenantOverrideTakesPrecedenceOverGlobal(t *testing.T) {
|
||||||
|
store, cleanup := setupStoreTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := store.Set(ctx, "test_precedence_key", GlobalScope, "global-wert"); err != nil {
|
||||||
|
t.Fatalf("set global: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := store.Set(ctx, "test_precedence_key", "test_acme", "tenant-wert"); err != nil {
|
||||||
|
t.Fatalf("set tenant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewService(store, time.Hour)
|
||||||
|
|
||||||
|
got, err := svc.Resolve(ctx, "test_acme", "test_precedence_key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve mit override: %v", err)
|
||||||
|
}
|
||||||
|
if got.Value != "tenant-wert" {
|
||||||
|
t.Fatalf("erwartet tenant-override, habe %q", got.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotOther, err := svc.Resolve(ctx, "test_anderer_tenant", "test_precedence_key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve ohne override: %v", err)
|
||||||
|
}
|
||||||
|
if gotOther.Value != "global-wert" {
|
||||||
|
t.Fatalf("erwartet global-default fuer tenant ohne override, habe %q", gotOther.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 2: Cache-Invalidierung nach
|
||||||
|
// Konfigurationsaenderung innerhalb dokumentierter Zeit gemessen.
|
||||||
|
func TestService_CacheInvalidationTiming(t *testing.T) {
|
||||||
|
store, cleanup := setupStoreTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const ttl = 150 * time.Millisecond
|
||||||
|
if _, err := store.Set(ctx, "test_ttl_key", GlobalScope, "alt"); err != nil {
|
||||||
|
t.Fatalf("set: %v", err)
|
||||||
|
}
|
||||||
|
svc := NewService(store, ttl)
|
||||||
|
|
||||||
|
v, err := svc.Resolve(ctx, "", "test_ttl_key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve: %v", err)
|
||||||
|
}
|
||||||
|
if v.Value != "alt" {
|
||||||
|
t.Fatalf("erwartet 'alt', habe %q", v.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
changedAt := time.Now()
|
||||||
|
if _, err := store.Set(ctx, "test_ttl_key", GlobalScope, "neu"); err != nil {
|
||||||
|
t.Fatalf("set: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
v, err = svc.Resolve(ctx, "", "test_ttl_key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve direkt nach aenderung: %v", err)
|
||||||
|
}
|
||||||
|
if v.Value != "alt" {
|
||||||
|
t.Fatalf("cache haette den alten wert liefern sollen, habe %q", v.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := changedAt.Add(ttl + 100*time.Millisecond)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
v, err := svc.Resolve(ctx, "", "test_ttl_key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve: %v", err)
|
||||||
|
}
|
||||||
|
if v.Value == "neu" {
|
||||||
|
t.Logf("aenderung wurde nach %s wirksam (ziel: innerhalb %s + toleranz)", time.Since(changedAt), ttl)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("aenderung wurde nicht innerhalb von %s wirksam", deadline.Sub(changedAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_InvalidateForcesImmediateRefresh(t *testing.T) {
|
||||||
|
store, cleanup := setupStoreTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := store.Set(ctx, "test_invalidate_key", GlobalScope, "alt"); err != nil {
|
||||||
|
t.Fatalf("set: %v", err)
|
||||||
|
}
|
||||||
|
svc := NewService(store, time.Hour)
|
||||||
|
_, _ = svc.Resolve(ctx, "", "test_invalidate_key")
|
||||||
|
|
||||||
|
if _, err := store.Set(ctx, "test_invalidate_key", GlobalScope, "neu"); err != nil {
|
||||||
|
t.Fatalf("set: %v", err)
|
||||||
|
}
|
||||||
|
svc.Invalidate("test_invalidate_key", "")
|
||||||
|
|
||||||
|
v, err := svc.Resolve(ctx, "", "test_invalidate_key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve: %v", err)
|
||||||
|
}
|
||||||
|
if v.Value != "neu" {
|
||||||
|
t.Fatalf("erwartet sofort sichtbaren neuen wert nach Invalidate, habe %q", v.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// Package cfgservice implementiert Core CFG-01: den zentralen Dienst fuer
|
||||||
|
// globale und tenant-spezifische Konfigurationswerte mit Versionierung und
|
||||||
|
// Cache-Invalidierung. Andere Module lesen Konfiguration AUSSCHLIESSLICH
|
||||||
|
// ueber dieses Paket (Akzeptanzkriterium 3), niemals ueber eigene Tabellen.
|
||||||
|
package cfgservice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GlobalScope ist der reservierte Scope-Wert fuer globale Defaults — jeder
|
||||||
|
// andere Scope-Wert ist ein Tenant-Slug (Akzeptanzkriterium 1).
|
||||||
|
const GlobalScope = "global"
|
||||||
|
|
||||||
|
var ErrNotFound = errors.New("cfgservice: kein wert fuer diesen key gefunden")
|
||||||
|
|
||||||
|
type Value struct {
|
||||||
|
Key string
|
||||||
|
Scope string
|
||||||
|
Value string
|
||||||
|
Version int
|
||||||
|
}
|
||||||
|
|
||||||
|
type HistoryEntry struct {
|
||||||
|
Key string
|
||||||
|
Scope string
|
||||||
|
Value string
|
||||||
|
Version int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store ist die Schreib-/Verwaltungsseite. Set schreibt IMMER sowohl den
|
||||||
|
// aktuellen Stand (config_values) als auch einen Historieneintrag
|
||||||
|
// (config_value_history) in derselben Transaktion — eine Aenderung ohne
|
||||||
|
// Versionshistorie ist strukturell ausgeschlossen (Akzeptanzkriterium 2).
|
||||||
|
type Store struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(pool *pgxpool.Pool) *Store {
|
||||||
|
return &Store{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set schreibt einen neuen Wert fuer (key, scope) und erhoeht die Version um 1
|
||||||
|
// (Version 1 bei erstmaligem Setzen).
|
||||||
|
func (s *Store) Set(ctx context.Context, key, scope, value string) (Value, error) {
|
||||||
|
if scope == "" {
|
||||||
|
return Value{}, errors.New("cfgservice: scope darf nicht leer sein")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return Value{}, fmt.Errorf("transaktion starten: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
|
var currentVersion int
|
||||||
|
err = tx.QueryRow(ctx, `SELECT version FROM config_values WHERE key = $1 AND scope = $2`, key, scope).Scan(¤tVersion)
|
||||||
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Value{}, fmt.Errorf("aktuelle version lesen: %w", err)
|
||||||
|
}
|
||||||
|
newVersion := currentVersion + 1
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO config_values (key, scope, value, version, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, now())
|
||||||
|
ON CONFLICT (key, scope) DO UPDATE SET value = $3, version = $4, updated_at = now()
|
||||||
|
`, key, scope, value, newVersion); err != nil {
|
||||||
|
return Value{}, fmt.Errorf("wert speichern: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO config_value_history (key, scope, value, version, changed_at)
|
||||||
|
VALUES ($1, $2, $3, $4, now())
|
||||||
|
`, key, scope, value, newVersion); err != nil {
|
||||||
|
return Value{}, fmt.Errorf("historie schreiben: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return Value{}, fmt.Errorf("transaktion committen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Value{Key: key, Scope: scope, Value: value, Version: newVersion}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get liefert den Wert fuer GENAU EINEN Scope (kein Vorrang-Fallback) — die
|
||||||
|
// Vorrangregel (Tenant vor Global) lebt bewusst in Service.Resolve, damit
|
||||||
|
// Store rein CRUD bleibt.
|
||||||
|
func (s *Store) Get(ctx context.Context, key, scope string) (Value, error) {
|
||||||
|
var v Value
|
||||||
|
v.Key, v.Scope = key, scope
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT value, version FROM config_values WHERE key = $1 AND scope = $2
|
||||||
|
`, key, scope).Scan(&v.Value, &v.Version)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Value{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return Value{}, fmt.Errorf("wert lesen: %w", err)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// History liefert die vollstaendige Versionshistorie eines (key, scope) in
|
||||||
|
// aufsteigender Reihenfolge (Akzeptanzkriterium 2 / Pruefung 3).
|
||||||
|
func (s *Store) History(ctx context.Context, key, scope string) ([]HistoryEntry, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT key, scope, value, version FROM config_value_history
|
||||||
|
WHERE key = $1 AND scope = $2 ORDER BY version
|
||||||
|
`, key, scope)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("historie abfragen: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []HistoryEntry
|
||||||
|
for rows.Next() {
|
||||||
|
var h HistoryEntry
|
||||||
|
if err := rows.Scan(&h.Key, &h.Scope, &h.Value, &h.Version); err != nil {
|
||||||
|
return nil, fmt.Errorf("historieneintrag lesen: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, h)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package cfgservice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupStoreTest(t *testing.T) (*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 config_values (
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
scope TEXT NOT NULL CHECK (scope <> ''),
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
version INT NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (key, scope)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS config_value_history (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
scope TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
version INT NOT NULL,
|
||||||
|
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM config_value_history WHERE key LIKE 'test\_%' ESCAPE '\'`)
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM config_values WHERE key LIKE 'test\_%' ESCAPE '\'`)
|
||||||
|
pool.Close()
|
||||||
|
}
|
||||||
|
return NewStore(pool), cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStore_SetIncrementsVersion(t *testing.T) {
|
||||||
|
store, cleanup := setupStoreTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
v1, err := store.Set(ctx, "test_key", GlobalScope, "erster-wert")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("set 1: %v", err)
|
||||||
|
}
|
||||||
|
if v1.Version != 1 {
|
||||||
|
t.Fatalf("erwartet version 1, habe %d", v1.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
v2, err := store.Set(ctx, "test_key", GlobalScope, "zweiter-wert")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("set 2: %v", err)
|
||||||
|
}
|
||||||
|
if v2.Version != 2 {
|
||||||
|
t.Fatalf("erwartet version 2, habe %d", v2.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := store.Get(ctx, "test_key", GlobalScope)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.Value != "zweiter-wert" || got.Version != 2 {
|
||||||
|
t.Fatalf("aktueller wert unerwartet: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 3: Versionierungshistorie ueber mehrere
|
||||||
|
// Aenderungen hinweg nachvollzogen.
|
||||||
|
func TestStore_HistoryTracksAllChanges(t *testing.T) {
|
||||||
|
store, cleanup := setupStoreTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
values := []string{"v1", "v2", "v3"}
|
||||||
|
for _, v := range values {
|
||||||
|
if _, err := store.Set(ctx, "test_history_key", GlobalScope, v); err != nil {
|
||||||
|
t.Fatalf("set %q: %v", v, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := store.History(ctx, "test_history_key", GlobalScope)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("history: %v", err)
|
||||||
|
}
|
||||||
|
if len(history) != 3 {
|
||||||
|
t.Fatalf("erwartet 3 historieneintraege, habe %d", len(history))
|
||||||
|
}
|
||||||
|
for i, h := range history {
|
||||||
|
if h.Version != i+1 || h.Value != values[i] {
|
||||||
|
t.Fatalf("historieneintrag[%d] unerwartet: %+v", i, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStore_GetUnknownKeyReturnsNotFound(t *testing.T) {
|
||||||
|
store, cleanup := setupStoreTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := store.Get(ctx, "test_nie_gesetzt", GlobalScope); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("erwartet ErrNotFound, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
// Package flag implementiert Core LIC-02: einen Feature-Flag-Dienst mit
|
|
||||||
// Strategien (global an/aus, Prozentsatz, Tenant-Zielgruppe) als Kernfunktion
|
|
||||||
// des Core-Dienstes selbst — keine zusaetzliche Infrastruktur (Unleash-Server
|
|
||||||
// + eigene DB), siehe "bewusst vermeiden" im LIC-02-Ticket.
|
|
||||||
package flag
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"hash/fnv"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
var ErrNotFound = errors.New("flag: nicht gefunden")
|
|
||||||
|
|
||||||
// Flag ist die zentrale Definition — Auswertung (Evaluate) ist bewusst davon
|
|
||||||
// getrennt (Unleash-Prinzip: Flag-Verwaltung vs. Flag-Auswertung).
|
|
||||||
type Flag struct {
|
|
||||||
Key string
|
|
||||||
Enabled bool
|
|
||||||
RolloutPercentage int
|
|
||||||
TargetTenantSlugs []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store ist die Verwaltungsseite (Admin): Flags definieren/lesen.
|
|
||||||
type Store struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewStore(pool *pgxpool.Pool) *Store {
|
|
||||||
return &Store{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) Set(ctx context.Context, f Flag) error {
|
|
||||||
if f.TargetTenantSlugs == nil {
|
|
||||||
f.TargetTenantSlugs = []string{} // pgx uebertraegt ein nil-Slice sonst als SQL NULL statt leerem Array.
|
|
||||||
}
|
|
||||||
_, err := s.pool.Exec(ctx, `
|
|
||||||
INSERT INTO feature_flags (key, enabled, rollout_percentage, target_tenant_slugs, updated_at)
|
|
||||||
VALUES ($1, $2, $3, $4, now())
|
|
||||||
ON CONFLICT (key) DO UPDATE SET
|
|
||||||
enabled = $2, rollout_percentage = $3, target_tenant_slugs = $4, updated_at = now()
|
|
||||||
`, f.Key, f.Enabled, f.RolloutPercentage, f.TargetTenantSlugs)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("flag speichern: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) Get(ctx context.Context, key string) (Flag, error) {
|
|
||||||
var f Flag
|
|
||||||
row := s.pool.QueryRow(ctx, `
|
|
||||||
SELECT key, enabled, rollout_percentage, target_tenant_slugs
|
|
||||||
FROM feature_flags WHERE key = $1
|
|
||||||
`, key)
|
|
||||||
if err := row.Scan(&f.Key, &f.Enabled, &f.RolloutPercentage, &f.TargetTenantSlugs); err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return Flag{}, ErrNotFound
|
|
||||||
}
|
|
||||||
return Flag{}, fmt.Errorf("flag lesen: %w", err)
|
|
||||||
}
|
|
||||||
return f, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// evaluate wendet die Strategien in fester Reihenfolge an: globaler
|
|
||||||
// An/Aus-Schalter zuerst, dann Tenant-Zielgruppe, dann Prozentsatz-Rollout.
|
|
||||||
// Ein unbekannter/nicht getroffener Fall ergibt false — Fail-Safe-Default,
|
|
||||||
// kein Feature wird versehentlich aktiv.
|
|
||||||
func evaluate(f Flag, tenantSlug string) bool {
|
|
||||||
if f.Enabled {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for _, target := range f.TargetTenantSlugs {
|
|
||||||
if target == tenantSlug {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if f.RolloutPercentage > 0 {
|
|
||||||
h := fnv.New32a()
|
|
||||||
_, _ = h.Write([]byte(f.Key + "|" + tenantSlug))
|
|
||||||
return int(h.Sum32()%100) < f.RolloutPercentage
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
package flag
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestEvaluate_GlobalEnabled(t *testing.T) {
|
|
||||||
f := Flag{Key: "k", Enabled: true}
|
|
||||||
if !evaluate(f, "irgendein-tenant") {
|
|
||||||
t.Fatal("global aktiviertes flag sollte fuer jeden tenant true liefern")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1 + Pruefung 2: Zielgruppen-Strategie.
|
|
||||||
func TestEvaluate_TargetTenantStrategy(t *testing.T) {
|
|
||||||
f := Flag{Key: "k", Enabled: false, TargetTenantSlugs: []string{"acme"}}
|
|
||||||
if !evaluate(f, "acme") {
|
|
||||||
t.Fatal("erwartet true fuer tenant in zielgruppe")
|
|
||||||
}
|
|
||||||
if evaluate(f, "globex") {
|
|
||||||
t.Fatal("erwartet false fuer tenant ausserhalb der zielgruppe")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluate_RolloutPercentageBoundaries(t *testing.T) {
|
|
||||||
full := Flag{Key: "k", RolloutPercentage: 100}
|
|
||||||
if !evaluate(full, "beliebiger-tenant-1") || !evaluate(full, "beliebiger-tenant-2") {
|
|
||||||
t.Fatal("100% rollout sollte immer true liefern")
|
|
||||||
}
|
|
||||||
|
|
||||||
none := Flag{Key: "k", RolloutPercentage: 0}
|
|
||||||
if evaluate(none, "beliebiger-tenant") {
|
|
||||||
t.Fatal("0% rollout ohne enabled/zielgruppe sollte false liefern")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluate_RolloutIsDeterministicPerTenant(t *testing.T) {
|
|
||||||
f := Flag{Key: "k", RolloutPercentage: 50}
|
|
||||||
first := evaluate(f, "stabiler-tenant")
|
|
||||||
for i := 0; i < 5; i++ {
|
|
||||||
if evaluate(f, "stabiler-tenant") != first {
|
|
||||||
t.Fatal("rollout-auswertung sollte fuer denselben tenant/key stabil sein")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
package flag
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log/slog"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DefaultCacheTTL ist die dokumentierte Cache-Invalidierungszeit
|
|
||||||
// (Akzeptanzkriterium 2/3): eine Aenderung wirkt spaetestens nach dieser
|
|
||||||
// Zeit auf allen Core-Instanzen, ohne dass ein Dienst neu gestartet werden
|
|
||||||
// muss (Akzeptanzkriterium 3).
|
|
||||||
const DefaultCacheTTL = 5 * time.Second
|
|
||||||
|
|
||||||
type cacheEntry struct {
|
|
||||||
flag Flag
|
|
||||||
expiresAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// Service ist die Auswertungsseite (SDK/Client-Analogon zu Unleash) mit
|
|
||||||
// lokalem TTL-Cache. Bewusst getrennt von Store (Verwaltung).
|
|
||||||
type Service struct {
|
|
||||||
store *Store
|
|
||||||
ttl time.Duration
|
|
||||||
|
|
||||||
mu sync.RWMutex
|
|
||||||
cache map[string]cacheEntry
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewService(store *Store, ttl time.Duration) *Service {
|
|
||||||
if ttl <= 0 {
|
|
||||||
ttl = DefaultCacheTTL
|
|
||||||
}
|
|
||||||
return &Service{store: store, ttl: ttl, cache: make(map[string]cacheEntry)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsEnabled wertet ein Flag fuer einen Tenant aus. Liefert IMMER einen
|
|
||||||
// bool ohne Fehlerwert — ein nicht erreichbarer Flag-Dienst darf abhaengige
|
|
||||||
// Aufrufer nicht zum Absturz bringen oder zu Fehlerbehandlungscode zwingen,
|
|
||||||
// der leicht vergessen wird (Akzeptanzkriterium 3 / Pruefung 3: dokumentiertes
|
|
||||||
// Fallback-Verhalten = false, ggf. aus dem zuletzt bekannten Zwischenspeicher).
|
|
||||||
func (s *Service) IsEnabled(ctx context.Context, tenantSlug, key string) bool {
|
|
||||||
f, ok := s.resolve(ctx, key)
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return evaluate(f, tenantSlug)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) resolve(ctx context.Context, key string) (Flag, bool) {
|
|
||||||
s.mu.RLock()
|
|
||||||
entry, exists := s.cache[key]
|
|
||||||
fresh := exists && time.Now().Before(entry.expiresAt)
|
|
||||||
s.mu.RUnlock()
|
|
||||||
if fresh {
|
|
||||||
return entry.flag, true
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := s.store.Get(ctx, key)
|
|
||||||
if err != nil {
|
|
||||||
if exists {
|
|
||||||
slog.Warn("feature-flag-dienst nicht erreichbar, nutze zwischengespeicherten stand",
|
|
||||||
"flag_key", key, "error", err)
|
|
||||||
return entry.flag, true
|
|
||||||
}
|
|
||||||
slog.Warn("feature-flag-dienst nicht erreichbar, kein zwischengespeicherter stand vorhanden, fallback: deaktiviert",
|
|
||||||
"flag_key", key, "error", err)
|
|
||||||
return Flag{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
s.cache[key] = cacheEntry{flag: f, expiresAt: time.Now().Add(s.ttl)}
|
|
||||||
s.mu.Unlock()
|
|
||||||
return f, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invalidate erzwingt beim naechsten IsEnabled-Aufruf ein sofortiges Neuladen
|
|
||||||
// aus der Datenbank statt auf den TTL-Ablauf zu warten — wird nach Store.Set
|
|
||||||
// auf derselben Instanz aufgerufen, damit der Schreiber die eigene Aenderung
|
|
||||||
// ohne Wartezeit sieht. Andere Core-Instanzen sehen sie spaetestens nach
|
|
||||||
// DefaultCacheTTL (siehe Akzeptanzkriterium 3).
|
|
||||||
func (s *Service) Invalidate(key string) {
|
|
||||||
s.mu.Lock()
|
|
||||||
delete(s.cache, key)
|
|
||||||
s.mu.Unlock()
|
|
||||||
}
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
package flag
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
func setupFlagStoreTest(t *testing.T) (*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()
|
|
||||||
)`); err != nil {
|
|
||||||
t.Fatalf("schema: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup := func() {
|
|
||||||
_, _ = pool.Exec(ctx, `DELETE FROM feature_flags WHERE key LIKE 'test\_%' ESCAPE '\'`)
|
|
||||||
pool.Close()
|
|
||||||
}
|
|
||||||
return NewStore(pool), cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1 + Pruefung 2: Zielgruppen-Strategie liefert im Test
|
|
||||||
// die erwartete Auswertung.
|
|
||||||
func TestService_TargetTenantStrategy(t *testing.T) {
|
|
||||||
store, cleanup := setupFlagStoreTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if err := store.Set(ctx, Flag{Key: "test_target_flag", TargetTenantSlugs: []string{"acme"}}); err != nil {
|
|
||||||
t.Fatalf("set: %v", err)
|
|
||||||
}
|
|
||||||
svc := NewService(store, time.Hour)
|
|
||||||
|
|
||||||
if !svc.IsEnabled(ctx, "acme", "test_target_flag") {
|
|
||||||
t.Fatal("erwartet true fuer tenant in zielgruppe")
|
|
||||||
}
|
|
||||||
if svc.IsEnabled(ctx, "globex", "test_target_flag") {
|
|
||||||
t.Fatal("erwartet false fuer tenant ausserhalb der zielgruppe")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 2 + 3 + Pruefung 1: Flag-Aenderung wirkt innerhalb der
|
|
||||||
// dokumentierten Cache-Invalidierungszeit, automatisiert gemessen.
|
|
||||||
func TestService_CacheInvalidationTiming(t *testing.T) {
|
|
||||||
store, cleanup := setupFlagStoreTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
const ttl = 150 * time.Millisecond
|
|
||||||
if err := store.Set(ctx, Flag{Key: "test_ttl_flag", Enabled: false}); err != nil {
|
|
||||||
t.Fatalf("set: %v", err)
|
|
||||||
}
|
|
||||||
svc := NewService(store, ttl)
|
|
||||||
|
|
||||||
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
|
|
||||||
t.Fatal("erwartet false vor der aenderung")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aenderung "auf einer anderen instanz" simulieren: direkt ueber den
|
|
||||||
// Store, ohne svc.Invalidate aufzurufen.
|
|
||||||
changedAt := time.Now()
|
|
||||||
if err := store.Set(ctx, Flag{Key: "test_ttl_flag", Enabled: true}); err != nil {
|
|
||||||
t.Fatalf("set: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sofort danach sollte der Cache noch den alten Stand liefern.
|
|
||||||
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
|
|
||||||
t.Fatal("cache haette den alten (false) stand liefern sollen, direkt nach der aenderung")
|
|
||||||
}
|
|
||||||
|
|
||||||
deadline := changedAt.Add(ttl + 100*time.Millisecond)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
|
|
||||||
elapsed := time.Since(changedAt)
|
|
||||||
t.Logf("aenderung wurde nach %s wirksam (ziel: innerhalb %s + toleranz)", elapsed, ttl)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
}
|
|
||||||
t.Fatalf("aenderung wurde nicht innerhalb von %s wirksam", deadline.Sub(changedAt))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestService_InvalidateForcesImmediateRefresh(t *testing.T) {
|
|
||||||
store, cleanup := setupFlagStoreTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if err := store.Set(ctx, Flag{Key: "test_invalidate_flag", Enabled: false}); err != nil {
|
|
||||||
t.Fatalf("set: %v", err)
|
|
||||||
}
|
|
||||||
svc := NewService(store, time.Hour) // lange TTL, damit Invalidate den unterschied macht
|
|
||||||
_ = svc.IsEnabled(ctx, "acme", "test_invalidate_flag")
|
|
||||||
|
|
||||||
if err := store.Set(ctx, Flag{Key: "test_invalidate_flag", Enabled: true}); err != nil {
|
|
||||||
t.Fatalf("set: %v", err)
|
|
||||||
}
|
|
||||||
svc.Invalidate("test_invalidate_flag")
|
|
||||||
|
|
||||||
if !svc.IsEnabled(ctx, "acme", "test_invalidate_flag") {
|
|
||||||
t.Fatal("erwartet sofort sichtbaren neuen stand nach Invalidate")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 3 + Pruefung 3: Ausfall des Flag-Dienstes fuehrt zu
|
|
||||||
// dokumentiertem Fallback-Verhalten, nicht zum Absturz.
|
|
||||||
func TestService_FallsBackOnStoreFailure(t *testing.T) {
|
|
||||||
store, cleanup := setupFlagStoreTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if err := store.Set(ctx, Flag{Key: "test_fallback_flag", Enabled: true}); err != nil {
|
|
||||||
t.Fatalf("set: %v", err)
|
|
||||||
}
|
|
||||||
svc := NewService(store, time.Hour)
|
|
||||||
|
|
||||||
// Cache vorwaermen, waehrend die DB noch erreichbar ist.
|
|
||||||
if !svc.IsEnabled(ctx, "acme", "test_fallback_flag") {
|
|
||||||
t.Fatal("erwartet true bei funktionierender db")
|
|
||||||
}
|
|
||||||
|
|
||||||
brokenPool, err := pgxpool.New(ctx, "postgresql://nonexistent-host-fuer-test:5432/x?connect_timeout=1")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("broken pool erstellen (sollte nicht sofort verbinden): %v", err)
|
|
||||||
}
|
|
||||||
brokenStore := NewStore(brokenPool)
|
|
||||||
|
|
||||||
svcWithCache := NewService(brokenStore, time.Nanosecond) // TTL sofort abgelaufen, erzwingt reload-versuch
|
|
||||||
svcWithCache.mu.Lock()
|
|
||||||
svcWithCache.cache["test_fallback_flag"] = cacheEntry{
|
|
||||||
flag: Flag{Key: "test_fallback_flag", Enabled: true},
|
|
||||||
expiresAt: time.Now().Add(-time.Hour), // bereits abgelaufen
|
|
||||||
}
|
|
||||||
svcWithCache.mu.Unlock()
|
|
||||||
|
|
||||||
func() {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
t.Fatalf("IsEnabled hat gepanict statt einen fallback zu liefern: %v", r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
if !svcWithCache.IsEnabled(ctx, "acme", "test_fallback_flag") {
|
|
||||||
t.Fatal("erwartet fallback auf zwischengespeicherten (true) stand bei db-ausfall")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Voellig frischer Dienst ohne jeglichen cache + kaputte db -> sicherer
|
|
||||||
// default false, kein absturz.
|
|
||||||
freshSvc := NewService(brokenStore, time.Hour)
|
|
||||||
func() {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
t.Fatalf("IsEnabled hat gepanict: %v", r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
if freshSvc.IsEnabled(ctx, "acme", "test_fallback_flag") {
|
|
||||||
t.Fatal("erwartet fail-safe false ohne cache und mit kaputter db")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE IF EXISTS config_value_history;
|
||||||
|
DROP TABLE IF EXISTS config_values;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- Zentraler Konfigurationsdienst (CFG-01, siehe core-kanban/tickets/CFG-01.md).
|
||||||
|
-- scope = 'global' fuer globale Defaults, sonst der Tenant-Slug. config_values
|
||||||
|
-- haelt den AKTUELLEN Stand je (key, scope); config_value_history haelt JEDE
|
||||||
|
-- Aenderung fest (Akzeptanzkriterium 2: versioniert nachvollziehbar).
|
||||||
|
CREATE TABLE config_values (
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
scope TEXT NOT NULL CHECK (scope <> ''),
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
version INT NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (key, scope)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE config_value_history (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
scope TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
version INT NOT NULL,
|
||||||
|
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX config_value_history_key_scope_idx ON config_value_history (key, scope, version);
|
||||||
@@ -1 +0,0 @@
|
|||||||
DROP TABLE IF EXISTS feature_flags;
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
-- Feature-Flags zentral je Mandant/Zielgruppe (LIC-02, siehe core-kanban/tickets/LIC-02.md).
|
|
||||||
-- Lebt in der Registry-DB, nicht pro Tenant-Datenbank — Flags sind eine
|
|
||||||
-- Core-weite Konfiguration, keine Mandanten-Geschaeftsdaten.
|
|
||||||
CREATE TABLE feature_flags (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
enabled BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
rollout_percentage INT NOT NULL DEFAULT 0 CHECK (rollout_percentage BETWEEN 0 AND 100),
|
|
||||||
target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}',
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
DROP TABLE IF EXISTS module_credentials;
|
|
||||||
DROP TABLE IF EXISTS modules;
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
-- 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()
|
|
||||||
);
|
|
||||||
@@ -14,6 +14,9 @@ ROLE="nexarch_test"
|
|||||||
export PGPASSWORD="$PASS"
|
export PGPASSWORD="$PASS"
|
||||||
|
|
||||||
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants CASCADE;"
|
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants CASCADE;"
|
||||||
|
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS audit_events CASCADE;"
|
||||||
|
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS config_value_history CASCADE;"
|
||||||
|
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS config_values CASCADE;"
|
||||||
|
|
||||||
dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'")
|
dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'")
|
||||||
for db in $dbs; do
|
for db in $dbs; do
|
||||||
|
|||||||
Reference in New Issue
Block a user