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.
|
// 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
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
|
// 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue