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>
124 lines
4.3 KiB
Go
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
|
|
}
|