feat(server): /.profile/effective-policy cascade tracer (admin-only)

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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-04 18:01:24 -05:00
parent b20e98b6aa
commit 2607ca9b8a
3 changed files with 313 additions and 24 deletions

View file

@ -417,24 +417,38 @@ guarantee these for the model above to hold:
### Debugging permissions ### Debugging permissions
When a user reports "I can't see /Project-X/" and you need to figure out why, 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 GET /.profile/effective-policy?path=/Project-X/sub/&email=alice@mycompany.com
that signs them in). The page shows every header on the request and the ```
`email` field zddc-server resolved.
2. **List the chain.** From `<ZDDC_ROOT>` down to the requested directory, (Admin-only — 404 to non-admins. Same gate as `/.profile/whoami`,
inspect each `.zddc` (most directories have none). For `/.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 `<ZDDC_ROOT>` 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/sub/`, that's `/.zddc`, `/Project-X/.zddc`,
`/Project-X/sub/.zddc`, `/Project-X/sub/sub/.zddc` — read whatever exists. `/Project-X/sub/.zddc`, `/Project-X/sub/sub/.zddc` — read whatever
3. **Walk bottom-up.** At each level, mentally run `AllowedAtLevel`: deny exists.
patterns first (any match → blocked), then allow (any match → allowed). 3. **Walk bottom-up.** At each level, mentally run `AllowedAtLevel`:
First explicit match in the bottom-up walk is the answer. Default-deny deny patterns first (any match → blocked), then allow (any match
if `HasAnyFile=true` and nothing matches. → 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.
### Directory visibility ### 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 Items the conversation flagged as friction in operator setup or as documented
gaps that warrant code, in addition to the federal-readiness items above: 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 - `.zddc.form.yaml` ACL editor (built on the form-data system) once
file-as-truth round-trip preserves comments — turn the manual YAML edit file-as-truth round-trip preserves comments — turn the manual YAML edit
into a self-service UI for project owners. 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 ## 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: 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 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 first thing to look at when access logs show `email=anonymous` — it tells you
exactly which (if any) header the upstream proxy is sending. 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. `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=<RFC3339>` query params. Optional `?level=info|warn|error|debug` and `?since=<RFC3339>` query params.
At `ZDDC_LOG_LEVEL=debug` every request also logs its full header map under At `ZDDC_LOG_LEVEL=debug` every request also logs its full header map under
`msg=request headers` — useful for diagnosing proxy / SSO header passthrough `msg=request headers` — useful for diagnosing proxy / SSO header passthrough
(e.g. confirming which header carries the email). Note: that dump includes (e.g. confirming which header carries the email). Note: that dump includes
auth tokens and cookies; only enable debug in trusted environments. 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 ### Authorization

View file

@ -3,6 +3,7 @@ package handler
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"path/filepath" "path/filepath"
"sort" "sort"
@ -63,6 +64,12 @@ func ServeProfile(cfg config.Config, ring *LogRing, w http.ResponseWriter, r *ht
return return
} }
serveProfileLogs(ring, w, r) serveProfileLogs(ring, w, r)
case "/effective-policy":
if !zddc.IsAdmin(cfg.Root, email) {
http.NotFound(w, r)
return
}
serveProfileEffectivePolicy(cfg, w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -250,3 +257,180 @@ func levelRank(s string) int {
return 1 // unknown → info return 1 // unknown → info
} }
} }
// serveProfileEffectivePolicy is the cascade-tracer endpoint:
// /.profile/effective-policy?path=<URL-path>&email=<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 <email> to <path>; 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
}

View file

@ -84,6 +84,12 @@ func TestServeProfileGateMatrix(t *testing.T) {
{"admin /.profile/whoami", "/.profile/whoami", "alice@example.com", http.StatusOK}, {"admin /.profile/whoami", "/.profile/whoami", "alice@example.com", http.StatusOK},
{"admin /.profile/config", "/.profile/config", "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}, {"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. // Unknown sub-route still 404.
{"admin unknown subroute", "/.profile/nope", "alice@example.com", http.StatusNotFound}, {"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) { func TestServeProfileNoAdminsConfiguredStillRendersPage(t *testing.T) {
// .zddc exists but has no admins list — page is still reachable, // .zddc exists but has no admins list — page is still reachable,
// but the admin/super-admin sections are absent. // but the admin/super-admin sections are absent.