internal/policy/module_scope.go: policy_module_scopes verknuepft optional eine (role, permission)-Regel mit einem LIC-02-Feature-Flag. Enforcer. AuthorizeForTenant ist DIESELBE zentrale Entscheidungsfunktion wie Authorize (Akzeptanzkriterium 3, kein zweiter Enforcement-Mechanismus) — prueft zusaetzlich zur Grundregel, ob das verknuepfte Modul fuer den Tenant aktiv ist. Existiert kein ModuleScope-Eintrag, bleibt eine Regel wie bisher ohne Lizenzbindung gueltig (Kombinationsfall). GuardModuleScoped erweitert policy.Guard um dieselbe Pruefung. internal/flag (LIC-02) wurde 1:1 aus dem lic-02-Branch uebernommen (git show aus derselben Repo-Historie, keine Aenderung) — RBAC-04 haengt sowohl an RBAC-01/02 als auch an LIC-02, aber diese leben auf getrennten, noch nicht gemergten Feature-Branches ohne gemeinsame Historie. Fail-Safe-Verhalten aus LIC-02 greift automatisch: ein nicht konfiguriertes oder nicht erreichbares Feature-Flag gilt als deaktiviert, nie als aktiviert (sicherer Default fuer Modul-Aktivierungspruefungen). Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Zugriff auf deaktiviertes Modul trotz passender Rolle abgewiesen — TestAuthorizeForTenant_DeniesWhenModuleNotActivated: GuardModuleScoped ruft die Query-Funktion nachweislich nicht auf. PASS. 2. Reaktivierung macht Berechtigung im laufenden Betrieb wirksam, kein Neustart — TestAuthorizeForTenant_BecomesActiveWithoutRestart: derselbe Enforcer/Service-Prozess, Flag per Store.Set aktiviert, TTL abgewartet, danach erlaubt. PASS. 3. Zusammenspiel Modul-Scope + Rollenscope in Kombinationsfaellen — TestAuthorizeForTenant_CombinationsOfRoleAndModuleScope: keine Regel -> verboten; Regel ohne Modul-Scope -> immer erlaubt; Regel mit Modul-Scope und Flag aus -> verboten; Flag an -> erlaubt. PASS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
88 lines
2.5 KiB
Go
88 lines
2.5 KiB
Go
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()
|
|
}
|