ZDDC/zddc/internal/handler/profilehandler.go
2026-06-11 13:32:31 -05:00

688 lines
25 KiB
Go

package handler
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"path/filepath"
"sort"
"strings"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// ProfilePathPrefix is the URL prefix at which the user-profile page is
// served. The dot-prefix keeps the namespace out of project-name space
// (resolvePath rejects dot-prefixed user paths) and matches the `.zddc`
// / `.archive` reserved-prefix convention.
const ProfilePathPrefix = "/.profile"
// ServeProfile is the entry point for /.profile/* routes. The top-level
// page and the access-summary JSON are reachable to anyone (anonymous
// included); admin-only sub-resources (whoami / config / logs / projects /
// the .zddc editor / reindex) keep their existing per-resource 404 leakage
// gates.
func ServeProfile(cfg config.Config, ring *LogRing, idx *archive.Index, w http.ResponseWriter, r *http.Request) {
sub := strings.TrimPrefix(r.URL.Path, ProfilePathPrefix)
if sub == "" {
sub = "/"
}
// /assets/ serves the profile page's custom.css when an operator
// has placed one at root.
if strings.HasPrefix(sub, "/assets/") {
serveProfileAssets(cfg, w, r)
return
}
email := EmailFromContext(r)
// adminOnly wraps an admin-gated sub-handler. Routes that need root-
// admin authority (sudo-style, elevation-gated) deny with 404 (not
// 403) so a non-admin probing the namespace can't enumerate which
// admin-only resources exist. Single helper instead of five copy-
// pasted gates.
adminOnly := func(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !zddc.IsAdmin(cfg.Root, PrincipalFromContext(r)) {
http.NotFound(w, r)
return
}
fn(w, r)
}
}
switch sub {
case "/", "":
serveProfilePage(cfg, w, r)
case "/access":
writeJSON(w, enumerateAccess(r.Context(), DeciderFromContext(r), cfg, PrincipalFromContext(r), r.URL.Query().Get("path")))
case "/projects":
serveProfileProjectsCreate(cfg, w, r)
case "/whoami":
adminOnly(func(w http.ResponseWriter, r *http.Request) {
serveProfileWhoami(cfg, email, w, r)
})(w, r)
case "/config":
adminOnly(func(w http.ResponseWriter, r *http.Request) {
serveProfileConfig(cfg, w, r)
})(w, r)
case "/logs":
adminOnly(func(w http.ResponseWriter, r *http.Request) {
serveProfileLogs(ring, w, r)
})(w, r)
case "/effective-policy":
adminOnly(func(w http.ResponseWriter, r *http.Request) {
serveProfileEffectivePolicy(cfg, w, r)
})(w, r)
case "/reindex":
adminOnly(func(w http.ResponseWriter, r *http.Request) {
serveProfileReindex(cfg, idx, email, w, r)
})(w, r)
default:
http.NotFound(w, r)
}
}
// serveProfileReindex is an admin-only POST endpoint that triggers an
// immediate full re-walk of the archive index. Useful when a write has
// landed on the share via a path the local watcher can't see (other SMB
// clients, the just-restarted dev pod hitting prod data, etc.) and the
// operator wants the index updated without waiting for the next periodic
// rescan.
func serveProfileReindex(cfg config.Config, idx *archive.Index, email string, w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
dur, projects, tracking, err := idx.Rebuild(cfg.Root)
if err != nil {
slog.Warn("admin reindex failed", "email", email, "err", err, "duration", dur)
http.Error(w, fmt.Sprintf("reindex failed: %v", err), http.StatusInternalServerError)
return
}
slog.Info("admin reindex ok", "email", email, "duration", dur, "projects", projects, "tracking", tracking)
writeJSON(w, map[string]any{
"duration_ms": dur.Milliseconds(),
"project_count": projects,
"tracking_count": tracking,
})
}
// treeEntry is one row in the AccessView's AdminSubtrees list — every
// directory containing a .zddc that the caller administers. The profile
// page renders them inline; the create-project form's parent-selector
// seeds from the same list.
type treeEntry struct {
Path string `json:"path"`
Title string `json:"title,omitempty"`
}
// AccessView is the data the profile page lazy-loads from /.profile/access
// after first paint. The HTML shell renders only Email/EmailHeader/
// IsSuperAdmin (all cheap); Projects + AdminSubtrees + HasAnyAdminScope come
// in via JS. AdminSubtrees doubles as the create-project parent-selector
// source — every entry is editable, since subtree admins own their own
// .zddc.
//
// IsSuperAdmin and HasAnyAdminScope reflect EFFECTIVE authority — gated
// by elevation. CanElevate is the independent "do you have any admin
// grant ANYWHERE in the tree, regardless of elevation?" signal that the
// header elevation toggle reads to decide whether to show itself.
type AccessView struct {
Email string `json:"email"`
EmailHeader string `json:"email_header"`
IsSuperAdmin bool `json:"is_super_admin"`
HasAnyAdminScope bool `json:"has_any_admin_scope"`
CanElevate bool `json:"can_elevate"`
// CanCreateProject is true when the caller is authorized to mkdir a
// new top-level project — either via the root .zddc granting `c` to
// their email/role, or via super-admin authority (elevated). Drives
// the visibility of the profile page's "+ New project" form so the
// UI doesn't dangle an affordance the server would 404.
CanCreateProject bool `json:"can_create_project"`
Projects []ProjectInfo `json:"projects"`
AdminSubtrees []treeEntry `json:"admin_subtrees"`
// Path-scoped fields. Populated only when the caller passes
// ?path=<url-path> on the request. Empty when the global view
// (no ?path=) was requested, so the existing global-shape clients
// keep their wire format unchanged.
//
// PathVerbs is the canonical "rwcda" subset granted to the caller
// at the requested path under their CURRENT elevation state. A
// top-of-tool affordance (transmittal's Publish, tables' +Add row,
// browse's +New folder toolbar) reads this once on load and gates
// itself accordingly.
//
// PathIsAdmin reports whether the caller has subtree-admin
// authority at the requested path, again under current elevation.
// Distinct from "verbs include 'a'": admin authority is the WORM-
// bypass capability, not just .zddc edit access.
//
// PathCanElevateGrant is the verb set the caller would hold AT
// THIS PATH if they elevated — empty when elevation would change
// nothing (already elevated, or no admin grant on the chain).
// Drives toast offers like "Elevate to delete this file" without
// the client second-guessing the cascade.
PathVerbs string `json:"path_verbs,omitempty"`
PathIsAdmin bool `json:"path_is_admin,omitempty"`
PathCanElevateGrant string `json:"path_can_elevate_grant,omitempty"`
// PathRoles is the set of cascade roles the caller belongs to AT
// THIS PATH (e.g. ["document_controller", "project_team"]). Roles
// are cascade-scoped, so this can differ between locations — it's
// the "which roles do I hold here?" answer the browse hovercard
// surfaces. Elevation-independent (role membership, not admin).
PathRoles []string `json:"path_roles,omitempty"`
}
// enumerateAccess builds an AccessView for the given caller. Used by the
// JSON endpoint at /.profile/access; the HTML page no longer calls this on
// the request hot path — it ships a shell first and the client fetches the
// view after first paint. The principal carries elevation: an un-elevated
// admin reports IsSuperAdmin=false here, so the UI naturally renders the
// non-elevated view (no admin scaffolds shown) until the user opts in.
//
// pathQuery is the optional ?path=<url-path> query value — when non-empty
// the path-scoped fields (PathVerbs, PathIsAdmin, PathCanElevateGrant) are
// populated so a single fetch answers both "what can I do globally" and
// "what can I do at this URL". An invalid or escape-attempting path is
// silently ignored (the global fields still return).
func enumerateAccess(ctx context.Context, decider policy.Decider, cfg config.Config, p zddc.Principal, pathQuery string) AccessView {
view := AccessView{
Email: p.Email,
EmailHeader: cfg.EmailHeader,
}
// Path-scoped query: return ONLY the access for THIS location. The
// global summary (every project, every admin subtree) requires tree
// walks that are irrelevant to "what can I do here?" — and the
// hovercard calls this per folder, so paying that cost per hover
// would be wasteful. Callers that want the global view omit ?path=.
if pathQuery != "" {
populatePathScopedAccess(ctx, decider, cfg, p, pathQuery, &view)
return view
}
// Global summary (the profile page).
view.IsSuperAdmin = zddc.IsAdmin(cfg.Root, p)
view.Projects, _ = EnumerateProjects(ctx, decider, cfg, p)
view.AdminSubtrees = enumerateAdminSubtrees(cfg, p)
view.HasAnyAdminScope = view.IsSuperAdmin || len(view.AdminSubtrees) > 0
// CanElevate is the elevation-INDEPENDENT discovery flag: "does
// this user have admin authority that they could opt into?"
// Drives the header elevation toggle's visibility — an un-
// elevated admin still needs to see the toggle they'd flip.
view.CanElevate = zddc.HasAnyAdminGrant(cfg.Root, p.Email)
// CanCreateProject mirrors the gate in serveProfileProjectsCreate —
// same decider call, same authority, no daylight between the UI
// affordance and the endpoint.
if rootChain, perr := zddc.EffectivePolicy(cfg.Root, cfg.Root); perr == nil {
allowed, _ := policy.AllowActionFromChainP(ctx, decider, rootChain, p, "/", policy.ActionCreate)
view.CanCreateProject = allowed
}
return view
}
// populatePathScopedAccess fills the PathVerbs / PathIsAdmin /
// PathCanElevateGrant fields by walking the cascade at pathQuery and
// running the decider for each verb under (1) the caller's actual
// elevation and (2) a hypothetical elevated principal. Path resolution
// mirrors serveProfileEffectivePolicy: must start with "/", must not
// escape ZDDC_ROOT. Validation failures leave the fields empty rather
// than 400ing — the global view is still useful, and the client can
// detect absence.
func populatePathScopedAccess(ctx context.Context, decider policy.Decider, cfg config.Config, p zddc.Principal, pathQuery string, view *AccessView) {
if !strings.HasPrefix(pathQuery, "/") {
return
}
rel := strings.TrimPrefix(pathQuery, "/")
rel = strings.TrimSuffix(rel, "/")
absDir, ok := safeJoin(cfg.Root, rel)
if !ok {
return
}
chain, err := zddc.EffectivePolicy(cfg.Root, absDir)
if err != nil {
return
}
verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, p, pathQuery)
view.PathVerbs = verbs.String()
view.PathIsAdmin = p.Elevated && p.Email != "" && zddc.IsAdminForChain(chain, p.Email)
// Which cascade roles the caller holds at this path — the answer to
// "the system thinks I'm a document_controller here, right?".
view.PathRoles = zddc.RolesForPrincipalInChain(chain, p.Email)
// would_elevate_grant: only meaningful when (a) the caller isn't
// already elevated and (b) elevation would actually change the
// verb set. Avoid noise — an empty value tells the client there
// is nothing to offer.
if !p.Elevated && p.Email != "" && zddc.IsAdminForChain(chain, p.Email) {
elevatedP := zddc.Principal{Email: p.Email, Elevated: true}
ifElevated := policy.EffectiveVerbsFromChainP(ctx, decider, chain, elevatedP, pathQuery)
if ifElevated != verbs {
view.PathCanElevateGrant = ifElevated.String()
}
}
}
// enumerateAdminSubtrees lists every directory containing a .zddc that the
// caller can see as an admin (super-admin or subtree-admin). Every entry
// is editable — subtree admins own their own .zddc. Returns empty for an
// un-elevated principal — the elevation flag short-circuits each admin
// check below.
func enumerateAdminSubtrees(cfg config.Config, p zddc.Principal) []treeEntry {
dirs, _ := zddc.ScanZddcFiles(cfg.Root)
out := make([]treeEntry, 0, len(dirs))
for _, d := range dirs {
if !zddc.IsSubtreeAdmin(cfg.Root, d, p) && !zddc.IsAdmin(cfg.Root, p) {
continue
}
var title string
if zf, err := zddc.ParseFile(filepath.Join(d, ".zddc")); err == nil {
title = zf.Title
}
out = append(out, treeEntry{
Path: urlPathOf(cfg.Root, d),
Title: title,
})
}
return out
}
// writeJSON writes v as indented JSON. Sets Content-Type and disables caching
// (profile views are always live).
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(v)
}
// serveProfileWhoami returns the data needed to debug header-passthrough
// problems: which header the server is configured to read, what value (if
// any) arrived under that header, the resolved email, and a dump of every
// header on the request.
func serveProfileWhoami(cfg config.Config, email string, w http.ResponseWriter, r *http.Request) {
keys := make([]string, 0, len(r.Header))
for k := range r.Header {
keys = append(keys, k)
}
sort.Strings(keys)
headers := make(map[string][]string, len(keys))
for _, k := range keys {
headers[k] = r.Header.Values(k)
}
type response struct {
ConfiguredEmailHeader string `json:"configured_email_header"`
ObservedEmail string `json:"observed_email"`
ResolvedEmail string `json:"resolved_email"`
RemoteAddr string `json:"remote_addr"`
Method string `json:"method"`
URL string `json:"url"`
Headers map[string][]string `json:"headers"`
}
resp := response{
ConfiguredEmailHeader: cfg.EmailHeader,
ObservedEmail: r.Header.Get(cfg.EmailHeader),
ResolvedEmail: email,
RemoteAddr: r.RemoteAddr,
Method: r.Method,
URL: r.URL.String(),
Headers: headers,
}
rows := []map[string]interface{}{
kvRow("Configured email header", resp.ConfiguredEmailHeader),
kvRow("Observed email (at that header)", resp.ObservedEmail),
kvRow("Resolved email", resp.ResolvedEmail),
kvRow("Remote addr", resp.RemoteAddr),
kvRow("Method", resp.Method),
kvRow("URL", resp.URL),
}
for _, k := range keys {
rows = append(rows, kvRow("header: "+k, strings.Join(headers[k], ", ")))
}
serveDiagTable(w, r, "Whoami", "How the server sees this request (identity + headers).", kvColumns, rows, resp)
}
// serveProfileConfig dumps the parsed Config. TLS cert/key paths are echoed,
// not their file contents; nothing else here is secret.
func serveProfileConfig(cfg config.Config, w http.ResponseWriter, r *http.Request) {
type response struct {
Root string `json:"root"`
Addr string `json:"addr"`
TLSCert string `json:"tls_cert"`
TLSKey string `json:"tls_key"`
TLSMode string `json:"tls_mode"`
LogLevel string `json:"log_level"`
IndexPath string `json:"index_path"`
EmailHeader string `json:"email_header"`
CORSOrigins []string `json:"cors_origins"`
}
resp := response{
Root: cfg.Root,
Addr: cfg.Addr,
TLSCert: cfg.TLSCert,
TLSKey: cfg.TLSKey,
TLSMode: cfg.TLSMode,
LogLevel: cfg.LogLevel,
IndexPath: cfg.IndexPath,
EmailHeader: cfg.EmailHeader,
CORSOrigins: cfg.CORSOrigins,
}
rows := []map[string]interface{}{
kvRow("Root", resp.Root),
kvRow("Addr", resp.Addr),
kvRow("TLS cert", resp.TLSCert),
kvRow("TLS key", resp.TLSKey),
kvRow("TLS mode", resp.TLSMode),
kvRow("Log level", resp.LogLevel),
kvRow("Index path", resp.IndexPath),
kvRow("Email header", resp.EmailHeader),
kvRow("CORS origins", strings.Join(resp.CORSOrigins, ", ")),
}
serveDiagTable(w, r, "Server config", "Effective server configuration.", kvColumns, rows, resp)
}
// serveProfileLogs returns the ring buffer's current contents. Optional query
// params: level=debug|info|warn|error and since=<RFC3339>.
// serveDiagTable renders an admin-diagnostic collection through the shared
// tables engine (header chrome + sortable/filterable columns) for browsers,
// while keeping the raw JSON for scripted callers — content-negotiated on
// Accept. Read-only; no apiActions. rawJSON is the existing JSON body, so the
// machine contract is unchanged. The profile page links to these endpoints,
// so a browser click lands on a real page, not raw JSON.
func serveDiagTable(w http.ResponseWriter, r *http.Request, title, desc string, columns, rows []map[string]interface{}, rawJSON interface{}) {
if !strings.Contains(r.Header.Get("Accept"), "text/html") || len(EmbeddedTablesHTML()) == 0 {
writeJSON(w, rawJSON)
return
}
injected, err := injectTableContextObj(EmbeddedTablesHTML(), map[string]interface{}{
"title": title, "description": desc, "addable": false, "readOnly": true,
"columns": columns, "rows": rows,
})
if err != nil {
writeJSON(w, rawJSON)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(injected)
}
func diagCol(field, title, width string) map[string]interface{} {
c := map[string]interface{}{"field": field, "title": title}
if width != "" {
c["width"] = width
}
return c
}
// kvRow / kvColumns render a record as a two-column Field/Value table.
func kvRow(field string, value interface{}) map[string]interface{} {
return map[string]interface{}{"editable": false, "data": map[string]interface{}{"field": field, "value": fmt.Sprintf("%v", value)}}
}
var kvColumns = []map[string]interface{}{
diagCol("field", "Field", "18em"),
diagCol("value", "Value", ""),
}
func serveProfileLogs(ring *LogRing, w http.ResponseWriter, r *http.Request) {
entries := []LogEntry{}
if ring != nil {
entries = ring.Snapshot()
}
if levelStr := r.URL.Query().Get("level"); levelStr != "" {
min := levelRank(levelStr)
out := entries[:0]
for _, e := range entries {
if levelRank(strings.ToLower(e.Level)) >= min {
out = append(out, e)
}
}
entries = out
}
if sinceStr := r.URL.Query().Get("since"); sinceStr != "" {
if since, err := time.Parse(time.RFC3339, sinceStr); err == nil {
out := entries[:0]
for _, e := range entries {
if !e.Time.Before(since) {
out = append(out, e)
}
}
entries = out
}
}
rows := make([]map[string]interface{}, 0, len(entries))
for i := len(entries) - 1; i >= 0; i-- { // newest first
e := entries[i]
detail := ""
if len(e.Attrs) > 0 {
if b, err := json.Marshal(e.Attrs); err == nil {
detail = string(b)
}
}
rows = append(rows, map[string]interface{}{"editable": false, "data": map[string]interface{}{
"time": e.Time.Format("2006-01-02 15:04:05"),
"level": e.Level,
"message": e.Message,
"detail": detail,
}})
}
serveDiagTable(w, r, "Server logs", "Recent server log entries (newest first).",
[]map[string]interface{}{
diagCol("time", "Time", "13em"),
diagCol("level", "Level", "6em"),
diagCol("message", "Message", ""),
diagCol("detail", "Detail", ""),
}, rows, entries)
}
func levelRank(s string) int {
switch strings.ToLower(s) {
case "debug":
return 0
case "info":
return 1
case "warn", "warning":
return 2
case "error":
return 3
default:
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": {"permissions": {...}}, "admins": [...]},
// {"path": "/Project-X/", "exists": false},
// {"path": "/Project-X/sub/", "exists": true, "acl": {"permissions": {...}}}
// ]
// }
// }
//
// 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"`
// Inherit is the level's explicit inherit setting if present
// (nil for absent — defaults to "inherit normally"). When
// false, this level fences ancestors above it from descendants.
Inherit *bool `json:"inherit,omitempty"`
}
// 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"`
// VisibleStart is the lowest chain index whose grants are
// visible to evaluation at the leaf, accounting for any
// inherit:false fence.
VisibleStart int `json:"visible_start"`
Levels []levelView `json:"levels"`
} `json:"chain"`
}{
Path: probePath,
Email: probeEmail,
Decision: allow,
DeciderKind: deciderKind(decider),
}
out.Chain.HasAnyFile = chain.HasAnyFile
out.Chain.VisibleStart = chain.VisibleStart(len(chain.Levels) - 1)
// 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.Permissions) > 0 || lvl.ACL.Inherit != nil,
Inherit: lvl.ACL.Inherit,
}
if entry.Exists {
entry.Acl = &lvl.ACL
entry.Admins = lvl.Admins
}
// Per-level email match: which permissions entry at this level
// would hit the email? Empty verbs = explicit deny; any non-
// empty verbs = grant. Mirrors GrantedVerbsAtLevel.
anyMatch := false
decisionAtLevel := "no_match"
for pattern, verbs := range lvl.ACL.Permissions {
if !zddc.MatchesPattern(pattern, probeEmail) {
continue
}
anyMatch = true
if verbs == "" {
decisionAtLevel = "deny"
break
}
decisionAtLevel = "allow"
// Don't break — keep scanning so an explicit deny still
// wins over a same-level grant.
}
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
}