diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3d6286e..8a81bfd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -544,7 +544,7 @@ none of them is load-bearing alone. |---|---|---| | Authentication | Establish caller identity (email) | Two paths: `Authorization: Bearer ` validated against `/.zddc.d/tokens/` (CLI / scripted callers); or `X-Auth-Request-Email` injected by an upstream auth proxy (browser users). Token system is built-in and self-issuing — no external IDP required | | Policy decider | Yield an allow/deny verdict for (identity, path, chain) | Pluggable via `ZDDC_OPA_URL`: in-process Go evaluator (default) or external OPA-compatible HTTP/socket endpoint. `zddc/internal/policy/` | -| ACL cascade | The default decider's rule set | Per-directory `.zddc` YAML with verb-set permissions (`r`/`w`/`c`/`d`/`a`) and roles, plus a baked-in default tree bottom layer (`zddc-server show-defaults`) that uses a recursive `paths:` tree to describe subfolder rules even before those folders exist. Walked deepest-first first-match-wins (`zddc/internal/zddc/cascade.go`, `walker.go`, `acl.go`). External OPA can replace this rule set with arbitrary Rego (the bundled `access_federal.rego` is the parent-deny-is-absolute / NIST AC-6 variant) while keeping the same `.zddc` files as input data | +| ACL cascade | The default decider's rule set | Per-directory `.zddc` YAML with verb-set permissions (`r`/`w`/`c`/`d`/`a`) and roles, plus a baked-in default tree bottom layer (`zddc-server show-defaults`) that uses a recursive `paths:` tree to describe subfolder rules even before those folders exist. Walked deepest-first first-match-wins (`zddc/internal/zddc/cascade.go`, `walker.go`, `acl.go`). External OPA can replace this rule set with arbitrary Rego the operator writes (e.g. ancestor-deny-absolute for NIST AC-6) while keeping the same `.zddc` files as input data; zddc-server ships only a fail-closed read-ACL skeleton (`--print-rego`) as a starting point | | Canonical-folder behaviour | Codify the bilateral exchange-record archetype | All driven by `.zddc` keys (baked into the embedded default tree): `auto_own:` / `auto_own_fenced:` — mkdir here writes a creator-owned `.zddc` (`: rwcda`; fenced adds `acl.inherit:false`); `worm: [principal…]` — write-once-read-many (`w`/`d`/`a` stripped for everyone non-admin, `c` survives only for the listed principals; admins exempt); `virtual:` — never materialise on disk; `drop_target:` — browse shows a drag-drop upload overlay. The defaults put `auto_own` on `working`/`staging`/`archive-party`/`incoming` and `worm: [document_controller]` on `archive//{received,issued}`, so the canonical convention is unchanged — but an operator can reshape it (rename `received`/`issued`, mark any path WORM, …) without a code change. `zddc/internal/zddc/lookups.go`, `worm.go`, `roles.go`; the embedded default tree | | Tool-rooted view | Make the caller's accessible subtree feel like their entire world (UX containment) | Archive auto-served at every directory; the URL it's served at *is* its root. No breadcrumb leads above | | URL canonicalization | Resolve URL paths to on-disk casing before any layer below sees them | `zddc/internal/fs/resolve.go ResolveCanonical` — case-insensitive lookup with lowercase-wins tiebreak when sibling case variants exist on disk. File and folder names preserve case on disk; the canonicalization is purely URL→FS-name mapping. Virtual prefixes (`.archive`, `.profile`, `.tokens`) flow through verbatim | @@ -700,7 +700,7 @@ whether to deploy the system should know which column they're in. | Cryptography | Go stdlib defaults | FIPS 140-3 validated module (microsoft/go or RHEL FIPS) | | TLS | Go stdlib defaults | Explicit MinVersion ≥ TLS 1.2, DoD-approved cipher allowlist, OCSP stapling, HSTS | | Access model | Per-verb (`r`/`w`/`c`/`d`/`a`) with first-class roles and an admin escape hatch — closes NIST AC-3(7) | (closed by default; external Rego still available for org-specific policy via `ZDDC_OPA_URL`) | -| Subtree authority | In-process decider: leaf grants override ancestor denies (delegation primitive). Federal posture: deploy OPA with `access_federal.rego` for ancestor-deny-absolute / NIST AC-6 | (closed; federal posture is the OPA path) | +| Subtree authority | In-process decider: leaf grants override ancestor denies (delegation primitive). Federal posture: deploy OPA with the operator's own ancestor-deny-absolute Rego (NIST AC-6) | (closed; federal posture is the OPA path) | | Audit log integrity | Local lumberjack rotation, filesystem-trusted | Tamper-evident (signed chain or external append-only sink), 1y online + 3y archive | | Information disclosure | Anonymous reaches `/` and `/.profile` (project picker, public-projects names) | All endpoints behind authenticated proxy; no anonymous discovery | | Apps URL fetches | Fetch-once-cached, no integrity check | SHA-256 pin + signature verification | @@ -723,7 +723,7 @@ Five permission verbs gate every read and write: `.zddc` files express grants under `acl.permissions: { principal → verb-set }`. A principal containing `@` is an email pattern matched by `MatchesPattern` (existing glob); a bare name is a role looked up against `roles:` definitions, walking the cascade for the closest definition. Empty verb set is an explicit deny. -Cascade evaluation walks leaf→root for the first level whose entries match the user; the union of matching verb sets at that level wins. A leaf allow overrides an ancestor explicit-deny — that's the load-bearing delegation primitive that lets a subtree owner grant access without root-admin involvement. Operators who need the opposite rule (ancestor-deny-absolute, NIST AC-6) deploy OPA with the bundled `access_federal.rego`. +Cascade evaluation walks leaf→root for the first level whose entries match the user; the union of matching verb sets at that level wins. A leaf allow overrides an ancestor explicit-deny — that's the load-bearing delegation primitive that lets a subtree owner grant access without root-admin involvement. Operators who need the opposite rule (ancestor-deny-absolute, NIST AC-6) deploy OPA with their own Rego (zddc-server ships only the fail-closed read-ACL skeleton at `--print-rego`). The `admins:` field (root or any subtree `.zddc`) confers admin authority over that level and below, but it splits into two powers — see the elevation section below: - **Standing config-edit (no elevation):** an admin — or anyone with the `a` verb — may edit the `.zddc`/roles of subtrees they administer. `IsConfigEditor` grants `VerbA` above the WORM clamp; it owns the subtree's policy but cannot write/delete records. diff --git a/zddc/README.md b/zddc/README.md index 6d7fb19..a1f6a81 100644 --- a/zddc/README.md +++ b/zddc/README.md @@ -433,8 +433,7 @@ fence is computed by `PolicyChain.VisibleStart`. The leaf-overrides-ancestor behaviour above is the in-process decider's only rule. Federal deployments needing absolute parent denies (NIST AC-6) deploy -OPA with the bundled `access_federal.rego` (or their own Rego); see -"External OPA" below. +OPA with their own Rego; see "External OPA" below. #### The `inherit:` directive @@ -471,10 +470,10 @@ Behaviour: fence; `inherit: false` does not change WORM behaviour. See "Canonical-folder behaviour via `.zddc` keys" below. -**Federal posture and `inherit: false`.** The bundled federal Rego at -`--print-rego=federal` makes ancestor explicit-denies absolute and -therefore ignores `inherit: false` (allowing a leaf to widen access an -ancestor refused would defeat NIST AC-6). Operators who need fence- +**Federal posture and `inherit: false`.** An external OPA policy with +ancestor-deny-absolute (NIST AC-6) semantics makes ancestor explicit-denies +absolute and therefore ignores `inherit: false` (allowing a leaf to widen +access an ancestor refused would defeat NIST AC-6). Operators who need fence- style "reset" semantics in a federal-track deployment should not use the directive — instead, restructure the tree so the permissive ancestor rule never appears. @@ -927,13 +926,14 @@ have to redo the gap analysis from scratch. Identity-source-driven role assignment plumbs through unchanged (the upstream proxy still asserts the email; role membership is evaluated server-side against the cascade). -- ~~**Least-privilege bounding** (NIST AC-6)~~ — *closed.* Operators - deploy OPA (`ZDDC_OPA_URL`) pointed at the bundled federal Rego - (`zddc-server --print-rego=federal`) or their own variant. Under - that policy any ancestor explicit-deny is absolute and cannot be - overridden by a leaf grant. The in-process Go evaluator implements - only the commercial "leaf grants override ancestor denies" rule; - federal posture is exclusively the OPA path. +- ~~**Least-privilege bounding** (NIST AC-6)~~ — *available via the OPA + path.* Operators deploy OPA (`ZDDC_OPA_URL`) pointed at their own + ancestor-deny-absolute Rego, under which any ancestor explicit-deny is + absolute and cannot be overridden by a leaf grant. The in-process Go + evaluator implements only the commercial "leaf grants override ancestor + denies" rule, and the bundled `--print-rego` skeleton models read-ACL + only (fail-closed for writes) — an AC-6 federal policy is the operator's + own Rego, not a shipped artifact. - **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 @@ -1266,56 +1266,47 @@ cache lookup would be. ### Reference Rego policy -The `--print-rego` flag emits the bundled reference Rego policies. Two -variants ship: +The `--print-rego` flag emits the bundled reference Rego **skeleton**: ```sh -zddc-server --print-rego # standard cascade (commercial) +zddc-server --print-rego # read-ACL skeleton (fail-closed) 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. +This skeleton models the **read-ACL cascade only** — glob patterns, +deny-first-within-a-level, default-deny once any `.zddc` exists, and the +leaf-allow-overrides-ancestor-deny delegation property. It is **NOT** a +semantic mirror of the internal Go decider: it does not implement per-verb +authorization (write/create/delete/admin), WORM zones, `roles:` resolution, +`inherit:false` fences, or standing config-edit. Because those are +unmodelled it is **fail-closed** — every non-read action is denied, and an +elevated admin (`input.user.is_active_admin`) is the only write-capable +principal. **Treat it as a starting point, not a turnkey policy:** an +operator relying on external OPA for write authorization must add the +missing semantics (and, for a NIST AC-6 ancestor-deny-absolute posture, +write that rule) before granting writes. -Parity is enforced at build time. `zddc/internal/policy/parity_test.go` -imports the OPA Go module **as a test-only dependency**, evaluates both -bundled Regos against fixture sets and asserts: +A build-time guard (`zddc/internal/policy/parity_test.go`, +`rego_failclosed_test.go`) imports the OPA Go module **as a test-only +dependency** and asserts the skeleton matches the internal Go evaluator on +the read-cascade dimension (`TestRegoParity_AllInternalCases`) and denies +every write verb (`TestReferenceRego_FailClosedOnWrites`). This is a +read-cascade + fail-closed guard, **not** a full-parity proof. The +test-only import means the production binary stays OPA-free (~13 MB) — the +OPA library is in `go.mod` but not in `go build`'s output. -- 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 production decider is pure Go (no library bloat, no extra process); the +wire format is OPA-canonical, so an operator can point an external OPA at it +and extend the skeleton. Typical extensions an operator writes on top: -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 -process), the wire format is OPA-canonical (just point an external OPA -at it and decisions delegate seamlessly), and the bundled reference -Rego is a parity-tested artifact you can ship alongside or extend. - -Typical federal customizations on top of the bundled Rego: - -- **Parent-deny-is-absolute** — flip the leaf-allow-overrides-parent-deny - rule for NIST AC-6 least-privilege posture. +- **Per-verb + WORM + roles + config-edit** — the semantics the skeleton + omits; required before the policy can authorize writes at all. +- **Parent-deny-is-absolute** — make any ancestor deny absolute for a NIST + AC-6 least-privilege posture. - **Role-based access** — read additional input fields like - `input.user.roles` populated by the upstream proxy from SAML/OIDC - claims, and decide based on those instead of (or alongside) email. -- **Time-of-day or IP-range constraints** — Rego can read - `input.context.now` and request metadata for context-aware - decisions. -- **SIEM-shipped decision logs** — OPA's logging plugins emit every - decision in a structured format ready for Splunk Government, Elastic - Federal, etc. + `input.user.roles` populated by the upstream proxy from SAML/OIDC claims. +- **Time-of-day or IP-range constraints**, and **SIEM-shipped decision + logs** via OPA's logging plugins (Splunk Government, Elastic Federal, etc.). ### Reference deployment shapes @@ -1326,7 +1317,7 @@ No sidecar, no extra port, no extra binary. **Federal sidecar**: deploy OPA alongside zddc-server (k8s sidecar, nomad task, or systemd service on the same host), bind it to `127.0.0.1:8181` (or a Unix socket), point `ZDDC_OPA_URL` at it. OPA -loads the deployment's bundled Rego policy from a configured source +loads the deployment's own Rego policy from a configured source (filesystem, signed bundle from S3, OPAL, etc.) and is patched independently of zddc-server. @@ -1350,10 +1341,12 @@ gaps that warrant code, in addition to the federal-readiness items above: CM-3 federal control above). - Per-decision caching for external OPA mode (small TTL on (email, path) to amortize the .archive listing's per-entry round-trip). -- A reference Rego bundle shipped alongside the binary that exactly - reproduces internal mode, plus a "federal-mode" variant that flips - the parent-deny-is-absolute toggle. Useful as a starting point for - customers who want to extend rather than write from scratch. +- A full-parity reference Rego (modelling per-verb / WORM / roles / + config-edit, not just the read-ACL skeleton shipped today) plus a + generative differential test against the internal decider — only worth + building if external OPA becomes a *supported deployment mode* rather than + a bring-your-own-policy escape hatch. See the skeleton's caveats under + "Reference Rego policy." ## Admin Debug Page diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index dd7c1c7..80404fd 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -36,21 +36,18 @@ import ( var version = "dev" func main() { - // --print-rego: dump a bundled reference Rego policy and exit. - // Cheap escape hatch for operators standing up an external OPA who want - // a parity-tested baseline as a starting point for customization. + // --print-rego: dump the bundled reference Rego skeleton and exit. + // A starting point for operators standing up an external OPA: it models + // the read-ACL cascade only and is fail-closed for writes (NOT a mirror + // of the internal decider), so it must be extended before granting writes. // - // --print-rego → standard cascade (commercial default) + // --print-rego → read-ACL skeleton (fail-closed) // --print-rego=standard → same - // --print-rego=federal → parent-deny-is-absolute (NIST AC-6) for _, a := range os.Args[1:] { switch a { case "--print-rego", "--print-rego=standard": fmt.Print(policy.ReferenceRego) return - case "--print-rego=federal": - fmt.Print(policy.FederalRego) - return case "show-defaults", "--show-defaults": // Emit the embedded baseline as a .zddc.zip (per-depth policy // tree, "*" wildcard members) to stdout. Redirect into a bundle diff --git a/zddc/internal/policy/federal_parity_test.go b/zddc/internal/policy/federal_parity_test.go deleted file mode 100644 index 0e573f6..0000000 --- a/zddc/internal/policy/federal_parity_test.go +++ /dev/null @@ -1,191 +0,0 @@ -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 { - m := make(map[string]string, len(p)) - for _, x := range p { - m[x] = "rwcd" - } - return zddc.ZddcFile{ACL: zddc.ACLRules{Permissions: m}} - } - deny := func(p ...string) zddc.ZddcFile { - m := make(map[string]string, len(p)) - for _, x := range p { - m[x] = "" - } - return zddc.ZddcFile{ACL: zddc.ACLRules{Permissions: m}} - } - 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 9ff6e99..998fc90 100644 --- a/zddc/internal/policy/rego.go +++ b/zddc/internal/policy/rego.go @@ -21,9 +21,7 @@ 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). For this specific -// case zddc-server ships a parity-tested federal-mode variant; see -// FederalRego and `--print-rego=federal`. +// are absolute (NIST AC-6 least-privilege posture). // - 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. @@ -32,21 +30,3 @@ 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 deleted file mode 100644 index 2a2786b..0000000 --- a/zddc/internal/policy/rego/access_federal.rego +++ /dev/null @@ -1,112 +0,0 @@ -# Federal-mode reference SKELETON: parent-deny-is-absolute (NIST AC-6), -# read-ACL only. -# -# Like access.rego this models the read cascade ONLY and is NOT a complete -# authorization policy — it does not implement per-verb (write/create/delete/ -# admin), WORM, roles, inherit:false fences, or config-edit. It is therefore -# FAIL-CLOSED: every non-read action is denied. This variant deliberately has -# NO admin bypass either — under AC-6 least-privilege the default posture is -# deny, and an operator who needs a write path must add the per-verb (and, if -# desired, admin-escape) semantics themselves. As shipped it authorizes reads -# only. -# -# The ONE modelled difference from access.rego: any deny anywhere on the chain -# is absolute — a leaf-level allow does NOT override an ancestor's deny. -# Required by NIST AC-6: a central admin's root deny must be unbypassable by -# a tenant who controls a subtree's .zddc. -# access.rego (commercial): leaf allow CAN override an ancestor deny. -# access_federal.rego: ancestor deny is absolute. -# -# The internal Go evaluator implements neither these federal semantics nor a -# tested mirror of this file; federal-mode is reachable only by running OPA -# with this policy and pointing ZDDC_OPA_URL at it. See federal_parity_test.go -# for the modelled read-cascade divergence fixtures. -# -# Input shape: identical to access.rego — see that file's docstring. -# acl.permissions maps principal patterns to verb strings; an empty -# verb string is an explicit deny. - -package zddc.access_federal - -import future.keywords.if -import future.keywords.in - -default allow := false - -# Read-ACL only: every grant rule is gated on a read action; any write/ -# create/delete/admin falls through to the default-deny above (fail-closed). -# Empty/absent action == read. (No admin bypass in federal mode — see header.) -is_read_action if { - not input.action -} - -is_read_action if { - input.action == "" -} - -is_read_action if { - input.action == "read" -} - -# Read allowed 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 { - is_read_action - not input.policy_chain.has_any_file - not any_deny_match - not any_allow_match -} - -# Read allowed 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 { - is_read_action - input.policy_chain.has_any_file - not any_deny_match - any_allow_match -} - -# Any explicit-deny permission entry at ANY level matches the email. -any_deny_match if { - some level in input.policy_chain.levels - some pattern, verbs in level.acl.permissions - verbs == "" - email_matches(pattern, input.user.email) -} - -# Any grant permission entry (non-empty verbs) at ANY level matches. -any_allow_match if { - some level in input.policy_chain.levels - some pattern, verbs in level.acl.permissions - verbs != "" - 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) -} diff --git a/zddc/internal/policy/rego_failclosed_test.go b/zddc/internal/policy/rego_failclosed_test.go index e1f2dff..7b39dd4 100644 --- a/zddc/internal/policy/rego_failclosed_test.go +++ b/zddc/internal/policy/rego_failclosed_test.go @@ -10,30 +10,21 @@ import ( ) // TestReferenceRego_FailClosedOnWrites pins the security contract of the -// bundled reference Rego skeletons: they model READ-ACL only, so any non-read -// action must be DENIED even when the read-ACL would grant — and the commercial -// variant's only write-capable principal is an elevated admin. This is the -// behavior that, untested, previously let a verb-blind policy ship claiming to -// "mirror the internal decider exactly." See rego/access.rego. +// bundled reference Rego skeleton: it models READ-ACL only, so any non-read +// action must be DENIED even when the read-ACL would grant — and the only +// write-capable principal is an elevated admin. This is the behavior that, +// untested, previously let a verb-blind policy ship claiming to "mirror the +// internal decider exactly." See rego/access.rego. func TestReferenceRego_FailClosedOnWrites(t *testing.T) { ctx := context.Background() - mkQuery := func(module, src string) rego.PreparedEvalQuery { - var pkg string - switch module { - case "access.rego": - pkg = "data.zddc.access.allow" - default: - pkg = "data.zddc.access_federal.allow" - } - q, err := rego.New(rego.Query(pkg), rego.Module(module, src)).PrepareForEval(ctx) - if err != nil { - t.Fatalf("compile %s: %v", module, err) - } - return q + stdQ, err := rego.New( + rego.Query("data.zddc.access.allow"), + rego.Module("access.rego", ReferenceRego), + ).PrepareForEval(ctx) + if err != nil { + t.Fatalf("compile access.rego: %v", err) } - stdQ := mkQuery("access.rego", ReferenceRego) - fedQ := mkQuery("access_federal.rego", FederalRego) // A chain that GRANTS full rwcd to alice — so any denial below is the // action gate, not a missing ACL. @@ -78,15 +69,9 @@ func TestReferenceRego_FailClosedOnWrites(t *testing.T) { {"access create denied", stdQ, ActionCreate, false, false}, {"access delete denied", stdQ, ActionDelete, false, false}, {"access admin-action denied", stdQ, ActionAdmin, false, false}, - // Commercial: an elevated admin is the one write-capable principal. + // An elevated admin is the one write-capable principal. {"access write allowed for active admin", stdQ, ActionWrite, true, true}, {"access delete allowed for active admin", stdQ, ActionDelete, true, true}, - - // Federal: reads granted, every write denied, and NO admin bypass. - {"federal read allowed", fedQ, ActionRead, false, true}, - {"federal write denied", fedQ, ActionWrite, false, false}, - {"federal admin-action denied", fedQ, ActionAdmin, false, false}, - {"federal write denied even for active admin", fedQ, ActionWrite, true, false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/zddc/internal/zddc/file.go b/zddc/internal/zddc/file.go index caae5b2..80f9feb 100644 --- a/zddc/internal/zddc/file.go +++ b/zddc/internal/zddc/file.go @@ -25,12 +25,13 @@ import ( // (e.g. a vendor folder where only the vendor and the doc controller // should have access regardless of broader project-level grants). // -// Federal deployments running the bundled `access_federal.rego` get -// parent-deny-is-absolute / NIST AC-6 semantics; the directive's -// fence-style "reset" should be avoided there because it would let a -// leaf widen access an ancestor refused. The cascade tracer at -// /.profile/effective-policy reports `chain.visible_start` so an -// operator can verify which level a fence is actually cutting off. +// A deployment running an external OPA with ancestor-deny-absolute +// (NIST AC-6) semantics should avoid the directive's fence-style "reset", +// since under that posture it would let a leaf widen access an ancestor +// refused. (zddc-server ships only the read-ACL skeleton at --print-rego; +// an AC-6 policy is the operator's own Rego.) The cascade tracer at +// /.profile/effective-policy reports `chain.visible_start` so an operator +// can verify which level a fence is actually cutting off. // // Inherit is per-level and not itself cascading: an ancestor's // `inherit: false` does not transitively block descendants from