The bundled reference Rego (`zddc-server --print-rego`) modeled the read-ACL
cascade only, but its header claimed to "mirror the internal decider exactly,
validated on every CI run." It is verb-blind, role-blind, WORM-blind, and
admin-blind: an external-OPA deployment (ZDDC_OPA_URL=http(s)/unix) loading it
granted writes/deletes to read-only principals and ignored WORM zones. The
parity tests never exercised a write action, a role principal, a WORM level, or
is_active_admin — so the divergence shipped silently behind a false "mirrors
exactly" claim.
Make both shipped policies fail-closed instead of falsely-complete:
- access.rego / access_federal.rego: gate every cascade grant on a read action
(empty/absent == read); non-read actions fall through to default-deny.
access.rego honors the single is_active_admin bypass (the one write-capable
principal); access_federal.rego deliberately has none (strict AC-6).
- Rewrite the access.rego / access_federal.rego / rego.go headers: these are
read-ACL SKELETONS, NOT a tested mirror of the internal decider; operators
must add write/WORM/role/admin semantics before granting writes.
- policy.go: fix the stale AllowInput doc claiming the internal decider "treats
read and write identically — any allow grants full CRUD" (it honors the
action verb, with the WORM clamp and admin/elevation bypass applied).
Tests:
- rego_failclosed_test.go: pins the contract — reads allowed, every write verb
denied, active-admin writes allowed (commercial) / denied (federal).
- embedded_neutral_test.go: pins that EmbeddedDefaults() carries no top-level
worm: and no role members — the invariant that makes policy.SerializableChain
dropping PolicyChain.Embedded behavior-neutral (a latent wire-contract gap).
Existing read-cascade parity + federal-divergence tests stay green; full Go
suite + vet pass. The default in-process InternalDecider is unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ship a second parity-tested Rego policy that flips the cascade's
leaf-allow-overrides-parent-deny rule for NIST AC-6 conformance.
Standard cascade (existing access.rego, mirrors internal Go evaluator):
Bottom-up walk; first explicit match wins; deny-first within a level.
A leaf-level allow CAN override an ancestor's deny. This is the
cascade's intentional delegation property — a project-owner who
re-allows a previously-denied collaborator works as expected.
Federal mode (new access_federal.rego):
Any deny anywhere along the chain is absolute. An allow only matters
if no level (any depth) has denied the same email. Required by
NIST AC-6 default expectations: a central admin's deny at the root
must be unbypassable by a tenant who controls a subtree's .zddc.
Operators run real OPA with this Rego and point ZDDC_OPA_URL at it;
the internal Go evaluator stays on the commercial cascade. The
toggle is "which policy does your OPA evaluate," not a knob inside
zddc-server.
Surfaced via --print-rego flag:
zddc-server --print-rego # standard (default)
zddc-server --print-rego=standard # same
zddc-server --print-rego=federal # AC-6 strict variant
Parity test (federal_parity_test.go) compiles both Regos and asserts:
* They AGREE on every cascade scenario where no ancestor-deny
intersects a leaf-allow (most cases).
* They DISAGREE — by design — on the three scenarios where the
AC-6 rule differs:
- "leaf allows what parent denied" → standard allows, federal denies
- "deep leaf re-allows after middle deny" → same
- "glob deny at root + specific allow at leaf" → same
Cross-checks the divergence flag explicitly so any future change that
accidentally collapses the two policies fails the test.
Closes the AC-6 row of the federal-readiness gap analysis (now marked
"partially complete" in zddc/README.md — the full bullet would be a
built-in --policy-mode=federal toggle that also flips the in-process
Go evaluator).
Production binary unchanged at 13.1 MB (Rego files embedded as bytes;
OPA library remains test-only).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>