ZDDC/zddc/internal/zddc/cascade.go
ZDDC 2f08418fb0 feat(zddc): Phase 2 — paths: walker, recursive cascade
Adds the recursive paths: schema and the cascade walker that threads
ancestor virtual contributions through to descendant levels.

Schema:
  paths:
    "*":              # literal-segment or "*" segment-wildcard key
      paths:          # recursive — each step matches one segment
        archive:
          paths:
            "*":
              paths:
                incoming:
                  title: "demo"

Each on-disk .zddc and the embedded defaults can declare paths:; the
walker collects every matching subtree and merges its contributions
into chain.Levels[depth] using mergeOverlay (per-field overlay with
on-disk most specific). The matched glob descends one segment at a
time; the value's own paths: becomes a new virtual source for deeper
matches.

Semantics:
  - matchGlob: literal key first (case-insensitive on segment),
    "*" wildcard fallback.
  - mergeOverlay: top wins per-field on scalars; maps merge key-by-
    key with top overriding; lists concat-dedupe; Paths replaces
    (recursive walker threads it through naturally).
  - inherit:false at any on-disk level drops accumulated ancestor
    virtual sources AND zeroes chain.Embedded — the operator owns
    every rule from that level outward.
  - Behaviour is bit-identical when no .zddc declares paths:; the
    walker reduces to the prior linear cascade.

Eight new tests cover the glob match table, ancestor-paths
contribution, on-disk-wins override, paths-absent bit-identical
behaviour, and inherit:false dropping ancestor paths: contributions.
All existing tests still pass.

Phase 3 next: populate defaults.zddc.yaml with the canonical
ZDDC convention via paths:, and replace apps.DefaultAppAt /
AppAvailableAt / AutoOwnCanonicalNames / VirtualOnlyCanonicalNames /
IsProjectRootFolder / IsArchivePartyFolder with cascade lookups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:55:12 -05:00

239 lines
7.6 KiB
Go

package zddc
import (
"os"
"path/filepath"
"strings"
"sync"
)
// PolicyChain represents a chain of .zddc files from root to leaf.
//
// Embedded sits BELOW Levels[0] (the on-disk root .zddc): it's the
// baked-in defaults that ship with the binary, used as a baseline
// when an on-disk .zddc doesn't specify a rule. Consumers that want
// the full effective view should consult Levels then fall back to
// Embedded for unresolved lookups.
//
// Inherit:false on any level in Levels (or at deeper levels of a
// future paths: walker) zeroes out Embedded for that policy chain —
// the operator has taken full responsibility for spelling out every
// rule from scratch.
type PolicyChain struct {
Levels []ZddcFile // ordered root (index 0) → leaf (last index)
HasAnyFile bool // true if at least one .zddc file exists in the chain
Embedded ZddcFile // baked-in defaults; zero ZddcFile{} if any level set inherit:false
}
// VisibleStart returns the lowest level index visible to evaluation at
// any level in [0, toIdx], honoring inherit:false fences. A level with
// `acl.inherit: false` is a fence: ancestors above it are invisible to
// descendants at-and-below the fence. The deepest fence in the prefix
// wins (nested fences are supported; the closer-to-leaf wins).
//
// In strict cascade mode, fences are ignored — returns 0 — because
// federal/AC-6 deployments require ancestor explicit-denies to be
// absolute, and the inherit directive would let a leaf widen access an
// ancestor refused.
//
// toIdx is clamped to len(chain.Levels)-1.
func (chain PolicyChain) VisibleStart(toIdx int, mode CascadeMode) int {
if mode == ModeStrict {
return 0
}
if toIdx >= len(chain.Levels) {
toIdx = len(chain.Levels) - 1
}
if toIdx < 0 {
return 0
}
for i := toIdx; i >= 0; i-- {
if !chain.Levels[i].ACL.InheritsAncestors() {
return i
}
}
return 0
}
// policyCache caches effective policies keyed by dirPath.
// Values are PolicyChain.
var policyCache sync.Map
// EffectivePolicy returns the ACL policy chain for dirPath by reading
// all .zddc files from fsRoot down to dirPath.
//
// Child directory rules take precedence over parent rules.
// A deny at any level cannot be overridden by a parent allow.
func EffectivePolicy(fsRoot, dirPath string) (PolicyChain, error) {
// Normalize: ensure fsRoot and dirPath use the same separator
fsRoot = filepath.Clean(fsRoot)
dirPath = filepath.Clean(dirPath)
// Build list of directories from root to dirPath (inclusive)
var dirs []string
if !strings.HasPrefix(dirPath, fsRoot) {
// dirPath must be under fsRoot; if not, treat it as an empty chain
return PolicyChain{}, nil
}
rel, err := filepath.Rel(fsRoot, dirPath)
if err != nil {
return PolicyChain{}, err
}
// Walk from root down: root, root/a, root/a/b, ...
dirs = append(dirs, fsRoot)
if rel != "." {
parts := strings.Split(rel, string(filepath.Separator))
current := fsRoot
for _, part := range parts {
current = filepath.Join(current, part)
dirs = append(dirs, current)
}
}
// Check cache for the most specific (deepest) cached entry we can reuse
cacheKey := dirPath
if cached, ok := policyCache.Load(cacheKey); ok {
return cached.(PolicyChain), nil
}
// Build policy chain: read each on-disk .zddc file.
onDisk := make([]ZddcFile, 0, len(dirs))
hasAny := false
for _, dir := range dirs {
zddcPath := filepath.Join(dir, ".zddc")
_, err := os.Stat(zddcPath)
if err == nil {
hasAny = true
parsed, perr := ParseFile(zddcPath)
if perr != nil {
onDisk = append(onDisk, ZddcFile{})
} else {
onDisk = append(onDisk, parsed)
}
} else {
onDisk = append(onDisk, ZddcFile{})
}
}
// Walk ancestor paths: trees alongside the on-disk chain. Each
// virtual source is a paths-map seeded by an ancestor's Paths
// (embedded or an on-disk level). At each segment we try to
// match the source's glob; on hit, the matched ZddcFile becomes
// a virtual contribution at THIS level, and its own Paths map
// becomes a new virtual source descending into deeper segments.
embedded := ZddcFile{}
if e, err := EmbeddedDefaults(); err == nil {
embedded = e
}
// segments[0] is the first child segment under fsRoot. dirs[0]
// is fsRoot itself, dirs[i+1] is the directory at segments[i].
var segments []string
if rel != "." {
segments = strings.Split(rel, string(filepath.Separator))
}
// Active virtual sources (paths-maps actively descending into the
// target). Seeded with the embedded defaults' Paths and the
// fsRoot/.zddc's Paths (both apply to fsRoot's children).
var virtualSources []map[string]ZddcFile
if embedded.Paths != nil {
virtualSources = append(virtualSources, embedded.Paths)
}
if onDisk[0].Paths != nil {
virtualSources = append(virtualSources, onDisk[0].Paths)
}
// inherit:false at any level along the way zeroes the embedded
// layer and drops accumulated ancestor contributions when first
// encountered. Track once.
embeddedActive := true
if onDisk[0].Inherit != nil && !*onDisk[0].Inherit {
embeddedActive = false
virtualSources = nil
if onDisk[0].Paths != nil {
// Re-seed only THIS level's paths; embedded.Paths drops.
virtualSources = append(virtualSources, onDisk[0].Paths)
}
}
// Assemble chain.Levels with virtual contributions merged in.
chain := PolicyChain{
Levels: make([]ZddcFile, len(dirs)),
HasAnyFile: hasAny,
}
chain.Levels[0] = onDisk[0] // root level has no ancestor virtual paths
for i, seg := range segments {
depth := i + 1
// Match each active virtual source against this segment.
var contributions []ZddcFile
var newSources []map[string]ZddcFile
for _, src := range virtualSources {
if match := matchGlob(src, seg); match != nil {
contributions = append(contributions, *match)
if match.Paths != nil {
newSources = append(newSources, match.Paths)
}
}
}
virtualSources = newSources
// Honor inherit:false on the on-disk level at this depth.
// Dropping ancestor sources also means dropping this level's
// inherited contributions; the level stands alone (plus its
// own Paths for descendants).
if onDisk[depth].Inherit != nil && !*onDisk[depth].Inherit {
embeddedActive = false
contributions = nil
virtualSources = nil
}
// Seed this level's own Paths as a virtual source for deeper
// segments. It applies BELOW this level, not at this level.
if onDisk[depth].Paths != nil {
virtualSources = append(virtualSources, onDisk[depth].Paths)
}
// Compose: ancestor contributions first (lowest specificity),
// then on-disk at this level (most specific) on top.
merged := ZddcFile{}
for _, c := range contributions {
merged = mergeOverlay(merged, c)
}
merged = mergeOverlay(merged, onDisk[depth])
chain.Levels[depth] = merged
}
// Layer in the embedded defaults as the bottom of the cascade
// (consulted as fallback by lookups that don't find a value in
// chain.Levels). The Paths-walking above has already threaded
// embedded.Paths through to the appropriate levels; this is the
// non-paths baseline for level-0-style lookups.
if embeddedActive {
chain.Embedded = embedded
for _, lvl := range onDisk {
if lvl.Inherit != nil && !*lvl.Inherit {
chain.Embedded = ZddcFile{}
break
}
}
}
policyCache.Store(cacheKey, chain)
return chain, nil
}
// InvalidateCache removes the cached policy for dirPath and all descendants.
func InvalidateCache(dirPath string) {
dirPath = filepath.Clean(dirPath)
policyCache.Range(func(key, _ any) bool {
k := key.(string)
if k == dirPath || strings.HasPrefix(k, dirPath+string(filepath.Separator)) {
policyCache.Delete(key)
}
return true
})
}