feat(zddcfile): ?effective=1 composed-cascade inspection query

Add GET /<path>/.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: "<embedded>",
                 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 → "<embedded>"; 0..n → "/<seg/seg/.../>.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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-21 09:39:29 -05:00
parent a0a3f8579b
commit b5a725e745
3 changed files with 405 additions and 0 deletions

View file

@ -91,6 +91,16 @@ func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) {
return 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") zddcPath := filepath.Join(abs, ".zddc")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/yaml; charset=utf-8") 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 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 "<embedded>" 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 = "<embedded>"
} 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 // isZeroZddcFile reports whether zf carries no declarations a user
// would want to see — every field is its zero value. Used to switch // 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 // the virtual body between the rich path (marshal the leaf) and the

View file

@ -2,6 +2,7 @@ package handler
import ( import (
"context" "context"
"encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "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 != "<embedded>" {
t.Errorf("embedded source url = %q, want <embedded>", 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 // TestServeZddcFile_VirtualPerPartyWorking — a deeper path declared
// by the embedded defaults (archive/<party>/working/) shows its own // by the embedded defaults (archive/<party>/working/) shows its own
// rich subtree: default_tool, available_tools, auto_own, etc. // rich subtree: default_tool, available_tools, auto_own, etc.

View file

@ -272,6 +272,141 @@ func (chain PolicyChain) EffectiveRecordRule(basename string) (string, RecordRul
return pattern, merged, true 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. // InvalidateCache removes the cached policy for dirPath and all descendants.
func InvalidateCache(dirPath string) { func InvalidateCache(dirPath string) {
dirPath = filepath.Clean(dirPath) dirPath = filepath.Clean(dirPath)