Single audit pass that removes pre-release back-compat, consolidates the
admin-policy decider, and fixes the .zddc write path.
Field removal — acl.allow / acl.deny:
- Drop ACLRules.Allow / Deny struct fields and mergeLegacyACL().
- Remove walker / lookups / validate / decider branches that read them.
- Migrate every test fixture (YAML strings and ACLRules struct literals)
to acl.permissions: { principal → verb-set }.
- Rewrite both bundled Rego policies (access.rego, access_federal.rego)
to traverse level.acl.permissions; rewrite parity-test helpers.
- Update create-project form (profile page) to collect permissions
instead of allow/deny lists.
Admin decider consolidation:
- Delete zddc.CanEditZddc — strict-ancestor rule retired. Subtree admins
own their own .zddc; the policy decider's IsActiveAdmin short-circuit
is the single bypass site.
- Migrate tablehandler.ServeTable to AllowActionFromChainP — closes the
same Forbidden bug already fixed for /browse.html.
- Drop AccessView.EditableParentChoices and treeEntry.CanEdit (always
true after the retirement). Profile page renders AdminSubtrees
directly for both lists.
- Drop the excludeLeaf parameter from AdminLevelInChain /
IsAdminForChain — no production caller passed true.
Dead code removed:
- policy.AllowWriteFromChain (zero production callers, zero tests).
- zddc.AllowedWithChain (zero production callers; tests deleted).
ModeStrict retirement — federal posture is OPA-only:
- Delete cascade_mode.go / cascade_mode_test.go and the ModeStrict
branches in cascade.go and acl.go.
- Drop --cascade-mode flag, CascadeMode config field, and the
InternalDecider.Mode field.
- Drop the mode parameter from every cascade helper:
GrantedVerbsAtLevel, AllowedAction, EffectiveVerbs,
EffectiveVerbsRange, RoleMembers, MatchesPrincipal,
MatchingPrincipals, WormZoneGrant, PolicyChain.VisibleStart.
- Strip cascade_mode from /.profile/config and
/.profile/effective-policy responses.
- Refresh README / ARCHITECTURE.md to describe federal posture as
"deploy OPA with access_federal.rego" (NIST AC-6); the bundled Rego
is the parent-deny-is-absolute variant. The in-process Go evaluator
implements only the commercial cascade.
Legacy redirects + .admin.css fallback:
- Drop /<dir>/.zddc.html → ?file=.zddc redirect and its test.
- Drop ?zip=1 retired comment + legacy test (handled by the
.zip virtual-URL path; covered by TestServeSubtreeZip).
- Drop .admin.css fallback in profile_assets.go — only .profile.css now.
- Refresh stale "retired" / "back-compat" / "legacy" comment markers.
.zddc write path fix:
- Dispatcher: route only GET/HEAD on .zddc URLs to ServeZddcFile; carve
.zddc out of the dot-prefix guard so PUT/DELETE/POST reach
ServeFileAPI. Before this, .zddc writes 405'd at ServeZddcFile and
the YAML editor's save flow had no live path.
- ServeFileAPI.resolveTargetPath: same .zddc-leaf carve-out so the file
API accepts the path; intermediate dot dirs (.zddc.d/) stay reserved.
- Listing: compute Writable per-file with ActionAdmin for .zddc
(matches the file API's gate) instead of ActionWrite for everything.
- Virtual .zddc placeholder: compute Writable via the same
parentActiveAdmin || ActionAdmin path. Was always false before.
- browse YAML editor canSave: exempt virtual .zddc — the synthetic
body is designed to materialize on PUT.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
514 lines
18 KiB
Go
514 lines
18 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)))
|
|
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"`
|
|
}
|
|
|
|
// 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.
|
|
func enumerateAccess(ctx context.Context, decider policy.Decider, cfg config.Config, p zddc.Principal) AccessView {
|
|
view := AccessView{
|
|
Email: p.Email,
|
|
EmailHeader: cfg.EmailHeader,
|
|
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
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
writeJSON(w, response{
|
|
ConfiguredEmailHeader: cfg.EmailHeader,
|
|
ObservedEmail: r.Header.Get(cfg.EmailHeader),
|
|
ResolvedEmail: email,
|
|
RemoteAddr: r.RemoteAddr,
|
|
Method: r.Method,
|
|
URL: r.URL.String(),
|
|
Headers: headers,
|
|
})
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
writeJSON(w, 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,
|
|
})
|
|
}
|
|
|
|
// serveProfileLogs returns the ring buffer's current contents. Optional query
|
|
// params: level=debug|info|warn|error and since=<RFC3339>.
|
|
func serveProfileLogs(ring *LogRing, w http.ResponseWriter, r *http.Request) {
|
|
if ring == nil {
|
|
writeJSON(w, []LogEntry{})
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
writeJSON(w, 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
|
|
}
|