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

351 lines
11 KiB
Go

package zddc
import (
"path/filepath"
"strings"
)
// DefaultToolAt returns the cascade-resolved default tool name for
// the directory at dirPath. Empty when no cascade level (on-disk or
// virtual via Paths) has declared a DefaultTool.
//
// Lookup walks chain.Levels from leaf toward root, returning the
// first non-empty value. This implements the "parent applies to
// descendants unless overridden" cascade rule: a working/ folder's
// default_tool=browse propagates to working/alice/notes/ even when
// no .zddc declares browse at the deeper levels.
//
// Used by the URL dispatcher to route no-slash directory URLs.
// Replaces apps.DefaultAppAt once consumers are migrated (Phase 3b).
func DefaultToolAt(fsRoot, dirPath string) string {
chain, err := EffectivePolicy(fsRoot, dirPath)
if err != nil {
return ""
}
for i := len(chain.Levels) - 1; i >= 0; i-- {
if dt := chain.Levels[i].DefaultTool; dt != "" {
return dt
}
}
return chain.Embedded.DefaultTool
}
// DirToolAt returns the cascade-resolved tool name served at the
// directory's TRAILING-SLASH URL form. Walks chain.Levels leaf→root
// (then the embedded defaults), returning the first non-empty
// DirTool. Floors at "browse": an undeclared directory serves the
// file-tree navigator, which is the site-wide convention. So callers
// never need to special-case the empty result.
//
// This is the slash half of the slash/no-slash routing convention;
// DefaultToolAt is the no-slash half.
func DirToolAt(fsRoot, dirPath string) string {
chain, err := EffectivePolicy(fsRoot, dirPath)
if err != nil {
return "browse"
}
for i := len(chain.Levels) - 1; i >= 0; i-- {
if dt := chain.Levels[i].DirTool; dt != "" {
return dt
}
}
if dt := chain.Embedded.DirTool; dt != "" {
return dt
}
return "browse"
}
// AutoOwnAt reports whether mkdir at THIS specific directory should
// write an auto-owned .zddc. Leaf-only lookup — auto-own does NOT
// propagate to descendants (creating working/alice/notes/sub/ does
// not auto-own sub/; only the explicitly-declared per-user home is
// auto-owned).
func AutoOwnAt(fsRoot, dirPath string) bool {
chain, err := EffectivePolicy(fsRoot, dirPath)
if err != nil {
return false
}
leaf := leafLevel(chain)
if leaf.AutoOwn != nil {
return *leaf.AutoOwn
}
if v := chain.Embedded.AutoOwn; v != nil {
return *v
}
return false
}
// AutoOwnFencedAt reports whether the auto-own .zddc at this dir
// should be written with `inherit: false` (private to creator).
// Leaf-only, same semantic as AutoOwnAt.
func AutoOwnFencedAt(fsRoot, dirPath string) bool {
chain, err := EffectivePolicy(fsRoot, dirPath)
if err != nil {
return false
}
leaf := leafLevel(chain)
if leaf.AutoOwnFenced != nil {
return *leaf.AutoOwnFenced
}
if v := chain.Embedded.AutoOwnFenced; v != nil {
return *v
}
return false
}
// DropTargetAt reports whether THIS specific directory accepts
// drag-drop uploads from a client (e.g. browse's drop-zone overlay).
// Leaf-only — the property describes a specific path, not a subtree.
func DropTargetAt(fsRoot, dirPath string) bool {
chain, err := EffectivePolicy(fsRoot, dirPath)
if err != nil {
return false
}
leaf := leafLevel(chain)
if leaf.DropTarget != nil {
return *leaf.DropTarget
}
if v := chain.Embedded.DropTarget; v != nil {
return *v
}
return false
}
// VirtualAt reports whether THIS specific directory is declared as
// purely virtual (never materialise on disk). Leaf-only: the virtual
// property describes a particular path, not a subtree. A child of a
// virtual directory is not automatically virtual itself.
func VirtualAt(fsRoot, dirPath string) bool {
chain, err := EffectivePolicy(fsRoot, dirPath)
if err != nil {
return false
}
leaf := leafLevel(chain)
if leaf.Virtual != nil {
return *leaf.Virtual
}
if v := chain.Embedded.Virtual; v != nil {
return *v
}
return false
}
// IsDeclaredPath reports whether dirPath is mentioned in the
// cascade — either by an on-disk .zddc at that level OR by any
// ancestor's paths: tree (including the embedded defaults).
//
// A declared path is one the cascade has *something to say about*
// even if the directory doesn't exist on disk. Used by listing
// fallbacks to decide whether a missing directory should return an
// empty listing (treat as virtual) vs 404 (truly unknown).
func IsDeclaredPath(fsRoot, dirPath string) bool {
chain, err := EffectivePolicy(fsRoot, dirPath)
if err != nil {
return false
}
if len(chain.Levels) == 0 {
return false
}
leaf := leafLevel(chain)
// A non-empty merged level at the leaf means at least one
// contribution reached here — either an on-disk file, or an
// ancestor's paths: glob matched.
return !isZeroZddcFile(leaf)
}
// AvailableToolsAt returns the cascade-unioned list of tool names
// the server may auto-serve at this directory. Built by walking
// chain.Levels from leaf to root, then the embedded defaults, and
// concat-deduping each level's AvailableTools.
//
// Empty result means no auto-routed tools are allowed at this path.
// The dispatcher gates auto-route fallbacks against this list;
// explicit static <dir>/tool.html files bypass the gate.
func AvailableToolsAt(fsRoot, dirPath string) []string {
chain, err := EffectivePolicy(fsRoot, dirPath)
if err != nil {
return nil
}
seen := make(map[string]struct{})
var out []string
add := func(list []string) {
for _, t := range list {
if _, ok := seen[t]; ok {
continue
}
seen[t] = struct{}{}
out = append(out, t)
}
}
for i := len(chain.Levels) - 1; i >= 0; i-- {
add(chain.Levels[i].AvailableTools)
}
add(chain.Embedded.AvailableTools)
return out
}
// IsToolAvailableAt reports whether tool is in the cascade's
// available-tools union at dirPath. The dispatcher gates apps-
// subsystem auto-route on this; explicit on-disk <tool>.html files
// always serve regardless.
func IsToolAvailableAt(fsRoot, dirPath, tool string) bool {
for _, t := range AvailableToolsAt(fsRoot, dirPath) {
if t == tool {
return true
}
}
return false
}
// ChildrenDeclaredAt returns the set of child directory names that
// the cascade declares should exist under dirPath. Includes
// wildcard "*" specs (caller decides how to expose those) and
// literal names. Used by fs.ListDirectory to inject virtual
// canonical-folder entries at a project root.
//
// Returns the literal names; "*" wildcards are NOT included
// (callers can't synthesise a meaningful name for a wildcard).
func ChildrenDeclaredAt(fsRoot, dirPath string) []string {
chain, err := EffectivePolicy(fsRoot, dirPath)
if err != nil {
return nil
}
leaf := leafLevel(chain)
if len(leaf.Paths) == 0 {
return nil
}
var out []string
for k := range leaf.Paths {
if k == "*" {
continue
}
out = append(out, k)
}
return out
}
// CanonicalFolderAt returns the canonical-folder name for THIS specific
// directory — one of "archive", "working", "staging", "reviewing",
// "incoming", "received", "issued", "mdl" — or "" if the path is not
// at a canonical-folder slot.
//
// Detection is structural against the canonical project layout declared
// in defaults.zddc.yaml: top-level <project>/{archive,working,staging,
// reviewing} and the second-level archive/<party>/{mdl,incoming,
// received,issued}. Operators don't rename these slots (the cascade
// keys them by literal name); a custom layout that does is on its own.
//
// Used by the browse SPA to scope-gate context-menu actions (Accept,
// Stage/Unstage, Create Transmittal folder) without re-implementing the
// cascade client-side. Surfaced via the X-ZDDC-Canonical-Folder header.
func CanonicalFolderAt(fsRoot, dirPath string) string {
segs := resolvePathSegments(fsRoot, dirPath)
// <project>/<folder>
if len(segs) == 2 {
switch segs[1] {
case "archive", "working", "staging", "reviewing":
return segs[1]
}
return ""
}
// <project>/archive/<party>/<folder>
if len(segs) == 4 && segs[1] == "archive" {
switch segs[3] {
case "incoming", "received", "issued", "mdl":
return segs[3]
}
}
return ""
}
// OnPlanReviewAt returns the cascade-resolved Plan Review configuration
// for dirPath, or nil if no level (on-disk, virtual via Paths, or
// embedded) declares one. Walks chain.Levels from leaf toward root,
// returning the first non-nil OnPlanReview. The block has to be present
// somewhere in the ancestry for the "Plan Review" menu item to surface
// in the browse client and for the composite endpoint to know where to
// scaffold workflow folders.
func OnPlanReviewAt(fsRoot, dirPath string) *OnPlanReviewConfig {
chain, err := EffectivePolicy(fsRoot, dirPath)
if err != nil {
return nil
}
for i := len(chain.Levels) - 1; i >= 0; i-- {
if cfg := chain.Levels[i].OnPlanReview; cfg != nil {
return cfg
}
}
return chain.Embedded.OnPlanReview
}
// leafLevel returns the deepest (most-specific) ZddcFile in chain.
// Caller's responsibility to check len(chain.Levels) > 0 — but
// returns ZddcFile{} on empty for ergonomic chaining.
func leafLevel(chain PolicyChain) ZddcFile {
if len(chain.Levels) == 0 {
return ZddcFile{}
}
return chain.Levels[len(chain.Levels)-1]
}
// isZeroZddcFile reports whether zf carries no meaningful content.
// Used by IsDeclaredPath to distinguish "ancestor paths: matched
// and stamped something here" from "no contribution at all".
func isZeroZddcFile(zf ZddcFile) bool {
if zf.Title != "" {
return false
}
if zf.DefaultTool != "" || zf.DirTool != "" {
return false
}
if zf.AutoOwn != nil || zf.AutoOwnFenced != nil || zf.Virtual != nil ||
zf.DropTarget != nil || zf.Inherit != nil {
return false
}
if zf.ReceivedPath != "" || zf.PlannedReviewDate != "" ||
zf.PlannedResponseDate != "" || zf.OnPlanReview != nil {
return false
}
if len(zf.AvailableTools) > 0 {
return false
}
if zf.AppsPubKey != "" || zf.CreatedBy != "" {
return false
}
if zf.Worm != nil { // non-nil even when empty — marks a WORM zone
return false
}
if len(zf.Admins) > 0 {
return false
}
if len(zf.ACL.Permissions) > 0 {
return false
}
if zf.ACL.Inherit != nil {
return false
}
if len(zf.Apps) > 0 || len(zf.Tables) > 0 || len(zf.Display) > 0 || len(zf.Paths) > 0 {
return false
}
if len(zf.Roles) > 0 {
return false
}
return true
}
// resolvePathSegments turns dirPath (absolute, under fsRoot) into a
// slice of segments relative to fsRoot. Used by helpers that walk
// the cascade by segment. Returns nil for dirPath == fsRoot or for
// any path outside fsRoot.
func resolvePathSegments(fsRoot, dirPath string) []string {
fsRoot = filepath.Clean(fsRoot)
dirPath = filepath.Clean(dirPath)
if dirPath == fsRoot {
return nil
}
rel, err := filepath.Rel(fsRoot, dirPath)
if err != nil || strings.HasPrefix(rel, "..") {
return nil
}
return strings.Split(rel, string(filepath.Separator))
}