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:
parent
84c1b58b66
commit
6d132572d3
8 changed files with 80 additions and 427 deletions
|
|
@ -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 |
|
||||
| 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 |
|
||||
| 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.
|
||||
|
|
|
|||
111
zddc/README.md
111
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
stdQ, err := rego.New(
|
||||
rego.Query("data.zddc.access.allow"),
|
||||
rego.Module("access.rego", ReferenceRego),
|
||||
).PrepareForEval(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("compile %s: %v", module, err)
|
||||
t.Fatalf("compile access.rego: %v", 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
|
||||
// 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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue