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>
119 lines
3.8 KiB
Rego
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)
|
|
}
|