EffectivePolicy now reads, at every directory in the walk, an optional <dir>/.zddc.zip policy bundle: its members are loaded into a PolicyTree, Assemble()d into a nested ZddcFile, and merged UNDER the dir's on-disk .zddc (most-specific human edit wins). Because Assemble produces an ordinary paths:-bearing ZddcFile, the existing walker threads the bundle's deeper members to descendants and honors inherit:false with zero new cascade logic — the bundle is just another per-level policy source. So a .zddc.zip dropped at ANY directory mounts a policy subtree there; combined with inherit:false + acl.inherit:false in its root member it's a self-contained island that ignores the site defaults (do-something-completely-different). Member paths use "*" wildcards, resolved by the same literal-first matching as paths:. A tool-HTML-only bundle (no .zddc members) contributes no policy. Test: a bundle at /Proj/special grants only *@vendor.com (rwcd at the mount, r at "*" descendants) and, fenced, blocks the embedded project_team grant that still applies outside the island. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
488 lines
17 KiB
Go
488 lines
17 KiB
Go
package zddc
|
|
|
|
import (
|
|
"archive/zip"
|
|
"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).
|
|
//
|
|
// toIdx is clamped to len(chain.Levels)-1.
|
|
func (chain PolicyChain) VisibleStart(toIdx int) int {
|
|
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
|
|
}
|
|
|
|
// EffectiveHistory reports whether edit-history versioning is enabled
|
|
// for writes at this chain's directory. Unlike DropTarget (leaf-only),
|
|
// history is a subtree behavior: the closest-to-leaf explicit setting
|
|
// wins and applies to all descendants. It deliberately IGNORES
|
|
// inherit:false ACL fences — versioning is a write behavior, not a
|
|
// permission, so a fenced per-user home under a history-enabled
|
|
// working/ still records history. Falls back to the embedded defaults.
|
|
func (chain PolicyChain) EffectiveHistory() bool {
|
|
for i := len(chain.Levels) - 1; i >= 0; i-- {
|
|
if v := chain.Levels[i].History; v != nil {
|
|
return *v
|
|
}
|
|
}
|
|
if v := chain.Embedded.History; v != nil {
|
|
return *v
|
|
}
|
|
return false
|
|
}
|
|
|
|
// EffectiveHistoryGlobs returns the basename globs selecting which files
|
|
// get text edit-history (deepest non-empty wins, then embedded defaults,
|
|
// then the built-in default ["*.md"]). Independent of EffectiveHistory:
|
|
// this says WHICH file types qualify; the bool gates whether snapshots are
|
|
// actually recorded.
|
|
func (chain PolicyChain) EffectiveHistoryGlobs() []string {
|
|
for i := len(chain.Levels) - 1; i >= 0; i-- {
|
|
if g := chain.Levels[i].HistoryGlobs; len(g) > 0 {
|
|
return g
|
|
}
|
|
}
|
|
if g := chain.Embedded.HistoryGlobs; len(g) > 0 {
|
|
return g
|
|
}
|
|
return []string{"*.md"}
|
|
}
|
|
|
|
// 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 level's on-disk policy. A level's
|
|
// contribution is an optional .zddc.zip policy bundle mounted here (a whole
|
|
// subtree: its own-level member at this level, its deeper members threaded
|
|
// to descendants via Paths) with the plain <dir>/.zddc overlaid on top
|
|
// (most-specific human edit wins). Either, both, or neither may be present.
|
|
onDisk := make([]ZddcFile, 0, len(dirs))
|
|
hasAny := false
|
|
for _, dir := range dirs {
|
|
level := ZddcFile{}
|
|
if zipZf, ok := zipPolicyAt(dir); ok {
|
|
hasAny = true
|
|
level = zipZf
|
|
}
|
|
zddcPath := filepath.Join(dir, ".zddc")
|
|
if _, err := os.Stat(zddcPath); err == nil {
|
|
hasAny = true
|
|
if parsed, perr := ParseFile(zddcPath); perr == nil {
|
|
level = mergeOverlay(level, parsed)
|
|
}
|
|
}
|
|
onDisk = append(onDisk, level)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// zipPolicyAt loads an operator policy bundle at <dir>/.zddc.zip and assembles
|
|
// it into a single nested ZddcFile (its own-level content + Paths threading its
|
|
// deeper members to descendants), or (_, false) when the bundle is absent,
|
|
// unreadable, or carries no .zddc members (e.g. a tool-HTML-only bundle — those
|
|
// are ignored for policy). Mounting the bundle at dir contributes a policy
|
|
// subtree there; inherit:false in its resolved .zddc makes that subtree a
|
|
// self-contained island. Member paths use "*" for the any-segment wildcard,
|
|
// resolved by the same literal-first matching as paths:.
|
|
func zipPolicyAt(dir string) (ZddcFile, bool) {
|
|
zipPath := filepath.Join(dir, ".zddc.zip")
|
|
if fi, err := os.Stat(zipPath); err != nil || fi.IsDir() {
|
|
return ZddcFile{}, false
|
|
}
|
|
zr, err := zip.OpenReader(zipPath)
|
|
if err != nil {
|
|
return ZddcFile{}, false
|
|
}
|
|
defer zr.Close()
|
|
tree, err := LoadPolicyTreeFromFS(zr, ".")
|
|
if err != nil || len(tree) == 0 {
|
|
return ZddcFile{}, false
|
|
}
|
|
return tree.Assemble(), true
|
|
}
|
|
|
|
// EffectiveFieldCodes returns the merged field-code vocabulary
|
|
// visible at the leaf of this chain. Walks root → leaf, applying
|
|
// map-merge per top-level key (a leaf entry for the same code
|
|
// replaces the root entry, mirroring mergeOverlay).
|
|
//
|
|
// Embedded defaults are layered in below the on-disk root unless
|
|
// inherit:false on any level dropped them (chain.Embedded is zeroed
|
|
// in that case, so reading it as a baseline is safe either way).
|
|
func (chain PolicyChain) EffectiveFieldCodes() map[string]FieldCode {
|
|
out := map[string]FieldCode{}
|
|
for k, v := range chain.Embedded.FieldCodes {
|
|
out[k] = v
|
|
}
|
|
for _, lvl := range chain.Levels {
|
|
for k, v := range lvl.FieldCodes {
|
|
out[k] = v
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// EffectiveRecordRule returns the merged RecordRule for files whose
|
|
// basename matches a pattern in any level's Records map. Walks root
|
|
// → leaf, mergeRecordRule-combining successive matches so a
|
|
// per-folder .zddc can refine an ancestor's rule (add a lock, set a
|
|
// default) without restating everything.
|
|
//
|
|
// pattern is the most-specific pattern that matched (deepest level's
|
|
// chosen key); rule is the merged result; ok is false when no level
|
|
// declared a matching pattern.
|
|
//
|
|
// Matching at each level prefers literal-key over glob; see
|
|
// matchRecordRule.
|
|
func (chain PolicyChain) EffectiveRecordRule(basename string) (string, RecordRule, bool) {
|
|
merged := RecordRule{}
|
|
any := false
|
|
pattern := ""
|
|
consider := func(rules map[string]RecordRule) {
|
|
if pat, rule, hit := matchRecordRule(rules, basename); hit {
|
|
merged = mergeRecordRule(merged, rule)
|
|
pattern = pat
|
|
any = true
|
|
}
|
|
}
|
|
consider(chain.Embedded.Records)
|
|
for _, lvl := range chain.Levels {
|
|
consider(lvl.Records)
|
|
}
|
|
if !any {
|
|
return "", RecordRule{}, false
|
|
}
|
|
return pattern, merged, true
|
|
}
|
|
|
|
// SourceEntry names one cascade contribution to an EffectiveZddc
|
|
// composition. Level -1 is the embedded defaults baseline (chain.
|
|
// Embedded); levels 0+ index into chain.Levels (root→leaf). Contributed
|
|
// lists the top-level ZddcFile field names this level supplied a non-
|
|
// zero value for — used by inspection clients to answer "where does
|
|
// this value come from?" without re-walking the cascade.
|
|
type SourceEntry struct {
|
|
Level int `json:"level"`
|
|
Contributed []string `json:"contributed,omitempty"`
|
|
}
|
|
|
|
// EffectiveZddc composes the cascade into a single ZddcFile by walking
|
|
// chain.Embedded then chain.Levels[VisibleStart..] through mergeOverlay,
|
|
// and folding the cross-level Roles union (via lookupRoleMembers) into
|
|
// merged.Roles so the result reflects the same role membership the
|
|
// runtime ACL evaluator sees.
|
|
//
|
|
// Returned alongside is a per-source list of which top-level fields
|
|
// each contributing level declared. Caller maps SourceEntry.Level to a
|
|
// URL (-1 = embedded baseline; 0..len(chain.Levels)-1 = dirs along the
|
|
// walk from fsRoot to the requested directory).
|
|
//
|
|
// Returns the zero ZddcFile + nil sources when the chain is empty.
|
|
// Used by the ?effective=1 query on /.zddc — distinct from the .zddc
|
|
// file itself, which serves only what's defined at the leaf level.
|
|
func EffectiveZddc(chain PolicyChain) (ZddcFile, []SourceEntry) {
|
|
if len(chain.Levels) == 0 {
|
|
return ZddcFile{}, nil
|
|
}
|
|
sources := make([]SourceEntry, 0, len(chain.Levels)+1)
|
|
var merged ZddcFile
|
|
|
|
// Embedded baseline (skipped when an inherit:false fence dropped
|
|
// it; cascade.go zeroes chain.Embedded in that case).
|
|
if c := nonZeroZddcFields(chain.Embedded); len(c) > 0 {
|
|
merged = mergeOverlay(merged, chain.Embedded)
|
|
sources = append(sources, SourceEntry{Level: -1, Contributed: c})
|
|
}
|
|
|
|
leafIdx := len(chain.Levels) - 1
|
|
floor := chain.VisibleStart(leafIdx)
|
|
for i := floor; i <= leafIdx; i++ {
|
|
lvl := chain.Levels[i]
|
|
if c := nonZeroZddcFields(lvl); len(c) > 0 {
|
|
merged = mergeOverlay(merged, lvl)
|
|
sources = append(sources, SourceEntry{Level: i, Contributed: c})
|
|
}
|
|
}
|
|
|
|
// Roles: mergeOverlay does per-level name-keyed replacement, but
|
|
// the runtime evaluator unions members across levels via
|
|
// lookupRoleMembers (handling reset:true and the embedded
|
|
// baseline). Re-resolve every role name reachable in the visible
|
|
// chain so merged.Roles matches what ACL evaluation sees.
|
|
roleNames := collectRoleNames(chain, floor, leafIdx)
|
|
if len(roleNames) > 0 {
|
|
out := make(map[string]Role, len(roleNames))
|
|
for _, name := range roleNames {
|
|
members, defined := lookupRoleMembers(chain, leafIdx, name)
|
|
if !defined {
|
|
continue
|
|
}
|
|
out[name] = Role{Members: members}
|
|
}
|
|
merged.Roles = out
|
|
} else {
|
|
merged.Roles = nil
|
|
}
|
|
|
|
return merged, sources
|
|
}
|
|
|
|
// nonZeroZddcFields returns the names of top-level ZddcFile fields zf
|
|
// has populated. Field names match the yaml tags (so "acl" not "ACL").
|
|
// Used to populate SourceEntry.Contributed.
|
|
func nonZeroZddcFields(zf ZddcFile) []string {
|
|
var out []string
|
|
add := func(name string, cond bool) {
|
|
if cond {
|
|
out = append(out, name)
|
|
}
|
|
}
|
|
add("title", zf.Title != "")
|
|
add("acl", len(zf.ACL.Permissions) > 0 || zf.ACL.Inherit != nil)
|
|
add("admins", len(zf.Admins) > 0)
|
|
add("tables", len(zf.Tables) > 0)
|
|
add("views", len(zf.Views) > 0)
|
|
add("display", len(zf.Display) > 0)
|
|
add("convert", zf.Convert != nil)
|
|
add("roles", len(zf.Roles) > 0)
|
|
add("created_by", zf.CreatedBy != "")
|
|
add("default_tool", zf.DefaultTool != "")
|
|
add("dir_tool", zf.DirTool != "")
|
|
add("auto_own", zf.AutoOwn != nil)
|
|
add("auto_own_fenced", zf.AutoOwnFenced != nil)
|
|
add("virtual", zf.Virtual != nil)
|
|
add("drop_target", zf.DropTarget != nil)
|
|
add("party_source", zf.PartySource != "")
|
|
add("history", zf.History != nil)
|
|
add("history_globs", len(zf.HistoryGlobs) > 0)
|
|
add("worm", zf.Worm != nil)
|
|
add("available_tools", len(zf.AvailableTools) > 0)
|
|
add("received_path", zf.ReceivedPath != "")
|
|
add("planned_review_date", zf.PlannedReviewDate != "")
|
|
add("planned_response_date", zf.PlannedResponseDate != "")
|
|
add("field_codes", len(zf.FieldCodes) > 0)
|
|
add("records", len(zf.Records) > 0)
|
|
add("paths", len(zf.Paths) > 0)
|
|
return out
|
|
}
|
|
|
|
// collectRoleNames returns every role name that has a definition in
|
|
// any visible level (or the embedded baseline). Used by EffectiveZddc
|
|
// to know which roles to resolve via lookupRoleMembers — without it
|
|
// we'd miss roles declared only at an ancestor not directly merged at
|
|
// the leaf level (since per-level mergeOverlay replaces Roles by key,
|
|
// not by union).
|
|
func collectRoleNames(chain PolicyChain, floor, leafIdx int) []string {
|
|
seen := make(map[string]struct{})
|
|
for i := floor; i <= leafIdx; i++ {
|
|
for name := range chain.Levels[i].Roles {
|
|
seen[name] = struct{}{}
|
|
}
|
|
}
|
|
for name := range chain.Embedded.Roles {
|
|
seen[name] = struct{}{}
|
|
}
|
|
if len(seen) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(seen))
|
|
for name := range seen {
|
|
out = append(out, name)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 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
|
|
})
|
|
}
|