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>
171 lines
6.5 KiB
Go
171 lines
6.5 KiB
Go
package policy
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
|
|
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
|
|
)
|
|
|
|
func setupModuleScopeTest(t *testing.T) (*Store, *Enforcer, *flag.Store, *flag.Service, 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 policy_rules (
|
|
role TEXT NOT NULL, permission TEXT NOT NULL,
|
|
granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (role, permission)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS policy_rule_changes (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), role TEXT NOT NULL, permission TEXT NOT NULL,
|
|
action TEXT NOT NULL CHECK (action IN ('grant','revoke')), actor TEXT NOT NULL,
|
|
version INT NOT NULL, changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
CREATE TABLE IF NOT EXISTS policy_module_scopes (
|
|
role TEXT NOT NULL, permission TEXT NOT NULL, module TEXT NOT NULL, flag_key TEXT NOT NULL,
|
|
PRIMARY KEY (role, permission)
|
|
);
|
|
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)
|
|
}
|
|
|
|
store := NewStore(pool)
|
|
flagStore := flag.NewStore(pool)
|
|
flagService := flag.NewService(flagStore, 10*time.Millisecond) // kurze TTL fuer testbare invalidierung
|
|
|
|
cleanup := func() {
|
|
_, _ = pool.Exec(ctx, `DELETE FROM policy_module_scopes WHERE role LIKE 'test\_%' ESCAPE '\'`)
|
|
_, _ = pool.Exec(ctx, `DELETE FROM policy_rule_changes WHERE role LIKE 'test\_%' ESCAPE '\'`)
|
|
_, _ = pool.Exec(ctx, `DELETE FROM policy_rules WHERE role LIKE 'test\_%' ESCAPE '\'`)
|
|
_, _ = pool.Exec(ctx, `DELETE FROM feature_flags WHERE key LIKE 'test\_%' ESCAPE '\'`)
|
|
pool.Close()
|
|
}
|
|
return store, NewEnforcer(store), flagStore, flagService, cleanup
|
|
}
|
|
|
|
// Akzeptanzkriterium 1 + Pruefung 1: Berechtigung fuer nicht aktiviertes
|
|
// Modul greift nicht, selbst bei sonst passender Rolle.
|
|
func TestAuthorizeForTenant_DeniesWhenModuleNotActivated(t *testing.T) {
|
|
store, enforcer, _, flagService, cleanup := setupModuleScopeTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
role := rbac.Role("test_dms_nutzer")
|
|
perm := rbac.Permission("test_dokumente_lesen")
|
|
if err := store.Grant(ctx, role, perm, "admin@example.com"); err != nil {
|
|
t.Fatalf("grant: %v", err)
|
|
}
|
|
if err := store.SetModuleScope(ctx, role, perm, "dms", "test_dms_enabled"); err != nil {
|
|
t.Fatalf("set module scope: %v", err)
|
|
}
|
|
// Flag existiert nicht/ist nicht gesetzt -> IsEnabled liefert false (Fail-Safe-Default).
|
|
|
|
queryCalled := false
|
|
_, err := GuardModuleScoped(ctx, enforcer, flagService, "acme", role, perm, func(ctx context.Context) (string, error) {
|
|
queryCalled = true
|
|
return "daten", nil
|
|
})
|
|
if !errors.Is(err, ErrDenied) {
|
|
t.Fatalf("erwartet ErrDenied bei deaktiviertem modul, habe %v", err)
|
|
}
|
|
if queryCalled {
|
|
t.Fatal("query haette bei deaktiviertem modul nicht aufgerufen werden duerfen")
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 2 + Pruefung 2: Aktivierung des Moduls macht die
|
|
// Berechtigung ohne Neustart wirksam.
|
|
func TestAuthorizeForTenant_BecomesActiveWithoutRestart(t *testing.T) {
|
|
store, enforcer, flagStore, flagService, cleanup := setupModuleScopeTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
role := rbac.Role("test_dms_nutzer2")
|
|
perm := rbac.Permission("test_dokumente_schreiben")
|
|
if err := store.Grant(ctx, role, perm, "admin@example.com"); err != nil {
|
|
t.Fatalf("grant: %v", err)
|
|
}
|
|
if err := store.SetModuleScope(ctx, role, perm, "dms", "test_dms_enabled2"); err != nil {
|
|
t.Fatalf("set module scope: %v", err)
|
|
}
|
|
|
|
if err := enforcer.AuthorizeForTenant(ctx, flagService, "acme", role, perm); !errors.Is(err, ErrDenied) {
|
|
t.Fatalf("vor aktivierung: erwartet ErrDenied, habe %v", err)
|
|
}
|
|
|
|
// Modul "im laufenden Betrieb" aktivieren — derselbe Prozess, kein Neustart.
|
|
if err := flagStore.Set(ctx, flag.Flag{Key: "test_dms_enabled2", Enabled: true}); err != nil {
|
|
t.Fatalf("flag setzen: %v", err)
|
|
}
|
|
time.Sleep(20 * time.Millisecond) // TTL abwarten statt Neustart
|
|
|
|
if err := enforcer.AuthorizeForTenant(ctx, flagService, "acme", role, perm); err != nil {
|
|
t.Fatalf("nach aktivierung sollte erlaubt sein: %v", err)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 3 + Pruefung 3: Zusammenspiel Modul-Scope + Rollenscope
|
|
// in Kombinationsfaellen.
|
|
func TestAuthorizeForTenant_CombinationsOfRoleAndModuleScope(t *testing.T) {
|
|
store, enforcer, flagStore, flagService, cleanup := setupModuleScopeTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
scopedRole := rbac.Role("test_scoped_rolle")
|
|
unscopedRole := rbac.Role("test_unscoped_rolle")
|
|
perm := rbac.Permission("test_kombiniert")
|
|
|
|
// Fall 1: Rolle ohne jegliche Regel -> verboten, unabhaengig vom Flag.
|
|
if err := enforcer.AuthorizeForTenant(ctx, flagService, "acme", rbac.Role("test_unbekannt"), perm); !errors.Is(err, ErrDenied) {
|
|
t.Fatalf("fall 1: erwartet ErrDenied (keine regel), habe %v", err)
|
|
}
|
|
|
|
// Fall 2: Regel vorhanden, KEIN Modul-Scope -> immer erlaubt (Grundrecht ohne Lizenzbindung).
|
|
if err := store.Grant(ctx, unscopedRole, perm, "admin@example.com"); err != nil {
|
|
t.Fatalf("grant unscoped: %v", err)
|
|
}
|
|
if err := enforcer.AuthorizeForTenant(ctx, flagService, "acme", unscopedRole, perm); err != nil {
|
|
t.Fatalf("fall 2: erwartet erlaubt ohne modul-scope, habe %v", err)
|
|
}
|
|
|
|
// Fall 3: Regel + Modul-Scope, Flag aus -> verboten.
|
|
if err := store.Grant(ctx, scopedRole, perm, "admin@example.com"); err != nil {
|
|
t.Fatalf("grant scoped: %v", err)
|
|
}
|
|
if err := store.SetModuleScope(ctx, scopedRole, perm, "mail", "test_mail_enabled"); err != nil {
|
|
t.Fatalf("set module scope: %v", err)
|
|
}
|
|
if err := enforcer.AuthorizeForTenant(ctx, flagService, "acme", scopedRole, perm); !errors.Is(err, ErrDenied) {
|
|
t.Fatalf("fall 3: erwartet ErrDenied (modul aus), habe %v", err)
|
|
}
|
|
|
|
// Fall 4: Regel + Modul-Scope, Flag an -> erlaubt.
|
|
if err := flagStore.Set(ctx, flag.Flag{Key: "test_mail_enabled", Enabled: true}); err != nil {
|
|
t.Fatalf("flag setzen: %v", err)
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
if err := enforcer.AuthorizeForTenant(ctx, flagService, "acme", scopedRole, perm); err != nil {
|
|
t.Fatalf("fall 4: erwartet erlaubt (modul an), habe %v", err)
|
|
}
|
|
}
|