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:
parent
63fc4338b6
commit
fd4f03afc3
11 changed files with 33 additions and 26 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue