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:
parent
b20e98b6aa
commit
2607ca9b8a
3 changed files with 313 additions and 24 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue