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 "/", "":
|
case "/", "":
|
||||||
serveProfilePage(cfg, w, r)
|
serveProfilePage(cfg, w, r)
|
||||||
case "/access":
|
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":
|
case "/projects":
|
||||||
serveProfileProjectsCreate(cfg, w, r)
|
serveProfileProjectsCreate(cfg, w, r)
|
||||||
case "/whoami":
|
case "/whoami":
|
||||||
|
|
@ -150,6 +150,31 @@ type AccessView struct {
|
||||||
CanCreateProject bool `json:"can_create_project"`
|
CanCreateProject bool `json:"can_create_project"`
|
||||||
Projects []ProjectInfo `json:"projects"`
|
Projects []ProjectInfo `json:"projects"`
|
||||||
AdminSubtrees []treeEntry `json:"admin_subtrees"`
|
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
|
// 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
|
// view after first paint. The principal carries elevation: an un-elevated
|
||||||
// admin reports IsSuperAdmin=false here, so the UI naturally renders the
|
// admin reports IsSuperAdmin=false here, so the UI naturally renders the
|
||||||
// non-elevated view (no admin scaffolds shown) until the user opts in.
|
// 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{
|
view := AccessView{
|
||||||
Email: p.Email,
|
Email: p.Email,
|
||||||
EmailHeader: cfg.EmailHeader,
|
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)
|
allowed, _ := policy.AllowActionFromChainP(ctx, decider, rootChain, p, "/", policy.ActionCreate)
|
||||||
view.CanCreateProject = allowed
|
view.CanCreateProject = allowed
|
||||||
}
|
}
|
||||||
|
if pathQuery != "" {
|
||||||
|
populatePathScopedAccess(ctx, decider, cfg, p, pathQuery, &view)
|
||||||
|
}
|
||||||
return 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
|
// enumerateAdminSubtrees lists every directory containing a .zddc that the
|
||||||
// caller can see as an admin (super-admin or subtree-admin). Every entry
|
// 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
|
// 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
|
// TestServeProfileEffectivePolicy: admin queries the cascade tracer for a
|
||||||
// (path, email) tuple and gets back the resolved chain plus the decision.
|
// (path, email) tuple and gets back the resolved chain plus the decision.
|
||||||
// The fixture mirrors the worked-example layout from zddc/README.md (a
|
// The fixture mirrors the worked-example layout from zddc/README.md (a
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue