ZDDC/zddc/internal/policy/parity_test.go
2026-06-11 13:32:31 -05:00

179 lines
7.8 KiB
Go

package policy
import (
"context"
"encoding/json"
"strings"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
"github.com/open-policy-agent/opa/rego"
)
// TestRegoParity_AllInternalCases validates that the bundled reference
// Rego (ReferenceRego, embedded from rego/access.rego) produces the same
// allow/deny decision as the InternalDecider for every fixture below.
//
// This test imports the OPA Go module — but only in a _test.go file, so
// it does NOT end up in `go build ./cmd/zddc-server`'s production
// binary. Operators get a 13-MB OPA-free binary; CI gets a parity check
// that catches semantic drift between the two implementations the moment
// it occurs.
//
// The fixture set is intentionally inherited from acl_test.go so any new
// case added there (or a case the conversation surfaces while the docs
// are being maintained) is automatically picked up here.
func TestRegoParity_AllInternalCases(t *testing.T) {
ctx := context.Background()
// Compile the reference Rego once; query it for each fixture.
preparedQuery, err := rego.New(
rego.Query("data.zddc.access.allow"),
rego.Module("access.rego", ReferenceRego),
).PrepareForEval(ctx)
if err != nil {
t.Fatalf("rego compilation failed: %v", err)
}
allow := func(p ...string) zddc.ZddcFile {
m := make(map[string]string, len(p))
for _, x := range p {
m[x] = "rwcd"
}
return zddc.ZddcFile{ACL: zddc.ACLRules{Permissions: m}}
}
deny := func(p ...string) zddc.ZddcFile {
m := make(map[string]string, len(p))
for _, x := range p {
m[x] = ""
}
return zddc.ZddcFile{ACL: zddc.ACLRules{Permissions: m}}
}
allowDeny := func(a, d []string) zddc.ZddcFile {
m := make(map[string]string, len(a)+len(d))
for _, x := range a {
m[x] = "rwcd"
}
for _, x := range d {
m[x] = ""
}
return zddc.ZddcFile{ACL: zddc.ACLRules{Permissions: m}}
}
empty := zddc.ZddcFile{}
cases := []struct {
name string
chain zddc.PolicyChain
email string
path string
}{
// All canonical cascade cases from acl_test.go — kept as a parallel
// list rather than imported because acl_test.go's helpers aren't
// exported and we want this file to be self-contained.
{"empty chain no files", zddc.PolicyChain{HasAnyFile: false}, "alice@example.com", "/"},
{"empty chain no files, anon", zddc.PolicyChain{HasAnyFile: false}, "", "/"},
{"files exist no rule matches", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@trusted.com")}, HasAnyFile: true}, "alice@example.com", "/"},
{"leaf allow wins", zddc.PolicyChain{Levels: []zddc.ZddcFile{empty, allow("*@example.com")}, HasAnyFile: true}, "alice@example.com", "/sub/"},
{"leaf deny beats parent allow", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@example.com"), deny("alice@example.com")}, HasAnyFile: true}, "alice@example.com", "/sub/"},
{"leaf no rule, parent allow", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@example.com"), allow("bob@example.com")}, HasAnyFile: true}, "alice@example.com", "/sub/"},
{"leaf re-allows what parent denied", zddc.PolicyChain{Levels: []zddc.ZddcFile{deny("alice@example.com"), allow("alice@example.com")}, HasAnyFile: true}, "alice@example.com", "/sub/"},
{"multi-level deepest 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", "/sub/sub/"},
{"empty levels, files present, deny", zddc.PolicyChain{Levels: []zddc.ZddcFile{empty, empty, empty}, HasAnyFile: true}, "alice@example.com", "/sub/"},
{"empty levels, no files, allow", zddc.PolicyChain{Levels: []zddc.ZddcFile{empty, empty, empty}, HasAnyFile: false}, "alice@example.com", "/sub/"},
// Glob-pattern parity — exercises email_matches in Rego against
// MatchesPattern in Go.
{"wildcard local part", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("alice@*")}, HasAnyFile: true}, "alice@anywhere.com", "/"},
{"wildcard domain", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@example.com")}, HasAnyFile: true}, "anyone@example.com", "/"},
{"exact match", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("alice@example.com")}, HasAnyFile: true}, "alice@example.com", "/"},
{"exact does not match anyone else", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("alice@example.com")}, HasAnyFile: true}, "bob@example.com", "/"},
{"wildcard does NOT cross @", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*example.com")}, HasAnyFile: true}, "alice@example.com", "/"},
{"bare * matches anyone", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*")}, HasAnyFile: true}, "alice@example.com", "/"},
// Worked-example layout traces (verify-it recipe in zddc/README.md).
// Insider on technical project.
{"insider on technical", zddc.PolicyChain{Levels: []zddc.ZddcFile{
zddc.ZddcFile{Admins: []string{"admin@mycompany.com"}},
allow("*@mycompany.com"),
}, HasAnyFile: true}, "alice@mycompany.com", "/Acme-tech/"},
// Insider not on closed project.
{"insider not on closed", zddc.PolicyChain{Levels: []zddc.ZddcFile{
zddc.ZddcFile{Admins: []string{"admin@mycompany.com"}},
allow("alice@mycompany.com"),
}, HasAnyFile: true}, "bob@mycompany.com", "/Acme-comm/"},
// Vendor at /Archive/Acme/.
{"vendor at own folder", zddc.PolicyChain{Levels: []zddc.ZddcFile{
zddc.ZddcFile{Admins: []string{"admin@mycompany.com"}},
allow("*@mycompany.com"),
allow("acme-rep@acme.com"),
}, HasAnyFile: true}, "acme-rep@acme.com", "/Archive/Acme/"},
// Vendor blocked at sibling-vendor folder.
{"vendor blocked at sibling", zddc.PolicyChain{Levels: []zddc.ZddcFile{
zddc.ZddcFile{Admins: []string{"admin@mycompany.com"}},
allow("*@mycompany.com"),
allow("beta-rep@beta.com"),
}, HasAnyFile: true}, "acme-rep@acme.com", "/Archive/Beta/"},
// The anti-pattern trap: same-level allow + deny *@company.com
// blocks the supposedly-allowed user too (deny is checked first
// within a level, so this is a documentation/rego parity check).
{"trap same-level allow+deny shadow", zddc.PolicyChain{Levels: []zddc.ZddcFile{
zddc.ZddcFile{Admins: []string{"admin@mycompany.com"}},
allowDeny([]string{"alice@mycompany.com"}, []string{"*@mycompany.com"}),
}, HasAnyFile: true}, "alice@mycompany.com", "/Trap/"},
}
internal := &InternalDecider{}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
input := AllowInput{Path: tc.path, PolicyChain: chainToSerializable(tc.chain)}
input.User.Email = tc.email
// Run the Go evaluator.
goAllow, err := internal.Allow(ctx, input)
if err != nil {
t.Fatalf("internal: %v", err)
}
// Run the Rego evaluator on identical input.
regoInput, err := canonicalInput(input)
if err != nil {
t.Fatalf("encode input: %v", err)
}
rs, err := preparedQuery.Eval(ctx, rego.EvalInput(regoInput))
if err != nil {
t.Fatalf("rego eval: %v", err)
}
if len(rs) == 0 {
t.Fatalf("rego: no result")
}
regoAllow, ok := rs[0].Expressions[0].Value.(bool)
if !ok {
t.Fatalf("rego: result is not bool: %v", rs[0].Expressions[0].Value)
}
if goAllow != regoAllow {
t.Errorf("PARITY BROKEN: internal=%v, rego=%v\n email=%q path=%q\n chain=%+v",
goAllow, regoAllow, tc.email, tc.path, tc.chain)
}
})
}
}
// canonicalInput serializes the AllowInput through JSON and back so that
// the Rego evaluator sees the exact same shape an external OPA would
// receive over the wire. Catches accidents where a struct-literal
// fixture would expose a Go-only field name (capitalized) that wouldn't
// reach a real OPA deployment.
func canonicalInput(in AllowInput) (map[string]interface{}, error) {
b, err := json.Marshal(in)
if err != nil {
return nil, err
}
var out map[string]interface{}
if err := json.NewDecoder(strings.NewReader(string(b))).Decode(&out); err != nil {
return nil, err
}
return out, nil
}