diff --git a/zddc/README.md b/zddc/README.md index 58a2f00..42102f8 100644 --- a/zddc/README.md +++ b/zddc/README.md @@ -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 diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 51ac972..87f982f 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -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 } } diff --git a/zddc/internal/policy/federal_parity_test.go b/zddc/internal/policy/federal_parity_test.go new file mode 100644 index 0000000..b48402d --- /dev/null +++ b/zddc/internal/policy/federal_parity_test.go @@ -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) + } +} diff --git a/zddc/internal/policy/rego.go b/zddc/internal/policy/rego.go index 32023da..21f6ae3 100644 --- a/zddc/internal/policy/rego.go +++ b/zddc/internal/policy/rego.go @@ -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 diff --git a/zddc/internal/policy/rego/access_federal.rego b/zddc/internal/policy/rego/access_federal.rego new file mode 100644 index 0000000..78031c3 --- /dev/null +++ b/zddc/internal/policy/rego/access_federal.rego @@ -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) +}