ZDDC/zddc/internal/policy/policy_test.go
ZDDC e911806eda feat(server): pluggable OPA-compatible policy decider
Add an internal access-decision boundary that all handlers go through
instead of calling zddc.AllowedWithChain directly. Two implementations
ship:

  * InternalDecider — wraps the existing zddc.AllowedWithChain. The
    default. No new dependencies, identical semantics to the legacy
    code path. ZDDC_OPA_URL=internal (or unset).

  * HTTPDecider — POSTs the canonical OPA wire format
    (POST /v1/data/zddc/access/allow with {"input": {...}}, response
    {"result": true|false}) over HTTP, HTTPS, or a Unix-domain socket.
    For federal customers running their own audited Rego policies
    alongside zddc-server. ZDDC_OPA_URL=http(s)://… or unix:///….

External-mode failure semantics: unreachable / non-2xx / malformed
response → fail closed (deny) by default with a WARN log. Operators
who prefer availability over correctness flip with ZDDC_OPA_FAIL_OPEN=1.

The decider is constructed once at startup, plumbed through ACLMiddleware
into the request context. Handlers retrieve it via DeciderFromContext;
non-request callers (fs.ListDirectory, EnumerateProjects, enumerateAccess)
take it as an explicit parameter.

zddc.ZddcFile and zddc.ACLRules gain JSON tags so external Rego authors
get idiomatic input shape (acl.allow, admins, …) instead of Go field
names (ACL.Allow, Admins, …).

Test coverage:
  * InternalDecider parity tests against zddc.AllowedWithChain (every
    documented cascade scenario: empty chain, leaf-allow-wins, leaf-
    deny-beats-parent, leaf-allows-what-parent-denies, deepest-match-
    wins, etc.)
  * HTTPDecider happy-path test (canonical wire format)
  * Fail-closed / fail-open / malformed-response tests

Production binary size unchanged (no new deps; HTTP transport is
stdlib net/http). 11 ACL call sites migrated. End-to-end verified
against the worked-example layout in zddc/README.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:45:07 -05:00

258 lines
7.1 KiB
Go

package policy
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// TestNew_ModeSelection: New() picks the right implementation per URL.
func TestNew_ModeSelection(t *testing.T) {
cases := []struct {
url string
wantType string
wantErr bool
}{
{"", "*policy.InternalDecider", false},
{"internal", "*policy.InternalDecider", false},
{"INTERNAL", "*policy.InternalDecider", false},
{"http://127.0.0.1:8181", "*policy.HTTPDecider", false},
{"https://opa.example:8181", "*policy.HTTPDecider", false},
{"unix:///run/opa.sock", "*policy.HTTPDecider", false},
{"ftp://nope", "", true},
{"garbage", "", true},
}
for _, tc := range cases {
t.Run(tc.url, func(t *testing.T) {
d, err := New(Config{URL: tc.url})
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) → %s, want %s", tc.url, got, tc.wantType)
}
})
}
}
func describe(v interface{}) string {
switch v.(type) {
case *InternalDecider:
return "*policy.InternalDecider"
case *HTTPDecider:
return "*policy.HTTPDecider"
default:
return "unknown"
}
}
// TestInternalDecider_ParityWithAllowedWithChain: the internal
// decider returns the same answer as zddc.AllowedWithChain for
// every documented cascade scenario.
func TestInternalDecider_ParityWithAllowedWithChain(t *testing.T) {
allow := func(p ...string) zddc.ZddcFile { return zddc.ZddcFile{ACL: zddc.ACLRules{Allow: p}} }
deny := func(p ...string) zddc.ZddcFile { return zddc.ZddcFile{ACL: zddc.ACLRules{Deny: p}} }
allowDeny := func(a, d []string) zddc.ZddcFile {
return zddc.ZddcFile{ACL: zddc.ACLRules{Allow: a, Deny: d}}
}
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{allow("*@trusted.com")}, HasAnyFile: true},
"alice@example.com",
false,
},
{
"leaf allow wins",
zddc.PolicyChain{Levels: []zddc.ZddcFile{empty, allow("*@example.com")}, HasAnyFile: true},
"alice@example.com",
true,
},
{
"leaf deny beats parent allow (bottom-up first match)",
zddc.PolicyChain{Levels: []zddc.ZddcFile{
allow("*@example.com"),
deny("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{
allow("*@example.com"),
allow("bob@example.com"),
}, HasAnyFile: true},
"alice@example.com",
true,
},
{
"leaf allows user that parent denies",
zddc.PolicyChain{Levels: []zddc.ZddcFile{
deny("alice@example.com"),
allow("alice@example.com"),
}, HasAnyFile: true},
"alice@example.com",
true,
},
{
"multi-level: deepest match wins",
zddc.PolicyChain{Levels: []zddc.ZddcFile{
allow("*@example.com"),
allowDeny([]string{"*@example.com"}, []string{"alice@example.com"}),
allow("alice@example.com"),
}, 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)
}
want := zddc.AllowedWithChain(tc.chain, tc.email)
if got != want {
t.Errorf("decider = %v, AllowedWithChain = %v (parity broken)", got, want)
}
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")
}
}
// 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)")
}
}