ZDDC/zddc/internal/policy/rego/access.rego
ZDDC a01315fd00 feat(server): reference Rego, parity test, decision cache, listing ETags
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>
2026-05-04 17:46:24 -05:00

119 lines
3.8 KiB
Rego

# Reference Rego policy that mirrors zddc-server's built-in `internal`
# decider exactly. Federal customers running their own OPA can use this
# as a starting point (and then tighten — e.g. flip the leaf-allow-overrides-
# parent-deny rule for NIST AC-6 compliance).
#
# The internal evaluator (in zddc/internal/zddc/acl.go) is the source of
# truth for production. This file is validated against that evaluator on
# every CI run via the parity test in zddc/internal/policy/parity_test.go.
# Both implementations must produce the same decision for every fixture.
#
# Input shape (matches zddc/internal/policy.AllowInput JSON encoding):
# {
# "user": {"email": "alice@example.com"},
# "path": "/Project-A/sub/",
# "policy_chain": {
# "levels": [
# {"acl": {}, "admins": ["admin@example.com"]},
# {"acl": {"allow": ["*@example.com"]}}
# ],
# "has_any_file": true
# }
# }
#
# Levels are ordered ROOT → LEAF (deepest level last). Cascade walks
# bottom-up (deepest first); first explicit match wins; within a single
# level, a deny pattern is checked before an allow pattern.
#
# Default-allow when has_any_file is false (no .zddc anywhere → public);
# default-deny when has_any_file is true and nothing matched (the safety
# net the file at <ZDDC_ROOT>/.zddc enables).
package zddc.access
import future.keywords.if
import future.keywords.in
default allow := false
# Allow when no .zddc files anywhere in the chain AND no rule matches.
allow if {
not input.policy_chain.has_any_file
count(matched_levels) == 0
}
# Allow when the deepest matching level grants.
allow if {
count(matched_levels) > 0
deepest := max(matched_levels)
level_grants(input.policy_chain.levels[deepest])
}
# Set of level indices where the email matches at least one allow or deny
# pattern. The deepest-index member is the level whose decision counts.
matched_levels := {i |
some i
level_matches(input.policy_chain.levels[i])
}
# A level "matches" if its email is in either its deny list or its allow
# list. Whether the level grants or denies is a separate question
# (level_grants below) — deny is checked before allow within a level.
level_matches(level) if {
some pattern in level.acl.deny
email_matches(pattern, input.user.email)
}
level_matches(level) if {
some pattern in level.acl.allow
email_matches(pattern, input.user.email)
}
# A level grants iff (a) no deny pattern matches at this level AND (b) some
# allow pattern matches. Mirrors AllowedAtLevel in acl.go: deny is checked
# first; if no deny hit, an allow match returns true.
level_grants(level) if {
not level_denies(level)
some pattern in level.acl.allow
email_matches(pattern, input.user.email)
}
level_denies(level) if {
some pattern in level.acl.deny
email_matches(pattern, input.user.email)
}
# email_matches: glob-match a pattern against an email, with the @-boundary
# rule from acl.go's MatchesPattern: * does not cross @. Four cases:
#
# 1. exact match (covers patterns with no wildcard)
# 2. bare "*" matches any non-empty email (special case because OPA's
# glob.match treats empty delimiters [] inconsistently for the
# lone-* pattern)
# 3. pattern has both * and @: standard glob with @ as a delimiter so
# `*@example.com` matches alice@example.com but `*example.com`
# does NOT match anything (* won't cross @)
# 4. pattern has * but no @: glob against the full email with no
# delimiter (so `alice*` matches alice@anything)
email_matches(pattern, email) if {
pattern == email
}
email_matches(pattern, email) if {
pattern == "*"
email != ""
}
email_matches(pattern, email) if {
contains(pattern, "*")
contains(pattern, "@")
glob.match(pattern, ["@"], email)
}
email_matches(pattern, email) if {
contains(pattern, "*")
not contains(pattern, "@")
pattern != "*"
glob.match(pattern, [], email)
}