From 477c8826a7ca3f89c7528fef5374645ed2f5361f Mon Sep 17 00:00:00 2001 From: ZDDC Date: Thu, 21 May 2026 08:14:38 -0500 Subject: [PATCH] feat(profile): path-scoped fields on /.profile/access?path= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existing /.profile/access stays unchanged when called without ?path=; the path-scoped fields are populated only when the caller passes a URL path, so each tool can fetch its root capabilities in one round trip and gate top-of-page affordances (transmittal Publish, tables +Add row, browse +New folder) accordingly. Three new fields (all omitempty so the global shape doesn't change): - path_verbs: rwcda subset granted at the requested path under the caller's CURRENT elevation state. - path_is_admin: subtree-admin authority at the requested path, again under current elevation. Distinct from "verbs include 'a'": admin authority is WORM-bypass capability, not just .zddc edits. - path_can_elevate_grant: verb set the caller would hold AT THIS PATH if they elevated — empty when elevation wouldn't change anything (already elevated, or no admin grant on chain). Drives toast offers like "Elevate to delete this file". 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. Co-Authored-By: Claude Opus 4.7 (1M context) --- zddc/internal/handler/profilehandler.go | 76 ++++++++++++- zddc/internal/handler/profilehandler_test.go | 109 +++++++++++++++++++ 2 files changed, 183 insertions(+), 2 deletions(-) diff --git a/zddc/internal/handler/profilehandler.go b/zddc/internal/handler/profilehandler.go index edd45b0..06ec793 100644 --- a/zddc/internal/handler/profilehandler.go +++ b/zddc/internal/handler/profilehandler.go @@ -62,7 +62,7 @@ func ServeProfile(cfg config.Config, ring *LogRing, idx *archive.Index, w http.R case "/", "": serveProfilePage(cfg, w, r) case "/access": - writeJSON(w, enumerateAccess(r.Context(), DeciderFromContext(r), cfg, PrincipalFromContext(r))) + writeJSON(w, enumerateAccess(r.Context(), DeciderFromContext(r), cfg, PrincipalFromContext(r), r.URL.Query().Get("path"))) case "/projects": serveProfileProjectsCreate(cfg, w, r) case "/whoami": @@ -150,6 +150,31 @@ type AccessView struct { 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= 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"` } // enumerateAccess builds an AccessView for the given caller. Used by the @@ -158,7 +183,13 @@ type AccessView struct { // 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 { +// +// pathQuery is the optional ?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, @@ -179,9 +210,50 @@ func enumerateAccess(ctx context.Context, decider policy.Decider, cfg config.Con allowed, _ := policy.AllowActionFromChainP(ctx, decider, rootChain, p, "/", policy.ActionCreate) view.CanCreateProject = allowed } + if pathQuery != "" { + populatePathScopedAccess(ctx, decider, cfg, p, pathQuery, &view) + } 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) + // 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 diff --git a/zddc/internal/handler/profilehandler_test.go b/zddc/internal/handler/profilehandler_test.go index 2a0c762..28964e4 100644 --- a/zddc/internal/handler/profilehandler_test.go +++ b/zddc/internal/handler/profilehandler_test.go @@ -487,6 +487,115 @@ func TestServeProfileAccessJSON_SubtreeAdminScopes(t *testing.T) { } } +// TestServeProfileAccessPathScoped — /.profile/access?path= answers +// "what can the caller do at this URL" alongside the global view. Three +// flavors cover the cases the toast/menu gating cares about: +// +// - non-admin caller with explicit ACL grant: PathVerbs reflects the +// grant; PathIsAdmin=false; PathCanElevateGrant empty (elevation +// wouldn't change anything for a non-admin). +// - un-elevated admin: PathVerbs reflects the explicit grant (no +// admin bypass yet); PathIsAdmin=false; PathCanElevateGrant carries +// the full "rwcda" elevation would unlock. +// - elevated admin: PathVerbs="rwcda" (admin bypass active); +// PathIsAdmin=true; PathCanElevateGrant empty (nothing to upgrade). +func TestServeProfileAccessPathScoped(t *testing.T) { + root := t.TempDir() + // Root admins list — sudo authority for admin@example.com (when + // elevated). Permissions grant alice rw at the project level. + if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte(`admins: + - admin@example.com +acl: + permissions: + "alice@example.com": rw +`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, "Proj"), 0o755); err != nil { + t.Fatal(err) + } + zddc.InvalidateCache(root) + zddc.InvalidateScanCache() + cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"} + ring := NewLogRing(50) + + fetch := func(email string, elevated bool) AccessView { + t.Helper() + rec := httptest.NewRecorder() + ServeProfile(cfg, ring, nil, rec, + requestAsUserMaybeElevated(http.MethodGet, "/.profile/access?path=/Proj/", email, elevated)) + if rec.Code != http.StatusOK { + t.Fatalf("email=%q elevated=%v status=%d body=%s", email, elevated, rec.Code, rec.Body.String()) + } + var v AccessView + if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil { + t.Fatalf("decode email=%q: %v", email, err) + } + return v + } + + // Non-admin caller with explicit grant: verbs reflect the ACL, + // no admin status, no elevation offer. + alice := fetch("alice@example.com", false) + if alice.PathVerbs != "rw" { + t.Errorf("alice PathVerbs = %q, want rw", alice.PathVerbs) + } + if alice.PathIsAdmin { + t.Errorf("alice PathIsAdmin = true, want false") + } + if alice.PathCanElevateGrant != "" { + t.Errorf("alice PathCanElevateGrant = %q, want empty (no admin grant on chain)", alice.PathCanElevateGrant) + } + + // Un-elevated admin: bypass not active, so explicit verbs are + // whatever ACL granted (here: nothing — admin@ has no permissions + // entry, only an admins: entry). PathCanElevateGrant tells the + // client "elevation would unlock rwcda". + adminUn := fetch("admin@example.com", false) + if adminUn.PathVerbs != "" { + t.Errorf("un-elevated admin PathVerbs = %q, want empty (no explicit grant)", adminUn.PathVerbs) + } + if adminUn.PathIsAdmin { + t.Errorf("un-elevated admin PathIsAdmin = true, want false") + } + if adminUn.PathCanElevateGrant != "rwcda" { + t.Errorf("un-elevated admin PathCanElevateGrant = %q, want rwcda", adminUn.PathCanElevateGrant) + } + + // Elevated admin: full bypass — verbs rwcda, PathIsAdmin true, + // no elevation offer (already elevated). + adminEl := fetch("admin@example.com", true) + if adminEl.PathVerbs != "rwcda" { + t.Errorf("elevated admin PathVerbs = %q, want rwcda", adminEl.PathVerbs) + } + if !adminEl.PathIsAdmin { + t.Errorf("elevated admin PathIsAdmin = false, want true") + } + if adminEl.PathCanElevateGrant != "" { + t.Errorf("elevated admin PathCanElevateGrant = %q, want empty (already elevated)", adminEl.PathCanElevateGrant) + } +} + +// TestServeProfileAccessNoPathQuery — without ?path=, the global view +// works unchanged: path-scoped fields are absent, every existing +// global field is populated. +func TestServeProfileAccessNoPathQuery(t *testing.T) { + cfg, ring := profileTestRoot(t, []string{"alice@example.com"}) + rec := httptest.NewRecorder() + ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, "/.profile/access", "alice@example.com")) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d", rec.Code) + } + var v AccessView + if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil { + t.Fatalf("decode: %v", err) + } + if v.PathVerbs != "" || v.PathIsAdmin || v.PathCanElevateGrant != "" { + t.Errorf("global view should not include path-scoped fields; got PathVerbs=%q PathIsAdmin=%v PathCanElevateGrant=%q", + v.PathVerbs, v.PathIsAdmin, v.PathCanElevateGrant) + } +} + // TestServeProfileEffectivePolicy: admin queries the cascade tracer for a // (path, email) tuple and gets back the resolved chain plus the decision. // The fixture mirrors the worked-example layout from zddc/README.md (a