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>
179 lines
7.8 KiB
Go
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
|
|
}
|