diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index e77b57d..e97738c 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -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 } diff --git a/zddc/internal/fs/tree.go b/zddc/internal/fs/tree.go index db15ecc..bd51814 100644 --- a/zddc/internal/fs/tree.go +++ b/zddc/internal/fs/tree.go @@ -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 } diff --git a/zddc/internal/handler/archivehandler.go b/zddc/internal/handler/archivehandler.go index 5d5f041..eb9c538 100644 --- a/zddc/internal/handler/archivehandler.go +++ b/zddc/internal/handler/archivehandler.go @@ -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 } diff --git a/zddc/internal/handler/directory.go b/zddc/internal/handler/directory.go index b915802..18770eb 100644 --- a/zddc/internal/handler/directory.go +++ b/zddc/internal/handler/directory.go @@ -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 } diff --git a/zddc/internal/handler/formhandler.go b/zddc/internal/handler/formhandler.go index cd0f0f6..8cb65dd 100644 --- a/zddc/internal/handler/formhandler.go +++ b/zddc/internal/handler/formhandler.go @@ -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 } diff --git a/zddc/internal/handler/profilehandler.go b/zddc/internal/handler/profilehandler.go index c1e6013..6adf970 100644 --- a/zddc/internal/handler/profilehandler.go +++ b/zddc/internal/handler/profilehandler.go @@ -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 diff --git a/zddc/internal/handler/projectshandler.go b/zddc/internal/handler/projectshandler.go index eb8fc6c..c35c02d 100644 --- a/zddc/internal/handler/projectshandler.go +++ b/zddc/internal/handler/projectshandler.go @@ -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 /.zddc — optional, ignored on parse error. diff --git a/zddc/internal/handler/subtreezip.go b/zddc/internal/handler/subtreezip.go index 6a058a2..d1e51ed 100644 --- a/zddc/internal/handler/subtreezip.go +++ b/zddc/internal/handler/subtreezip.go @@ -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 } diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index 799dd2f..f46b3b8 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -1559,7 +1559,7 @@ body.is-elevated {
ZDDC Table - v0.0.17-alpha · 2026-05-18 13:40:38 · 03d008f-dirty + v0.0.17-alpha · 2026-05-18 14:54:41 · 63fc433-dirty
diff --git a/zddc/internal/handler/zddcfile.go b/zddc/internal/handler/zddcfile.go index 0b90c99..72485bb 100644 --- a/zddc/internal/handler/zddcfile.go +++ b/zddc/internal/handler/zddcfile.go @@ -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 /.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 } diff --git a/zddc/internal/policy/policy.go b/zddc/internal/policy/policy.go index 52c26d2..25c9dbc 100644 --- a/zddc/internal/policy/policy.go +++ b/zddc/internal/policy/policy.go @@ -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