ZDDC/zddc/internal/zddc/acl.go
ZDDC e44ccc3500 feat(zddc-server): delegated subtree admins + built-in .zddc editor
Generalize the admin model from "single root super-admin" to a
delegated chain: a `<dir>/.zddc/admins` list grants admin authority
for that subtree, with a strict-ancestor rule preventing
self-elevation (you cannot edit the .zddc that grants your own
authority — only files strictly below it).

Add a guided server-rendered editor at /.admin/zddc/edit?path=<dir>
so subtree admins can manage their fiefdoms without filesystem
access. JSON API at /.admin/zddc covers GET (file + effective chain
+ can_edit), POST (atomic write + cache invalidation), DELETE,
plus a /tree endpoint listing every .zddc visible to the caller.
Optional theming via <root>/.admin.css.

Validation: glob syntax check, root-self-demotion rejection,
reserved-prefix path guard, YAML round-trip sanity. Writes are
atomic (temp file + fsync + rename) and invalidate the policy
cache.

Also includes the prior in-flight `Title` field on ProjectInfo
so per-project .zddc titles surface on the landing-page picker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 12:52:06 -05:00

124 lines
4.3 KiB
Go

package zddc
import "strings"
// AllowedAtLevel checks whether email is explicitly allowed or denied by a single
// .zddc level. Returns (decision, matched):
// - (false, true) — email matched a deny pattern → deny
// - (true, true) — email matched an allow pattern → allow
// - (false, false) — no match in this level → keep walking up
func AllowedAtLevel(level ZddcFile, email string) (decision bool, matched bool) {
// deny checked first
for _, pattern := range level.ACL.Deny {
if MatchesPattern(pattern, email) {
return false, true
}
}
for _, pattern := range level.ACL.Allow {
if MatchesPattern(pattern, email) {
return true, true
}
}
return false, false
}
// AllowedWithChain evaluates a PolicyChain bottom-up (deepest level first).
// First explicit match (allow or deny) wins.
// If no level matches and HasAnyFile is false → allow (no rules = public).
// If no level matches and HasAnyFile is true → deny (user not on any list).
func AllowedWithChain(chain PolicyChain, email string) bool {
for i := len(chain.Levels) - 1; i >= 0; i-- {
decision, matched := AllowedAtLevel(chain.Levels[i], email)
if matched {
return decision
}
}
return !chain.HasAnyFile
}
// 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
}