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
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
152 lines
6.2 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|