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:
parent
2607ca9b8a
commit
d3a9ea7ad9
5 changed files with 341 additions and 17 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
179
zddc/internal/policy/federal_parity_test.go
Normal file
179
zddc/internal/policy/federal_parity_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
95
zddc/internal/policy/rego/access_federal.rego
Normal file
95
zddc/internal/policy/rego/access_federal.rego
Normal 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)
|
||||
}
|
||||
Loading…
Reference in a new issue