Phase 2 enhancements to the policy decider, plus listing-level ETags
that benefit every deployment regardless of decider mode.
Reference Rego policy
---------------------
internal/policy/rego/access.rego mirrors InternalDecider's semantics
exactly — bottom-up walk, deny-first within a level, default-deny when
HasAnyFile=true, glob matching with @-boundary semantics (special-cased
bare "*" because OPA's glob.match treats empty delimiters
inconsistently for that pattern).
Embedded into the binary via go:embed; --print-rego dumps it to stdout
so federal customers standing up an external OPA can use it as a
parity-tested baseline:
zddc-server --print-rego > /etc/opa/policies/zddc-access.rego
Parity test runner
------------------
parity_test.go imports the OPA Go module as a TEST-ONLY dependency
(github.com/open-policy-agent/opa@v0.70.0). Every fixture from the
internal Go evaluator's test set runs through both implementations;
any divergence fails CI. The test-only import means production
binaries (built by `go build ./cmd/zddc-server`) stay OPA-free —
release-flag binary size unchanged at ~13 MB.
The parity test caught a real bug on first run: bare "*" patterns
didn't match through OPA's glob.match with empty delimiters. Fixed
in access.rego with a special-case rule. This is exactly the kind of
subtle drift the parity guard exists to catch.
External-mode decision cache
----------------------------
HTTPDecider is now wrapped in a cachingDecider with a default 1s TTL.
Bursty patterns like .archive listings (one OPA round-trip per entry
before, one per (email, decision-input) tuple per TTL window after)
amortize cleanly. Verified: 20 identical /D/ requests produce 1 OPA
hit with cache, 40 hits without (each listing makes 2 ACL queries).
ZDDC_OPA_CACHE_TTL knob (default 1s) lets operators tune. 0 disables.
1s matches the fsnotify watcher debounce window — staleness is
bounded the same way other policy-edit propagation already is.
Internal mode unchanged; the in-process Go evaluator is already
cheaper than a cache lookup would be.
Listing ETags
-------------
GET / (project list) and GET /<dir>/ (directory listing JSON) now
carry content-hash ETag + Cache-Control: private, max-age=0,
must-revalidate. SHA-256 of the rendered JSON, truncated to 16 hex
chars (64 bits — collision risk on a listing of any realistic size
is vanishingly small).
Server-side caching deliberately not added: it would require
mtime-based invalidation, and Azure Files SMB mounts (a common
deployment substrate) don't support fsnotify reliably. The
content-hash ETag delivers the bandwidth savings (304 on identical
fetches) without depending on watcher correctness — the hash is the
actual response, so it can't lie about staleness regardless of
underlying watcher behavior.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
164 lines
7.5 KiB
Go
164 lines
7.5 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 {
|
|
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
|
|
}
|