diff --git a/zddc/internal/zddc/acl.go b/zddc/internal/zddc/acl.go index ce40c92..2b73156 100644 --- a/zddc/internal/zddc/acl.go +++ b/zddc/internal/zddc/acl.go @@ -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 } diff --git a/zddc/internal/zddc/cascade.go b/zddc/internal/zddc/cascade.go index 081fcf2..d7623cd 100644 --- a/zddc/internal/zddc/cascade.go +++ b/zddc/internal/zddc/cascade.go @@ -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 diff --git a/zddc/internal/zddc/file.go b/zddc/internal/zddc/file.go index 3e8e8ec..1b9380a 100644 --- a/zddc/internal/zddc/file.go +++ b/zddc/internal/zddc/file.go @@ -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 diff --git a/zddc/internal/zddc/file_test.go b/zddc/internal/zddc/file_test.go index a15e620..3291244 100644 --- a/zddc/internal/zddc/file_test.go +++ b/zddc/internal/zddc/file_test.go @@ -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) + } + }) + } +} diff --git a/zddc/internal/zddc/inherit_test.go b/zddc/internal/zddc/inherit_test.go new file mode 100644 index 0000000..8b31f44 --- /dev/null +++ b/zddc/internal/zddc/inherit_test.go @@ -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) + } +} diff --git a/zddc/internal/zddc/roles.go b/zddc/internal/zddc/roles.go index b7c7f0e..c13f39b 100644 --- a/zddc/internal/zddc/roles.go +++ b/zddc/internal/zddc/roles.go @@ -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) } } diff --git a/zddc/internal/zddc/roles_test.go b/zddc/internal/zddc/roles_test.go index 0f36ff0..c9ec577 100644 --- a/zddc/internal/zddc/roles_test.go +++ b/zddc/internal/zddc/roles_test.go @@ -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") } }