ZDDC/zddc/internal/zddc/acl.go
ZDDC f196205622 refactor(audit): pre-release cleanup pass
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>
2026-05-18 16:28:07 -05:00

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
}