From 2607ca9b8a6958f04229dd7330077ef0c218966d Mon Sep 17 00:00:00 2001 From: ZDDC Date: Mon, 4 May 2026 18:01:24 -0500 Subject: [PATCH] feat(server): /.profile/effective-policy cascade tracer (admin-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminates the manual cascade-trace ritual when debugging "why can't alice see /Project-X" reports. New endpoint returns the resolved policy chain plus the active decider's verdict in JSON: GET /.profile/effective-policy?path=/Project-X/sub/&email=alice@… Response shape: { "path": "/Project-X/sub/", "email": "alice@…", "decision": true, "decider_kind": "*policy.InternalDecider", "chain": { "has_any_file": true, "levels": [ {"index": 0, "zddc_path": "/.zddc", "exists": true, "acl": {...}, "admins": [...], "matches_email": false, "decision_at_level": "no_match"}, {"index": 1, "zddc_path": "/Project-X/.zddc", "exists": true, "acl": {...}, "matches_email": true, "decision_at_level": "allow"} ] } } Per-level email matching reuses the same MatchesPattern code the live evaluator uses, so the trace can never disagree with the actual verdict — and when ZDDC_OPA_URL points at an external OPA, the decision goes through that OPA, making the endpoint a useful smoke test for OPA wiring too. Admin-only via the existing /.profile gate (404 to non-admins). Required params; 400 if either is missing or path doesn't escape ROOT. Test coverage: * TestServeProfileGateMatrix: anonymous → 404, non-admin → 404, admin without params → 400 (gate cleared, validator rejected) * TestServeProfileEffectivePolicy: full payload-shape assertion against a worked-example fixture (closed project where alice is allow-listed but bob is not) Also fixes pre-existing doc drift: README's "Admin Debug Page" section referenced /.admin/whoami|config|logs but the actual code mounts /.profile/* (the rename predates this PR; the doc was stale). Closes the "/.admin/effective-policy debug endpoint" item from the federal-readiness future-work list. Co-Authored-By: Claude Opus 4.7 (1M context) --- zddc/README.md | 67 ++++--- zddc/internal/handler/profilehandler.go | 184 +++++++++++++++++++ zddc/internal/handler/profilehandler_test.go | 86 +++++++++ 3 files changed, 313 insertions(+), 24 deletions(-) diff --git a/zddc/README.md b/zddc/README.md index 9327111..58a2f00 100644 --- a/zddc/README.md +++ b/zddc/README.md @@ -417,24 +417,38 @@ guarantee these for the model above to hold: ### Debugging permissions When a user reports "I can't see /Project-X/" and you need to figure out why, -manual cascade-tracing is the current path: +the fastest path is the built-in cascade tracer: -1. **Confirm the resolved email** — hit `/.admin/whoami` as the user (you'll - need to have proxy auth working, or run the request *through* the proxy - that signs them in). The page shows every header on the request and the - `email` field zddc-server resolved. -2. **List the chain.** From `` down to the requested directory, - inspect each `.zddc` (most directories have none). For +``` +GET /.profile/effective-policy?path=/Project-X/sub/&email=alice@mycompany.com +``` + +(Admin-only — 404 to non-admins. Same gate as `/.profile/whoami`, +`/.profile/config`, `/.profile/logs`.) + +Returns JSON with the resolved policy chain (every level along the +walk from `ZDDC_ROOT` to the requested directory), the decision the +active decider produces, the per-level email-match breakdown +(`decision_at_level: "allow" | "deny" | "no_match"`), and which +decider is wired in (`*policy.InternalDecider`, +`*policy.cachingDecider`, etc.). With `ZDDC_OPA_URL` pointing at an +external OPA, the decision goes through that OPA — so this endpoint +also doubles as a smoke test for the OPA wiring. + +Manual procedure (if the endpoint isn't reachable for some reason): + +1. **Confirm the resolved email** — hit `/.profile/whoami` as the + user. Shows every header on the request and the `email` field + zddc-server resolved. +2. **List the chain.** From `` down to the requested + directory, inspect each `.zddc` (most directories have none). For `/Project-X/sub/sub/`, that's `/.zddc`, `/Project-X/.zddc`, - `/Project-X/sub/.zddc`, `/Project-X/sub/sub/.zddc` — read whatever exists. -3. **Walk bottom-up.** At each level, mentally run `AllowedAtLevel`: deny - patterns first (any match → blocked), then allow (any match → allowed). - First explicit match in the bottom-up walk is the answer. Default-deny - if `HasAnyFile=true` and nothing matches. - -A built-in `/.admin/effective-policy?path=...&email=...` endpoint that does -this trace and returns the chain + decision is on the future-work list (see -below); until it ships, the manual procedure is the only path. + `/Project-X/sub/.zddc`, `/Project-X/sub/sub/.zddc` — read whatever + exists. +3. **Walk bottom-up.** At each level, mentally run `AllowedAtLevel`: + deny patterns first (any match → blocked), then allow (any match + → allowed). First explicit match in the bottom-up walk is the + answer. Default-deny if `HasAnyFile=true` and nothing matches. ### Directory visibility @@ -758,9 +772,6 @@ the *interpretation* of those files differs per tenant. Items the conversation flagged as friction in operator setup or as documented gaps that warrant code, in addition to the federal-readiness items above: -- `/.admin/effective-policy?path=...&email=...` endpoint returning the - resolved chain + decision, so debugging permissions stops requiring manual - cascade tracing. - `.zddc.form.yaml` ACL editor (built on the form-data system) once file-as-truth round-trip preserves comments — turn the manual YAML edit into a self-service UI for project owners. @@ -778,22 +789,30 @@ gaps that warrant code, in addition to the federal-readiness items above: ## Admin Debug Page -`zddc-server` exposes a built-in debug page at `/.admin/` for operators who can +`zddc-server` exposes a built-in debug page at `/.profile/` for operators who can push code/images but cannot `kubectl exec` into the running container. It surfaces: -- **`/.admin/whoami`** — every header on the current request, the configured email +- **`/.profile/whoami`** — every header on the current request, the configured email header name, the value observed at that name, and the resolved email. This is the first thing to look at when access logs show `email=anonymous` — it tells you exactly which (if any) header the upstream proxy is sending. -- **`/.admin/config`** — the resolved `Config` (env vars). Equivalent to +- **`/.profile/config`** — the resolved `Config` (env vars). Equivalent to `kubectl exec -- env | grep ^ZDDC_` for diagnosing chart / deployment overrides. -- **`/.admin/logs`** — recent log entries (last 500) from an in-memory ring buffer. +- **`/.profile/logs`** — recent log entries (last 500) from an in-memory ring buffer. Optional `?level=info|warn|error|debug` and `?since=` query params. At `ZDDC_LOG_LEVEL=debug` every request also logs its full header map under `msg=request headers` — useful for diagnosing proxy / SSO header passthrough (e.g. confirming which header carries the email). Note: that dump includes auth tokens and cookies; only enable debug in trusted environments. -- **`/.admin/`** — HTML dashboard that fetches the three JSON endpoints client-side. +- **`/.profile/effective-policy?path=...&email=...`** — cascade tracer. + Returns the resolved policy chain (every level along the walk from + `ZDDC_ROOT` to the requested path), the active decider's allow/deny + verdict, the per-level email-match breakdown, and the decider kind + (`*policy.InternalDecider` / `*policy.cachingDecider`). When + `ZDDC_OPA_URL` points at an external OPA, the decision goes through + that OPA — also a useful smoke test for OPA wiring. See "Debugging + permissions" above. +- **`/.profile/`** — HTML dashboard that fetches the JSON endpoints client-side. ### Authorization diff --git a/zddc/internal/handler/profilehandler.go b/zddc/internal/handler/profilehandler.go index 0fc2796..fb3c971 100644 --- a/zddc/internal/handler/profilehandler.go +++ b/zddc/internal/handler/profilehandler.go @@ -3,6 +3,7 @@ package handler import ( "context" "encoding/json" + "fmt" "net/http" "path/filepath" "sort" @@ -63,6 +64,12 @@ func ServeProfile(cfg config.Config, ring *LogRing, w http.ResponseWriter, r *ht return } serveProfileLogs(ring, w, r) + case "/effective-policy": + if !zddc.IsAdmin(cfg.Root, email) { + http.NotFound(w, r) + return + } + serveProfileEffectivePolicy(cfg, w, r) default: http.NotFound(w, r) } @@ -250,3 +257,180 @@ func levelRank(s string) int { return 1 // unknown → info } } + +// serveProfileEffectivePolicy is the cascade-tracer endpoint: +// /.profile/effective-policy?path=&email= +// returns the resolved policy chain plus the allow/deny decision the +// active decider produces, in JSON. Eliminates the need for operators +// to manual-trace .zddc files when debugging "why can't alice see +// /Project-X?" reports. +// +// Both query params are required. The endpoint is admin-only (404 to +// non-admins via the dispatch gate). +// +// Response shape (each chain level is a directory along the walk +// from ZDDC_ROOT down to the requested path): +// +// { +// "path": "/Project-X/sub/", +// "email": "alice@mycompany.com", +// "decision": true, +// "decider_kind": "*policy.InternalDecider", +// "chain": { +// "has_any_file": true, +// "levels": [ +// {"path": "/", "exists": true, "acl": {"allow": [...]}, "admins": [...]}, +// {"path": "/Project-X/", "exists": false}, +// {"path": "/Project-X/sub/", "exists": true, "acl": {"allow": [...]}} +// ] +// } +// } +// +// Note: this evaluates the same input the production hot path would +// build for a request from to ; if zddc-server is +// configured for external OPA, the decision goes through that OPA +// (so this endpoint is also a useful smoke test for the OPA wiring). +func serveProfileEffectivePolicy(cfg config.Config, w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + probePath := q.Get("path") + probeEmail := q.Get("email") + if probePath == "" || probeEmail == "" { + http.Error(w, "both ?path= and ?email= are required", http.StatusBadRequest) + return + } + if !strings.HasPrefix(probePath, "/") { + http.Error(w, "path must start with /", http.StatusBadRequest) + return + } + + // Resolve the URL path to a filesystem directory the same way the + // dispatch hot path does. + rel := strings.TrimPrefix(probePath, "/") + rel = strings.TrimSuffix(rel, "/") + absDir, ok := safeJoin(cfg.Root, rel) + if !ok { + http.Error(w, "path escapes ZDDC_ROOT", http.StatusBadRequest) + return + } + + chain, err := zddc.EffectivePolicy(cfg.Root, absDir) + if err != nil { + http.Error(w, "policy chain error: "+err.Error(), http.StatusInternalServerError) + return + } + + // Evaluate the decision through whatever decider is wired into the + // request — internal in commercial deployments, an external OPA in + // federal ones. The returned bool is "allow". + ctx := r.Context() + decider := DeciderFromContext(r) + allow, _ := policy.AllowFromChain(ctx, decider, chain, probeEmail, probePath) + + type levelView struct { + Index int `json:"index"` + ZddcPath string `json:"zddc_path"` + Exists bool `json:"exists"` + Acl *zddc.ACLRules `json:"acl,omitempty"` + Admins []string `json:"admins,omitempty"` + AnyMatch bool `json:"matches_email"` + Decision string `json:"decision_at_level"` + } + + // Build the per-level breakdown by walking the chain levels in + // the same order the cascade does (root → leaf in the data, but + // the live evaluator walks bottom-up). For each level we report + // whether the file actually existed (HasAnyFile is global; we + // don't have per-level existence, but ZddcFile.Admins/ACL being + // non-empty is a reasonable proxy). + out := struct { + Path string `json:"path"` + Email string `json:"email"` + Decision bool `json:"decision"` + DeciderKind string `json:"decider_kind"` + Chain struct { + HasAnyFile bool `json:"has_any_file"` + Levels []levelView `json:"levels"` + } `json:"chain"` + }{ + Path: probePath, + Email: probeEmail, + Decision: allow, + DeciderKind: deciderKind(decider), + } + out.Chain.HasAnyFile = chain.HasAnyFile + + // Reconstruct level paths from cfg.Root. This mirrors how + // zddc.EffectivePolicy builds the chain (see cascade.go). + levelPaths := []string{cfg.Root} + if rel != "" { + current := cfg.Root + for _, seg := range strings.Split(rel, "/") { + if seg == "" { + continue + } + current = current + "/" + seg + levelPaths = append(levelPaths, current) + } + } + + for i, lvl := range chain.Levels { + var lp string + if i < len(levelPaths) { + // Map filesystem path back to a URL-style path under + // cfg.Root for legibility in the response. + fsPath := levelPaths[i] + urlPath := strings.TrimPrefix(fsPath, cfg.Root) + if urlPath == "" { + urlPath = "/" + } + lp = urlPath + "/.zddc" + } + entry := levelView{ + Index: i, + ZddcPath: lp, + Exists: len(lvl.Admins) > 0 || len(lvl.ACL.Allow) > 0 || len(lvl.ACL.Deny) > 0, + } + if entry.Exists { + entry.Acl = &lvl.ACL + entry.Admins = lvl.Admins + } + // Per-level email match: would this level's deny or allow + // patterns hit the email if checked? Reuses the same + // MatchesPattern code the live evaluator does. + anyMatch := false + decisionAtLevel := "no_match" + for _, p := range lvl.ACL.Deny { + if zddc.MatchesPattern(p, probeEmail) { + anyMatch = true + decisionAtLevel = "deny" + break + } + } + if !anyMatch { + for _, p := range lvl.ACL.Allow { + if zddc.MatchesPattern(p, probeEmail) { + anyMatch = true + decisionAtLevel = "allow" + break + } + } + } + entry.AnyMatch = anyMatch + entry.Decision = decisionAtLevel + out.Chain.Levels = append(out.Chain.Levels, entry) + } + + writeJSON(w, out) +} + +// deciderKind returns a short string label for the active decider. +// Mirrors the helper used in policy package tests; duplicated here +// to avoid a cross-package import that would only exist for one +// debug-endpoint string. +func deciderKind(d policy.Decider) string { + if d == nil { + return "nil" + } + t := fmt.Sprintf("%T", d) + return t +} diff --git a/zddc/internal/handler/profilehandler_test.go b/zddc/internal/handler/profilehandler_test.go index 7a68a6d..68e6726 100644 --- a/zddc/internal/handler/profilehandler_test.go +++ b/zddc/internal/handler/profilehandler_test.go @@ -84,6 +84,12 @@ func TestServeProfileGateMatrix(t *testing.T) { {"admin /.profile/whoami", "/.profile/whoami", "alice@example.com", http.StatusOK}, {"admin /.profile/config", "/.profile/config", "alice@example.com", http.StatusOK}, {"admin /.profile/logs", "/.profile/logs", "alice@example.com", http.StatusOK}, + // effective-policy is admin-only too. With no params an admin + // gets 400 (bad request), confirming the gate cleared. Same + // 404 for non-admins as the other admin-only routes. + {"anonymous /.profile/effective-policy", "/.profile/effective-policy", "", http.StatusNotFound}, + {"non-admin /.profile/effective-policy", "/.profile/effective-policy", "bob@example.com", http.StatusNotFound}, + {"admin /.profile/effective-policy without params", "/.profile/effective-policy", "alice@example.com", http.StatusBadRequest}, // Unknown sub-route still 404. {"admin unknown subroute", "/.profile/nope", "alice@example.com", http.StatusNotFound}, @@ -469,6 +475,86 @@ func TestServeProfileAccessJSON_SubtreeAdminScopes(t *testing.T) { } } +// TestServeProfileEffectivePolicy: admin queries the cascade tracer for a +// (path, email) tuple and gets back the resolved chain plus the decision. +// The fixture mirrors the worked-example layout from zddc/README.md (a +// closed project where alice is allow-listed but bob is not, even though +// /Archive/ would let *@mycompany.com in). +func TestServeProfileEffectivePolicy(t *testing.T) { + cfg, ring := profileTestRoot(t, []string{"super@admin.com"}) + if err := os.MkdirAll(filepath.Join(cfg.Root, "Closed-Project"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(cfg.Root, "Closed-Project", ".zddc"), + []byte("acl:\n allow:\n - alice@mycompany.com\n"), 0o644); err != nil { + t.Fatalf("write child .zddc: %v", err) + } + zddc.InvalidateCache(cfg.Root) + + // Trace alice (allowed at the leaf). + rec := httptest.NewRecorder() + r := requestWithEmail(http.MethodGet, + "/.profile/effective-policy?path=/Closed-Project/&email=alice@mycompany.com", + "super@admin.com") + ServeProfile(cfg, ring, rec, r) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var resp struct { + Path string `json:"path"` + Email string `json:"email"` + Decision bool `json:"decision"` + Chain struct { + HasAnyFile bool `json:"has_any_file"` + Levels []struct { + Index int `json:"index"` + Exists bool `json:"exists"` + MatchesEmail bool `json:"matches_email"` + DecisionAtLevel string `json:"decision_at_level"` + } `json:"levels"` + } `json:"chain"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.Path != "/Closed-Project/" || resp.Email != "alice@mycompany.com" { + t.Errorf("path/email round-trip mismatch: %+v", resp) + } + if !resp.Decision { + t.Errorf("decision = false, want true (alice is allow-listed at /Closed-Project/)") + } + if !resp.Chain.HasAnyFile { + t.Error("HasAnyFile = false, want true (.zddc files exist)") + } + if len(resp.Chain.Levels) != 2 { + t.Fatalf("levels count = %d, want 2 (root + Closed-Project/)", len(resp.Chain.Levels)) + } + // Leaf level should have matched alice with allow. + leaf := resp.Chain.Levels[1] + if !leaf.MatchesEmail || leaf.DecisionAtLevel != "allow" { + t.Errorf("leaf decision = %q (matches=%v), want allow (matches=true)", leaf.DecisionAtLevel, leaf.MatchesEmail) + } + + // Trace bob (not allow-listed; root has no broad allow either). + rec2 := httptest.NewRecorder() + r2 := requestWithEmail(http.MethodGet, + "/.profile/effective-policy?path=/Closed-Project/&email=bob@mycompany.com", + "super@admin.com") + ServeProfile(cfg, ring, rec2, r2) + if rec2.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec2.Code, rec2.Body.String()) + } + var resp2 struct { + Decision bool `json:"decision"` + } + if err := json.Unmarshal(rec2.Body.Bytes(), &resp2); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp2.Decision { + t.Error("decision = true for bob, want false (no .zddc match anywhere; HasAnyFile=true → default-deny)") + } +} + func TestServeProfileNoAdminsConfiguredStillRendersPage(t *testing.T) { // .zddc exists but has no admins list — page is still reachable, // but the admin/super-admin sections are absent.