ZDDC/zddc/internal/zddc/acl.go
ZDDC dc7bf8ab04 docs(zddc): tighten inherit/strict-mode docstrings + AllowedAtLevel deprecation
Address two follow-ups from the security review of feat/zddc-inherit-directive:

1. file.go's Inherit docstring previously claimed "the internal decider
   treats it as inherit:true and emits a warning at evaluation time" —
   the decider does the first part but the warning was never wired up.
   Strike the over-promise; point operators at the cascade tracer
   (`/.profile/effective-policy`) which surfaces both `cascade_mode`
   and `chain.visible_start` so a fenced configuration that's being
   ignored under strict mode is visible.

2. AllowedAtLevel hardcodes ModeDelegated. Safe today (1-level
   synthetic chain, no ancestors) but a footgun if anyone migrates
   the shim to a real PolicyChain later. Add a `// Deprecated:`
   marker pointing at GrantedVerbsAtLevel for fence-aware paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:10:31 -05:00

266 lines
9.6 KiB
Go

package zddc
import "strings"
// AllowedAtLevel is a thin shim over GrantedVerbsAtLevel preserved for
// callers that only need the legacy boolean read decision on a single
// ZddcFile (no cascade chain).
//
// Hardcodes ModeDelegated — safe because the synthetic chain has only
// one level and no ancestors to fence — but callers that operate on a
// real PolicyChain must call GrantedVerbsAtLevel directly with the
// active mode.
//
// Deprecated: prefer GrantedVerbsAtLevel for any code path that may
// later need fence-aware or strict-mode evaluation.
func AllowedAtLevel(level ZddcFile, email string) (decision bool, matched bool) {
chain := PolicyChain{Levels: []ZddcFile{level}, HasAnyFile: true}
v, m := GrantedVerbsAtLevel(chain, 0, email, ModeDelegated)
if !m {
return false, false
}
return v.Has(VerbR), true
}
// GrantedVerbsAtLevel computes the verb set granted to email at
// chain.Levels[levelIdx]. Returns (set, matched):
// - matched=false → no entry in this level matches the user; cascade walks on
// - matched=true, set={} → an entry matched with the empty verb set; explicit deny
// - matched=true, set!={} → union of verb sets from every matching entry
//
// Role lookups for principal keys without "@" use RoleMembers, which
// 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, mode CascadeMode) (VerbSet, bool) {
if levelIdx < 0 || levelIdx >= len(chain.Levels) {
return 0, false
}
level := chain.Levels[levelIdx]
perms := effectivePermissions(level.ACL)
if len(perms) == 0 {
return 0, false
}
matched := false
deniedExplicit := false
var grant VerbSet
for principal, verbStr := range perms {
if !MatchesPrincipal(principal, email, chain, levelIdx, mode) {
continue
}
matched = true
v, _ := ParseVerbSet(verbStr) // unknown letters silently dropped
if verbStr == "" {
deniedExplicit = true
continue
}
grant = grant.Union(v)
}
if !matched {
return 0, false
}
if deniedExplicit {
// Empty-set match wins over any grant entries at the same level —
// explicit deny is always more specific than a permissive role
// membership at the same scope.
return 0, true
}
return grant, true
}
// effectivePermissions returns the union of acl.permissions and the
// legacy acl.allow / acl.deny fields, with permissions winning on
// collision. Returns nil if all three are empty. Does not mutate rules.
func effectivePermissions(rules ACLRules) map[string]string {
if len(rules.Permissions) == 0 && len(rules.Allow) == 0 && len(rules.Deny) == 0 {
return nil
}
out := make(map[string]string, len(rules.Permissions)+len(rules.Allow)+len(rules.Deny))
for _, pat := range rules.Allow {
out[pat] = "rwcd"
}
for _, pat := range rules.Deny {
out[pat] = ""
}
for k, v := range rules.Permissions {
out[k] = v
}
return out
}
// AllowedWithChain evaluates a PolicyChain leaf→root (deepest level first)
// for the read action. Preserved for legacy callers and existing read paths
// that haven't migrated to AllowedAction yet.
func AllowedWithChain(chain PolicyChain, email string) bool {
return AllowedAction(chain, email, VerbR, ModeDelegated)
}
// AllowedAction evaluates a PolicyChain for a specific verb and cascade mode.
// Thin wrapper around EffectiveVerbs that surfaces the boolean answer.
func AllowedAction(chain PolicyChain, email string, verb VerbSet, mode CascadeMode) bool {
return EffectiveVerbs(chain, email, mode).Has(verb)
}
// EffectiveVerbs computes the verb set granted to email by the cascade.
// Walks the full chain and applies the default-allow rule (no .zddc
// anywhere → public access).
func EffectiveVerbs(chain PolicyChain, email string, mode CascadeMode) VerbSet {
v := EffectiveVerbsRange(chain, 0, len(chain.Levels), email, mode)
if v == 0 && !chain.HasAnyFile {
// Public-tree default: empty chain with no .zddc files anywhere
// → grant everything. EffectiveVerbsRange returns 0 in this
// case because it has no opinion on default semantics outside
// a sub-range walk; the full-chain wrapper applies the rule.
return VerbAll
}
return v
}
// EffectiveVerbsRange computes the verb set granted by walking only
// chain.Levels[fromIdx:toIdx] for matching permission entries. Role
// definitions are still looked up over the FULL chain via
// GrantedVerbsAtLevel → MatchesPrincipal → lookupRoleMembers, so an
// ancestor's role definition remains visible to a sub-range walk.
//
// Used by the WORM split: above-the-WORM-folder and at-or-below-the-
// WORM-folder are evaluated as separate ranges, then their grants are
// masked and unioned.
//
// Cascade mode controls whether ancestor explicit-denies are absolute
// (Strict) or can be overridden by a leaf grant (Delegated). The
// strict-mode pass is restricted to the same range — splitting the
// chain implies splitting the strict-mode walk too.
//
// This function does NOT consult the admins:/IsAdmin escape hatch and
// does NOT apply the Issued/Received WORM mask.
func EffectiveVerbsRange(chain PolicyChain, fromIdx, toIdx int, email string, mode CascadeMode) VerbSet {
if fromIdx < 0 {
fromIdx = 0
}
if toIdx > len(chain.Levels) {
toIdx = len(chain.Levels)
}
if fromIdx >= toIdx {
// Empty range — no levels to consult. Caller is responsible
// for the default-deny semantics in this case (typically the
// 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, mode)
if matched && grant == 0 {
return 0
}
}
}
for i := toIdx - 1; i >= fromIdx; i-- {
grant, matched := GrantedVerbsAtLevel(chain, i, email, mode)
if !matched {
continue
}
return grant
}
// No match in range. The "no .zddc anywhere → public" default is
// applied by the EffectiveVerbs wrapper, not here, because callers
// using sub-ranges (e.g. WORM split) want a sub-range with no match
// to contribute nothing rather than implicitly granting everything.
return 0
}
// MatchesPattern checks if email matches a glob pattern.
//
// The pattern may use * as a wildcard within the local part or domain part,
// but * does not cross the @ boundary. Examples:
// - "*@mycompany.com" matches any user at mycompany.com
// - "alice@*" matches alice at any domain
// - "alice@example.com" matches exactly
// - "*" matches any non-empty email (the @ boundary rule means * must stay in one segment)
//
// Exported so handlers can reuse it — for example, to verify that the
// writer of a root .zddc remains in the Admins list after the edit, the
// editor's POST handler calls MatchesPattern directly rather than going
// through AllowedAtLevel/IsAdmin/etc.
func MatchesPattern(pattern, email string) bool {
// Exact match (fast path)
if pattern == email {
return true
}
// Wildcard-free check already handled above; split on @
patternParts := strings.SplitN(pattern, "@", 2)
emailParts := strings.SplitN(email, "@", 2)
if len(patternParts) == 2 && len(emailParts) == 2 {
// Both have @: match local and domain separately
return globMatch(patternParts[0], emailParts[0]) &&
globMatch(patternParts[1], emailParts[1])
}
if len(patternParts) == 1 {
// Pattern has no @ — match against the full email string
return globMatch(pattern, email)
}
return false
}
// globMatch performs simple glob matching where * matches any sequence of chars.
func globMatch(pattern, s string) bool {
// Two-pointer approach: handle multiple * segments
if pattern == "*" {
return true
}
if !strings.Contains(pattern, "*") {
return pattern == s
}
// Split pattern on * and match segments in order
parts := strings.Split(pattern, "*")
remaining := s
for i, part := range parts {
if part == "" {
continue
}
idx := strings.Index(remaining, part)
if idx < 0 {
return false
}
// First segment must match at start
if i == 0 && idx != 0 {
return false
}
remaining = remaining[idx+len(part):]
}
// Last segment must match at end (no trailing *)
if parts[len(parts)-1] != "" {
// The remaining string must be empty because the last part already consumed it
// Actually we need to check: if the last part is non-empty, remaining must be empty
// because we consumed up through that part above. If there's a trailing *, remaining
// can be anything.
// Re-check: split("a*b", "*") → ["a", "b"]. After matching "a" at start and "b"
// somewhere after, remaining should be "" if "b" is last segment.
// The loop above leaves remaining = s[after last part matched]. If last part is
// non-empty and pattern doesn't end with *, remaining must be "".
// Actually, the last segment check needs to verify that remaining is empty after
// the last part was matched. If there's a trailing *, remaining can be anything.
// The last part in parts is what remains after the last *. If it's non-empty,
// we need remaining to be empty (pattern didn't end with *).
// Since we're processing left-to-right and the last part must match at the end,
// remaining must be empty after consuming the last part.
if !strings.HasSuffix(pattern, "*") && remaining != "" {
return false
}
}
return true
}