feat(server): federal-mode reference Rego (parent-deny-is-absolute)

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>
This commit is contained in:
ZDDC 2026-05-04 18:05:44 -05:00
parent 2607ca9b8a
commit d3a9ea7ad9
5 changed files with 341 additions and 17 deletions

View file

@ -585,10 +585,16 @@ have to redo the gap analysis from scratch.
allow/deny + a single root-admin role. Required: roles as first-class allow/deny + a single root-admin role. Required: roles as first-class
entities, `.zddc` syntax for role grants, identity-source-driven role entities, `.zddc` syntax for role grants, identity-source-driven role
assignment. assignment.
- **Least-privilege bounding** (NIST AC-6) — leaf-allow-overrides-parent-deny - **Least-privilege bounding** (NIST AC-6) — *partially complete.*
is incompatible with default federal expectations. Required: a configurable Leaf-allow-overrides-parent-deny is the cascade's intentional
enforcement mode where parent denies are absolute and only root admins can delegation behavior in commercial mode and is preserved in the
override. internal Go evaluator. For federal deployments, `--print-rego=federal`
emits a parity-tested Rego policy where parent denies are absolute;
drop it into an external OPA and point `ZDDC_OPA_URL` at it. *Still
required for full coverage:* a built-in toggle (e.g. `ZDDC_POLICY_MODE=federal`)
that switches the in-process Go evaluator's semantics without
requiring an OPA sidecar — currently federal-mode is reachable only
via the external-OPA path.
- **Account lifecycle** (NIST AC-2) — emails as identifiers must tie to - **Account lifecycle** (NIST AC-2) — emails as identifiers must tie to
authoritative sources (PIV cert subject, IdP-managed identity). Required: authoritative sources (PIV cert subject, IdP-managed identity). Required:
documented integration with at least one IdP supporting federal identity documented integration with at least one IdP supporting federal identity
@ -714,20 +720,36 @@ cache lookup would be.
### Reference Rego policy ### Reference Rego policy
The `--print-rego` flag emits the bundled reference Rego policy that The `--print-rego` flag emits the bundled reference Rego policies. Two
mirrors internal-mode semantics exactly. Federal customers standing up variants ship:
their own OPA instance can use it as a starting point:
```sh ```sh
zddc-server --print-rego > /etc/opa/policies/zddc-access.rego zddc-server --print-rego # standard cascade (commercial)
zddc-server --print-rego=standard # same
zddc-server --print-rego=federal # parent-deny-is-absolute (NIST AC-6)
``` ```
The standard variant mirrors internal-mode semantics exactly — leaf-
level allows can override an ancestor's deny (the cascade's intentional
delegation property). The federal variant is the strict-least-privilege
posture: any deny anywhere in the chain is absolute, no leaf-level
override possible. Federal customers running their own OPA can drop
the federal Rego in unchanged, or use either as a starting point for
further customization.
Parity is enforced at build time. `zddc/internal/policy/parity_test.go` Parity is enforced at build time. `zddc/internal/policy/parity_test.go`
imports the OPA Go module **as a test-only dependency**, evaluates the imports the OPA Go module **as a test-only dependency**, evaluates both
bundled Rego against the same fixture set the internal Go evaluator bundled Regos against fixture sets and asserts:
runs, and fails CI on any divergence. The test-only import means the
production binary stays OPA-free (still 13 MB) — the OPA library is in - The standard Rego matches the internal Go evaluator on every documented
`go.mod` but not in `go build`'s output. cascade scenario (`TestRegoParity_AllInternalCases`).
- The federal Rego agrees with the standard policy on every case where
no ancestor-deny intersects a leaf-allow, AND **disagrees** on the
cases where the AC-6 rule differs (`TestFederalRego_DivergencesFromStandard`).
This way both policies are guaranteed to behave as documented.
The test-only import means the production binary stays OPA-free (still
13 MB) — the OPA library is in `go.mod` but not in `go build`'s output.
This gives you both ends of the spectrum: a single OPA-aware codebase This gives you both ends of the spectrum: a single OPA-aware codebase
where the production decider is pure Go (no library bloat, no extra where the production decider is pure Go (no library bloat, no extra

View file

@ -32,13 +32,21 @@ import (
var version = "dev" var version = "dev"
func main() { func main() {
// --print-rego: dump the bundled reference Rego policy and exit. // --print-rego: dump a bundled reference Rego policy and exit.
// Cheap escape hatch for operators standing up an external OPA who want // Cheap escape hatch for operators standing up an external OPA who want
// the parity-tested baseline as a starting point for customization. // a parity-tested baseline as a starting point for customization.
//
// --print-rego → standard cascade (commercial default)
// --print-rego=standard → same
// --print-rego=federal → parent-deny-is-absolute (NIST AC-6)
for _, a := range os.Args[1:] { for _, a := range os.Args[1:] {
if a == "--print-rego" { switch a {
case "--print-rego", "--print-rego=standard":
fmt.Print(policy.ReferenceRego) fmt.Print(policy.ReferenceRego)
return return
case "--print-rego=federal":
fmt.Print(policy.FederalRego)
return
} }
} }

View file

@ -0,0 +1,179 @@
package policy
import (
"context"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
"github.com/open-policy-agent/opa/rego"
)
// TestFederalRego_DivergencesFromStandard validates the federal-mode
// variant by asserting both that:
//
// (a) most cascade scenarios produce the same verdict as standard
// (the federal rule reduces to standard whenever no parent deny
// intersects a leaf allow), AND
//
// (b) the specific scenarios where the rules differ (a leaf-level
// allow overlaying an ancestor's deny) produce DIFFERENT verdicts:
// standard says allow (leaf wins); federal says deny (ancestor
// deny is absolute — NIST AC-6 default).
//
// Like the standard parity test, this imports the OPA library as a
// test-only dependency. The federal Rego is a deployable artifact
// (operators dump it via --print-rego=federal); the parity guard
// here proves the artifact behaves as documented.
func TestFederalRego_DivergencesFromStandard(t *testing.T) {
ctx := context.Background()
standard, err := rego.New(
rego.Query("data.zddc.access.allow"),
rego.Module("access.rego", ReferenceRego),
).PrepareForEval(ctx)
if err != nil {
t.Fatalf("compile standard rego: %v", err)
}
federal, err := rego.New(
rego.Query("data.zddc.access_federal.allow"),
rego.Module("access_federal.rego", FederalRego),
).PrepareForEval(ctx)
if err != nil {
t.Fatalf("compile federal rego: %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}} }
empty := zddc.ZddcFile{}
cases := []struct {
name string
chain zddc.PolicyChain
email string
wantStandard bool
wantFederal bool
divergesByDesign bool // true if standard and federal must disagree here
}{
// ── Cases where the two policies must AGREE ────────────────
{
"empty chain, no files",
zddc.PolicyChain{HasAnyFile: false},
"alice@example.com",
true, true, false,
},
{
"files exist, no rule matches → both deny",
zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@trusted.com")}, HasAnyFile: true},
"alice@example.com",
false, false, false,
},
{
"leaf allow with no ancestor deny → both allow",
zddc.PolicyChain{Levels: []zddc.ZddcFile{empty, allow("*@example.com")}, HasAnyFile: true},
"alice@example.com",
true, true, false,
},
{
"only deny anywhere → both deny",
zddc.PolicyChain{Levels: []zddc.ZddcFile{deny("alice@example.com")}, HasAnyFile: true},
"alice@example.com",
false, false, false,
},
{
"glob allow, no deny → both allow",
zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@example.com")}, HasAnyFile: true},
"alice@example.com",
true, true, false,
},
// ── The signature divergence: leaf allow overlaying ancestor deny ──
{
"leaf allows what parent denied → standard allows, federal denies (AC-6)",
zddc.PolicyChain{Levels: []zddc.ZddcFile{
deny("alice@example.com"),
allow("alice@example.com"),
}, HasAnyFile: true},
"alice@example.com",
true, // standard: leaf wins
false, // federal: parent deny is absolute
true,
},
{
"deep leaf re-allows after middle deny → standard allows, federal denies",
zddc.PolicyChain{Levels: []zddc.ZddcFile{
allow("*@example.com"),
deny("alice@example.com"),
allow("alice@example.com"),
}, HasAnyFile: true},
"alice@example.com",
true,
false,
true,
},
{
"glob deny at root, specific allow at leaf → both differ",
zddc.PolicyChain{Levels: []zddc.ZddcFile{
deny("*@example.com"),
allow("alice@example.com"),
}, HasAnyFile: true},
"alice@example.com",
true,
false,
true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
input := AllowInput{Path: "/test", PolicyChain: chainToSerializable(tc.chain)}
input.User.Email = tc.email
regoInput, err := canonicalInput(input)
if err != nil {
t.Fatalf("encode input: %v", err)
}
std, err := standard.Eval(ctx, rego.EvalInput(regoInput))
if err != nil {
t.Fatalf("standard eval: %v", err)
}
fed, err := federal.Eval(ctx, rego.EvalInput(regoInput))
if err != nil {
t.Fatalf("federal eval: %v", err)
}
if len(std) == 0 || len(fed) == 0 {
t.Fatal("rego returned empty result set")
}
stdAllow := std[0].Expressions[0].Value.(bool)
fedAllow := fed[0].Expressions[0].Value.(bool)
if stdAllow != tc.wantStandard {
t.Errorf("standard rego: got %v, want %v", stdAllow, tc.wantStandard)
}
if fedAllow != tc.wantFederal {
t.Errorf("federal rego: got %v, want %v", fedAllow, tc.wantFederal)
}
// Cross-check the divergence flag itself: if we said the cases
// must disagree, they must; if we said they agree, they must.
diverges := stdAllow != fedAllow
if diverges != tc.divergesByDesign {
t.Errorf("divergence = %v, want %v (standard=%v, federal=%v)",
diverges, tc.divergesByDesign, stdAllow, fedAllow)
}
})
}
}
// TestFederalRego_RegoCompiles is a sanity check that the embedded
// federal Rego file parses without error in OPA, separate from the
// behavior tests. Catches accidental syntax breakage in
// access_federal.rego before running the (slower) parity matrix.
func TestFederalRego_RegoCompiles(t *testing.T) {
_, err := rego.New(
rego.Query("data.zddc.access_federal.allow"),
rego.Module("access_federal.rego", FederalRego),
).PrepareForEval(context.Background())
if err != nil {
t.Fatalf("federal rego does not compile: %v", err)
}
}

View file

@ -15,7 +15,9 @@ import _ "embed"
// Customizations typical for federal deployments: // Customizations typical for federal deployments:
// //
// - Flip the leaf-allow-overrides-parent-deny semantics so parent denies // - Flip the leaf-allow-overrides-parent-deny semantics so parent denies
// are absolute (NIST AC-6 least-privilege posture). // are absolute (NIST AC-6 least-privilege posture). For this specific
// case zddc-server ships a parity-tested federal-mode variant; see
// FederalRego and `--print-rego=federal`.
// - Add role-based access via additional input fields (input.user.roles // - Add role-based access via additional input fields (input.user.roles
// populated by the upstream proxy from SAML/OIDC claims). // populated by the upstream proxy from SAML/OIDC claims).
// - Add time-of-day or IP-range constraints. // - Add time-of-day or IP-range constraints.
@ -24,3 +26,21 @@ import _ "embed"
// //
//go:embed rego/access.rego //go:embed rego/access.rego
var ReferenceRego string var ReferenceRego string
// FederalRego is the strict-least-privilege variant of ReferenceRego
// where parent denies are absolute (NIST AC-6). Drop-in for federal
// customers who need the AC-6 posture without writing Rego from
// scratch:
//
// zddc-server --print-rego=federal > /etc/opa/policies/zddc-access.rego
//
// The internal Go evaluator does NOT implement these semantics — it
// stays on the commercial cascade. Federal-mode is reachable only by
// running OPA with this policy and pointing ZDDC_OPA_URL at it. See
// zddc/internal/policy/rego/access_federal.rego for the policy itself
// and federal_parity_test.go for the divergence-test fixtures (cases
// where federal-mode and commercial-mode disagree, asserting each gives
// the expected verdict).
//
//go:embed rego/access_federal.rego
var FederalRego string

View file

@ -0,0 +1,95 @@
# 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)
}