ZDDC/zddc/internal/policy/rego_failclosed_test.go
ZDDC d14516a74d fix(server): fail-close the reference Rego; stop claiming internal-decider parity
The bundled reference Rego (`zddc-server --print-rego`) modeled the read-ACL
cascade only, but its header claimed to "mirror the internal decider exactly,
validated on every CI run." It is verb-blind, role-blind, WORM-blind, and
admin-blind: an external-OPA deployment (ZDDC_OPA_URL=http(s)/unix) loading it
granted writes/deletes to read-only principals and ignored WORM zones. The
parity tests never exercised a write action, a role principal, a WORM level, or
is_active_admin — so the divergence shipped silently behind a false "mirrors
exactly" claim.

Make both shipped policies fail-closed instead of falsely-complete:
- access.rego / access_federal.rego: gate every cascade grant on a read action
  (empty/absent == read); non-read actions fall through to default-deny.
  access.rego honors the single is_active_admin bypass (the one write-capable
  principal); access_federal.rego deliberately has none (strict AC-6).
- Rewrite the access.rego / access_federal.rego / rego.go headers: these are
  read-ACL SKELETONS, NOT a tested mirror of the internal decider; operators
  must add write/WORM/role/admin semantics before granting writes.
- policy.go: fix the stale AllowInput doc claiming the internal decider "treats
  read and write identically — any allow grants full CRUD" (it honors the
  action verb, with the WORM clamp and admin/elevation bypass applied).

Tests:
- rego_failclosed_test.go: pins the contract — reads allowed, every write verb
  denied, active-admin writes allowed (commercial) / denied (federal).
- embedded_neutral_test.go: pins that EmbeddedDefaults() carries no top-level
  worm: and no role members — the invariant that makes policy.SerializableChain
  dropping PolicyChain.Embedded behavior-neutral (a latent wire-contract gap).

Existing read-cascade parity + federal-divergence tests stay green; full Go
suite + vet pass. The default in-process InternalDecider is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:30:09 -05:00

98 lines
3.4 KiB
Go

package policy
import (
"context"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
"github.com/open-policy-agent/opa/rego"
)
// 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.
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 := 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.
grant := zddc.PolicyChain{
Levels: []zddc.ZddcFile{{ACL: zddc.ACLRules{Permissions: map[string]string{"*@example.com": "rwcd"}}}},
HasAnyFile: true,
}
evalAllow := func(q rego.PreparedEvalQuery, action string, admin bool) bool {
in := AllowInput{Path: "/p/", Action: action, PolicyChain: chainToSerializable(grant)}
in.User.Email = "alice@example.com"
in.User.IsActiveAdmin = admin
regoInput, err := canonicalInput(in)
if err != nil {
t.Fatalf("encode: %v", err)
}
rs, err := q.Eval(ctx, rego.EvalInput(regoInput))
if err != nil {
t.Fatalf("eval: %v", err)
}
if len(rs) == 0 {
t.Fatalf("no result")
}
v, ok := rs[0].Expressions[0].Value.(bool)
if !ok {
t.Fatalf("result not bool: %v", rs[0].Expressions[0].Value)
}
return v
}
cases := []struct {
name string
q rego.PreparedEvalQuery
action string
admin bool
wantAllow bool
}{
// Commercial: reads granted, every write verb denied (fail-closed).
{"access read allowed", stdQ, ActionRead, false, true},
{"access empty-action(read) allowed", stdQ, "", false, true},
{"access write denied", stdQ, ActionWrite, false, false},
{"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.
{"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) {
if got := evalAllow(tc.q, tc.action, tc.admin); got != tc.wantAllow {
t.Errorf("allow=%v, want %v (action=%q active_admin=%v)", got, tc.wantAllow, tc.action, tc.admin)
}
})
}
}