Compare commits
No commits in common. "28ebaa19cd01e5607258ce92cb6bb1e0ff4276d8" and "303bf7aade2009aa922ce80a0248a146029efc9c" have entirely different histories.
28ebaa19cd
...
303bf7aade
13 changed files with 40 additions and 270 deletions
|
|
@ -89,24 +89,6 @@
|
||||||
return 'File';
|
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) {
|
function buildRowsHtml(node) {
|
||||||
var tree = window.app.modules.tree;
|
var tree = window.app.modules.tree;
|
||||||
var z = window.zddc;
|
var z = window.zddc;
|
||||||
|
|
@ -165,18 +147,6 @@
|
||||||
if (node.modTime) html += kv('Modified', fmtDate(node.modTime));
|
if (node.modTime) html += kv('Modified', fmtDate(node.modTime));
|
||||||
if (node.virtual) html += kv('Virtual', 'Not yet created on disk');
|
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 += '<div class="tree-hovercard__sep"></div>';
|
|
||||||
html += kv('Your permissions', permsValue(node.verbs));
|
|
||||||
if (state.source === 'server') {
|
|
||||||
html += '<span class="tree-hovercard__key">Your roles</span>'
|
|
||||||
+ '<span class="tree-hovercard__val" id="hc-roles">…</span>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Path comes last (longest, most likely to wrap).
|
// Path comes last (longest, most likely to wrap).
|
||||||
var path = tree ? tree.pathFor(node) : '';
|
var path = tree ? tree.pathFor(node) : '';
|
||||||
if (path) html += kv('Path', path, true);
|
if (path) html += kv('Path', path, true);
|
||||||
|
|
@ -269,25 +239,6 @@
|
||||||
render(node);
|
render(node);
|
||||||
position(row);
|
position(row);
|
||||||
card.classList.add('is-visible');
|
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() {
|
function init() {
|
||||||
|
|
|
||||||
|
|
@ -484,14 +484,9 @@
|
||||||
// .zddc files without diverting into the editor. User
|
// .zddc files without diverting into the editor. User
|
||||||
// clicks (or tabs) into the editor when they want to type.
|
// clicks (or tabs) into the editor when they want to type.
|
||||||
autofocus: false,
|
autofocus: false,
|
||||||
// Read-only uses readOnly:true (NOT "nocursor"): the editor
|
// CodeMirror's "nocursor" mode is the truest read-only:
|
||||||
// stays focusable so the user can click in, select text, and
|
// selection allowed for copy, no caret, no edit affordances.
|
||||||
// copy — they just can't edit. "nocursor" removes the textarea
|
readOnly: !writable ? 'nocursor' : false,
|
||||||
// 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
|
// Stash the node on the editor so the lint helper can decide
|
||||||
// whether to apply the .zddc schema layer.
|
// whether to apply the .zddc schema layer.
|
||||||
|
|
|
||||||
|
|
@ -2582,7 +2582,7 @@ td[data-field="trackingNumber"] {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Archive</span>
|
<span class="app-header__title">ZDDC Archive</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-01 18:30:53 · 5ed4f85</span></span>
|
<span class="build-timestamp">v0.0.26</span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
||||||
|
|
|
||||||
|
|
@ -2476,7 +2476,7 @@ body {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Browse</span>
|
<span class="app-header__title">ZDDC Browse</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-01 18:30:54 · 5ed4f85</span></span>
|
<span class="build-timestamp">v0.0.26</span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing">⟳</button>
|
||||||
|
|
@ -9511,14 +9511,9 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
// .zddc files without diverting into the editor. User
|
// .zddc files without diverting into the editor. User
|
||||||
// clicks (or tabs) into the editor when they want to type.
|
// clicks (or tabs) into the editor when they want to type.
|
||||||
autofocus: false,
|
autofocus: false,
|
||||||
// Read-only uses readOnly:true (NOT "nocursor"): the editor
|
// CodeMirror's "nocursor" mode is the truest read-only:
|
||||||
// stays focusable so the user can click in, select text, and
|
// selection allowed for copy, no caret, no edit affordances.
|
||||||
// copy — they just can't edit. "nocursor" removes the textarea
|
readOnly: !writable ? 'nocursor' : false,
|
||||||
// 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
|
// Stash the node on the editor so the lint helper can decide
|
||||||
// whether to apply the .zddc schema layer.
|
// whether to apply the .zddc schema layer.
|
||||||
|
|
@ -9686,24 +9681,6 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
return 'File';
|
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) {
|
function buildRowsHtml(node) {
|
||||||
var tree = window.app.modules.tree;
|
var tree = window.app.modules.tree;
|
||||||
var z = window.zddc;
|
var z = window.zddc;
|
||||||
|
|
@ -9762,18 +9739,6 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
if (node.modTime) html += kv('Modified', fmtDate(node.modTime));
|
if (node.modTime) html += kv('Modified', fmtDate(node.modTime));
|
||||||
if (node.virtual) html += kv('Virtual', 'Not yet created on disk');
|
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 += '<div class="tree-hovercard__sep"></div>';
|
|
||||||
html += kv('Your permissions', permsValue(node.verbs));
|
|
||||||
if (state.source === 'server') {
|
|
||||||
html += '<span class="tree-hovercard__key">Your roles</span>'
|
|
||||||
+ '<span class="tree-hovercard__val" id="hc-roles">…</span>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Path comes last (longest, most likely to wrap).
|
// Path comes last (longest, most likely to wrap).
|
||||||
var path = tree ? tree.pathFor(node) : '';
|
var path = tree ? tree.pathFor(node) : '';
|
||||||
if (path) html += kv('Path', path, true);
|
if (path) html += kv('Path', path, true);
|
||||||
|
|
@ -9866,25 +9831,6 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
render(node);
|
render(node);
|
||||||
position(row);
|
position(row);
|
||||||
card.classList.add('is-visible');
|
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() {
|
function init() {
|
||||||
|
|
|
||||||
|
|
@ -1793,7 +1793,7 @@ body.is-elevated::after {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Classifier</span>
|
<span class="app-header__title">ZDDC Classifier</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-01 18:30:54 · 5ed4f85</span></span>
|
<span class="build-timestamp">v0.0.26</span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
||||||
|
|
|
||||||
|
|
@ -1536,7 +1536,7 @@ body {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC</span>
|
<span class="app-header__title">ZDDC</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-01 18:30:54 · 5ed4f85</span></span>
|
<span class="build-timestamp">v0.0.26</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
|
||||||
|
|
@ -2635,7 +2635,7 @@ dialog.modal--narrow {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Transmittal</span>
|
<span class="app-header__title">ZDDC Transmittal</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-01 18:30:53 · 5ed4f85</span></span>
|
<span class="build-timestamp">v0.0.26</span>
|
||||||
</div>
|
</div>
|
||||||
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
||||||
<!-- Publish split-button (Transmittal-specific primary action;
|
<!-- Publish split-button (Transmittal-specific primary action;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
||||||
archive=v0.0.27-beta · 2026-06-01 18:30:53 · 5ed4f85
|
archive=v0.0.26
|
||||||
transmittal=v0.0.27-beta · 2026-06-01 18:30:53 · 5ed4f85
|
transmittal=v0.0.26
|
||||||
classifier=v0.0.27-beta · 2026-06-01 18:30:54 · 5ed4f85
|
classifier=v0.0.26
|
||||||
landing=v0.0.27-beta · 2026-06-01 18:30:54 · 5ed4f85
|
landing=v0.0.26
|
||||||
form=v0.0.27-beta · 2026-06-01 18:30:54 · 5ed4f85
|
form=v0.0.26
|
||||||
tables=v0.0.27-beta · 2026-06-01 18:30:54 · 5ed4f85
|
tables=v0.0.26
|
||||||
browse=v0.0.27-beta · 2026-06-01 18:30:54 · 5ed4f85
|
browse=v0.0.26
|
||||||
|
|
|
||||||
|
|
@ -175,12 +175,6 @@ type AccessView struct {
|
||||||
PathVerbs string `json:"path_verbs,omitempty"`
|
PathVerbs string `json:"path_verbs,omitempty"`
|
||||||
PathIsAdmin bool `json:"path_is_admin,omitempty"`
|
PathIsAdmin bool `json:"path_is_admin,omitempty"`
|
||||||
PathCanElevateGrant string `json:"path_can_elevate_grant,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
|
// enumerateAccess builds an AccessView for the given caller. Used by the
|
||||||
|
|
@ -199,20 +193,8 @@ func enumerateAccess(ctx context.Context, decider policy.Decider, cfg config.Con
|
||||||
view := AccessView{
|
view := AccessView{
|
||||||
Email: p.Email,
|
Email: p.Email,
|
||||||
EmailHeader: cfg.EmailHeader,
|
EmailHeader: cfg.EmailHeader,
|
||||||
|
IsSuperAdmin: zddc.IsAdmin(cfg.Root, p),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.Projects, _ = EnumerateProjects(ctx, decider, cfg, p)
|
||||||
view.AdminSubtrees = enumerateAdminSubtrees(cfg, p)
|
view.AdminSubtrees = enumerateAdminSubtrees(cfg, p)
|
||||||
view.HasAnyAdminScope = view.IsSuperAdmin || len(view.AdminSubtrees) > 0
|
view.HasAnyAdminScope = view.IsSuperAdmin || len(view.AdminSubtrees) > 0
|
||||||
|
|
@ -228,6 +210,9 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -256,9 +241,6 @@ func populatePathScopedAccess(ctx context.Context, decider policy.Decider, cfg c
|
||||||
verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, p, pathQuery)
|
verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, p, pathQuery)
|
||||||
view.PathVerbs = verbs.String()
|
view.PathVerbs = verbs.String()
|
||||||
view.PathIsAdmin = p.Elevated && p.Email != "" && zddc.IsAdminForChain(chain, p.Email)
|
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
|
// would_elevate_grant: only meaningful when (a) the caller isn't
|
||||||
// already elevated and (b) elevation would actually change the
|
// already elevated and (b) elevation would actually change the
|
||||||
// verb set. Avoid noise — an empty value tells the client there
|
// verb set. Avoid noise — an empty value tells the client there
|
||||||
|
|
|
||||||
|
|
@ -540,12 +540,6 @@ acl:
|
||||||
if alice.PathVerbs != "rw" {
|
if alice.PathVerbs != "rw" {
|
||||||
t.Errorf("alice PathVerbs = %q, want rw", alice.PathVerbs)
|
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 {
|
if alice.PathIsAdmin {
|
||||||
t.Errorf("alice PathIsAdmin = true, want false")
|
t.Errorf("alice PathIsAdmin = true, want false")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1534,7 +1534,7 @@ body.is-elevated::after {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-01 18:30:54 · 5ed4f85</span></span>
|
<span class="build-timestamp">v0.0.26</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
|
||||||
|
|
@ -215,43 +215,3 @@ func MatchingPrincipals(chain PolicyChain, levelIdx int, email string) []string
|
||||||
sort.Strings(out)
|
sort.Strings(out)
|
||||||
return 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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -163,61 +163,3 @@ func TestMatchesPrincipalRoleNamePrefersRole(t *testing.T) {
|
||||||
t.Errorf("rep@other.com should NOT match role vendor_acme — fallback to pattern would wrongly succeed")
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue