From b5a725e745b29a595ec2b75371ce1ac3ec121336 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Thu, 21 May 2026 09:39:29 -0500 Subject: [PATCH] feat(zddcfile): ?effective=1 composed-cascade inspection query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add GET //.zddc?effective=1 returning JSON with the composed ZddcFile across the full cascade plus a per-level source list. The .zddc file itself still serves only what's defined at that level (YAML, the source of truth); the new query is inspection-only (JSON, never written back). The virtual .zddc body's header comment already pointed at this URL — now it's live. Wire shape: { url_path: "/Project-1/archive/Acme/working/", merged: { …ZddcFile JSON, composed view… }, sources: [ { level: -1, url: "", contributed: ["roles", "available_tools", "paths"] }, { level: 0, url: "/.zddc", contributed: ["acl", "admins"] }, { level: 4, url: "/Project-1/archive/Acme/working/.zddc", contributed: ["default_tool", "auto_own", …] } ] } New zddc.EffectiveZddc(chain) walks chain.Embedded then chain.Levels[VisibleStart..leaf] through mergeOverlay, and folds the cross-level Roles union (via the existing lookupRoleMembers, matching the runtime ACL evaluator's semantics). Returns ([]SourceEntry) listing each contributing level with its non-zero top-level fields. The handler maps SourceEntry.Level to a directory URL: -1 → ""; 0..n → "/.zddc". ACL gate is the same as the YAML view (read on the directory). X-ZDDC-Source: virtual:effective so clients can distinguish. Four tests cover the contract: - BasicCompose: alice's root grant + project_team baseline from embedded + the project's title all surface in merged; sources include -1 (embedded), 0 (root), 1 (project). - InheritFence: top-level inherit:false on /Closed/.zddc drops every ancestor including the embedded baseline from sources. - RoleMemberUnion: document_controller declared at root and project unions members in merged.roles (matches the runtime cross-level union the ACL evaluator performs). - existing virtual-body tests still pass — they hit the YAML path, not the JSON branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- zddc/internal/handler/zddcfile.go | 89 ++++++++++++ zddc/internal/handler/zddcfile_test.go | 181 +++++++++++++++++++++++++ zddc/internal/zddc/cascade.go | 135 ++++++++++++++++++ 3 files changed, 405 insertions(+) diff --git a/zddc/internal/handler/zddcfile.go b/zddc/internal/handler/zddcfile.go index 2e3787b..5685334 100644 --- a/zddc/internal/handler/zddcfile.go +++ b/zddc/internal/handler/zddcfile.go @@ -91,6 +91,16 @@ func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) { return } + // ?effective=1 branch: return the composed cascade view as JSON. + // Distinct from the .zddc file itself — the YAML body is "what's + // defined at this level" (source of truth); this is "what's + // effective after merging every ancestor" (inspection only, not + // PUT-saveable as a .zddc). + if r.URL.Query().Get("effective") == "1" { + serveEffectiveZddc(cfg, dirURL, chain, w, r) + return + } + zddcPath := filepath.Join(abs, ".zddc") w.Header().Set("Cache-Control", "no-store") w.Header().Set("Content-Type", "application/yaml; charset=utf-8") @@ -175,6 +185,85 @@ func renderVirtualZddc(chain zddc.PolicyChain) (string, error) { return b.String(), nil } +// effectiveSourceView is the wire shape for one entry in the +// `sources` array of the ?effective=1 response. Level matches +// zddc.SourceEntry.Level (-1 = embedded baseline, 0+ = chain index); +// URL is the directory URL of that level (or "" for the +// baseline); Contributed lists the top-level fields the level +// declared. +type effectiveSourceView struct { + Level int `json:"level"` + URL string `json:"url"` + Contributed []string `json:"contributed,omitempty"` +} + +// effectiveZddcView is the wire shape for the ?effective=1 response. +// Merged is the composed cascade as a ZddcFile (same struct shape the +// editor consumes for an on-disk .zddc; client-side renderers can +// reuse the same parser). Sources lists per-level contributions so +// the user can trace any value back to its origin without re-walking +// the cascade by hand. +type effectiveZddcView struct { + URLPath string `json:"url_path"` + Merged zddc.ZddcFile `json:"merged"` + Sources []effectiveSourceView `json:"sources"` +} + +// serveEffectiveZddc writes the JSON composed-cascade view for the +// .zddc URL. Same ACL as the YAML view (already enforced by the +// caller). +func serveEffectiveZddc(cfg config.Config, dirURL string, chain zddc.PolicyChain, w http.ResponseWriter, r *http.Request) { + merged, sources := zddc.EffectiveZddc(chain) + levelURLs := levelURLsFor(cfg.Root, dirURL, len(chain.Levels)) + view := effectiveZddcView{ + URLPath: dirURL, + Merged: merged, + Sources: make([]effectiveSourceView, 0, len(sources)), + } + for _, s := range sources { + entry := effectiveSourceView{Level: s.Level, Contributed: s.Contributed} + if s.Level < 0 { + entry.URL = "" + } else if s.Level < len(levelURLs) { + entry.URL = levelURLs[s.Level] + ".zddc" + } + view.Sources = append(view.Sources, entry) + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-ZDDC-Source", "virtual:effective") + if r.Method == http.MethodHead { + return + } + writeJSON(w, view) +} + +// levelURLsFor maps each chain level index to its directory URL. The +// chain walks dirs root→leaf so levelURLs[0] = "/", levelURLs[1] is +// the first segment, etc. Length must equal len(chain.Levels). +// +// Used by serveEffectiveZddc to populate SourceEntry.URL — clients +// receive concrete .zddc URLs they can navigate to rather than bare +// integer indices. +func levelURLsFor(_, dirURL string, n int) []string { + dirURL = strings.TrimSuffix(dirURL, "/") + out := make([]string, n) + out[0] = "/" + if dirURL == "" || n == 1 { + return out + } + segs := strings.Split(strings.TrimPrefix(dirURL, "/"), "/") + cur := "" + for i, seg := range segs { + if i+1 >= n { + break + } + cur += "/" + seg + out[i+1] = cur + "/" + } + return out +} + // isZeroZddcFile reports whether zf carries no declarations a user // would want to see — every field is its zero value. Used to switch // the virtual body between the rich path (marshal the leaf) and the diff --git a/zddc/internal/handler/zddcfile_test.go b/zddc/internal/handler/zddcfile_test.go index d6259f0..0179266 100644 --- a/zddc/internal/handler/zddcfile_test.go +++ b/zddc/internal/handler/zddcfile_test.go @@ -2,6 +2,7 @@ package handler import ( "context" + "encoding/json" "net/http" "net/http/httptest" "os" @@ -160,6 +161,186 @@ func TestServeZddcFile_VirtualEmpty(t *testing.T) { } } +// TestServeZddcFile_Effective_BasicCompose — ?effective=1 returns the +// merged composed view across embedded baseline + on-disk levels. +// Body is JSON with the merged ZddcFile and per-level source list. +func TestServeZddcFile_Effective_BasicCompose(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, ".zddc"), + "acl:\n permissions:\n \"alice@example.com\": rwcda\n") + proj := filepath.Join(root, "Project") + if err := os.Mkdir(proj, 0o755); err != nil { + t.Fatal(err) + } + mustWrite(t, filepath.Join(proj, ".zddc"), + "title: My Project\n") + zddc.InvalidateCache(root) + cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"} + + req := httptest.NewRequest(http.MethodGet, "/Project/.zddc?effective=1", nil) + req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com")) + rec := httptest.NewRecorder() + ServeZddcFile(cfg, rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/json") { + t.Errorf("Content-Type = %q, want application/json", got) + } + if got := rec.Header().Get("X-ZDDC-Source"); got != "virtual:effective" { + t.Errorf("X-ZDDC-Source = %q, want virtual:effective", got) + } + + var view effectiveZddcView + if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil { + t.Fatalf("decode: %v (body: %s)", err, rec.Body.String()) + } + if view.URLPath != "/Project" { + t.Errorf("url_path = %q, want /Project", view.URLPath) + } + // Merged should carry alice's grant (from root) AND the title + // from /Project, AND the project_team grant from the embedded + // defaults' paths.* contribution. + if view.Merged.ACL.Permissions["alice@example.com"] != "rwcda" { + t.Errorf("merged.acl.permissions missing alice's grant: %+v", view.Merged.ACL.Permissions) + } + if view.Merged.ACL.Permissions["project_team"] != "r" { + t.Errorf("merged.acl.permissions missing project_team (from embedded defaults paths.*): %+v", view.Merged.ACL.Permissions) + } + if view.Merged.Title != "My Project" { + t.Errorf("merged.title = %q, want My Project (from /Project/.zddc)", view.Merged.Title) + } + // Sources should include the embedded baseline (level -1) and + // the two on-disk levels. + var levels []int + for _, s := range view.Sources { + levels = append(levels, s.Level) + } + wantLevels := map[int]bool{-1: true, 0: true, 1: true} + for _, l := range levels { + delete(wantLevels, l) + } + if len(wantLevels) > 0 { + t.Errorf("missing source levels %v in %v", wantLevels, levels) + } + // Per-level URLs are populated. + for _, s := range view.Sources { + if s.Level == -1 && s.URL != "" { + t.Errorf("embedded source url = %q, want ", s.URL) + } + if s.Level == 0 && s.URL != "/.zddc" { + t.Errorf("root source url = %q, want /.zddc", s.URL) + } + if s.Level == 1 && s.URL != "/Project/.zddc" { + t.Errorf("project source url = %q, want /Project/.zddc", s.URL) + } + } +} + +// TestServeZddcFile_Effective_InheritFence — inherit:false at a level +// drops every ancestor (including the embedded baseline) from the +// composed view. Only the fence-and-below contribute. +func TestServeZddcFile_Effective_InheritFence(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, ".zddc"), + "acl:\n permissions:\n \"alice@example.com\": rwcda\n") + proj := filepath.Join(root, "Closed") + if err := os.Mkdir(proj, 0o755); err != nil { + t.Fatal(err) + } + // inherit:false on Closed/.zddc — root + embedded both drop + // out of the visible chain. + // Top-level inherit:false drops EVERY ancestor including the + // embedded baseline. (ACL.inherit:false would only fence ACL + // evaluation — roles, paths-tree, and embedded baseline still + // cascade through, which is a separate test.) + mustWrite(t, filepath.Join(proj, ".zddc"), + "inherit: false\n"+ + "acl:\n inherit: false\n permissions:\n \"bob@example.com\": rwcda\n") + zddc.InvalidateCache(root) + cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"} + + // Bob has the only grant inside the fence; alice's root grant + // is hidden by inherit:false so she'd 404 on the read gate. + req := httptest.NewRequest(http.MethodGet, "/Closed/.zddc?effective=1", nil) + req = req.WithContext(context.WithValue(req.Context(), EmailKey, "bob@example.com")) + rec := httptest.NewRecorder() + ServeZddcFile(cfg, rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var view effectiveZddcView + if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil { + t.Fatalf("decode: %v", err) + } + // Alice's root grant must be invisible behind the fence. + if _, ok := view.Merged.ACL.Permissions["alice@example.com"]; ok { + t.Errorf("alice's root grant should be hidden by fence; got %+v", view.Merged.ACL.Permissions) + } + // Bob's grant at Closed/ is visible. + if view.Merged.ACL.Permissions["bob@example.com"] != "rwcda" { + t.Errorf("bob's fence-level grant missing: %+v", view.Merged.ACL.Permissions) + } + // Embedded baseline (level -1) must not appear in sources — the + // fence zeroed it. + for _, s := range view.Sources { + if s.Level == -1 { + t.Errorf("embedded baseline leaked past inherit:false fence: %+v", s) + } + if s.Level == 0 { + t.Errorf("root /.zddc leaked past inherit:false fence: %+v", s) + } + } +} + +// TestServeZddcFile_Effective_RoleMemberUnion — roles defined at +// multiple levels show the union of members (the runtime ACL +// evaluator uses lookupRoleMembers' union, and the composed view +// must match). +func TestServeZddcFile_Effective_RoleMemberUnion(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, ".zddc"), + "acl:\n permissions:\n \"alice@example.com\": r\n"+ + "roles:\n document_controller:\n members:\n - root-dc@example.com\n") + proj := filepath.Join(root, "Project") + if err := os.Mkdir(proj, 0o755); err != nil { + t.Fatal(err) + } + mustWrite(t, filepath.Join(proj, ".zddc"), + "roles:\n document_controller:\n members:\n - project-dc@example.com\n") + zddc.InvalidateCache(root) + cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"} + + req := httptest.NewRequest(http.MethodGet, "/Project/.zddc?effective=1", nil) + req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com")) + rec := httptest.NewRecorder() + ServeZddcFile(cfg, rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var view effectiveZddcView + if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil { + t.Fatalf("decode: %v", err) + } + dc, ok := view.Merged.Roles["document_controller"] + if !ok { + t.Fatalf("merged.roles missing document_controller: %+v", view.Merged.Roles) + } + wantMembers := map[string]bool{ + "root-dc@example.com": true, + "project-dc@example.com": true, + } + for _, m := range dc.Members { + delete(wantMembers, m) + } + if len(wantMembers) > 0 { + t.Errorf("document_controller members missing %v; got %v", wantMembers, dc.Members) + } +} + // TestServeZddcFile_VirtualPerPartyWorking — a deeper path declared // by the embedded defaults (archive//working/) shows its own // rich subtree: default_tool, available_tools, auto_own, etc. diff --git a/zddc/internal/zddc/cascade.go b/zddc/internal/zddc/cascade.go index e581fe4..afe973d 100644 --- a/zddc/internal/zddc/cascade.go +++ b/zddc/internal/zddc/cascade.go @@ -272,6 +272,141 @@ func (chain PolicyChain) EffectiveRecordRule(basename string) (string, RecordRul 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("apps", len(zf.Apps) > 0) + add("apps_pubkey", zf.AppsPubKey != "") + add("tables", len(zf.Tables) > 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("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)