chore(server): drop the federal reference Rego (bring-your-own-policy)

Decision: external OPA is a bring-your-own-policy escape hatch, not a
supported turnkey mode — so stop shipping access_federal.rego. A verb-blind
read-ACL policy under NIST AC-6 branding is a liability to hand a federal
evaluator, and (like access.rego before the fail-close) it over-granted writes
and ignored WORM. The HTTPDecider + Decider interface stay: operators who want
an AC-6 ancestor-deny-absolute posture write their own Rego.

- Delete rego/access_federal.rego, FederalRego, --print-rego=federal, and
  federal_parity_test.go; trim the federal cases from rego_failclosed_test.go.
- Reframe every doc reference (rego.go, main.go, file.go, ARCHITECTURE.md,
  README.md) to "operators write their own Rego"; rewrite the README
  "Reference Rego policy" section to describe the single fail-closed read-ACL
  skeleton accurately (it also still carried the now-removed "mirrors exactly"
  parity claim).

Out of scope (flagged): the broader federal-readiness narrative
(FedRAMP/FIPS/IdP) and the separate website page federal.html still discuss
federal posture — the OPA bring-your-own-Rego path stays valid, but a
deliberate review with the federal go-to-market in mind is warranted.

go vet + full go test ./... green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-10 08:45:21 -05:00
parent 84c1b58b66
commit 6d132572d3
8 changed files with 80 additions and 427 deletions

View file

@ -544,7 +544,7 @@ none of them is load-bearing alone.
|---|---|---| |---|---|---|
| Authentication | Establish caller identity (email) | Two paths: `Authorization: Bearer <token>` validated against `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>` (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 | | Authentication | Establish caller identity (email) | Two paths: `Authorization: Bearer <token>` validated against `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>` (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/` | | 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` (`<email>: 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/<party>/{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 | | 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` (`<email>: 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/<party>/{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 | | 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 | | 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) | | 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 | | 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`) | | 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 | | 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 | | 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 | | 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. `.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: 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. - **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.

View file

@ -433,8 +433,7 @@ fence is computed by `PolicyChain.VisibleStart`.
The leaf-overrides-ancestor behaviour above is the in-process decider's only The leaf-overrides-ancestor behaviour above is the in-process decider's only
rule. Federal deployments needing absolute parent denies (NIST AC-6) deploy rule. Federal deployments needing absolute parent denies (NIST AC-6) deploy
OPA with the bundled `access_federal.rego` (or their own Rego); see OPA with their own Rego; see "External OPA" below.
"External OPA" below.
#### The `inherit:` directive #### The `inherit:` directive
@ -471,10 +470,10 @@ Behaviour:
fence; `inherit: false` does not change WORM behaviour. See fence; `inherit: false` does not change WORM behaviour. See
"Canonical-folder behaviour via `.zddc` keys" below. "Canonical-folder behaviour via `.zddc` keys" below.
**Federal posture and `inherit: false`.** The bundled federal Rego at **Federal posture and `inherit: false`.** An external OPA policy with
`--print-rego=federal` makes ancestor explicit-denies absolute and ancestor-deny-absolute (NIST AC-6) semantics makes ancestor explicit-denies
therefore ignores `inherit: false` (allowing a leaf to widen access an absolute and therefore ignores `inherit: false` (allowing a leaf to widen
ancestor refused would defeat NIST AC-6). Operators who need fence- access an ancestor refused would defeat NIST AC-6). Operators who need fence-
style "reset" semantics in a federal-track deployment should not use style "reset" semantics in a federal-track deployment should not use
the directive — instead, restructure the tree so the permissive the directive — instead, restructure the tree so the permissive
ancestor rule never appears. ancestor rule never appears.
@ -927,13 +926,14 @@ have to redo the gap analysis from scratch.
Identity-source-driven role assignment plumbs through unchanged Identity-source-driven role assignment plumbs through unchanged
(the upstream proxy still asserts the email; role membership is (the upstream proxy still asserts the email; role membership is
evaluated server-side against the cascade). evaluated server-side against the cascade).
- ~~**Least-privilege bounding** (NIST AC-6)~~*closed.* Operators - ~~**Least-privilege bounding** (NIST AC-6)~~ — *available via the OPA
deploy OPA (`ZDDC_OPA_URL`) pointed at the bundled federal Rego path.* Operators deploy OPA (`ZDDC_OPA_URL`) pointed at their own
(`zddc-server --print-rego=federal`) or their own variant. Under ancestor-deny-absolute Rego, under which any ancestor explicit-deny is
that policy any ancestor explicit-deny is absolute and cannot be absolute and cannot be overridden by a leaf grant. The in-process Go
overridden by a leaf grant. The in-process Go evaluator implements evaluator implements only the commercial "leaf grants override ancestor
only the commercial "leaf grants override ancestor denies" rule; denies" rule, and the bundled `--print-rego` skeleton models read-ACL
federal posture is exclusively the OPA path. 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 - **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
@ -1266,56 +1266,47 @@ cache lookup would be.
### Reference Rego policy ### Reference Rego policy
The `--print-rego` flag emits the bundled reference Rego policies. Two The `--print-rego` flag emits the bundled reference Rego **skeleton**:
variants ship:
```sh ```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=standard # same
zddc-server --print-rego=federal # parent-deny-is-absolute (NIST AC-6)
``` ```
The standard variant mirrors internal-mode semantics exactly — leaf- This skeleton models the **read-ACL cascade only** — glob patterns,
level allows can override an ancestor's deny (the cascade's intentional deny-first-within-a-level, default-deny once any `.zddc` exists, and the
delegation property). The federal variant is the strict-least-privilege leaf-allow-overrides-ancestor-deny delegation property. It is **NOT** a
posture: any deny anywhere in the chain is absolute, no leaf-level semantic mirror of the internal Go decider: it does not implement per-verb
override possible. Federal customers running their own OPA can drop authorization (write/create/delete/admin), WORM zones, `roles:` resolution,
the federal Rego in unchanged, or use either as a starting point for `inherit:false` fences, or standing config-edit. Because those are
further customization. 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` A build-time guard (`zddc/internal/policy/parity_test.go`,
imports the OPA Go module **as a test-only dependency**, evaluates both `rego_failclosed_test.go`) imports the OPA Go module **as a test-only
bundled Regos against fixture sets and asserts: 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 The production decider is pure Go (no library bloat, no extra process); the
cascade scenario (`TestRegoParity_AllInternalCases`). wire format is OPA-canonical, so an operator can point an external OPA at it
- The federal Rego agrees with the standard policy on every case where and extend the skeleton. Typical extensions an operator writes on top:
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 - **Per-verb + WORM + roles + config-edit** — the semantics the skeleton
13 MB) — the OPA library is in `go.mod` but not in `go build`'s output. omits; required before the policy can authorize writes at all.
- **Parent-deny-is-absolute** — make any ancestor deny absolute for a NIST
This gives you both ends of the spectrum: a single OPA-aware codebase AC-6 least-privilege posture.
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.
- **Role-based access** — read additional input fields like - **Role-based access** — read additional input fields like
`input.user.roles` populated by the upstream proxy from SAML/OIDC `input.user.roles` populated by the upstream proxy from SAML/OIDC claims.
claims, and decide based on those instead of (or alongside) email. - **Time-of-day or IP-range constraints**, and **SIEM-shipped decision
- **Time-of-day or IP-range constraints** — Rego can read logs** via OPA's logging plugins (Splunk Government, Elastic Federal, etc.).
`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.
### Reference deployment shapes ### Reference deployment shapes
@ -1326,7 +1317,7 @@ No sidecar, no extra port, no extra binary.
**Federal sidecar**: deploy OPA alongside zddc-server (k8s sidecar, **Federal sidecar**: deploy OPA alongside zddc-server (k8s sidecar,
nomad task, or systemd service on the same host), bind it to 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 `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 (filesystem, signed bundle from S3, OPAL, etc.) and is patched
independently of zddc-server. 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). CM-3 federal control above).
- Per-decision caching for external OPA mode (small TTL on (email, path) - Per-decision caching for external OPA mode (small TTL on (email, path)
to amortize the .archive listing's per-entry round-trip). to amortize the .archive listing's per-entry round-trip).
- A reference Rego bundle shipped alongside the binary that exactly - A full-parity reference Rego (modelling per-verb / WORM / roles /
reproduces internal mode, plus a "federal-mode" variant that flips config-edit, not just the read-ACL skeleton shipped today) plus a
the parent-deny-is-absolute toggle. Useful as a starting point for generative differential test against the internal decider — only worth
customers who want to extend rather than write from scratch. 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 ## Admin Debug Page

View file

@ -36,21 +36,18 @@ import (
var version = "dev" var version = "dev"
func main() { func main() {
// --print-rego: dump a bundled reference Rego policy and exit. // --print-rego: dump the bundled reference Rego skeleton and exit.
// Cheap escape hatch for operators standing up an external OPA who want // A starting point for operators standing up an external OPA: it models
// a parity-tested baseline as a starting point for customization. // 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=standard → same
// --print-rego=federal → parent-deny-is-absolute (NIST AC-6)
for _, a := range os.Args[1:] { for _, a := range os.Args[1:] {
switch a { switch a {
case "--print-rego", "--print-rego=standard": 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
case "show-defaults", "--show-defaults": case "show-defaults", "--show-defaults":
// Emit the embedded baseline as a .zddc.zip (per-depth policy // Emit the embedded baseline as a .zddc.zip (per-depth policy
// tree, "*" wildcard members) to stdout. Redirect into a bundle // tree, "*" wildcard members) to stdout. Redirect into a bundle

View file

@ -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)
}
}

View file

@ -21,9 +21,7 @@ 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). For this specific // are absolute (NIST AC-6 least-privilege posture).
// 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.
@ -32,21 +30,3 @@ 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

@ -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)
}

View file

@ -10,30 +10,21 @@ import (
) )
// TestReferenceRego_FailClosedOnWrites pins the security contract of the // TestReferenceRego_FailClosedOnWrites pins the security contract of the
// bundled reference Rego skeletons: they model READ-ACL only, so any non-read // 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 commercial // action must be DENIED even when the read-ACL would grant — and the only
// variant's only write-capable principal is an elevated admin. This is the // write-capable principal is an elevated admin. This is the behavior that,
// behavior that, untested, previously let a verb-blind policy ship claiming to // untested, previously let a verb-blind policy ship claiming to "mirror the
// "mirror the internal decider exactly." See rego/access.rego. // internal decider exactly." See rego/access.rego.
func TestReferenceRego_FailClosedOnWrites(t *testing.T) { func TestReferenceRego_FailClosedOnWrites(t *testing.T) {
ctx := context.Background() ctx := context.Background()
mkQuery := func(module, src string) rego.PreparedEvalQuery { stdQ, err := rego.New(
var pkg string rego.Query("data.zddc.access.allow"),
switch module { rego.Module("access.rego", ReferenceRego),
case "access.rego": ).PrepareForEval(ctx)
pkg = "data.zddc.access.allow" if err != nil {
default: t.Fatalf("compile access.rego: %v", err)
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 := mkQuery("access.rego", ReferenceRego)
fedQ := mkQuery("access_federal.rego", FederalRego)
// A chain that GRANTS full rwcd to alice — so any denial below is the // A chain that GRANTS full rwcd to alice — so any denial below is the
// action gate, not a missing ACL. // action gate, not a missing ACL.
@ -78,15 +69,9 @@ func TestReferenceRego_FailClosedOnWrites(t *testing.T) {
{"access create denied", stdQ, ActionCreate, false, false}, {"access create denied", stdQ, ActionCreate, false, false},
{"access delete denied", stdQ, ActionDelete, false, false}, {"access delete denied", stdQ, ActionDelete, false, false},
{"access admin-action denied", stdQ, ActionAdmin, 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 write allowed for active admin", stdQ, ActionWrite, true, true},
{"access delete allowed for active admin", stdQ, ActionDelete, 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 { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {

View file

@ -25,12 +25,13 @@ import (
// (e.g. a vendor folder where only the vendor and the doc controller // (e.g. a vendor folder where only the vendor and the doc controller
// should have access regardless of broader project-level grants). // should have access regardless of broader project-level grants).
// //
// Federal deployments running the bundled `access_federal.rego` get // A deployment running an external OPA with ancestor-deny-absolute
// parent-deny-is-absolute / NIST AC-6 semantics; the directive's // (NIST AC-6) semantics should avoid the directive's fence-style "reset",
// fence-style "reset" should be avoided there because it would let a // since under that posture it would let a leaf widen access an ancestor
// leaf widen access an ancestor refused. The cascade tracer at // refused. (zddc-server ships only the read-ACL skeleton at --print-rego;
// /.profile/effective-policy reports `chain.visible_start` so an // an AC-6 policy is the operator's own Rego.) The cascade tracer at
// operator can verify which level a fence is actually cutting off. // /.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 is per-level and not itself cascading: an ancestor's
// `inherit: false` does not transitively block descendants from // `inherit: false` does not transitively block descendants from