398 lines
12 KiB
Go
398 lines
12 KiB
Go
package policy
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
|
)
|
|
|
|
// TestNew_ModeSelection: New() picks the right implementation per URL.
|
|
// External-mode URLs return a cachingDecider wrapping an HTTPDecider
|
|
// by default; CacheTTL<0 disables the wrapper.
|
|
func TestNew_ModeSelection(t *testing.T) {
|
|
cases := []struct {
|
|
url string
|
|
ttl time.Duration
|
|
wantType string
|
|
wantErr bool
|
|
}{
|
|
{"", 0, "*policy.InternalDecider", false},
|
|
{"internal", 0, "*policy.InternalDecider", false},
|
|
{"INTERNAL", 0, "*policy.InternalDecider", false},
|
|
{"http://127.0.0.1:8181", 0, "*policy.cachingDecider", false},
|
|
{"https://opa.example:8181", 0, "*policy.cachingDecider", false},
|
|
{"unix:///run/opa.sock", 0, "*policy.cachingDecider", false},
|
|
{"http://127.0.0.1:8181", -1, "*policy.HTTPDecider", false}, // cache disabled
|
|
{"ftp://nope", 0, "", true},
|
|
{"garbage", 0, "", true},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.url, func(t *testing.T) {
|
|
d, err := New(Config{URL: tc.url, CacheTTL: tc.ttl})
|
|
if tc.wantErr {
|
|
if err == nil {
|
|
t.Fatal("New() = nil error, want error")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("New() unexpected error: %v", err)
|
|
}
|
|
got := describe(d)
|
|
if got != tc.wantType {
|
|
t.Errorf("New(%q, ttl=%v) → %s, want %s", tc.url, tc.ttl, got, tc.wantType)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func describe(v interface{}) string {
|
|
switch v.(type) {
|
|
case *InternalDecider:
|
|
return "*policy.InternalDecider"
|
|
case *HTTPDecider:
|
|
return "*policy.HTTPDecider"
|
|
case *cachingDecider:
|
|
return "*policy.cachingDecider"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// TestInternalDecider_CascadeScenarios exercises the internal decider
|
|
// against the documented cascade rules: default-allow on empty trees,
|
|
// default-deny when .zddc files exist but nothing matches, leaf-wins
|
|
// for first match bottom-up, and re-allow at the deepest level.
|
|
func TestInternalDecider_CascadeScenarios(t *testing.T) {
|
|
perm := func(p map[string]string) zddc.ZddcFile {
|
|
return zddc.ZddcFile{ACL: zddc.ACLRules{Permissions: p}}
|
|
}
|
|
empty := zddc.ZddcFile{}
|
|
|
|
cases := []struct {
|
|
name string
|
|
chain zddc.PolicyChain
|
|
email string
|
|
want bool
|
|
}{
|
|
{
|
|
"empty chain, no files → allow",
|
|
zddc.PolicyChain{HasAnyFile: false},
|
|
"alice@example.com",
|
|
true,
|
|
},
|
|
{
|
|
"files exist but no rule matches → deny",
|
|
zddc.PolicyChain{Levels: []zddc.ZddcFile{perm(map[string]string{"*@trusted.com": "r"})}, HasAnyFile: true},
|
|
"alice@example.com",
|
|
false,
|
|
},
|
|
{
|
|
"leaf allow wins",
|
|
zddc.PolicyChain{Levels: []zddc.ZddcFile{empty, perm(map[string]string{"*@example.com": "r"})}, HasAnyFile: true},
|
|
"alice@example.com",
|
|
true,
|
|
},
|
|
{
|
|
"leaf deny beats parent allow (bottom-up first match)",
|
|
zddc.PolicyChain{Levels: []zddc.ZddcFile{
|
|
perm(map[string]string{"*@example.com": "r"}),
|
|
perm(map[string]string{"alice@example.com": ""}),
|
|
}, HasAnyFile: true},
|
|
"alice@example.com",
|
|
false,
|
|
},
|
|
{
|
|
"leaf has no rule for user, falls back to parent allow",
|
|
zddc.PolicyChain{Levels: []zddc.ZddcFile{
|
|
perm(map[string]string{"*@example.com": "r"}),
|
|
perm(map[string]string{"bob@example.com": "r"}),
|
|
}, HasAnyFile: true},
|
|
"alice@example.com",
|
|
true,
|
|
},
|
|
{
|
|
"leaf allows user that parent denies",
|
|
zddc.PolicyChain{Levels: []zddc.ZddcFile{
|
|
perm(map[string]string{"alice@example.com": ""}),
|
|
perm(map[string]string{"alice@example.com": "r"}),
|
|
}, HasAnyFile: true},
|
|
"alice@example.com",
|
|
true,
|
|
},
|
|
{
|
|
"multi-level: deepest match wins",
|
|
zddc.PolicyChain{Levels: []zddc.ZddcFile{
|
|
perm(map[string]string{"*@example.com": "r"}),
|
|
perm(map[string]string{"*@example.com": "r", "alice@example.com": ""}),
|
|
perm(map[string]string{"alice@example.com": "r"}),
|
|
}, HasAnyFile: true},
|
|
"alice@example.com",
|
|
true,
|
|
},
|
|
}
|
|
d := &InternalDecider{}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, err := AllowFromChain(context.Background(), d, tc.chain, tc.email, "/test")
|
|
if err != nil {
|
|
t.Fatalf("AllowFromChain: %v", err)
|
|
}
|
|
if got != tc.want {
|
|
t.Errorf("decider = %v, want %v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHTTPDecider_HappyPath: the HTTP decider posts the canonical
|
|
// OPA shape and acts on a 200 with {"result": true|false}.
|
|
func TestHTTPDecider_HappyPath(t *testing.T) {
|
|
var captured struct {
|
|
path string
|
|
contentType string
|
|
body []byte
|
|
}
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
captured.path = r.URL.Path
|
|
captured.contentType = r.Header.Get("Content-Type")
|
|
captured.body, _ = io.ReadAll(r.Body)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"result": true}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
d, err := New(Config{URL: srv.URL})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
in := AllowInput{Path: "/p"}
|
|
in.User.Email = "alice@example.com"
|
|
in.PolicyChain = &SerializableChain{HasAnyFile: true}
|
|
|
|
got, err := d.Allow(context.Background(), in)
|
|
if err != nil {
|
|
t.Fatalf("Allow: %v", err)
|
|
}
|
|
if got != true {
|
|
t.Errorf("Allow = false, want true")
|
|
}
|
|
if captured.path != "/v1/data/zddc/access/allow" {
|
|
t.Errorf("path = %q, want /v1/data/zddc/access/allow", captured.path)
|
|
}
|
|
if !strings.HasPrefix(captured.contentType, "application/json") {
|
|
t.Errorf("content-type = %q, want application/json", captured.contentType)
|
|
}
|
|
// Body should be {"input": {...}}
|
|
var wrap struct{ Input AllowInput }
|
|
if err := json.Unmarshal(captured.body, &wrap); err != nil {
|
|
t.Fatalf("body did not unmarshal as {input}: %v (body=%s)", err, captured.body)
|
|
}
|
|
if wrap.Input.User.Email != "alice@example.com" {
|
|
t.Errorf("body.input.user.email = %q, want alice@example.com", wrap.Input.User.Email)
|
|
}
|
|
}
|
|
|
|
// TestHTTPDecider_FailClosed: any transport/encoding/HTTP error
|
|
// returns deny (false) by default, with no Go error returned to
|
|
// the caller (handlers don't have to special-case it).
|
|
func TestHTTPDecider_FailClosed(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
d, err := New(Config{URL: srv.URL})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
got, err := d.Allow(context.Background(), AllowInput{Path: "/p"})
|
|
if err != nil {
|
|
t.Fatalf("Allow: %v", err)
|
|
}
|
|
if got != false {
|
|
t.Errorf("on 500, Allow = true, want false (fail-closed default)")
|
|
}
|
|
}
|
|
|
|
// TestHTTPDecider_FailOpen: with FailOpen=true, a transport error
|
|
// returns allow.
|
|
func TestHTTPDecider_FailOpen(t *testing.T) {
|
|
d, err := New(Config{URL: "http://127.0.0.1:1", FailOpen: true})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
got, err := d.Allow(context.Background(), AllowInput{Path: "/p"})
|
|
if err != nil {
|
|
t.Fatalf("Allow: %v", err)
|
|
}
|
|
if got != true {
|
|
t.Errorf("on unreachable host with FailOpen, Allow = false, want true")
|
|
}
|
|
}
|
|
|
|
// TestCachingDecider_AmortizesRoundTrips: a HTTPDecider wrapped in
|
|
// the default 1s cache only round-trips once for a burst of identical
|
|
// queries. Verifies the listing-amortization benefit for external mode.
|
|
func TestCachingDecider_AmortizesRoundTrips(t *testing.T) {
|
|
var hits int
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
hits++
|
|
_, _ = w.Write([]byte(`{"result": true}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
d, err := New(Config{URL: srv.URL}) // CacheTTL=0 → default 1s
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
in := AllowInput{Path: "/p"}
|
|
in.User.Email = "alice@example.com"
|
|
|
|
// 50 identical calls → exactly 1 round-trip thanks to the cache.
|
|
for i := 0; i < 50; i++ {
|
|
got, err := d.Allow(context.Background(), in)
|
|
if err != nil {
|
|
t.Fatalf("Allow #%d: %v", i, err)
|
|
}
|
|
if !got {
|
|
t.Errorf("Allow #%d = false, want true", i)
|
|
}
|
|
}
|
|
if hits != 1 {
|
|
t.Errorf("OPA round-trips = %d, want 1 (cache miss-and-fill)", hits)
|
|
}
|
|
}
|
|
|
|
// TestCachingDecider_DifferentInputsSeparatelyKeyed: changing email or
|
|
// path produces a separate cache entry; a different-decision answer is
|
|
// not masked by the cached one.
|
|
func TestCachingDecider_DifferentInputsSeparatelyKeyed(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var wrap struct {
|
|
Input AllowInput `json:"input"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&wrap)
|
|
// Allow only "alice"; deny everyone else.
|
|
allow := wrap.Input.User.Email == "alice@example.com"
|
|
resp, _ := json.Marshal(map[string]bool{"result": allow})
|
|
_, _ = w.Write(resp)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
d, err := New(Config{URL: srv.URL})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
email string
|
|
want bool
|
|
}{
|
|
{"alice@example.com", true},
|
|
{"bob@example.com", false},
|
|
{"alice@example.com", true}, // cached
|
|
{"bob@example.com", false}, // cached
|
|
{"carol@example.com", false}, // new
|
|
} {
|
|
in := AllowInput{Path: "/p"}
|
|
in.User.Email = tc.email
|
|
got, err := d.Allow(context.Background(), in)
|
|
if err != nil {
|
|
t.Fatalf("Allow(%s): %v", tc.email, err)
|
|
}
|
|
if got != tc.want {
|
|
t.Errorf("Allow(%s) = %v, want %v", tc.email, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCachingDecider_TTLExpires: a cached decision is re-fetched after
|
|
// the TTL window. Using a very short TTL (10ms) so the test runs fast.
|
|
func TestCachingDecider_TTLExpires(t *testing.T) {
|
|
var hits int
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
hits++
|
|
_, _ = w.Write([]byte(`{"result": true}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
d, err := New(Config{URL: srv.URL, CacheTTL: 10 * 1000000}) // 10ms in ns
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
in := AllowInput{Path: "/p"}
|
|
in.User.Email = "alice@example.com"
|
|
|
|
if _, err := d.Allow(context.Background(), in); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := d.Allow(context.Background(), in); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Two calls so far; the second is a cache hit. hits==1.
|
|
if hits != 1 {
|
|
t.Errorf("after 2 calls within TTL, hits=%d, want 1", hits)
|
|
}
|
|
// Wait past the TTL.
|
|
time.Sleep(20 * 1000000) // 20ms
|
|
if _, err := d.Allow(context.Background(), in); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if hits != 2 {
|
|
t.Errorf("after TTL expiry, hits=%d, want 2 (re-fetched)", hits)
|
|
}
|
|
}
|
|
|
|
// TestCachingDecider_NegativeTTLDisablesCache: CacheTTL<0 returns the
|
|
// inner decider unwrapped, useful for tests that want predictable
|
|
// per-call HTTP traffic.
|
|
func TestCachingDecider_NegativeTTLDisablesCache(t *testing.T) {
|
|
var hits int
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
hits++
|
|
_, _ = w.Write([]byte(`{"result": true}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
d, err := New(Config{URL: srv.URL, CacheTTL: -1})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
in := AllowInput{Path: "/p"}
|
|
in.User.Email = "alice@example.com"
|
|
for i := 0; i < 5; i++ {
|
|
_, _ = d.Allow(context.Background(), in)
|
|
}
|
|
if hits != 5 {
|
|
t.Errorf("with cache disabled, hits=%d, want 5 (one per Allow)", hits)
|
|
}
|
|
}
|
|
|
|
// TestHTTPDecider_MalformedResponse: a 200 with a missing/garbage
|
|
// result field also fails closed.
|
|
func TestHTTPDecider_MalformedResponse(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(`{"unexpected": "shape"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
d, err := New(Config{URL: srv.URL})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
got, err := d.Allow(context.Background(), AllowInput{Path: "/p"})
|
|
if err != nil {
|
|
t.Fatalf("Allow: %v", err)
|
|
}
|
|
if got != false {
|
|
t.Errorf("on missing result, Allow = true, want false (fail-closed)")
|
|
}
|
|
}
|