feat(profile): path-scoped fields on /.profile/access?path=<url>

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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-21 08:14:38 -05:00
parent 53a10ab119
commit 477c8826a7
2 changed files with 183 additions and 2 deletions

View file

@ -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=<url-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=<url-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

View file

@ -487,6 +487,115 @@ func TestServeProfileAccessJSON_SubtreeAdminScopes(t *testing.T) {
}
}
// TestServeProfileAccessPathScoped — /.profile/access?path=<url> 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