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>
95 lines
3.2 KiB
Rego
95 lines
3.2 KiB
Rego
# Federal-mode reference policy: parent-deny-is-absolute (NIST AC-6).
|
|
#
|
|
# This is a strict-least-privilege variant of access.rego. The two policies
|
|
# differ in exactly one rule, but the semantic difference is meaningful for
|
|
# federal evaluators:
|
|
#
|
|
# access.rego (commercial, default):
|
|
# "Bottom-up walk; first explicit match wins; deny-first within a level.
|
|
# A leaf-level allow CAN override an ancestor's deny."
|
|
# Test: cascade_test.go "leaf allows user that parent denies → leaf wins".
|
|
#
|
|
# access_federal.rego (federal):
|
|
# "Any deny anywhere along the chain is absolute. An allow only matters
|
|
# if no ancestor (or sibling level) has denied the same email. Leaf-
|
|
# level allows do NOT override ancestor denies."
|
|
# Required by NIST AC-6 (Least Privilege) default expectations: a
|
|
# central admin's deny at the root must be unbypassable by a tenant
|
|
# who controls a subtree's .zddc.
|
|
#
|
|
# Why ship two policies? The internal Go evaluator (in zddc/internal/zddc/
|
|
# acl.go) implements only the commercial cascade — it's the rule the
|
|
# default deployment exercises. Federal customers running their own OPA
|
|
# with this file get the strict variant without any zddc-server code
|
|
# change. They can also write a hybrid policy (e.g. "deny is absolute
|
|
# only for emails matching some pattern; cascade rules for everyone
|
|
# else") since once they're hosting their own OPA, the constraint is
|
|
# whatever they write.
|
|
#
|
|
# Input shape: identical to access.rego — see that file's docstring.
|
|
|
|
package zddc.access_federal
|
|
|
|
import future.keywords.if
|
|
import future.keywords.in
|
|
|
|
default allow := false
|
|
|
|
# Allow when no .zddc files exist anywhere AND no rule matches.
|
|
# Same default-allow case as commercial; preserves the empty-tree
|
|
# behaviour. (zddc-server's --insecure check at startup makes this
|
|
# unreachable in any non-deliberately-public deployment.)
|
|
allow if {
|
|
not input.policy_chain.has_any_file
|
|
not any_deny_match
|
|
not any_allow_match
|
|
}
|
|
|
|
# Allow when files exist, no level (any depth) denies, and at least
|
|
# one level allows. The "any level" check is what makes parent denies
|
|
# absolute — there is no "deepest match wins" rule here.
|
|
allow if {
|
|
input.policy_chain.has_any_file
|
|
not any_deny_match
|
|
any_allow_match
|
|
}
|
|
|
|
# Any deny pattern at ANY level matches the email.
|
|
any_deny_match if {
|
|
some level in input.policy_chain.levels
|
|
some pattern in level.acl.deny
|
|
email_matches(pattern, input.user.email)
|
|
}
|
|
|
|
# Any allow pattern at ANY level matches the email.
|
|
any_allow_match if {
|
|
some level in input.policy_chain.levels
|
|
some pattern in level.acl.allow
|
|
email_matches(pattern, input.user.email)
|
|
}
|
|
|
|
# email_matches: identical to access.rego — see that file for the
|
|
# rationale on the four cases. Duplicated rather than imported so this
|
|
# file is self-contained for operators who copy it as a starting point.
|
|
|
|
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)
|
|
}
|