ZDDC/zddc/internal/zddc/roles.go
ZDDC 2ccd72fa35 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>
2026-05-07 10:59:20 -05:00

200 lines
6.6 KiB
Go

package zddc
import (
"sort"
"strings"
)
// VerbSet is a bitmask over the five permission verbs r, w, c, d, a.
// Construct via ParseVerbSet (tolerant of any letter order, ignores
// duplicates and whitespace, rejects unknown letters as a deny). The
// canonical string form sorts to "rwcda" — see VerbSet.String.
type VerbSet uint8
const (
VerbR VerbSet = 1 << iota // read file bytes / list directory
VerbW // overwrite existing / rename existing
VerbC // create new file or directory
VerbD // delete file
VerbA // modify ACL of this subtree
VerbAll = VerbR | VerbW | VerbC | VerbD | VerbA
// VerbsRWCD is the verb set the legacy acl.allow translation grants —
// every right except admin (which always required the admins: list).
VerbsRWCD = VerbR | VerbW | VerbC | VerbD
// VerbsRC is the WORM-mask survivor: read + create only. Drop boxes
// (doc controller filing into Issued/Received) and any other principal
// with cascade-derived broader rights end up here once the mask runs.
VerbsRC = VerbR | VerbC
)
// ParseVerbSet parses a verb-set string like "rwcd" or "cra". Empty
// string returns an explicit-deny (zero VerbSet). Any unknown letter
// returns ok=false; callers that round-trip operator-authored YAML
// should surface this as a parse error rather than silently dropping
// the entry.
func ParseVerbSet(s string) (VerbSet, bool) {
var v VerbSet
for _, r := range s {
switch r {
case 'r', 'R':
v |= VerbR
case 'w', 'W':
v |= VerbW
case 'c', 'C':
v |= VerbC
case 'd', 'D':
v |= VerbD
case 'a', 'A':
v |= VerbA
case ' ', '\t':
// tolerate whitespace
default:
return 0, false
}
}
return v, true
}
// String returns the canonical "rwcda" ordering with only the verbs
// present in the set. The empty set serializes to "" — round-trippable
// as the explicit-deny entry.
func (v VerbSet) String() string {
var b strings.Builder
if v&VerbR != 0 {
b.WriteByte('r')
}
if v&VerbW != 0 {
b.WriteByte('w')
}
if v&VerbC != 0 {
b.WriteByte('c')
}
if v&VerbD != 0 {
b.WriteByte('d')
}
if v&VerbA != 0 {
b.WriteByte('a')
}
return b.String()
}
// Has reports whether the set contains every verb in mask.
func (v VerbSet) Has(mask VerbSet) bool { return v&mask == mask }
// Union returns the verb-wise union.
func (v VerbSet) Union(o VerbSet) VerbSet { return v | o }
// Intersect returns the verb-wise intersection.
func (v VerbSet) Intersect(o VerbSet) VerbSet { return v & o }
// IsPrincipalRole reports whether a Permissions key is a role
// reference (no "@") rather than a direct email pattern. This is the
// disambiguation rule: any principal containing "@" is treated as an
// email pattern matched via MatchesPattern; everything else is a role
// name looked up via Roles maps in the cascade.
func IsPrincipalRole(principal string) bool {
return !strings.Contains(principal, "@")
}
// RoleMembers returns the member-pattern list for roleName as visible
// 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, mode CascadeMode) []string {
if levelIdx < 0 || levelIdx >= len(chain.Levels) {
return nil
}
floor := chain.VisibleStart(levelIdx, mode)
for i := levelIdx; i >= floor; i-- {
role, ok := chain.Levels[i].Roles[roleName]
if !ok {
continue
}
return role.Members
}
return nil
}
// MatchesPrincipal reports whether email satisfies the given Permissions
// 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, 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, mode)
if !defined {
// Legacy pattern compatibility — bare wildcards / unqualified
// strings continue to match via the email-pattern matcher.
return MatchesPattern(principal, email)
}
for _, m := range members {
if MatchesPattern(m, email) {
return true
}
}
return false
}
// 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. 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
}
floor := chain.VisibleStart(levelIdx, mode)
for i := levelIdx; i >= floor; i-- {
role, ok := chain.Levels[i].Roles[roleName]
if !ok {
continue
}
return role.Members, true
}
return nil, false
}
// 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. 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
}
level := chain.Levels[levelIdx]
if len(level.ACL.Permissions) == 0 {
return nil
}
var out []string
for principal := range level.ACL.Permissions {
if MatchesPrincipal(principal, email, chain, levelIdx, mode) {
out = append(out, principal)
}
}
sort.Strings(out)
return out
}