ZDDC/zddc/internal/zddc/cascade.go
ZDDC d84c1908f6 feat(zddc): Phase 1 — embedded defaults.zddc + inherit + show-defaults
First step of the .zddc-first-configuration rollout: pure plumbing
that makes the future move-everything-out-of-Go work mechanically
possible without changing any current behaviour.

New pieces:

1. zddc/internal/zddc/defaults.zddc.yaml — a real YAML file in the
   repo. Single source of truth for the baked-in baseline; intentionally
   minimal in Phase 1 (just title + empty acl) so existing deployments
   stay bit-identical until Phase 2 starts populating the schema.

2. //go:embed (defaults.go) bakes the bytes into the binary so
   shipped deployments don't need the file. Operators who want a
   starting point export with:

       zddc-server show-defaults > /var/lib/zddc/root/.zddc

3. PolicyChain gains an Embedded ZddcFile field. EffectivePolicy
   layers in the embedded defaults as a baseline below the on-disk
   chain. Consumers that want the full effective view consult both;
   existing consumers that only read chain.Levels keep working
   bit-identically (the new field is additive).

4. New top-level `inherit:` key on ZddcFile. Default true. Set
   `inherit: false` on any on-disk .zddc to zero out chain.Embedded
   — the operator owns every rule from that level outward. Useful at
   the on-disk root to fully reject the embedded defaults; useful at
   deeper levels for sandbox subtrees.

5. `zddc-server show-defaults` (also accepts --show-defaults) subcommand
   dumps the embedded bytes to stdout — same shape as --print-rego.
   No flag plumbing needed beyond the existing args walk.

6. Tests: parse-roundtrip on the embedded file, presence in chain by
   default, inherit:false drops it, explicit inherit:true is a no-op
   versus the default.

Phase 2 (next): add a `paths:` recursive map + `default_tool:` /
`auto_own:` / `virtual:` keys, populate defaults.zddc.yaml with the
canonical ZDDC convention, and migrate apps.DefaultAppAt /
AutoOwnCanonicalNames / VirtualOnlyCanonicalNames to cascade lookups.

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

154 lines
4.7 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 .zddc file
var chain PolicyChain
for _, dir := range dirs {
zddcPath := filepath.Join(dir, ".zddc")
// Check if .zddc file exists
_, err := os.Stat(zddcPath)
if err == nil {
// File exists
chain.HasAnyFile = true
parsed, err := ParseFile(zddcPath)
if err != nil {
// Parse error — append empty file but continue
chain.Levels = append(chain.Levels, ZddcFile{})
} else {
chain.Levels = append(chain.Levels, parsed)
}
} else if os.IsNotExist(err) {
// File doesn't exist
chain.Levels = append(chain.Levels, ZddcFile{})
} else {
// Other error (permission, etc.)
chain.Levels = append(chain.Levels, ZddcFile{})
}
}
// Layer in the embedded defaults as the bottom of the cascade
// (used as fallback by consumers that consult Embedded). If any
// on-disk level set top-level inherit:false, the embedded layer
// is dropped — the operator owns every rule.
if embedded, err := EmbeddedDefaults(); err == nil {
chain.Embedded = embedded
for _, lvl := range chain.Levels {
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
})
}