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:
parent
821ed3ee19
commit
2ccd72fa35
7 changed files with 320 additions and 31 deletions
|
|
@ -7,7 +7,7 @@ import "strings"
|
|||
// should call GrantedVerbsAtLevel directly.
|
||||
func AllowedAtLevel(level ZddcFile, email string) (decision bool, matched bool) {
|
||||
chain := PolicyChain{Levels: []ZddcFile{level}, HasAnyFile: true}
|
||||
v, m := GrantedVerbsAtLevel(chain, 0, email)
|
||||
v, m := GrantedVerbsAtLevel(chain, 0, email, ModeDelegated)
|
||||
if !m {
|
||||
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
|
||||
//
|
||||
// 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
|
||||
// parse time) so this function works correctly on test-constructed
|
||||
// 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) {
|
||||
return 0, false
|
||||
}
|
||||
|
|
@ -40,7 +41,7 @@ func GrantedVerbsAtLevel(chain PolicyChain, levelIdx int, email string) (VerbSet
|
|||
deniedExplicit := false
|
||||
var grant VerbSet
|
||||
for principal, verbStr := range perms {
|
||||
if !MatchesPrincipal(principal, email, chain, levelIdx) {
|
||||
if !MatchesPrincipal(principal, email, chain, levelIdx, mode) {
|
||||
continue
|
||||
}
|
||||
matched = true
|
||||
|
|
@ -141,16 +142,22 @@ func EffectiveVerbsRange(chain PolicyChain, fromIdx, toIdx int, email string, mo
|
|||
// caller has another range to combine with).
|
||||
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 {
|
||||
for i := fromIdx; i < toIdx; i++ {
|
||||
grant, matched := GrantedVerbsAtLevel(chain, i, email)
|
||||
grant, matched := GrantedVerbsAtLevel(chain, i, email, mode)
|
||||
if matched && grant == 0 {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := toIdx - 1; i >= fromIdx; i-- {
|
||||
grant, matched := GrantedVerbsAtLevel(chain, i, email)
|
||||
grant, matched := GrantedVerbsAtLevel(chain, i, email, mode)
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,36 @@ type PolicyChain struct {
|
|||
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.
|
||||
// Values are PolicyChain.
|
||||
var policyCache sync.Map
|
||||
|
|
|
|||
|
|
@ -27,6 +27,26 @@ import (
|
|||
// (and so existing operator-authored .zddc files render unchanged in
|
||||
// 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
|
||||
// in the external-OPA input body (see internal/policy). The canonical
|
||||
// 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"`
|
||||
Deny []string `yaml:"deny,omitempty" json:"deny,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
|
||||
|
|
|
|||
|
|
@ -74,3 +74,63 @@ acl:
|
|||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
152
zddc/internal/zddc/inherit_test.go
Normal file
152
zddc/internal/zddc/inherit_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -100,17 +100,22 @@ func IsPrincipalRole(principal string) bool {
|
|||
}
|
||||
|
||||
// RoleMembers returns the member-pattern list for roleName as visible
|
||||
// at chain.Levels[levelIdx]. Lookup walks levelIdx → root and returns
|
||||
// the first definition found (closer-to-leaf wins). Returns nil if no
|
||||
// level in the visible chain defines the role.
|
||||
// at chain.Levels[levelIdx]. Lookup walks levelIdx → fence-or-root and
|
||||
// returns the first definition found (closer-to-leaf wins). The lower
|
||||
// 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
|
||||
// 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) {
|
||||
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]
|
||||
if !ok {
|
||||
continue
|
||||
|
|
@ -121,23 +126,24 @@ func RoleMembers(chain PolicyChain, levelIdx int, roleName string) []string {
|
|||
}
|
||||
|
||||
// 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:
|
||||
//
|
||||
// 1. Principals containing "@" are always email patterns; dispatch to
|
||||
// MatchesPattern.
|
||||
// 2. Principals without "@" are role-or-pattern. Look up the name in
|
||||
// the cascade's roles. If a role definition is found, match the
|
||||
// user against the role's members. If no role definition exists
|
||||
// anywhere in the cascade, fall back to MatchesPattern. The
|
||||
// fallback preserves legacy patterns like "*" or "*example.com"
|
||||
// that pre-date the roles feature.
|
||||
func MatchesPrincipal(principal, email string, chain PolicyChain, levelIdx int) bool {
|
||||
// the cascade's roles, honoring fences. If a role definition is
|
||||
// found in the visible chain, match the user against the role's
|
||||
// members. If no role definition exists in the visible chain, fall
|
||||
// back to MatchesPattern. The fallback preserves legacy patterns
|
||||
// like "*" or "*example.com" that pre-date the roles feature.
|
||||
func MatchesPrincipal(principal, email string, chain PolicyChain, levelIdx int, mode CascadeMode) bool {
|
||||
if !IsPrincipalRole(principal) {
|
||||
return MatchesPattern(principal, email)
|
||||
}
|
||||
members, defined := lookupRoleMembers(chain, levelIdx, principal)
|
||||
members, defined := lookupRoleMembers(chain, levelIdx, principal, mode)
|
||||
if !defined {
|
||||
// Legacy pattern compatibility — bare wildcards / unqualified
|
||||
// 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
|
||||
// defined anywhere in the visible chain. Distinguishes "role exists
|
||||
// but is empty" (defined=true, empty members) from "role not defined"
|
||||
// (defined=false), which the principal-fallback logic depends on.
|
||||
func lookupRoleMembers(chain PolicyChain, levelIdx int, roleName string) ([]string, bool) {
|
||||
// (defined=false), which the principal-fallback logic depends on. The
|
||||
// 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) {
|
||||
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]
|
||||
if !ok {
|
||||
continue
|
||||
|
|
@ -171,8 +179,9 @@ func lookupRoleMembers(chain PolicyChain, levelIdx int, roleName string) ([]stri
|
|||
|
||||
// MatchingPrincipals returns the keys of level.ACL.Permissions whose
|
||||
// principal matches email at chain.Levels[levelIdx]. Output is sorted
|
||||
// for stable iteration in tests and audit logs.
|
||||
func MatchingPrincipals(chain PolicyChain, levelIdx int, email string) []string {
|
||||
// for stable iteration in tests and audit logs. mode is forwarded to
|
||||
// MatchesPrincipal for fence-aware role resolution.
|
||||
func MatchingPrincipals(chain PolicyChain, levelIdx int, email string, mode CascadeMode) []string {
|
||||
if levelIdx < 0 || levelIdx >= len(chain.Levels) {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -182,7 +191,7 @@ func MatchingPrincipals(chain PolicyChain, levelIdx int, email string) []string
|
|||
}
|
||||
var out []string
|
||||
for principal := range level.ACL.Permissions {
|
||||
if MatchesPrincipal(principal, email, chain, levelIdx) {
|
||||
if MatchesPrincipal(principal, email, chain, levelIdx, mode) {
|
||||
out = append(out, principal)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,12 +78,12 @@ func TestRoleMembersClosestLeafWins(t *testing.T) {
|
|||
},
|
||||
HasAnyFile: true,
|
||||
}
|
||||
got := RoleMembers(chain, 1, "editors")
|
||||
got := RoleMembers(chain, 1, "editors", ModeDelegated)
|
||||
if len(got) != 1 || got[0] != "bob@example.com" {
|
||||
t.Errorf("leaf shadow failed: %v", got)
|
||||
}
|
||||
// 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" {
|
||||
t.Errorf("root visibility failed: %v", got)
|
||||
}
|
||||
|
|
@ -96,10 +96,10 @@ func TestMatchesPrincipalLegacyPatternFallback(t *testing.T) {
|
|||
Levels: []ZddcFile{{}},
|
||||
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")
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
|
@ -115,10 +115,10 @@ func TestMatchesPrincipalRoleNamePrefersRole(t *testing.T) {
|
|||
}},
|
||||
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")
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue