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:
parent
53a10ab119
commit
477c8826a7
2 changed files with 183 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue