Completes the migration. The embedded per-depth tree (internal/zddc/defaults/)
is now the sole source of the shipped baseline; defaults.zddc.yaml is deleted.
- EmbeddedDefaults() assembles the tree (no yaml). show-defaults now emits a
.zddc.zip (per-depth, "*" wildcard members) via EmbeddedDefaultsZip() —
operators redirect it to <ROOT>/.zddc.zip (or any directory) and edit/add/
delete individual members.
- Dropped EmbeddedDefaultsBytes; reworked the dumpable test to validate the
emitted zip; removed the now-redundant tree-vs-yaml oracle (the Layer-2
matrix is the ongoing behavioral guarantee, and it stays green).
- Swept stale "defaults.zddc.yaml" comment references to the embedded tree.
- GRAMMAR.md §1/§6 updated: .zddc.zip is a policy bundle mountable at ANY
directory (subtree mount; inherit:false + acl.inherit:false = island); the
shipped baseline is the embedded bundle at the root.
Net of the 6-phase migration: policy is per-depth .zddc files in a .zddc.zip
that an operator can drop at any level to override the cascade; the engine
(Assemble + the unchanged walker) enforces it. Full Go suite + matrix green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
307 lines
11 KiB
Go
307 lines
11 KiB
Go
package handler
|
|
|
|
import (
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
|
)
|
|
|
|
// ZddcFileBasename is the leaf the dispatcher recognises as a raw
|
|
// .zddc YAML view request. Carved out of the dot-prefix guard so any
|
|
// directory's .zddc is reachable at <dir>/.zddc — without it, the
|
|
// dispatcher 404s anything beginning with a dot.
|
|
const ZddcFileBasename = ".zddc"
|
|
|
|
// IsZddcFileRequest reports whether urlPath ends with the raw .zddc
|
|
// leaf. Used by the dispatcher to route a GET/HEAD to ServeZddcFile
|
|
// before the dot-prefix guard rejects it.
|
|
func IsZddcFileRequest(urlPath string) bool {
|
|
clean := strings.TrimSuffix(urlPath, "/")
|
|
return strings.HasSuffix(clean, "/"+ZddcFileBasename) ||
|
|
clean == "/"+ZddcFileBasename
|
|
}
|
|
|
|
// ServeZddcFile serves a directory's .zddc as a plain YAML view.
|
|
//
|
|
// Method: GET / HEAD only — the dispatcher routes writes
|
|
//
|
|
// (PUT/DELETE/POST) directly to ServeFileAPI.
|
|
//
|
|
// ACL: the parent directory's read permission gates access. A
|
|
//
|
|
// user who can read the directory can read its .zddc.
|
|
//
|
|
// On-disk: if <dir>/.zddc exists, its bytes are returned verbatim
|
|
//
|
|
// with Content-Type: application/yaml.
|
|
//
|
|
// Virtual: if it does not exist, the body is the cascade's
|
|
//
|
|
// leaf-level ZddcFile (what internal/zddc/defaults/'s paths:
|
|
// tree declares for THIS exact directory, plus any
|
|
// virtual contributions threaded through by the walker)
|
|
// marshalled as YAML. A header comment names the source
|
|
// and points at ?effective=1 for the composed view. The
|
|
// virtual body is itself valid YAML — PUT-saving it back
|
|
// (with or without edits) through the file API
|
|
// materialises a real on-disk override carrying exactly
|
|
// the bytes the user saved. The response sets
|
|
// X-ZDDC-Source: virtual:zddc so clients can distinguish.
|
|
func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|
decider := DeciderFromContext(r)
|
|
|
|
// URL is <dir>/.zddc. Strip the leaf to get the directory.
|
|
urlPath := r.URL.Path
|
|
leaf := "/" + ZddcFileBasename
|
|
if !strings.HasSuffix(urlPath, leaf) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
dirURL := strings.TrimSuffix(urlPath, leaf)
|
|
if dirURL == "" {
|
|
dirURL = "/"
|
|
}
|
|
|
|
// Translate the URL into an absolute filesystem path. The parent
|
|
// directory must exist on disk (with one exception: the root
|
|
// itself, which always exists). We do NOT require the directory
|
|
// to exist if it's a canonical virtual folder — the cascade is
|
|
// still defined for those paths via the ancestors.
|
|
rel := strings.Trim(dirURL, "/")
|
|
abs := cfg.Root
|
|
if rel != "" {
|
|
abs = filepath.Join(cfg.Root, filepath.FromSlash(rel))
|
|
if !strings.HasPrefix(abs, cfg.Root+string(filepath.Separator)) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
}
|
|
|
|
// ACL gate: read permission on the parent directory. We resolve
|
|
// against the directory's effective policy chain, not the .zddc
|
|
// file's own permissions (the file isn't a separate ACL target —
|
|
// it's the source of the rules themselves).
|
|
chain, err := zddc.EffectivePolicy(cfg.Root, abs)
|
|
if err != nil {
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if allowed, _ := policy.AllowFromChainP(r.Context(), decider, chain, PrincipalFromContext(r), dirURL); !allowed {
|
|
http.NotFound(w, r) // hide existence from unauthorised callers
|
|
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")
|
|
|
|
// On-disk file: serve bytes verbatim.
|
|
if data, err := os.ReadFile(zddcPath); err == nil {
|
|
w.Header().Set("X-ZDDC-Source", "file:"+filepath.ToSlash(strings.TrimPrefix(zddcPath, cfg.Root)))
|
|
if r.Method == http.MethodHead {
|
|
return
|
|
}
|
|
_, _ = w.Write(data)
|
|
return
|
|
} else if !os.IsNotExist(err) {
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// No file on disk → render the cascade's leaf level as YAML.
|
|
// What the user sees is the embedded defaults' declared shape
|
|
// for this exact path; PUT-saving it back materialises an
|
|
// on-disk override verbatim.
|
|
body, err := renderVirtualZddc(chain)
|
|
if err != nil {
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("X-ZDDC-Source", "virtual:zddc")
|
|
if r.Method == http.MethodHead {
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(body))
|
|
}
|
|
|
|
// renderVirtualZddc produces a YAML body for a directory that has no
|
|
// .zddc on disk. The body is the cascade's leaf-level ZddcFile —
|
|
// i.e. what internal/zddc/defaults/'s paths: tree declares for this exact
|
|
// directory, plus any contributions the walker threaded through. The
|
|
// goal is to expose the embedded defaults' source of truth: a new
|
|
// user opening the virtual .zddc here sees, in the same yaml shape
|
|
// they would write themselves, what behavior is currently declared
|
|
// at this level. A header comment names the source and points at
|
|
// ?effective=1 for the composed view across the chain.
|
|
//
|
|
// PUT-saving these bytes back through the file API materialises a
|
|
// real on-disk override carrying exactly the saved content — the
|
|
// virtual body is a template, not a contract; the operator can
|
|
// trim / extend / overwrite freely.
|
|
func renderVirtualZddc(chain zddc.PolicyChain) (string, error) {
|
|
var leaf zddc.ZddcFile
|
|
if n := len(chain.Levels); n > 0 {
|
|
leaf = chain.Levels[n-1]
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.WriteString("# Virtual .zddc — no file on disk at this directory.\n")
|
|
b.WriteString("# The content below is what the embedded defaults\n")
|
|
b.WriteString("# (internal/zddc/defaults/'s paths: tree) declare for this\n")
|
|
b.WriteString("# exact path. Edit and save through the YAML editor in\n")
|
|
b.WriteString("# browse to materialise a real .zddc here carrying your\n")
|
|
b.WriteString("# changes; the bytes you save become the override\n")
|
|
b.WriteString("# verbatim (no merge, no synthesis — .zddc files drive\n")
|
|
b.WriteString("# policy and are the single source of truth).\n")
|
|
b.WriteString("#\n")
|
|
b.WriteString("# For the COMPOSED effective config across the whole\n")
|
|
b.WriteString("# cascade (all ancestors merged), query:\n")
|
|
b.WriteString("# GET <this-url>?effective=1 (JSON, not a .zddc).\n")
|
|
|
|
if isZeroZddcFile(leaf) {
|
|
b.WriteString("#\n")
|
|
b.WriteString("# No rules declared at this exact level — every rule\n")
|
|
b.WriteString("# currently in effect here is inherited from ancestors.\n")
|
|
b.WriteString("{}\n")
|
|
return b.String(), nil
|
|
}
|
|
|
|
body, err := yaml.Marshal(&leaf)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
b.WriteByte('\n')
|
|
b.Write(body)
|
|
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
|
|
// 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
|
|
// empty-placeholder path (just say "nothing declared here").
|
|
//
|
|
// The ACL substruct's Inherit pointer being nil is part of "zero"
|
|
// here; an explicit inherit: false is itself a declaration worth
|
|
// surfacing.
|
|
func isZeroZddcFile(zf zddc.ZddcFile) bool {
|
|
return zf.Title == "" &&
|
|
zf.CreatedBy == "" &&
|
|
zf.DefaultTool == "" &&
|
|
zf.DirTool == "" &&
|
|
zf.ReceivedPath == "" &&
|
|
zf.PlannedReviewDate == "" &&
|
|
zf.PlannedResponseDate == "" &&
|
|
zf.ACL.Inherit == nil &&
|
|
zf.AutoOwn == nil &&
|
|
zf.AutoOwnFenced == nil &&
|
|
zf.Virtual == nil &&
|
|
zf.DropTarget == nil &&
|
|
zf.Convert == nil &&
|
|
len(zf.ACL.Permissions) == 0 &&
|
|
len(zf.Admins) == 0 &&
|
|
len(zf.Tables) == 0 &&
|
|
len(zf.Views) == 0 &&
|
|
len(zf.Display) == 0 &&
|
|
len(zf.Roles) == 0 &&
|
|
len(zf.FieldCodes) == 0 &&
|
|
len(zf.Records) == 0 &&
|
|
len(zf.AvailableTools) == 0 &&
|
|
len(zf.Worm) == 0 &&
|
|
len(zf.Paths) == 0
|
|
}
|