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
entities, `.zddc` syntax for role grants, identity-source-driven role
assignment.
- **Least-privilege bounding** (NIST AC-6) — leaf-allow-overrides-parent-deny
is incompatible with default federal expectations. Required: a configurable
enforcement mode where parent denies are absolute and only root admins can
override.
- **Least-privilege bounding** (NIST AC-6) — *partially complete.*
Leaf-allow-overrides-parent-deny is the cascade's intentional
delegation behavior in commercial mode and is preserved in the
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
authoritative sources (PIV cert subject, IdP-managed identity). Required:
documented integration with at least one IdP supporting federal identity
@ -714,20 +720,36 @@ cache lookup would be.
### Reference Rego policy
The `--print-rego` flag emits the bundled reference Rego policy that
mirrors internal-mode semantics exactly. Federal customers standing up
their own OPA instance can use it as a starting point:
The `--print-rego` flag emits the bundled reference Rego policies. Two
variants ship:
```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`
imports the OPA Go module **as a test-only dependency**, evaluates the
bundled Rego against the same fixture set the internal Go evaluator
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
`go.mod` but not in `go build`'s output.
imports the OPA Go module **as a test-only dependency**, evaluates both
bundled Regos against fixture sets and asserts:
- The standard Rego matches the internal Go evaluator on every documented
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
where the production decider is pure Go (no library bloat, no extra

View file

@ -32,13 +32,21 @@ import (
var version = "dev"
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
// 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:] {
if a == "--print-rego" {
switch a {
case "--print-rego", "--print-rego=standard":
fmt.Print(policy.ReferenceRego)
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:
//
// - 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
// populated by the upstream proxy from SAML/OIDC claims).
// - Add time-of-day or IP-range constraints.
@ -24,3 +26,21 @@ import _ "embed"
//
//go:embed rego/access.rego
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)
}