feat(zddc): inherit:false fence + strict-mode refusal

A .zddc may now declare `acl.inherit: false` to fence off ancestor
grants and roles from the descendant subtree — the "complete reset
plus add back" pattern operators want for vendor folders and other
narrowly-scoped subtrees. The cascade walker honors the deepest fence
in [0, toIdx] when evaluating any level at-or-below it, both for
GrantedVerbsAtLevel/EffectiveVerbsRange and for role lookup
(RoleMembers / lookupRoleMembers).

Federal/strict cascade mode IGNORES the fence — required by
NIST AC-6 ("ancestor deny is absolute; no leaf-level override"). So
inherit:false has no effect under strict mode and ancestor grants
remain visible. Operators running the federal Rego preset get the
same behaviour from external policy enforcement.

API surface: ACLRules.Inherit (*bool, nil = unset = inherit-true);
ACLRules.InheritsAncestors() bool; PolicyChain.VisibleStart(toIdx,
mode) int. The mode parameter is now threaded through
GrantedVerbsAtLevel, MatchesPrincipal, MatchingPrincipals,
RoleMembers, and lookupRoleMembers so role resolution is fence-aware.

Tests:
- file_test.go: parser round-trip for absent / true / false inherit
- inherit_test.go: VisibleStart (no fence, fence clamps, nested fences,
  strict-mode override), EffectiveVerbs (fence hides ancestor grants,
  strict-mode keeps them), RoleMembers (ancestor roles hidden by fence,
  local redefinition still works)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-07 10:59:20 -05:00
parent 821ed3ee19
commit 2ccd72fa35
7 changed files with 320 additions and 31 deletions

View file

@ -7,7 +7,7 @@ import "strings"
// should call GrantedVerbsAtLevel directly. // should call GrantedVerbsAtLevel directly.
func AllowedAtLevel(level ZddcFile, email string) (decision bool, matched bool) { func AllowedAtLevel(level ZddcFile, email string) (decision bool, matched bool) {
chain := PolicyChain{Levels: []ZddcFile{level}, HasAnyFile: true} chain := PolicyChain{Levels: []ZddcFile{level}, HasAnyFile: true}
v, m := GrantedVerbsAtLevel(chain, 0, email) v, m := GrantedVerbsAtLevel(chain, 0, email, ModeDelegated)
if !m { if !m {
return false, false return false, false
} }
@ -21,12 +21,13 @@ func AllowedAtLevel(level ZddcFile, email string) (decision bool, matched bool)
// - matched=true, set!={} → union of verb sets from every matching entry // - matched=true, set!={} → union of verb sets from every matching entry
// //
// Role lookups for principal keys without "@" use RoleMembers, which // Role lookups for principal keys without "@" use RoleMembers, which
// walks levelIdx → root for the closest definition. // walks levelIdx → fence-or-root for the closest definition. mode
// controls whether inherit:false fences are honored — see VisibleStart.
// //
// Legacy acl.allow / acl.deny entries are folded in here (rather than at // Legacy acl.allow / acl.deny entries are folded in here (rather than at
// parse time) so this function works correctly on test-constructed // parse time) so this function works correctly on test-constructed
// ZddcFile literals as well as parser output. // ZddcFile literals as well as parser output.
func GrantedVerbsAtLevel(chain PolicyChain, levelIdx int, email string) (VerbSet, bool) { func GrantedVerbsAtLevel(chain PolicyChain, levelIdx int, email string, mode CascadeMode) (VerbSet, bool) {
if levelIdx < 0 || levelIdx >= len(chain.Levels) { if levelIdx < 0 || levelIdx >= len(chain.Levels) {
return 0, false return 0, false
} }
@ -40,7 +41,7 @@ func GrantedVerbsAtLevel(chain PolicyChain, levelIdx int, email string) (VerbSet
deniedExplicit := false deniedExplicit := false
var grant VerbSet var grant VerbSet
for principal, verbStr := range perms { for principal, verbStr := range perms {
if !MatchesPrincipal(principal, email, chain, levelIdx) { if !MatchesPrincipal(principal, email, chain, levelIdx, mode) {
continue continue
} }
matched = true matched = true
@ -141,16 +142,22 @@ func EffectiveVerbsRange(chain PolicyChain, fromIdx, toIdx int, email string, mo
// caller has another range to combine with). // caller has another range to combine with).
return 0 return 0
} }
// Honor inherit:false fences — clamp fromIdx upward to the deepest
// fence visible from the leaf end of the range. In strict mode the
// fence helper returns 0 unconditionally, so this is a no-op.
if fence := chain.VisibleStart(toIdx-1, mode); fence > fromIdx {
fromIdx = fence
}
if mode == ModeStrict { if mode == ModeStrict {
for i := fromIdx; i < toIdx; i++ { for i := fromIdx; i < toIdx; i++ {
grant, matched := GrantedVerbsAtLevel(chain, i, email) grant, matched := GrantedVerbsAtLevel(chain, i, email, mode)
if matched && grant == 0 { if matched && grant == 0 {
return 0 return 0
} }
} }
} }
for i := toIdx - 1; i >= fromIdx; i-- { for i := toIdx - 1; i >= fromIdx; i-- {
grant, matched := GrantedVerbsAtLevel(chain, i, email) grant, matched := GrantedVerbsAtLevel(chain, i, email, mode)
if !matched { if !matched {
continue continue
} }

View file

@ -13,6 +13,36 @@ type PolicyChain struct {
HasAnyFile bool // true if at least one .zddc file exists in the chain HasAnyFile bool // true if at least one .zddc file exists in the chain
} }
// VisibleStart returns the lowest level index visible to evaluation at
// any level in [0, toIdx], honoring inherit:false fences. A level with
// `acl.inherit: false` is a fence: ancestors above it are invisible to
// descendants at-and-below the fence. The deepest fence in the prefix
// wins (nested fences are supported; the closer-to-leaf wins).
//
// In strict cascade mode, fences are ignored — returns 0 — because
// federal/AC-6 deployments require ancestor explicit-denies to be
// absolute, and the inherit directive would let a leaf widen access an
// ancestor refused.
//
// toIdx is clamped to len(chain.Levels)-1.
func (chain PolicyChain) VisibleStart(toIdx int, mode CascadeMode) int {
if mode == ModeStrict {
return 0
}
if toIdx >= len(chain.Levels) {
toIdx = len(chain.Levels) - 1
}
if toIdx < 0 {
return 0
}
for i := toIdx; i >= 0; i-- {
if !chain.Levels[i].ACL.InheritsAncestors() {
return i
}
}
return 0
}
// policyCache caches effective policies keyed by dirPath. // policyCache caches effective policies keyed by dirPath.
// Values are PolicyChain. // Values are PolicyChain.
var policyCache sync.Map var policyCache sync.Map

View file

@ -27,6 +27,26 @@ import (
// (and so existing operator-authored .zddc files render unchanged in // (and so existing operator-authored .zddc files render unchanged in
// the admin UI); the cascade evaluator reads only Permissions. // the admin UI); the cascade evaluator reads only Permissions.
// //
// Inherit controls whether this level imports grants and roles from
// its ancestors. The default (when the field is absent — represented
// here as a nil pointer) is "inherit normally." Setting `inherit: false`
// makes this level a fence: grants and roles defined in any ancestor
// .zddc are invisible at-and-below this point in the cascade. Useful
// for "complete reset, then add back specific principals" patterns
// (e.g. a vendor folder where only the vendor and the doc controller
// should have access regardless of broader project-level grants).
//
// In strict cascade mode (federal / NIST AC-6), inherit:false is
// REFUSED — a leaf-level directive cannot widen access an ancestor
// refused. The internal decider treats it as inherit:true and emits a
// warning at evaluation time. Operators running the federal Rego
// preset get the same behaviour from policy enforcement.
//
// Inherit is per-level and not itself cascading: an ancestor's
// `inherit: false` does not transitively block descendants from
// adding their own grants — it only fences off ANCESTORS of the
// fenced level from the descendant subtree.
//
// JSON tags are present so this type round-trips cleanly when included // JSON tags are present so this type round-trips cleanly when included
// in the external-OPA input body (see internal/policy). The canonical // in the external-OPA input body (see internal/policy). The canonical
// in-repo serialization is YAML; JSON is only used for OPA queries. // in-repo serialization is YAML; JSON is only used for OPA queries.
@ -34,6 +54,17 @@ type ACLRules struct {
Allow []string `yaml:"allow,omitempty" json:"allow,omitempty"` Allow []string `yaml:"allow,omitempty" json:"allow,omitempty"`
Deny []string `yaml:"deny,omitempty" json:"deny,omitempty"` Deny []string `yaml:"deny,omitempty" json:"deny,omitempty"`
Permissions map[string]string `yaml:"permissions,omitempty" json:"permissions,omitempty"` Permissions map[string]string `yaml:"permissions,omitempty" json:"permissions,omitempty"`
// Inherit *bool: nil = unset (inherit normally), &true = same,
// &false = fence ancestors. Using a pointer so the default is
// distinguishable from an explicit `inherit: true`.
Inherit *bool `yaml:"inherit,omitempty" json:"inherit,omitempty"`
}
// InheritsAncestors reports whether this level imports grants and
// roles from ancestors. True when Inherit is unset or explicitly true;
// false only when explicitly set to false.
func (r ACLRules) InheritsAncestors() bool {
return r.Inherit == nil || *r.Inherit
} }
// Role is the named principal-grouping primitive. Members are email // Role is the named principal-grouping primitive. Members are email

View file

@ -74,3 +74,63 @@ acl:
t.Errorf("Tables = %+v want nil for absent tables: key", zf.Tables) t.Errorf("Tables = %+v want nil for absent tables: key", zf.Tables)
} }
} }
// Inherit defaults to "inherit normally" when the field is absent;
// explicit true behaves the same; explicit false marks the level as
// a fence.
func TestParseFile_InheritDirective(t *testing.T) {
cases := []struct {
name string
body string
wantPtrNil bool
wantInherit bool
}{
{
name: "absent → nil pointer, inherits",
body: `acl:
permissions:
"*@example.com": r
`,
wantPtrNil: true,
wantInherit: true,
},
{
name: "explicit true → non-nil, inherits",
body: `acl:
inherit: true
permissions:
"*@example.com": r
`,
wantPtrNil: false,
wantInherit: true,
},
{
name: "explicit false → non-nil, fences",
body: `acl:
inherit: false
permissions:
"*@vendor.com": rwcd
`,
wantPtrNil: false,
wantInherit: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), ".zddc")
if err := os.WriteFile(path, []byte(tc.body), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
zf, err := ParseFile(path)
if err != nil {
t.Fatalf("ParseFile: %v", err)
}
if (zf.ACL.Inherit == nil) != tc.wantPtrNil {
t.Errorf("Inherit pointer nil=%v want %v", zf.ACL.Inherit == nil, tc.wantPtrNil)
}
if got := zf.ACL.InheritsAncestors(); got != tc.wantInherit {
t.Errorf("InheritsAncestors() = %v want %v", got, tc.wantInherit)
}
})
}
}

View file

@ -0,0 +1,152 @@
package zddc
import "testing"
// helper: build a chain from levels (root-to-leaf), HasAnyFile=true.
func buildChain(levels ...ZddcFile) PolicyChain {
return PolicyChain{Levels: levels, HasAnyFile: true}
}
// helper: ACL with a permissions map and an explicit inherit setting.
func aclFenced(perms map[string]string, inherit bool) ACLRules {
return ACLRules{Permissions: perms, Inherit: &inherit}
}
func aclOpen(perms map[string]string) ACLRules {
return ACLRules{Permissions: perms}
}
func TestVisibleStart_NoFence(t *testing.T) {
chain := buildChain(
ZddcFile{ACL: aclOpen(map[string]string{"*@example.com": "r"})},
ZddcFile{ACL: aclOpen(map[string]string{"*@example.com": "rwcd"})},
ZddcFile{ACL: aclOpen(map[string]string{"*@example.com": "rwcda"})},
)
if got := chain.VisibleStart(2, ModeDelegated); got != 0 {
t.Errorf("no fence: VisibleStart = %d, want 0", got)
}
}
func TestVisibleStart_FenceClampsToFence(t *testing.T) {
chain := buildChain(
ZddcFile{ACL: aclOpen(map[string]string{"*@example.com": "r"})},
ZddcFile{ACL: aclFenced(map[string]string{"*@vendor.com": "rwcd"}, false)},
ZddcFile{ACL: aclOpen(map[string]string{})},
)
if got := chain.VisibleStart(2, ModeDelegated); got != 1 {
t.Errorf("fence at 1: VisibleStart(2) = %d, want 1", got)
}
if got := chain.VisibleStart(1, ModeDelegated); got != 1 {
t.Errorf("fence at 1: VisibleStart(1) = %d, want 1", got)
}
// Fence above toIdx is irrelevant.
if got := chain.VisibleStart(0, ModeDelegated); got != 0 {
t.Errorf("fence at 1: VisibleStart(0) = %d, want 0 (fence not yet in scope)", got)
}
}
func TestVisibleStart_NestedFencesDeepestWins(t *testing.T) {
chain := buildChain(
ZddcFile{ACL: aclOpen(map[string]string{"*@example.com": "r"})},
ZddcFile{ACL: aclFenced(map[string]string{"*@a.com": "r"}, false)},
ZddcFile{ACL: aclFenced(map[string]string{"*@b.com": "rwcd"}, false)},
ZddcFile{ACL: aclOpen(map[string]string{})},
)
if got := chain.VisibleStart(3, ModeDelegated); got != 2 {
t.Errorf("nested fence: deepest wins, got %d want 2", got)
}
}
func TestVisibleStart_StrictModeIgnoresFence(t *testing.T) {
chain := buildChain(
ZddcFile{ACL: aclOpen(map[string]string{"*@example.com": "r"})},
ZddcFile{ACL: aclFenced(map[string]string{"*@vendor.com": "rwcd"}, false)},
ZddcFile{ACL: aclOpen(map[string]string{})},
)
if got := chain.VisibleStart(2, ModeStrict); got != 0 {
t.Errorf("strict mode must ignore fence: got %d, want 0", got)
}
}
// End-to-end: a fence at the vendor folder hides root-level grants for
// users who don't match the vendor-folder grants.
func TestEffectiveVerbs_FenceHidesAncestorGrants(t *testing.T) {
chain := buildChain(
// Root: everyone-at-example reads everywhere.
ZddcFile{ACL: aclOpen(map[string]string{"*@example.com": "rwcd"})},
// Vendor folder: deny everyone-at-example, allow vendor explicitly,
// AND fence — so the root grant doesn't sneak through.
ZddcFile{ACL: aclFenced(map[string]string{
"*@vendor.com": "rwcd",
"_doc_controller": "rwcda",
}, false)},
)
// alice@example.com used to inherit root rwcd; with the fence she has
// no grant in the vendor folder → 0 verbs.
if got := EffectiveVerbs(chain, "alice@example.com", ModeDelegated); got != 0 {
t.Errorf("alice should be denied by fence; got %s", got)
}
// rep@vendor.com matches the local rule.
if got := EffectiveVerbs(chain, "rep@vendor.com", ModeDelegated); got != VerbsRWCD {
t.Errorf("vendor should have rwcd; got %s", got)
}
}
// In strict mode the fence is ignored: alice keeps her root grant
// because ancestor grants ARE absolute under AC-6 / strict cascade.
func TestEffectiveVerbs_StrictModeKeepsAncestorGrants(t *testing.T) {
chain := buildChain(
ZddcFile{ACL: aclOpen(map[string]string{"*@example.com": "rwcd"})},
ZddcFile{ACL: aclFenced(map[string]string{
"*@vendor.com": "rwcd",
}, false)},
)
// In strict mode, alice's root rwcd is visible — fence ignored.
// She doesn't match anything in the vendor folder, so leaf walk
// continues to root, finds rwcd, and returns it.
if got := EffectiveVerbs(chain, "alice@example.com", ModeStrict); got != VerbsRWCD {
t.Errorf("strict mode: alice should retain root rwcd; got %s", got)
}
}
// Roles defined above the fence are invisible to descendants — operators
// who fence must redefine roles locally if they want to use them.
func TestRoleMembers_FenceHidesAncestorRoles(t *testing.T) {
rootLevel := ZddcFile{
Roles: map[string]Role{"_doc_controller": {Members: []string{"dc@example.com"}}},
ACL: aclOpen(map[string]string{"*@example.com": "r"}),
}
fencedLevel := ZddcFile{
ACL: aclFenced(map[string]string{"*@vendor.com": "rwcd"}, false),
}
chain := buildChain(rootLevel, fencedLevel)
// Below the fence, the role from root is invisible.
if got := RoleMembers(chain, 1, "_doc_controller", ModeDelegated); got != nil {
t.Errorf("role above fence should be invisible; got %v", got)
}
// In strict mode, the fence is ignored and the role is visible.
if got := RoleMembers(chain, 1, "_doc_controller", ModeStrict); len(got) != 1 || got[0] != "dc@example.com" {
t.Errorf("strict mode: role should be visible; got %v", got)
}
}
// A role redefined locally below the fence shadows correctly because
// the redefinition is at-or-below the fence (visible).
func TestRoleMembers_LocalRedefinitionWorks(t *testing.T) {
chain := buildChain(
ZddcFile{
Roles: map[string]Role{"_doc_controller": {Members: []string{"dc@example.com"}}},
},
ZddcFile{
ACL: aclFenced(map[string]string{"_doc_controller": "rwcda"}, false),
Roles: map[string]Role{"_doc_controller": {Members: []string{"vendor-dc@example.com"}}},
},
)
got := RoleMembers(chain, 1, "_doc_controller", ModeDelegated)
if len(got) != 1 || got[0] != "vendor-dc@example.com" {
t.Errorf("local redefinition should win; got %v", got)
}
}

View file

@ -100,17 +100,22 @@ func IsPrincipalRole(principal string) bool {
} }
// RoleMembers returns the member-pattern list for roleName as visible // RoleMembers returns the member-pattern list for roleName as visible
// at chain.Levels[levelIdx]. Lookup walks levelIdx → root and returns // at chain.Levels[levelIdx]. Lookup walks levelIdx → fence-or-root and
// the first definition found (closer-to-leaf wins). Returns nil if no // returns the first definition found (closer-to-leaf wins). The lower
// level in the visible chain defines the role. // bound is determined by chain.VisibleStart(levelIdx, mode): in
// delegated mode, an inherit:false fence at-or-below levelIdx hides
// any role definitions in levels above it; in strict mode the full
// chain is visible. Returns nil if no level in the visible chain
// defines the role.
// //
// Levels are stored root (index 0) → leaf (last index), matching the // Levels are stored root (index 0) → leaf (last index), matching the
// EffectivePolicy convention. // EffectivePolicy convention.
func RoleMembers(chain PolicyChain, levelIdx int, roleName string) []string { func RoleMembers(chain PolicyChain, levelIdx int, roleName string, mode CascadeMode) []string {
if levelIdx < 0 || levelIdx >= len(chain.Levels) { if levelIdx < 0 || levelIdx >= len(chain.Levels) {
return nil return nil
} }
for i := levelIdx; i >= 0; i-- { floor := chain.VisibleStart(levelIdx, mode)
for i := levelIdx; i >= floor; i-- {
role, ok := chain.Levels[i].Roles[roleName] role, ok := chain.Levels[i].Roles[roleName]
if !ok { if !ok {
continue continue
@ -121,23 +126,24 @@ func RoleMembers(chain PolicyChain, levelIdx int, roleName string) []string {
} }
// MatchesPrincipal reports whether email satisfies the given Permissions // MatchesPrincipal reports whether email satisfies the given Permissions
// key at chain.Levels[levelIdx]. // key at chain.Levels[levelIdx]. mode controls whether inherit:false
// fences truncate the visible chain when resolving role definitions.
// //
// Resolution order: // Resolution order:
// //
// 1. Principals containing "@" are always email patterns; dispatch to // 1. Principals containing "@" are always email patterns; dispatch to
// MatchesPattern. // MatchesPattern.
// 2. Principals without "@" are role-or-pattern. Look up the name in // 2. Principals without "@" are role-or-pattern. Look up the name in
// the cascade's roles. If a role definition is found, match the // the cascade's roles, honoring fences. If a role definition is
// user against the role's members. If no role definition exists // found in the visible chain, match the user against the role's
// anywhere in the cascade, fall back to MatchesPattern. The // members. If no role definition exists in the visible chain, fall
// fallback preserves legacy patterns like "*" or "*example.com" // back to MatchesPattern. The fallback preserves legacy patterns
// that pre-date the roles feature. // like "*" or "*example.com" that pre-date the roles feature.
func MatchesPrincipal(principal, email string, chain PolicyChain, levelIdx int) bool { func MatchesPrincipal(principal, email string, chain PolicyChain, levelIdx int, mode CascadeMode) bool {
if !IsPrincipalRole(principal) { if !IsPrincipalRole(principal) {
return MatchesPattern(principal, email) return MatchesPattern(principal, email)
} }
members, defined := lookupRoleMembers(chain, levelIdx, principal) members, defined := lookupRoleMembers(chain, levelIdx, principal, mode)
if !defined { if !defined {
// Legacy pattern compatibility — bare wildcards / unqualified // Legacy pattern compatibility — bare wildcards / unqualified
// strings continue to match via the email-pattern matcher. // strings continue to match via the email-pattern matcher.
@ -154,12 +160,14 @@ func MatchesPrincipal(principal, email string, chain PolicyChain, levelIdx int)
// lookupRoleMembers returns the member list and whether the role was // lookupRoleMembers returns the member list and whether the role was
// defined anywhere in the visible chain. Distinguishes "role exists // defined anywhere in the visible chain. Distinguishes "role exists
// but is empty" (defined=true, empty members) from "role not defined" // but is empty" (defined=true, empty members) from "role not defined"
// (defined=false), which the principal-fallback logic depends on. // (defined=false), which the principal-fallback logic depends on. The
func lookupRoleMembers(chain PolicyChain, levelIdx int, roleName string) ([]string, bool) { // visible-chain bound is determined by chain.VisibleStart(levelIdx, mode).
func lookupRoleMembers(chain PolicyChain, levelIdx int, roleName string, mode CascadeMode) ([]string, bool) {
if levelIdx < 0 || levelIdx >= len(chain.Levels) { if levelIdx < 0 || levelIdx >= len(chain.Levels) {
return nil, false return nil, false
} }
for i := levelIdx; i >= 0; i-- { floor := chain.VisibleStart(levelIdx, mode)
for i := levelIdx; i >= floor; i-- {
role, ok := chain.Levels[i].Roles[roleName] role, ok := chain.Levels[i].Roles[roleName]
if !ok { if !ok {
continue continue
@ -171,8 +179,9 @@ func lookupRoleMembers(chain PolicyChain, levelIdx int, roleName string) ([]stri
// MatchingPrincipals returns the keys of level.ACL.Permissions whose // MatchingPrincipals returns the keys of level.ACL.Permissions whose
// principal matches email at chain.Levels[levelIdx]. Output is sorted // principal matches email at chain.Levels[levelIdx]. Output is sorted
// for stable iteration in tests and audit logs. // for stable iteration in tests and audit logs. mode is forwarded to
func MatchingPrincipals(chain PolicyChain, levelIdx int, email string) []string { // MatchesPrincipal for fence-aware role resolution.
func MatchingPrincipals(chain PolicyChain, levelIdx int, email string, mode CascadeMode) []string {
if levelIdx < 0 || levelIdx >= len(chain.Levels) { if levelIdx < 0 || levelIdx >= len(chain.Levels) {
return nil return nil
} }
@ -182,7 +191,7 @@ func MatchingPrincipals(chain PolicyChain, levelIdx int, email string) []string
} }
var out []string var out []string
for principal := range level.ACL.Permissions { for principal := range level.ACL.Permissions {
if MatchesPrincipal(principal, email, chain, levelIdx) { if MatchesPrincipal(principal, email, chain, levelIdx, mode) {
out = append(out, principal) out = append(out, principal)
} }
} }

View file

@ -78,12 +78,12 @@ func TestRoleMembersClosestLeafWins(t *testing.T) {
}, },
HasAnyFile: true, HasAnyFile: true,
} }
got := RoleMembers(chain, 1, "editors") got := RoleMembers(chain, 1, "editors", ModeDelegated)
if len(got) != 1 || got[0] != "bob@example.com" { if len(got) != 1 || got[0] != "bob@example.com" {
t.Errorf("leaf shadow failed: %v", got) t.Errorf("leaf shadow failed: %v", got)
} }
// At root level, only the root definition is visible. // At root level, only the root definition is visible.
got = RoleMembers(chain, 0, "editors") got = RoleMembers(chain, 0, "editors", ModeDelegated)
if len(got) != 1 || got[0] != "alice@example.com" { if len(got) != 1 || got[0] != "alice@example.com" {
t.Errorf("root visibility failed: %v", got) t.Errorf("root visibility failed: %v", got)
} }
@ -96,10 +96,10 @@ func TestMatchesPrincipalLegacyPatternFallback(t *testing.T) {
Levels: []ZddcFile{{}}, Levels: []ZddcFile{{}},
HasAnyFile: true, HasAnyFile: true,
} }
if !MatchesPrincipal("*", "alice@example.com", chain, 0) { if !MatchesPrincipal("*", "alice@example.com", chain, 0, ModeDelegated) {
t.Errorf("bare * should match any email via legacy fallback") t.Errorf("bare * should match any email via legacy fallback")
} }
if !MatchesPrincipal("*example.com", "alice@example.com", chain, 0) { if !MatchesPrincipal("*example.com", "alice@example.com", chain, 0, ModeDelegated) {
t.Errorf("*example.com should match alice@example.com via legacy fallback") t.Errorf("*example.com should match alice@example.com via legacy fallback")
} }
} }
@ -115,10 +115,10 @@ func TestMatchesPrincipalRoleNamePrefersRole(t *testing.T) {
}}, }},
HasAnyFile: true, HasAnyFile: true,
} }
if !MatchesPrincipal("vendor_acme", "rep@acme.com", chain, 0) { if !MatchesPrincipal("vendor_acme", "rep@acme.com", chain, 0, ModeDelegated) {
t.Errorf("rep@acme.com should match role vendor_acme") t.Errorf("rep@acme.com should match role vendor_acme")
} }
if MatchesPrincipal("vendor_acme", "rep@other.com", chain, 0) { if MatchesPrincipal("vendor_acme", "rep@other.com", chain, 0, ModeDelegated) {
t.Errorf("rep@other.com should NOT match role vendor_acme — fallback to pattern would wrongly succeed") t.Errorf("rep@other.com should NOT match role vendor_acme — fallback to pattern would wrongly succeed")
} }
} }