ZDDC/zddc/internal/zddc/roles.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

217 lines
7 KiB
Go

package zddc
import (
"sort"
"strings"
)
// VerbSet is a bitmask over the five permission verbs r, w, c, d, a.
// Construct via ParseVerbSet (tolerant of any letter order, ignores
// duplicates and whitespace, rejects unknown letters as a deny). The
// canonical string form sorts to "rwcda" — see VerbSet.String.
type VerbSet uint8
const (
VerbR VerbSet = 1 << iota // read file bytes / list directory
VerbW // overwrite existing / rename existing
VerbC // create new file or directory
VerbD // delete file
VerbA // modify ACL of this subtree
VerbAll = VerbR | VerbW | VerbC | VerbD | VerbA
// VerbsRWCD is the every-non-admin verb set — granted to a principal
// that holds read+write+create+delete but not admin authority.
VerbsRWCD = VerbR | VerbW | VerbC | VerbD
// VerbsRC is the WORM-mask survivor: read + create only. Drop boxes
// (doc controller filing into Issued/Received) and any other principal
// with cascade-derived broader rights end up here once the mask runs.
VerbsRC = VerbR | VerbC
)
// ParseVerbSet parses a verb-set string like "rwcd" or "cra". Empty
// string returns an explicit-deny (zero VerbSet). Any unknown letter
// returns ok=false; callers that round-trip operator-authored YAML
// should surface this as a parse error rather than silently dropping
// the entry.
func ParseVerbSet(s string) (VerbSet, bool) {
var v VerbSet
for _, r := range s {
switch r {
case 'r', 'R':
v |= VerbR
case 'w', 'W':
v |= VerbW
case 'c', 'C':
v |= VerbC
case 'd', 'D':
v |= VerbD
case 'a', 'A':
v |= VerbA
case ' ', '\t':
// tolerate whitespace
default:
return 0, false
}
}
return v, true
}
// String returns the canonical "rwcda" ordering with only the verbs
// present in the set. The empty set serializes to "" — round-trippable
// as the explicit-deny entry.
func (v VerbSet) String() string {
var b strings.Builder
if v&VerbR != 0 {
b.WriteByte('r')
}
if v&VerbW != 0 {
b.WriteByte('w')
}
if v&VerbC != 0 {
b.WriteByte('c')
}
if v&VerbD != 0 {
b.WriteByte('d')
}
if v&VerbA != 0 {
b.WriteByte('a')
}
return b.String()
}
// Has reports whether the set contains every verb in mask.
func (v VerbSet) Has(mask VerbSet) bool { return v&mask == mask }
// Union returns the verb-wise union.
func (v VerbSet) Union(o VerbSet) VerbSet { return v | o }
// Intersect returns the verb-wise intersection.
func (v VerbSet) Intersect(o VerbSet) VerbSet { return v & o }
// IsPrincipalRole reports whether a Permissions key is a role
// reference (no "@") rather than a direct email pattern. This is the
// disambiguation rule: any principal containing "@" is treated as an
// email pattern matched via MatchesPattern; everything else is a role
// name looked up via Roles maps in the cascade.
func IsPrincipalRole(principal string) bool {
return !strings.Contains(principal, "@")
}
// RoleMembers returns the effective member-pattern list for roleName
// as visible at chain.Levels[levelIdx] — the UNION of every level's
// definition in the visible chain, with a role.Reset=true level
// stopping the walk (its members plus anything deeper; ancestors
// above the reset excluded). The visible-chain lower bound is
// chain.VisibleStart(levelIdx) — an inherit:false fence at-or-below
// levelIdx hides definitions above it. Returns nil if no level in the
// visible chain defines the role.
//
// Levels are stored root (index 0) → leaf (last index), matching the
// EffectivePolicy convention.
func RoleMembers(chain PolicyChain, levelIdx int, roleName string) []string {
members, _ := lookupRoleMembers(chain, levelIdx, roleName)
return members
}
// MatchesPrincipal reports whether email satisfies the given Permissions
// key at chain.Levels[levelIdx].
//
// Resolution order:
//
// 1. Principals containing "@" are always email patterns; dispatch to
// MatchesPattern.
// 2. Principals without "@" are role-or-pattern. Look up the name in
// the cascade's roles, honoring fences. If a role definition is
// found in the visible chain, match the user against the role's
// members. If no role definition exists in the visible chain, fall
// back to MatchesPattern so bare wildcards like "*" still match.
func MatchesPrincipal(principal, email string, chain PolicyChain, levelIdx int) bool {
if !IsPrincipalRole(principal) {
return MatchesPattern(principal, email)
}
members, defined := lookupRoleMembers(chain, levelIdx, principal)
if !defined {
// Bare wildcards / unqualified strings still match via the
// email-pattern matcher when no role of that name exists.
return MatchesPattern(principal, email)
}
for _, m := range members {
if MatchesPattern(m, email) {
return true
}
}
return false
}
// lookupRoleMembers returns the member list and whether the role was
// defined anywhere in the visible chain. Distinguishes "role exists
// but is empty" (defined=true, empty members) from "role not defined"
// (defined=false), which the principal-fallback logic depends on. The
// visible-chain bound is determined by chain.VisibleStart(levelIdx).
//
// Members UNION across every level that defines the role. Walking
// deep→shallow, a level with role.Reset=true stops the walk: its
// members (plus anything deeper that already accumulated) are the
// final set; ancestor definitions above the reset are excluded.
func lookupRoleMembers(chain PolicyChain, levelIdx int, roleName string) ([]string, bool) {
if levelIdx < 0 || levelIdx >= len(chain.Levels) {
return nil, false
}
floor := chain.VisibleStart(levelIdx)
var members []string
seen := make(map[string]struct{})
defined := false
addAll := func(ms []string) {
for _, m := range ms {
if _, dup := seen[m]; dup {
continue
}
seen[m] = struct{}{}
members = append(members, m)
}
}
for i := levelIdx; i >= floor; i-- {
role, ok := chain.Levels[i].Roles[roleName]
if !ok {
continue
}
defined = true
addAll(role.Members)
if role.Reset {
return members, true // authoritative; ignore ancestors + embedded
}
}
// The embedded defaults sit below chain.Levels[0] in the cascade.
// Fold its role definitions in as the baseline (so a role declared
// only in defaults.zddc.yaml is "defined", and a deployment's
// on-disk redefinition unions on top). Skipped above only if a
// reset:true level already returned.
if role, ok := chain.Embedded.Roles[roleName]; ok {
defined = true
addAll(role.Members)
}
return members, defined
}
// MatchingPrincipals returns the keys of level.ACL.Permissions whose
// principal matches email at chain.Levels[levelIdx]. Output is sorted
// for stable iteration in tests and audit logs.
func MatchingPrincipals(chain PolicyChain, levelIdx int, email string) []string {
if levelIdx < 0 || levelIdx >= len(chain.Levels) {
return nil
}
level := chain.Levels[levelIdx]
if len(level.ACL.Permissions) == 0 {
return nil
}
var out []string
for principal := range level.ACL.Permissions {
if MatchesPrincipal(principal, email, chain, levelIdx) {
out = append(out, principal)
}
}
sort.Strings(out)
return out
}