Files
patrickandClaude Sonnet 5 77a89a9fcb
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
RBAC-11: objektrechte mit vererbung für ordner
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 11:54:06 +02:00

152 lines
6.2 KiB
Go

package policyapi
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
"gitea.perlbach24.de/scripte/nexarch/internal/policy"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
"gitea.perlbach24.de/scripte/nexarch/internal/testdb"
)
const testToken = "test-service-token"
func setupTest(t *testing.T) (*policy.Enforcer, *flag.Service) {
e, f, _, _ := setupTestFull(t)
return e, f
}
func setupTestFull(t *testing.T) (*policy.Enforcer, *flag.Service, *policy.Store, *flag.Store) {
t.Helper()
dsn := os.Getenv("TEST_ADMIN_DSN")
if dsn == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool := testdb.Pool(t, dsn)
if _, err := pool.Exec(ctx, `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
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()
);
CREATE TABLE IF NOT EXISTS object_acl (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_slug TEXT NOT NULL, object_type TEXT NOT NULL CHECK (object_type IN ('folder','document')),
object_id TEXT NOT NULL, principal_type TEXT NOT NULL CHECK (principal_type IN ('user','group')),
principal_id TEXT NOT NULL, object_right TEXT NOT NULL CHECK (object_right IN ('read','write','manage')),
effect TEXT NOT NULL CHECK (effect IN ('allow','deny')), created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_slug, object_type, object_id, principal_type, principal_id, object_right)
);
CREATE TABLE IF NOT EXISTS object_acl_breaks (
tenant_slug TEXT NOT NULL, folder_id TEXT NOT NULL, created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (tenant_slug, folder_id)
);
CREATE TABLE IF NOT EXISTS object_acl_changes (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, tenant_slug TEXT NOT NULL,
object_type TEXT NOT NULL, object_id TEXT NOT NULL, action TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '', actor TEXT NOT NULL, changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM object_acl_changes WHERE tenant_slug LIKE 'rbac11\_%' ESCAPE '\'`)
_, _ = pool.Exec(context.Background(), `DELETE FROM object_acl_breaks WHERE tenant_slug LIKE 'rbac11\_%' ESCAPE '\'`)
_, _ = pool.Exec(context.Background(), `DELETE FROM object_acl WHERE tenant_slug LIKE 'rbac11\_%' ESCAPE '\'`)
_, _ = pool.Exec(context.Background(), `DELETE FROM policy_module_scopes WHERE role LIKE 'rbac06\_%' ESCAPE '\'`)
_, _ = pool.Exec(context.Background(), `DELETE FROM feature_flags WHERE key LIKE 'rbac06\_%' ESCAPE '\'`)
_, _ = pool.Exec(context.Background(), `DELETE FROM policy_rule_changes WHERE role LIKE 'rbac06\_%' ESCAPE '\'`)
_, _ = pool.Exec(context.Background(), `DELETE FROM policy_rules WHERE role LIKE 'rbac06\_%' ESCAPE '\'`)
})
store := policy.NewStore(pool)
if err := store.Grant(ctx, rbac.Role("rbac06_erlaubte_rolle"), rbac.Permission("rbac06_recht"), "test"); err != nil {
t.Fatalf("grant: %v", err)
}
flagStore := flag.NewStore(pool)
return policy.NewEnforcer(store), flag.NewService(flagStore, 10*time.Millisecond), store, flagStore
}
func authorize(t *testing.T, server *httptest.Server, role, permission string) (int, bool) {
t.Helper()
body, _ := json.Marshal(authorizeRequest{Role: role, Permission: permission})
req, _ := http.NewRequest(http.MethodPost, server.URL+"/authorize", bytes.NewReader(body))
req.Header.Set("X-Service-Token", testToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return resp.StatusCode, false
}
var out authorizeResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("antwort dekodieren: %v", err)
}
return resp.StatusCode, out.Allowed
}
// TestAuthorizeHandler_MatchesDirectEnforcerResult ist die geforderte
// Pflichtpruefung: Endpunkt-Antwort stimmt in 100 Stichproben exakt mit
// dem direkten Enforcer.Authorize-Ergebnis ueberein - real fuer sowohl
// erlaubte als auch verbotene role+permission-Kombinationen.
func TestAuthorizeHandler_MatchesDirectEnforcerResult(t *testing.T) {
enforcer, flags := setupTest(t)
mux := http.NewServeMux()
Mount(mux, enforcer, flags, testToken)
server := httptest.NewServer(mux)
defer server.Close()
cases := []struct {
role, permission string
}{
{"rbac06_erlaubte_rolle", "rbac06_recht"}, // erlaubt
{"rbac06_unbekannte_rolle", "rbac06_recht"}, // verboten (default-deny)
{"rbac06_erlaubte_rolle", "rbac06_anderes"}, // verboten (falsches recht)
}
for i := 0; i < 100; i++ {
c := cases[i%len(cases)]
directErr := enforcer.Authorize(context.Background(), rbac.Role(c.role), rbac.Permission(c.permission))
directAllowed := directErr == nil
if directErr != nil && !errors.Is(directErr, policy.ErrDenied) {
t.Fatalf("stichprobe %d: unerwarteter enforcer-fehler: %v", i, directErr)
}
status, httpAllowed := authorize(t, server, c.role, c.permission)
if status != http.StatusOK {
t.Fatalf("stichprobe %d: unerwarteter status %d", i, status)
}
if httpAllowed != directAllowed {
t.Fatalf("stichprobe %d (%s/%s): http=%t, direkt=%t - weichen ab", i, c.role, c.permission, httpAllowed, directAllowed)
}
}
}