Single audit pass that removes pre-release back-compat, consolidates the
admin-policy decider, and fixes the .zddc write path.
Field removal — acl.allow / acl.deny:
- Drop ACLRules.Allow / Deny struct fields and mergeLegacyACL().
- Remove walker / lookups / validate / decider branches that read them.
- Migrate every test fixture (YAML strings and ACLRules struct literals)
to acl.permissions: { principal → verb-set }.
- Rewrite both bundled Rego policies (access.rego, access_federal.rego)
to traverse level.acl.permissions; rewrite parity-test helpers.
- Update create-project form (profile page) to collect permissions
instead of allow/deny lists.
Admin decider consolidation:
- Delete zddc.CanEditZddc — strict-ancestor rule retired. Subtree admins
own their own .zddc; the policy decider's IsActiveAdmin short-circuit
is the single bypass site.
- Migrate tablehandler.ServeTable to AllowActionFromChainP — closes the
same Forbidden bug already fixed for /browse.html.
- Drop AccessView.EditableParentChoices and treeEntry.CanEdit (always
true after the retirement). Profile page renders AdminSubtrees
directly for both lists.
- Drop the excludeLeaf parameter from AdminLevelInChain /
IsAdminForChain — no production caller passed true.
Dead code removed:
- policy.AllowWriteFromChain (zero production callers, zero tests).
- zddc.AllowedWithChain (zero production callers; tests deleted).
ModeStrict retirement — federal posture is OPA-only:
- Delete cascade_mode.go / cascade_mode_test.go and the ModeStrict
branches in cascade.go and acl.go.
- Drop --cascade-mode flag, CascadeMode config field, and the
InternalDecider.Mode field.
- Drop the mode parameter from every cascade helper:
GrantedVerbsAtLevel, AllowedAction, EffectiveVerbs,
EffectiveVerbsRange, RoleMembers, MatchesPrincipal,
MatchingPrincipals, WormZoneGrant, PolicyChain.VisibleStart.
- Strip cascade_mode from /.profile/config and
/.profile/effective-policy responses.
- Refresh README / ARCHITECTURE.md to describe federal posture as
"deploy OPA with access_federal.rego" (NIST AC-6); the bundled Rego
is the parent-deny-is-absolute variant. The in-process Go evaluator
implements only the commercial cascade.
Legacy redirects + .admin.css fallback:
- Drop /<dir>/.zddc.html → ?file=.zddc redirect and its test.
- Drop ?zip=1 retired comment + legacy test (handled by the
.zip virtual-URL path; covered by TestServeSubtreeZip).
- Drop .admin.css fallback in profile_assets.go — only .profile.css now.
- Refresh stale "retired" / "back-compat" / "legacy" comment markers.
.zddc write path fix:
- Dispatcher: route only GET/HEAD on .zddc URLs to ServeZddcFile; carve
.zddc out of the dot-prefix guard so PUT/DELETE/POST reach
ServeFileAPI. Before this, .zddc writes 405'd at ServeZddcFile and
the YAML editor's save flow had no live path.
- ServeFileAPI.resolveTargetPath: same .zddc-leaf carve-out so the file
API accepts the path; intermediate dot dirs (.zddc.d/) stay reserved.
- Listing: compute Writable per-file with ActionAdmin for .zddc
(matches the file API's gate) instead of ActionWrite for everything.
- Virtual .zddc placeholder: compute Writable via the same
parentActiveAdmin || ActionAdmin path. Was always false before.
- browse YAML editor canSave: exempt virtual .zddc — the synthetic
body is designed to materialize on PUT.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
211 lines
7.4 KiB
Go
211 lines
7.4 KiB
Go
package zddc
|
|
|
|
import "strings"
|
|
|
|
// AllowedAtLevel is a thin shim over GrantedVerbsAtLevel for callers
|
|
// that have a single ZddcFile (no cascade chain) and only need the
|
|
// boolean read decision.
|
|
func AllowedAtLevel(level ZddcFile, email string) (decision bool, matched bool) {
|
|
chain := PolicyChain{Levels: []ZddcFile{level}, HasAnyFile: true}
|
|
v, m := GrantedVerbsAtLevel(chain, 0, email)
|
|
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.
|
|
func GrantedVerbsAtLevel(chain PolicyChain, levelIdx int, email string) (VerbSet, bool) {
|
|
if levelIdx < 0 || levelIdx >= len(chain.Levels) {
|
|
return 0, false
|
|
}
|
|
level := chain.Levels[levelIdx]
|
|
if len(level.ACL.Permissions) == 0 {
|
|
return 0, false
|
|
}
|
|
|
|
matched := false
|
|
deniedExplicit := false
|
|
var grant VerbSet
|
|
for principal, verbStr := range level.ACL.Permissions {
|
|
if !MatchesPrincipal(principal, email, chain, levelIdx) {
|
|
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
|
|
}
|
|
|
|
// AllowedAction evaluates a PolicyChain for a specific verb.
|
|
// Thin wrapper over EffectiveVerbs.
|
|
func AllowedAction(chain PolicyChain, email string, verb VerbSet) bool {
|
|
return EffectiveVerbs(chain, email).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) VerbSet {
|
|
v := EffectiveVerbsRange(chain, 0, len(chain.Levels), email)
|
|
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.
|
|
//
|
|
// 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) 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.
|
|
if fence := chain.VisibleStart(toIdx - 1); fence > fromIdx {
|
|
fromIdx = fence
|
|
}
|
|
for i := toIdx - 1; i >= fromIdx; i-- {
|
|
grant, matched := GrantedVerbsAtLevel(chain, i, email)
|
|
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
|
|
}
|