internal/policy: deklarativer, DB-gehaltener Regelsatz (policy_rules) statt hartcodierter Go-Entscheidungslogik — Store.IsAllowed schaut ausschliesslich in die Datenbank, kein Go-Fallback. Ein Regelwechsel (Grant/Revoke) wirkt sich sofort aus, ohne Codeaenderung/Deploy (Akzeptanzkriterium 3). Jede Aenderung wird atomar mit einem versionierten Historieneintrag in policy_rule_changes festgehalten (grant/revoke, Akteur, Version). Enforcer.Authorize ist Default-Deny: existiert keine Regel fuer role+ permission, ist der Zugriff verboten (Akzeptanzkriterium 2), fuer sich genommen ohne Anwendungslogik testbar. Guard/GuardTenantScoped sind die zentrale Enforcement-Funktion (Akzeptanzkriterium 1): die uebergebene Query-Funktion wird NUR bei erfolgreicher Autorisierung aufgerufen — es gibt keinen Weg, Daten ohne vorherige Authorize-Entscheidung zu erhalten. GuardTenantScoped erzwingt zusaetzlich per Funktionssignatur, dass tenantSlug TEIL der Query-Funktion ist (Akzeptanzkriterium 3) — ein nachgelagerter Post-Filter (der archivmail-Fehler aus "Bekannte Fehler vermeiden": Tenant-Filter nach statt in der Query) ist mit dieser Signatur strukturell nicht moeglich, da die Repository-Implementierung tenantSlug selbst fuer ihre eigene WHERE-Klausel entgegennimmt. Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Kein Datenzugriffs-Pfad umgeht die zentrale Enforcement-Schicht — TestGuard_NeverCallsQueryWithoutAuthorization: query-Funktion wird nachweislich NICHT aufgerufen ohne vorherige Regel, erst nach Grant. PASS. 2. Anfrage ohne passende Policy wird zuverlaessig abgewiesen (Default-Deny) — TestAuthorize_DefaultDeny: keine Regel konfiguriert -> ErrDenied, nicht automatisch erlaubt. PASS. 3. Policy-Regelsatz versioniert, Regelwechsel ohne Codeaenderung nachvollziehbar — TestGrantRevoke_ChangesBehaviorWithoutCodeChange: Verhalten aendert sich durch reinen Datenbank-Grant/Revoke, Historie zeigt beide Versionen korrekt. PASS. Zusaetzlich: TestGuardTenantScoped_IsolatesDataBetweenTenants belegt das Tenant-Scoping-Muster aus Akzeptanzkriterium 3 konkret anhand zweier Tenants. PASS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
188 lines
5.9 KiB
Go
188 lines
5.9 KiB
Go
package policy
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
|
|
)
|
|
|
|
func setupTest(t *testing.T) (*Store, *Enforcer, 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()
|
|
);
|
|
`); err != nil {
|
|
t.Fatalf("schema: %v", err)
|
|
}
|
|
|
|
store := NewStore(pool)
|
|
cleanup := func() {
|
|
_, _ = 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.Close()
|
|
}
|
|
return store, NewEnforcer(store), cleanup
|
|
}
|
|
|
|
// Akzeptanzkriterium 2 + Pruefung 2: Anfrage ohne passende Policy wird
|
|
// zuverlaessig abgewiesen (Default-Deny), nicht standardmaessig erlaubt.
|
|
func TestAuthorize_DefaultDeny(t *testing.T) {
|
|
_, enforcer, cleanup := setupTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
err := enforcer.Authorize(ctx, rbac.Role("test_niemand"), rbac.Permission("test_irgendwas"))
|
|
if !errors.Is(err, ErrDenied) {
|
|
t.Fatalf("erwartet ErrDenied ohne konfigurierte regel, habe %v", err)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 1 + Pruefung 1: Guard ruft query NUR bei Autorisierung
|
|
// auf — kein Datenzugriffs-Pfad umgeht die Enforcement-Schicht.
|
|
func TestGuard_NeverCallsQueryWithoutAuthorization(t *testing.T) {
|
|
store, enforcer, cleanup := setupTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
role := rbac.Role("test_rolle_guard")
|
|
perm := rbac.Permission("test_lesen")
|
|
|
|
queryCalled := false
|
|
_, err := Guard(ctx, enforcer, role, perm, func(ctx context.Context) (string, error) {
|
|
queryCalled = true
|
|
return "geheime-daten", nil
|
|
})
|
|
if !errors.Is(err, ErrDenied) {
|
|
t.Fatalf("erwartet ErrDenied, habe %v", err)
|
|
}
|
|
if queryCalled {
|
|
t.Fatal("query haette bei fehlender autorisierung NICHT aufgerufen werden duerfen")
|
|
}
|
|
|
|
if err := store.Grant(ctx, role, perm, "admin@example.com"); err != nil {
|
|
t.Fatalf("grant: %v", err)
|
|
}
|
|
|
|
result, err := Guard(ctx, enforcer, role, perm, func(ctx context.Context) (string, error) {
|
|
queryCalled = true
|
|
return "geheime-daten", nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("guard nach grant: %v", err)
|
|
}
|
|
if !queryCalled || result != "geheime-daten" {
|
|
t.Fatalf("erwartet query-aufruf mit ergebnis nach autorisierung, habe queryCalled=%v result=%q", queryCalled, result)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 2 + 3: Policy-Entscheidungen deklarativ und unabhaengig
|
|
// von Anwendungslogik testbar — Regelwechsel wirkt ohne Codeaenderung,
|
|
// Historie ist versioniert nachvollziehbar.
|
|
func TestGrantRevoke_ChangesBehaviorWithoutCodeChange(t *testing.T) {
|
|
store, enforcer, cleanup := setupTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
role := rbac.Role("test_rolle_history")
|
|
perm := rbac.Permission("test_schreiben")
|
|
|
|
allowed, err := store.IsAllowed(ctx, role, perm)
|
|
if err != nil {
|
|
t.Fatalf("is allowed (vorher): %v", err)
|
|
}
|
|
if allowed {
|
|
t.Fatal("erwartet nicht erlaubt vor grant")
|
|
}
|
|
|
|
if err := store.Grant(ctx, role, perm, "admin@example.com"); err != nil {
|
|
t.Fatalf("grant: %v", err)
|
|
}
|
|
if err := enforcer.Authorize(ctx, role, perm); err != nil {
|
|
t.Fatalf("authorize nach grant sollte erlauben: %v", err)
|
|
}
|
|
|
|
if err := store.Revoke(ctx, role, perm, "admin@example.com"); err != nil {
|
|
t.Fatalf("revoke: %v", err)
|
|
}
|
|
if err := enforcer.Authorize(ctx, role, perm); !errors.Is(err, ErrDenied) {
|
|
t.Fatalf("authorize nach revoke sollte verweigern, habe %v", err)
|
|
}
|
|
|
|
history, err := store.History(ctx, role, perm)
|
|
if err != nil {
|
|
t.Fatalf("history: %v", err)
|
|
}
|
|
if len(history) != 2 {
|
|
t.Fatalf("erwartet 2 historieneintraege (grant, revoke), habe %d", len(history))
|
|
}
|
|
if history[0].Action != "grant" || history[0].Version != 1 {
|
|
t.Fatalf("history[0] unerwartet: %+v", history[0])
|
|
}
|
|
if history[1].Action != "revoke" || history[1].Version != 2 {
|
|
t.Fatalf("history[1] unerwartet: %+v", history[1])
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 3 + Pruefung 3 (Tenant-Scoping): GuardTenantScoped
|
|
// zwingt tenantSlug in die Query-Funktion selbst, kein Post-Filter moeglich.
|
|
func TestGuardTenantScoped_IsolatesDataBetweenTenants(t *testing.T) {
|
|
_, enforcer, cleanup := setupTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
// Simuliertes Repository: die WHERE-Klausel (hier: Map-Lookup) liegt
|
|
// INNERHALB der Query-Funktion, nicht als nachgelagerter Filter.
|
|
data := map[string][]string{
|
|
"acme": {"dokument-a1", "dokument-a2"},
|
|
"globex": {"dokument-b1"},
|
|
}
|
|
repoQuery := func(ctx context.Context, tenantSlug string) ([]string, error) {
|
|
return data[tenantSlug], nil
|
|
}
|
|
|
|
role := rbac.Role("test_tenant_rolle")
|
|
perm := rbac.Permission("test_dokumente_lesen")
|
|
if err := enforcer.store.Grant(ctx, role, perm, "admin@example.com"); err != nil {
|
|
t.Fatalf("grant: %v", err)
|
|
}
|
|
|
|
gotAcme, err := GuardTenantScoped(ctx, enforcer, role, perm, "acme", repoQuery)
|
|
if err != nil {
|
|
t.Fatalf("guard tenant scoped (acme): %v", err)
|
|
}
|
|
if len(gotAcme) != 2 {
|
|
t.Fatalf("erwartet 2 dokumente fuer acme, habe %d", len(gotAcme))
|
|
}
|
|
|
|
gotGlobex, err := GuardTenantScoped(ctx, enforcer, role, perm, "globex", repoQuery)
|
|
if err != nil {
|
|
t.Fatalf("guard tenant scoped (globex): %v", err)
|
|
}
|
|
if len(gotGlobex) != 1 {
|
|
t.Fatalf("erwartet 1 dokument fuer globex, habe %d", len(gotGlobex))
|
|
}
|
|
}
|