fix(policy): read-path ACL honors admin bypass via AllowFromChainP

Reads (apps resolution, directory listing, file GET, archive index,
profile pages, subtree zip, form render) used policy.AllowFromChain
with email — no admin-bypass branch fired even for elevated admins,
because IsActiveAdmin only landed in AllowActionFromChainP.

Symptom: elevated admin navigating to /browse.html got 403 because
the root cascade has no explicit read grants in my refactored root
.zddc (role memberships + admins only; no acl.permissions). The
app-resolution path's AllowFromChain didn't see admin status.

Fix: new policy.AllowFromChainP that forwards to
AllowActionFromChainP(action=read). Migrate every read-path caller
to the principal-aware variant. The decider's single bypass branch
now fires uniformly across read and write decisions.

Migrated:
  cmd/zddc-server/main.go        (9 sites)
  handler/directory.go           (1)
  handler/archivehandler.go      (2)
  handler/zddcfile.go            (1)
  handler/formhandler.go         (3)
  handler/projectshandler.go     (1; EnumerateProjects sig takes Principal)
  handler/subtreezip.go          (1)
  fs/tree.go                     (1; uses already-built principal)

profilehandler.go:400 stays on AllowFromChain — it probes ACL for a
DIFFERENT email (the enumeration target, not the request principal),
so admin bypass on the request's principal doesn't apply.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-18 09:54:46 -05:00
parent 63fc4338b6
commit fd4f03afc3
11 changed files with 33 additions and 26 deletions

View file

@ -620,7 +620,7 @@ func serveSpecializedNoSlash(cfg config.Config, appsSrv *apps.Server, w http.Res
return false
}
chain, _ := zddc.EffectivePolicy(cfg.Root, dirAbs)
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return true
}
@ -938,7 +938,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
if err != nil {
slog.Warn("ACL policy error on zip parent", "path", filepath.Dir(zipAbs), "err", err)
}
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
@ -1013,7 +1013,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
if r.Method == http.MethodGet || r.Method == http.MethodHead {
if bytes, ok := handler.IsDefaultMdlSpec(cfg.Root, urlPath); ok {
chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(absPath))
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
@ -1042,7 +1042,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
if r.Method == http.MethodGet || r.Method == http.MethodHead {
if absDir, ok := handler.RecognizeVirtualSubtreeZip(cfg.Root, urlPath); ok {
chain, _ := zddc.EffectivePolicy(cfg.Root, absDir)
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
@ -1051,7 +1051,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
}
if mdAbs, format, ok := handler.RecognizeVirtualConvert(cfg.Root, urlPath); ok {
chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(mdAbs))
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
@ -1070,7 +1070,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
requestDir := filepath.Join(cfg.Root, filepath.FromSlash(requestDirRel))
if apps.AppAvailableAt(cfg.Root, requestDir, app) {
chain, _ := zddc.EffectivePolicy(cfg.Root, requestDir)
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
@ -1101,7 +1101,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
// received's chain, then serve the canonical bytes
// while keeping the workflow URL in the address bar.
chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(vr.ReceivedAbs))
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
@ -1156,7 +1156,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
isRoot := urlPath == "/"
if !isRoot {
chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
@ -1208,7 +1208,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
// Regular file: ACL on parent directory
chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(absPath))
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}

View file

@ -133,7 +133,7 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
continue
}
subURLPath := baseURL + name + "/"
allowed, _ := policy.AllowFromChain(ctx, decider, chain, userEmail, subURLPath)
allowed, _ := policy.AllowFromChainP(ctx, decider, chain, principal, subURLPath)
if !allowed {
continue // omit denied directories silently
}

View file

@ -71,7 +71,7 @@ func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter,
if err != nil {
slog.Warn("ACL policy error on resolved file", "path", fileDir, "err", err)
}
if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, "/"+target); !allowed {
if allowed, _ := policy.AllowFromChainP(ctx, decider, chain, PrincipalFromContext(r), "/"+target); !allowed {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
@ -121,7 +121,7 @@ func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseW
aclCache[fileDir] = false
return false
}
v, _ := policy.AllowFromChain(ctx, decider, chain, email, "/"+targetPath)
v, _ := policy.AllowFromChainP(ctx, decider, chain, PrincipalFromContext(r), "/"+targetPath)
aclCache[fileDir] = v
return v
}

View file

@ -77,7 +77,7 @@ func ServeDirectory(cfg config.Config, appsSrv *apps.Server, w http.ResponseWrit
}
isRoot := dirPath == ""
if !isRoot {
if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, urlPath); !allowed {
if allowed, _ := policy.AllowFromChainP(ctx, decider, chain, PrincipalFromContext(r), urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}

View file

@ -202,8 +202,6 @@ func ServeForm(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *ht
// in v0 — POST returns JSON 422 and the client patches errors into the live
// form via JS).
func serveFormRender(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *http.Request, validationErrs []jsonschema.Error) {
email := EmailFromContext(r)
// ACL: read-rights at the directory holding the spec (and, for edits, at
// the directory holding the data file). Cascade chain is the same for
// every entity in the same directory — a single check covers both.
@ -215,7 +213,7 @@ func serveFormRender(cfg config.Config, req *FormRequest, w http.ResponseWriter,
if err != nil {
slog.Warn("form: policy error", "path", gateDir, "err", err)
}
if allowed, _ := policy.AllowFromChain(r.Context(), DeciderFromContext(r), chain, email, r.URL.Path); !allowed {
if allowed, _ := policy.AllowFromChainP(r.Context(), DeciderFromContext(r), chain, PrincipalFromContext(r), r.URL.Path); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
@ -294,7 +292,7 @@ func serveFormCreate(cfg config.Config, req *FormRequest, w http.ResponseWriter,
if err != nil {
slog.Warn("form: policy error", "path", gateDir, "err", err)
}
if allowed, _ := policy.AllowFromChain(r.Context(), DeciderFromContext(r), chain, email, r.URL.Path); !allowed {
if allowed, _ := policy.AllowFromChainP(r.Context(), DeciderFromContext(r), chain, PrincipalFromContext(r), r.URL.Path); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
@ -377,7 +375,7 @@ func serveFormUpdate(cfg config.Config, req *FormRequest, w http.ResponseWriter,
if err != nil {
slog.Warn("form: policy error", "path", req.DataPath, "err", err)
}
if allowed, _ := policy.AllowFromChain(r.Context(), DeciderFromContext(r), chain, email, r.URL.Path); !allowed {
if allowed, _ := policy.AllowFromChainP(r.Context(), DeciderFromContext(r), chain, PrincipalFromContext(r), r.URL.Path); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}

View file

@ -165,7 +165,7 @@ func enumerateAccess(ctx context.Context, decider policy.Decider, cfg config.Con
EmailHeader: cfg.EmailHeader,
IsSuperAdmin: zddc.IsAdmin(cfg.Root, p),
}
view.Projects, _ = EnumerateProjects(ctx, decider, cfg, p.Email)
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

View file

@ -30,7 +30,7 @@ type ProjectInfo struct {
// EnumerateProjects returns the visible top-level projects for the given
// caller. Exported for the profile page's server-rendered project list.
// A nil decider falls back to the internal Go evaluator.
func EnumerateProjects(ctx context.Context, decider policy.Decider, cfg config.Config, email string) ([]ProjectInfo, error) {
func EnumerateProjects(ctx context.Context, decider policy.Decider, cfg config.Config, p zddc.Principal) ([]ProjectInfo, error) {
if decider == nil {
decider = &policy.InternalDecider{}
}
@ -59,7 +59,7 @@ func EnumerateProjects(ctx context.Context, decider policy.Decider, cfg config.C
if err != nil {
slog.Warn("ACL policy error", "path", absPath, "err", err)
}
if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, "/"+name+"/"); !allowed {
if allowed, _ := policy.AllowFromChainP(ctx, decider, chain, p, "/"+name+"/"); !allowed {
continue
}
// Title comes from <project>/.zddc — optional, ignored on parse error.

View file

@ -108,7 +108,7 @@ func ServeSubtreeZip(cfg config.Config, w http.ResponseWriter, r *http.Request,
return
}
email := EmailFromContext(r)
principal := PrincipalFromContext(r)
decider := DeciderFromContext(r)
ctx := r.Context()
@ -128,7 +128,7 @@ func ServeSubtreeZip(cfg config.Config, w http.ResponseWriter, r *http.Request,
if relErr == nil && rel != "." {
urlPath = "/" + filepath.ToSlash(rel)
}
v, _ := policy.AllowFromChain(ctx, decider, chain, email, urlPath)
v, _ := policy.AllowFromChainP(ctx, decider, chain, principal, urlPath)
aclCache[fileDir] = v
return v
}

View file

@ -1559,7 +1559,7 @@ body.is-elevated {
</svg>
<div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-18 13:40:38 · 03d008f-dirty</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-18 14:54:41 · 63fc433-dirty</span></span>
</div>
</div>
<div class="header-right">

View file

@ -53,7 +53,6 @@ func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) {
http.StatusMethodNotAllowed)
return
}
email := EmailFromContext(r)
decider := DeciderFromContext(r)
// URL is <dir>/.zddc. Strip the leaf to get the directory.
@ -92,7 +91,7 @@ func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if allowed, _ := policy.AllowFromChain(r.Context(), decider, chain, email, dirURL); !allowed {
if allowed, _ := policy.AllowFromChainP(r.Context(), decider, chain, PrincipalFromContext(r), dirURL); !allowed {
http.NotFound(w, r) // hide existence from unauthorised callers
return
}

View file

@ -379,6 +379,16 @@ func AllowActionFromChain(ctx context.Context, d Decider, chain zddc.PolicyChain
return d.Allow(ctx, in)
}
// AllowFromChainP is the principal-aware read shortcut. Equivalent to
// AllowActionFromChainP with ActionRead. Use this when you want the
// decider's admin-bypass branch to fire on reads (apps resolution,
// directory listings, file GETs, archive index, profile pages) for
// elevated admins — without it, an admin who'd otherwise have no
// explicit read grant on a path 403s.
func AllowFromChainP(ctx context.Context, d Decider, chain zddc.PolicyChain, p zddc.Principal, path string) (bool, error) {
return AllowActionFromChainP(ctx, d, chain, p, path, ActionRead)
}
// AllowActionFromChainP is the principal-aware entry point. Computes
// IsActiveAdmin from the chain + Principal.Elevated and threads it
// into AllowInput, so the decider's single admin-bypass branch fires