ZDDC/zddc/internal/policy/parity_test.go
ZDDC f196205622 refactor(audit): pre-release cleanup pass
Single audit pass that removes pre-release back-compat, consolidates the
admin-policy decider, and fixes the .zddc write path.

Field removal — acl.allow / acl.deny:
- Drop ACLRules.Allow / Deny struct fields and mergeLegacyACL().
- Remove walker / lookups / validate / decider branches that read them.
- Migrate every test fixture (YAML strings and ACLRules struct literals)
  to acl.permissions: { principal → verb-set }.
- Rewrite both bundled Rego policies (access.rego, access_federal.rego)
  to traverse level.acl.permissions; rewrite parity-test helpers.
- Update create-project form (profile page) to collect permissions
  instead of allow/deny lists.

Admin decider consolidation:
- Delete zddc.CanEditZddc — strict-ancestor rule retired. Subtree admins
  own their own .zddc; the policy decider's IsActiveAdmin short-circuit
  is the single bypass site.
- Migrate tablehandler.ServeTable to AllowActionFromChainP — closes the
  same Forbidden bug already fixed for /browse.html.
- Drop AccessView.EditableParentChoices and treeEntry.CanEdit (always
  true after the retirement). Profile page renders AdminSubtrees
  directly for both lists.
- Drop the excludeLeaf parameter from AdminLevelInChain /
  IsAdminForChain — no production caller passed true.

Dead code removed:
- policy.AllowWriteFromChain (zero production callers, zero tests).
- zddc.AllowedWithChain (zero production callers; tests deleted).

ModeStrict retirement — federal posture is OPA-only:
- Delete cascade_mode.go / cascade_mode_test.go and the ModeStrict
  branches in cascade.go and acl.go.
- Drop --cascade-mode flag, CascadeMode config field, and the
  InternalDecider.Mode field.
- Drop the mode parameter from every cascade helper:
  GrantedVerbsAtLevel, AllowedAction, EffectiveVerbs,
  EffectiveVerbsRange, RoleMembers, MatchesPrincipal,
  MatchingPrincipals, WormZoneGrant, PolicyChain.VisibleStart.
- Strip cascade_mode from /.profile/config and
  /.profile/effective-policy responses.
- Refresh README / ARCHITECTURE.md to describe federal posture as
  "deploy OPA with access_federal.rego" (NIST AC-6); the bundled Rego
  is the parent-deny-is-absolute variant. The in-process Go evaluator
  implements only the commercial cascade.

Legacy redirects + .admin.css fallback:
- Drop /<dir>/.zddc.html → ?file=.zddc redirect and its test.
- Drop ?zip=1 retired comment + legacy test (handled by the
  .zip virtual-URL path; covered by TestServeSubtreeZip).
- Drop .admin.css fallback in profile_assets.go — only .profile.css now.
- Refresh stale "retired" / "back-compat" / "legacy" comment markers.

.zddc write path fix:
- Dispatcher: route only GET/HEAD on .zddc URLs to ServeZddcFile; carve
  .zddc out of the dot-prefix guard so PUT/DELETE/POST reach
  ServeFileAPI. Before this, .zddc writes 405'd at ServeZddcFile and
  the YAML editor's save flow had no live path.
- ServeFileAPI.resolveTargetPath: same .zddc-leaf carve-out so the file
  API accepts the path; intermediate dot dirs (.zddc.d/) stay reserved.
- Listing: compute Writable per-file with ActionAdmin for .zddc
  (matches the file API's gate) instead of ActionWrite for everything.
- Virtual .zddc placeholder: compute Writable via the same
  parentActiveAdmin || ActionAdmin path. Was always false before.
- browse YAML editor canSave: exempt virtual .zddc — the synthetic
  body is designed to materialize on PUT.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:28:07 -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
}