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>
266 lines
9.6 KiB
Go
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
|
|
}
|