Files
nexarch/internal/policy/module_scope_test.go
T
sysopsandClaude Sonnet 5 3d5f53f103 RBAC-04: modul-scoped-berechtigungen
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>
2026-08-27 22:04:58 +02:00

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)
}
}