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). // // 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 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 } // 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 }) }