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 { 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 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 }