From e258b0fa3da45d65121db0b2c9687a4ee061808c Mon Sep 17 00:00:00 2001 From: ZDDC Date: Mon, 1 Jun 2026 11:12:39 -0500 Subject: [PATCH 1/3] feat: show effective permissions + roles per location in the browse hovercard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hovering a folder/file now shows "Your permissions" (the rwcda verbs you hold there) and "Your roles" (the cascade roles you're a member of at that location — e.g. document_controller, project_team). Roles are cascade- scoped, so they can differ by location; this answers "does the system think I'm a document_controller here?". - server: RolesForPrincipalInChain(chain, email) resolves the caller's role memberships at a path (honouring fences/resets, incl. embedded standard roles); /.profile/access?path= now returns path_roles alongside path_verbs. - browse hovercard: "Your permissions" from node.verbs (sync); "Your roles" async-filled from /.profile/access?path= via zddc.cap.at (memoised). Offline mode shows "local folder (filesystem)" and no roles row. Tests: RolesForPrincipalInChain unit tests (member union, wildcard members, non-member, fence-hides-ancestor-role, empty email). Co-Authored-By: Claude Opus 4.8 (1M context) --- browse/js/hovercard.js | 49 +++++++++++++++++ zddc/internal/handler/profilehandler.go | 25 ++++++--- zddc/internal/zddc/roles.go | 40 ++++++++++++++ zddc/internal/zddc/roles_test.go | 72 ++++++++++++++++++++++--- 4 files changed, 171 insertions(+), 15 deletions(-) diff --git a/browse/js/hovercard.js b/browse/js/hovercard.js index 3a8a7c8..54d975c 100644 --- a/browse/js/hovercard.js +++ b/browse/js/hovercard.js @@ -89,6 +89,24 @@ return 'File'; } + var VERB_NAMES = { r: 'read', w: 'write', c: 'create', d: 'delete', a: 'admin' }; + function verbsLabel(verbs) { + return ['r', 'w', 'c', 'd', 'a'] + .filter(function (v) { return verbs.indexOf(v) !== -1; }) + .map(function (v) { return VERB_NAMES[v]; }) + .join(', '); + } + // permsValue renders the per-entry verb set the principal holds here. + // Server mode: node.verbs ("rwcda" subset). Offline (FS-API) mode has + // no ACL — access is whatever the filesystem grants. + function permsValue(verbs) { + if (typeof verbs !== 'string') { + return state.source === 'fs' ? 'local folder (filesystem)' : 'unknown'; + } + if (!verbs) return 'none (read-only)'; + return verbsLabel(verbs) + ' (' + verbs + ')'; + } + function buildRowsHtml(node) { var tree = window.app.modules.tree; var z = window.zddc; @@ -147,6 +165,18 @@ if (node.modTime) html += kv('Modified', fmtDate(node.modTime)); if (node.virtual) html += kv('Virtual', 'Not yet created on disk'); + // ── Effective access for the current principal at this location ── + // "Your permissions" is the per-entry verb set (sync, from the + // listing). "Your roles" is cascade-scoped — it can differ by + // location — so it needs a path-scoped fetch; render a placeholder + // that fillRoles() updates once /.profile/access?path= resolves. + html += '
'; + html += kv('Your permissions', permsValue(node.verbs)); + if (state.source === 'server') { + html += 'Your roles' + + ''; + } + // Path comes last (longest, most likely to wrap). var path = tree ? tree.pathFor(node) : ''; if (path) html += kv('Path', path, true); @@ -239,6 +269,25 @@ render(node); position(row); card.classList.add('is-visible'); + fillRoles(row, node); + } + + // Async-fill the "Your roles" row from the path-scoped access view + // (zddc.cap.at memoises per path, so repeat hovers are instant). + // Bails if the card has moved to another row before the fetch lands. + async function fillRoles(row, node) { + if (state.source !== 'server') return; + if (!window.zddc || !window.zddc.cap) return; + var tree = window.app.modules.tree; + var path = tree ? tree.pathFor(node) : ''; + if (!path) return; + var view; + try { view = await window.zddc.cap.at(path); } catch (_e) { return; } + if (currentRow !== row) return; + var el = card && card.querySelector('#hc-roles'); + if (!el) return; + var roles = (view && Array.isArray(view.path_roles)) ? view.path_roles : []; + el.textContent = roles.length ? roles.join(', ') : 'none'; } function init() { diff --git a/zddc/internal/handler/profilehandler.go b/zddc/internal/handler/profilehandler.go index 06ec793..eed1e08 100644 --- a/zddc/internal/handler/profilehandler.go +++ b/zddc/internal/handler/profilehandler.go @@ -175,6 +175,12 @@ type AccessView struct { PathVerbs string `json:"path_verbs,omitempty"` PathIsAdmin bool `json:"path_is_admin,omitempty"` PathCanElevateGrant string `json:"path_can_elevate_grant,omitempty"` + // PathRoles is the set of cascade roles the caller belongs to AT + // THIS PATH (e.g. ["document_controller", "project_team"]). Roles + // are cascade-scoped, so this can differ between locations — it's + // the "which roles do I hold here?" answer the browse hovercard + // surfaces. Elevation-independent (role membership, not admin). + PathRoles []string `json:"path_roles,omitempty"` } // enumerateAccess builds an AccessView for the given caller. Used by the @@ -241,6 +247,9 @@ func populatePathScopedAccess(ctx context.Context, decider policy.Decider, cfg c verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, p, pathQuery) view.PathVerbs = verbs.String() view.PathIsAdmin = p.Elevated && p.Email != "" && zddc.IsAdminForChain(chain, p.Email) + // Which cascade roles the caller holds at this path — the answer to + // "the system thinks I'm a document_controller here, right?". + view.PathRoles = zddc.RolesForPrincipalInChain(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 @@ -470,17 +479,17 @@ func serveProfileEffectivePolicy(cfg config.Config, w http.ResponseWriter, r *ht allow, _ := policy.AllowFromChain(ctx, decider, chain, probeEmail, probePath) type levelView struct { - Index int `json:"index"` - ZddcPath string `json:"zddc_path"` - Exists bool `json:"exists"` - Acl *zddc.ACLRules `json:"acl,omitempty"` - Admins []string `json:"admins,omitempty"` - AnyMatch bool `json:"matches_email"` - Decision string `json:"decision_at_level"` + Index int `json:"index"` + ZddcPath string `json:"zddc_path"` + Exists bool `json:"exists"` + Acl *zddc.ACLRules `json:"acl,omitempty"` + Admins []string `json:"admins,omitempty"` + AnyMatch bool `json:"matches_email"` + Decision string `json:"decision_at_level"` // Inherit is the level's explicit inherit setting if present // (nil for absent — defaults to "inherit normally"). When // false, this level fences ancestors above it from descendants. - Inherit *bool `json:"inherit,omitempty"` + Inherit *bool `json:"inherit,omitempty"` } // Build the per-level breakdown by walking the chain levels in diff --git a/zddc/internal/zddc/roles.go b/zddc/internal/zddc/roles.go index f5f7be7..cb3758d 100644 --- a/zddc/internal/zddc/roles.go +++ b/zddc/internal/zddc/roles.go @@ -215,3 +215,43 @@ func MatchingPrincipals(chain PolicyChain, levelIdx int, email string) []string sort.Strings(out) return out } + +// RolesForPrincipalInChain returns the sorted, de-duplicated role names +// that email is a member of, as roles resolve at the chain's leaf level — +// honouring inherit:false fences and role resets via MatchesPrincipal. +// Role names declared anywhere in the visible chain OR in the embedded +// defaults are considered (so a standard role like document_controller +// that ships empty but gains members from an on-disk .zddc is reported). +// Returns nil for an empty email or empty chain. +// +// This is "which roles do I hold HERE" — roles are cascade-scoped, so the +// answer can differ between locations. The file handler surfaces it via +// /.profile/access?path=… (AccessView.PathRoles). +func RolesForPrincipalInChain(chain PolicyChain, email string) []string { + if email == "" || len(chain.Levels) == 0 { + return nil + } + leaf := len(chain.Levels) - 1 + seen := make(map[string]struct{}) + var out []string + consider := func(name string) { + if _, dup := seen[name]; dup { + return + } + seen[name] = struct{}{} + if MatchesPrincipal(name, email, chain, leaf) { + out = append(out, name) + } + } + floor := chain.VisibleStart(leaf) + for i := leaf; i >= floor; i-- { + for name := range chain.Levels[i].Roles { + consider(name) + } + } + for name := range chain.Embedded.Roles { + consider(name) + } + sort.Strings(out) + return out +} diff --git a/zddc/internal/zddc/roles_test.go b/zddc/internal/zddc/roles_test.go index d14df24..a8c19fb 100644 --- a/zddc/internal/zddc/roles_test.go +++ b/zddc/internal/zddc/roles_test.go @@ -10,7 +10,7 @@ func TestParseVerbSetRoundTrip(t *testing.T) { {"", ""}, {"r", "r"}, {"rw", "rw"}, - {"wr", "rw"}, // canonical reorder + {"wr", "rw"}, // canonical reorder {"rwcd", "rwcd"}, {"adcwr", "rwcda"}, // canonical reorder {"RWCDA", "rwcda"}, // case-insensitive @@ -50,12 +50,12 @@ func TestVerbSetHasAndUnion(t *testing.T) { func TestIsPrincipalRole(t *testing.T) { cases := map[string]bool{ - "alice@example.com": false, - "*@example.com": false, - "alice@*": false, - "_doc_controller": true, - "vendor_acme": true, - "*": true, // legacy bare wildcard — treated as role-or-pattern + "alice@example.com": false, + "*@example.com": false, + "alice@*": false, + "_doc_controller": true, + "vendor_acme": true, + "*": true, // legacy bare wildcard — treated as role-or-pattern } for in, want := range cases { if got := IsPrincipalRole(in); got != want { @@ -163,3 +163,61 @@ func TestMatchesPrincipalRoleNamePrefersRole(t *testing.T) { t.Errorf("rep@other.com should NOT match role vendor_acme — fallback to pattern would wrongly succeed") } } + +func sameStrs(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// TestRolesForPrincipalInChain — "which roles do I hold here?" honours +// member unions across the visible chain, wildcard members, and reports +// nothing for non-members / empty email. Output is sorted. +func TestRolesForPrincipalInChain(t *testing.T) { + chain := buildChain( + ZddcFile{ + Roles: map[string]Role{ + "document_controller": {Members: []string{"dc@example.com"}}, + "project_team": {Members: []string{"*@example.com"}}, + }, + ACL: aclOpen(map[string]string{"*@example.com": "r"}), + }, + // A deeper level adds another DC; the root members still union in. + ZddcFile{ + Roles: map[string]Role{ + "document_controller": {Members: []string{"vendor-dc@example.com"}}, + }, + }, + ) + + if got := RolesForPrincipalInChain(chain, "dc@example.com"); !sameStrs(got, []string{"document_controller", "project_team"}) { + t.Errorf("dc: got %v, want [document_controller project_team]", got) + } + if got := RolesForPrincipalInChain(chain, "alice@example.com"); !sameStrs(got, []string{"project_team"}) { + t.Errorf("alice: got %v, want [project_team]", got) + } + if got := RolesForPrincipalInChain(chain, "x@other.com"); len(got) != 0 { + t.Errorf("outsider: got %v, want none", got) + } + if got := RolesForPrincipalInChain(chain, ""); got != nil { + t.Errorf("empty email: got %v, want nil", got) + } +} + +// A role defined above an inherit:false fence is invisible below it, so +// membership there reports no such role. +func TestRolesForPrincipalInChain_FenceHidesAncestorRole(t *testing.T) { + chain := buildChain( + ZddcFile{Roles: map[string]Role{"document_controller": {Members: []string{"dc@example.com"}}}}, + ZddcFile{ACL: aclFenced(map[string]string{"*@vendor.com": "rwcd"}, false)}, + ) + if got := RolesForPrincipalInChain(chain, "dc@example.com"); len(got) != 0 { + t.Errorf("role above fence must be invisible below it; got %v", got) + } +} From 91e6d1ec82a2d7c569bbabf9deeaceebc2cfbe5e Mon Sep 17 00:00:00 2001 From: ZDDC Date: Mon, 1 Jun 2026 13:13:03 -0500 Subject: [PATCH 2/3] fix(browse): keep read-only YAML/.zddc selectable & copyable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The YAML viewer mounted CodeMirror with readOnly:'nocursor', which removes the textarea from focus and kills click-drag selection — so copying a read-only .zddc snippet was impossible without flipping on admin mode to make it writable. Use readOnly:true instead: non-editable but focusable, so the user can select and copy. autofocus:false still keeps arrow-key tree navigation intact until they click into the editor. Co-Authored-By: Claude Opus 4.8 (1M context) --- browse/js/preview-yaml.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/browse/js/preview-yaml.js b/browse/js/preview-yaml.js index c6b4883..fe017be 100644 --- a/browse/js/preview-yaml.js +++ b/browse/js/preview-yaml.js @@ -484,9 +484,14 @@ // .zddc files without diverting into the editor. User // clicks (or tabs) into the editor when they want to type. autofocus: false, - // CodeMirror's "nocursor" mode is the truest read-only: - // selection allowed for copy, no caret, no edit affordances. - readOnly: !writable ? 'nocursor' : false, + // Read-only uses readOnly:true (NOT "nocursor"): the editor + // stays focusable so the user can click in, select text, and + // copy — they just can't edit. "nocursor" removes the textarea + // from focus, which also kills click-drag selection (the whole + // reason a viewer would otherwise force admin mode just to copy + // a .zddc snippet). autofocus:false keeps arrow-key tree nav + // intact until the user deliberately clicks into the editor. + readOnly: !writable, }); // Stash the node on the editor so the lint helper can decide // whether to apply the .zddc schema layer. From 1cf3f3a9b3ea73556d3dd3e025bf64aedd93b638 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Mon, 1 Jun 2026 13:23:22 -0500 Subject: [PATCH 3/3] perf(server): scope /.profile/access?path= to the requested location only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit enumerateAccess always computed the global summary — every project (EnumerateProjects) and every admin subtree (enumerateAdminSubtrees tree walk) — and merely appended the path-scoped fields when ?path= was given. The browse hovercard calls this per folder hovered, so each distinct folder paid a full global enumeration for data it never reads. Split the two: a ?path= query now returns ONLY identity + path_verbs/ path_is_admin/path_can_elevate_grant/path_roles and skips the tree walks; the no-path call still returns the full global view for the profile page. Verified all path-scoped consumers (browse hovercard, form, tables) read only path_* fields; the global consumers (elevation, stage, plan-review, accept-transmittal) all call without ?path=. Co-Authored-By: Claude Opus 4.8 (1M context) --- zddc/internal/handler/profilehandler.go | 21 ++++++++++++++------ zddc/internal/handler/profilehandler_test.go | 6 ++++++ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/zddc/internal/handler/profilehandler.go b/zddc/internal/handler/profilehandler.go index eed1e08..9421209 100644 --- a/zddc/internal/handler/profilehandler.go +++ b/zddc/internal/handler/profilehandler.go @@ -197,10 +197,22 @@ type AccessView struct { // 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, - IsSuperAdmin: zddc.IsAdmin(cfg.Root, p), + Email: p.Email, + EmailHeader: cfg.EmailHeader, } + + // Path-scoped query: return ONLY the access for THIS location. The + // global summary (every project, every admin subtree) requires tree + // walks that are irrelevant to "what can I do here?" — and the + // hovercard calls this per folder, so paying that cost per hover + // would be wasteful. Callers that want the global view omit ?path=. + if pathQuery != "" { + populatePathScopedAccess(ctx, decider, cfg, p, pathQuery, &view) + return view + } + + // Global summary (the profile page). + view.IsSuperAdmin = zddc.IsAdmin(cfg.Root, p) view.Projects, _ = EnumerateProjects(ctx, decider, cfg, p) view.AdminSubtrees = enumerateAdminSubtrees(cfg, p) view.HasAnyAdminScope = view.IsSuperAdmin || len(view.AdminSubtrees) > 0 @@ -216,9 +228,6 @@ 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 } diff --git a/zddc/internal/handler/profilehandler_test.go b/zddc/internal/handler/profilehandler_test.go index 28964e4..612249f 100644 --- a/zddc/internal/handler/profilehandler_test.go +++ b/zddc/internal/handler/profilehandler_test.go @@ -540,6 +540,12 @@ acl: if alice.PathVerbs != "rw" { t.Errorf("alice PathVerbs = %q, want rw", alice.PathVerbs) } + // A path-scoped query returns ONLY the access for this location — the + // global summary (projects + admin-subtree walks) is omitted. + if len(alice.Projects) != 0 || len(alice.AdminSubtrees) != 0 || alice.CanCreateProject { + t.Errorf("path-scoped response leaked global fields: Projects=%d AdminSubtrees=%d CanCreateProject=%v", + len(alice.Projects), len(alice.AdminSubtrees), alice.CanCreateProject) + } if alice.PathIsAdmin { t.Errorf("alice PathIsAdmin = true, want false") }