feat(server,shared): tell denied users who can — subtly, before wasted effort

When a user lacks permission, the app should (a) not let them do data entry it
will reject and (b) subtly say who can. General mechanism + the key gates.

Server — compute & expose "who can <verb> here":
- zddc.WhoCan(chain, verb) → Authority{Roles, People}: the acl.permissions
  grantees holding the verb across the cascade (roles + their members) plus the
  admins (who bypass). New whocan.go + whocan_test.go.
- AccessView gains path_who_can (profilehandler.go), populated only for verbs the
  caller LACKS and only when they can read the path (mirrors .zddc readability),
  so one cap.at() answers "can I?" and "if not, who?".
- writeForbiddenWho enriches the 403 body with who_can for the missing verb
  (errors.go); authorizeAction uses it (fileapi.go) as the safety net for denials
  that weren't pre-checked.

Shared — shared/cap.js:
- cap.whoCan(view, verb) + cap.denyHint(view, verb) → {text, title}, role-first
  ("Only the document controller can create here") with the people in the tooltip.
- handleForbidden appends the hint (from the 403 body, else the cached view), so
  every tool that already routes 403s through it (form save, tables save, browse)
  now explains who can — for free.

Key gates:
- Browse party-create (the reported bug): pre-check create authority on ssr/ and
  the slot BEFORE opening the picker — if the user can do neither, show the hint
  instead of the form; if only existing parties are usable, disable "+ New party"
  with the who-can hint. The post-hoc 403 catch now names who can too.
- Tables +Add row disabled state shows the who-can hint.

Plus: subtle /_apps/{browse,archive,classifier}.html links in the landing footer.

Tests: Go WhoCan unit test (role/person split, admin bypass, dedupe); cap.spec.js
(denyHint role-first/people/fallback, whoCan, handleForbidden enrichment) — 5
green; Go handler+zddc+policy suites green. (Pre-existing stale browse toolbar
test browse.spec.js:274 unaffected.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-12 14:58:20 -05:00
parent 3d553ce9d4
commit 7c0b66590c
12 changed files with 378 additions and 10 deletions

View file

@ -792,6 +792,13 @@
function openPartyPicker(opts) {
return new Promise(function (resolve) {
var kindWord = opts.kind === 'folder' ? 'folder' : 'file';
// The "+ New party" affordance is gated on create authority over ssr/
// (pre-checked in createInAggregator). When denied, disable it and say
// who can — role-first text inline, the specific people in the tooltip.
var newPartyAllowed = opts.canNewParty !== false;
var newPartyNote = newPartyAllowed ? '(registers a new party)'
: (opts.newPartyHint && opts.newPartyHint.text) || 'You cant register a new party here.';
var newPartyTitle = (!newPartyAllowed && opts.newPartyHint && opts.newPartyHint.title) || '';
var overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;';
var box = document.createElement('div');
@ -810,10 +817,10 @@
'Pick the party this ' + kindWord + ' belongs to — it lands under <code>' + escapeHtml(opts.slot) + '/&lt;party&gt;/</code>.' +
'</p>' +
'<div style="max-height:14rem;overflow-y:auto;border:1px solid rgba(0,0,0,0.1);padding:0.3rem 0.6rem;margin-bottom:0.5rem;">' +
(partyList || '<em style="color:#888;">No parties yet — create one below.</em>') +
'<label style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;cursor:pointer;border-top:1px solid rgba(0,0,0,0.05);margin-top:0.3rem;padding-top:0.5rem;">' +
'<input type="radio" name="pp-party" value="__new__">' +
'<span><strong>+ New party…</strong> <span style="color:#888;font-size:0.8rem;">(document controller only)</span></span></label>' +
(partyList || '<em style="color:#888;">No parties yet.</em>') +
'<label title="' + escapeHtml(newPartyTitle) + '" style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;border-top:1px solid rgba(0,0,0,0.05);margin-top:0.3rem;padding-top:0.5rem;' + (newPartyAllowed ? 'cursor:pointer;' : 'opacity:0.6;') + '">' +
'<input type="radio" name="pp-party" value="__new__"' + (newPartyAllowed ? '' : ' disabled') + '>' +
'<span><strong>+ New party…</strong> <span style="color:#888;font-size:0.8rem;">' + escapeHtml(newPartyNote) + '</span></span></label>' +
'</div>' +
'<div id="pp-newparty-row" style="display:none;margin-bottom:0.5rem;font-size:0.9rem;">' +
'<label for="pp-newparty">New party name</label><br>' +
@ -870,8 +877,28 @@
async function createInAggregator(agg, kind) {
var up = window.app.modules.upload;
if (!up) return;
var cap = window.zddc && window.zddc.cap;
var ssrPath = '/' + agg.project + '/ssr/';
var slotPath = '/' + agg.project + '/' + agg.slot + '/';
// Pre-check create authority BEFORE any data entry: registering a new
// party needs `c` on ssr/, creating under an existing party needs `c` on
// the slot. If the user can do neither, don't open the form just to deny
// them — say (subtly) who can. If they can only use existing parties,
// open the picker with the "+ New party" option disabled + explained.
var canNewParty = true, ssrView = null, slotView = null;
if (cap && cap.at) {
slotView = await cap.at(slotPath);
ssrView = await cap.at(ssrPath);
var canSlot = !slotView || cap.has({ verbs: slotView.path_verbs }, 'c');
canNewParty = !ssrView || cap.has({ verbs: ssrView.path_verbs }, 'c');
if (slotView && !canSlot && !canNewParty) {
statusError(cap.denyHint(slotView, 'c').text);
return;
}
}
var parties = await fetchParties(agg.project);
var choice = await openPartyPicker({ project: agg.project, slot: agg.slot, kind: kind, parties: parties });
var newPartyHint = (!canNewParty && ssrView && cap.denyHint) ? cap.denyHint(ssrView, 'c') : null;
var choice = await openPartyPicker({ project: agg.project, slot: agg.slot, kind: kind, parties: parties, canNewParty: canNewParty, newPartyHint: newPartyHint });
if (!choice) return;
// Party names are validated to a URL-safe charset, so no encoding
// needed for the party segment; makeDir/makeFile encode the leaf.
@ -894,7 +921,10 @@
} catch (e) {
var msg = (e && e.message) || String(e);
if (/\b403\b/.test(msg)) {
statusError('Not allowed — registering a new party requires the document-controller role.');
// Name who can — best-effort, for the path the denial came from.
var denied = choice.isNew ? ssrPath : ('/' + agg.project + '/' + agg.slot + '/' + choice.party + '/');
var v = (cap && cap.at) ? await cap.at(denied) : null;
statusError(v && cap.denyHint ? cap.denyHint(v, 'c').text : 'Not allowed — you dont have create access here.');
} else if (/\b409\b/.test(msg)) {
statusError('Unknown party — register it first (document controller).');
} else {

View file

@ -472,6 +472,21 @@ body {
text-decoration: underline;
}
/* Subtle standalone-tools footer. */
.landing-apps {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin: 2rem auto 1rem;
padding: 0 1rem;
max-width: 60rem;
font-size: 0.82rem;
color: var(--text-muted, #6b7280);
}
.landing-apps__label { color: var(--text-muted, #6b7280); }
.landing-apps .browse-link { margin-top: 0; }
#projectView ol {
padding-left: 1.5rem;
}

View file

@ -195,6 +195,15 @@
</div><!-- /projectView -->
</main>
<!-- Subtle links to the standalone tools (run against your own local files,
no server). Served at /_apps/<tool>.html by zddc-server. -->
<footer class="landing-apps">
<span class="landing-apps__label">Standalone tools for your own files:</span>
<a class="browse-link" href="/_apps/browse.html">Browse</a>
<a class="browse-link" href="/_apps/archive.html">Archive</a>
<a class="browse-link" href="/_apps/classifier.html">Classifier</a>
</footer>
<!-- Help Panel -->
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
<div class="help-panel__header">

View file

@ -95,6 +95,10 @@ export default defineConfig({
name: 'tables',
testMatch: 'tables.spec.js',
},
{
name: 'cap',
testMatch: 'cap.spec.js',
},
{
name: 'tables-mdl',
testMatch: 'tables-mdl.spec.js',

View file

@ -98,6 +98,48 @@
a: 'edit access rules'
};
// ── "who can?" — tell a denied user who to ask, subtly ──────────────────
// The PATTERN: gate an affordance on cap.has(node, verb) / a path view's
// path_verbs; when the verb is ABSENT, don't just disable — render
// cap.denyHint(view, verb) as the disabled control's tooltip/subtitle (or
// a small inline note) so the user learns who can do it instead of acting
// and bouncing off a 403. handleForbidden() does the same on a denial that
// slips past a pre-check.
function humanizeRole(name) { return String(name || '').replace(/[_-]+/g, ' ').trim(); }
// whoCan(view, verb) → the Authority {roles, people} the server computed for
// a verb the caller lacks at view's path, or null. `view` is a /.profile/
// access?path= result (from cap.at) OR a 403 body carrying who_can.
function whoCan(view, verb) {
if (!view) return null;
var map = view.path_who_can;
if (map && map[verb]) return map[verb];
if (view.who_can && view.missing_verb === verb) return view.who_can;
return null;
}
// denyHint(view, verb) → { text, title } for a subtle "who can" line.
// Role-first: "Only the document controller can create here", with the
// specific people (admins / role members) as the tooltip detail. Falls back
// to naming people, then to "Ask an administrator". Returns null when the
// verb is actually granted (nothing to hint) and a generic hint when no
// authority is known.
function denyHint(view, verb) {
var a = whoCan(view, verb);
var doing = VERB_LABELS[verb] || verb || 'do that';
if (!a || (!(a.roles && a.roles.length) && !(a.people && a.people.length))) {
return { text: 'Ask an administrator to ' + doing + ' here.', title: '' };
}
var people = (a.people || []).slice();
var detail = people.length ? people.join(', ') : '';
if (a.roles && a.roles.length) {
return { text: 'Only the ' + humanizeRole(a.roles[0]) + ' can ' + doing + ' here.', title: detail };
}
var shown = people.slice(0, 2).join(', ') + (people.length > 2 ? ', …' : '');
return { text: 'Ask ' + shown + ' to ' + doing + ' here.', title: detail };
}
// handleForbidden(resp, opts) — render a 403 toast naming the
// missing verb. opts.path (optional) is the URL the failed request
// hit; when provided, the helper consults /.profile/access?path= to
@ -111,8 +153,9 @@
async function handleForbidden(resp, opts) {
opts = opts || {};
var missing = '';
var body = null;
try {
var body = await resp.clone().json();
body = await resp.clone().json();
if (body && typeof body.missing_verb === 'string') {
missing = body.missing_verb;
}
@ -127,6 +170,16 @@
msg = prefix + 'Forbidden.';
}
// Append the subtle "who can" hint: prefer who_can from the 403 body,
// else fall back to the path-scoped access view. So even a denial the
// UI didn't pre-check still tells the user who to ask.
if (missing) {
var src = (body && body.who_can) ? { who_can: body.who_can, missing_verb: missing } : null;
if (!src && opts.path) src = await at(opts.path);
var hint = src ? denyHint(src, missing) : null;
if (hint && hint.text) msg += ' ' + hint.text;
}
// Optional elevate offer: only when the caller supplied a
// path AND the path-scoped access view reports an elevation
// grant covering the missing verb. Render as a clickable
@ -159,5 +212,5 @@
}
}
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden, whoCan: whoCan, denyHint: denyHint };
})();

View file

@ -152,7 +152,9 @@
if (verbs.indexOf('c') === -1) {
addRowBtn.classList.add('is-disabled');
addRowBtn.setAttribute('aria-disabled', 'true');
addRowBtn.title = "You don't have create access in this folder.";
// Tell them who can (subtly): role-first text + people in the tooltip.
var hint = window.zddc.cap.denyHint ? window.zddc.cap.denyHint(view, 'c') : null;
addRowBtn.title = hint ? (hint.text + (hint.title ? ' (' + hint.title + ')' : '')) : "You don't have create access in this folder.";
// Swallow clicks so the no-op feedback is the
// tooltip, not a 403 toast on submission.
addRowBtn.addEventListener('click', function (ev) {

66
tests/cap.spec.js Normal file
View file

@ -0,0 +1,66 @@
import { test, expect } from '@playwright/test';
import * as path from 'path';
// shared/cap.js — the "who can?" helpers (denyHint / whoCan) + handleForbidden
// enrichment. cap.js is bundled into every server-mode tool; tables.html is a
// convenient host. Pure helpers run fine on a file:// page (cap.at short-circuits
// offline, but denyHint/whoCan/handleForbidden don't need the network).
const HOST = 'file://' + path.resolve('tables/dist/tables.html');
async function load(page) {
await page.goto(HOST, { waitUntil: 'load' });
await page.waitForFunction(() => window.zddc && window.zddc.cap && window.zddc.cap.denyHint);
}
test.describe('cap.js — who-can hints', () => {
test('denyHint is role-first with people as the tooltip detail', async ({ page }) => {
await load(page);
const h = await page.evaluate(() => {
const view = { path_who_can: { c: { roles: ['document_controller'], people: ['alice@example.com', 'bob@example.com'] } } };
return window.zddc.cap.denyHint(view, 'c');
});
expect(h.text).toBe('Only the document controller can create here.'); // role-first, humanized
expect(h.title).toBe('alice@example.com, bob@example.com'); // people in the tooltip
});
test('denyHint names people when no role grants the verb', async ({ page }) => {
await load(page);
const h = await page.evaluate(() =>
window.zddc.cap.denyHint({ path_who_can: { w: { people: ['sam@example.com'] } } }, 'w'));
expect(h.text).toBe('Ask sam@example.com to write here.');
});
test('denyHint falls back to "an administrator" when nobody is named', async ({ page }) => {
await load(page);
const h = await page.evaluate(() => window.zddc.cap.denyHint({ path_who_can: {} }, 'd'));
expect(h.text).toBe('Ask an administrator to delete here.');
});
test('whoCan reads either a path view or a 403 body', async ({ page }) => {
await load(page);
const r = await page.evaluate(() => {
const fromView = window.zddc.cap.whoCan({ path_who_can: { c: { roles: ['r1'] } } }, 'c');
const fromBody = window.zddc.cap.whoCan({ missing_verb: 'c', who_can: { roles: ['r2'] } }, 'c');
const miss = window.zddc.cap.whoCan({ path_who_can: { w: {} } }, 'c');
return { fromView: fromView && fromView.roles[0], fromBody: fromBody && fromBody.roles[0], miss };
});
expect(r.fromView).toBe('r1');
expect(r.fromBody).toBe('r2');
expect(r.miss).toBeNull();
});
test('handleForbidden appends the who-can hint from the 403 body', async ({ page }) => {
await load(page);
const msg = await page.evaluate(async () => {
let captured = '';
window.zddc.toast = (m) => { captured = m; return document.createElement('div'); };
const body = JSON.stringify({ error: 'Forbidden', missing_verb: 'c', who_can: { roles: ['document_controller'], people: ['alice@example.com'] } });
const resp = new Response(body, { status: 403, headers: { 'Content-Type': 'application/json' } });
await window.zddc.cap.handleForbidden(resp, { context: 'Create' });
return captured;
});
expect(msg).toContain('You do not have create access here.');
expect(msg).toContain('Only the document controller can create here.'); // who-can appended
});
});

View file

@ -5,6 +5,7 @@ import (
"net/http"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// writeForbidden emits a 403 JSON response naming the missing verb. Used
@ -34,6 +35,25 @@ func writeForbidden(w http.ResponseWriter, action string) {
_, _ = w.Write(body)
}
// writeForbiddenWho is writeForbidden enriched with a "who_can" Authority for
// the missing verb, computed from the deny site's policy chain. Lets the toast
// tell the user who to ask even when the action wasn't pre-checked (a race, or
// a path the client didn't gate). The pre-check path (AccessView.PathWhoCan) is
// the primary surface; this is the safety net.
func writeForbiddenWho(w http.ResponseWriter, action string, chain zddc.PolicyChain) {
verb := verbForAction(action)
body := map[string]any{"error": "Forbidden", "missing_verb": verb}
if vs, ok := zddc.ParseVerbSet(verb); ok {
if a := zddc.WhoCan(chain, vs); !a.Empty() {
body["who_can"] = a
}
}
raw, _ := json.Marshal(body)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write(raw)
}
// verbForAction maps a policy.Action constant to its single-character
// verb. Mirrors policy.actionVerb but emits the wire-format letter
// rather than the bitmask, so the JSON body carries "r"/"w"/"c"/"d"/"a"

View file

@ -152,7 +152,7 @@ func authorizeAction(cfg config.Config, w http.ResponseWriter, r *http.Request,
decider := DeciderFromContext(r)
allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, urlPath, action)
if !allowed {
writeForbidden(w, action)
writeForbiddenWho(w, action, chain) // name who CAN, so the toast can explain
return false
}
return true

View file

@ -181,6 +181,15 @@ type AccessView struct {
// the "which roles do I hold here?" answer the browse hovercard
// surfaces. Elevation-independent (role membership, not admin).
PathRoles []string `json:"path_roles,omitempty"`
// PathWhoCan answers "if I can't, who can?" for the gated verbs the caller
// LACKS at this path (keyed by verb letter: "c"/"w"/"d"). Each entry names
// the roles + people that hold the verb, so a denied affordance can show a
// subtle "ask a document controller" hint instead of letting the user act
// and bounce off a 403. Populated only when the caller can READ the path
// (mirrors .zddc readability — no new disclosure), and only for verbs not
// already in PathVerbs. Omitted (nil) when the caller can do everything or
// can't read here.
PathWhoCan map[string]zddc.Authority `json:"path_who_can,omitempty"`
}
// enumerateAccess builds an AccessView for the given caller. Used by the
@ -256,6 +265,27 @@ 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)
// "If I can't, who can?" — only for verbs the caller LACKS, and only when
// they can read here (the .zddc that backs this is read-ACL-governed, so a
// reader could see it anyway). Lets a denied affordance explain itself
// up-front instead of letting the user act and bounce off a 403.
if verbs.Has(zddc.VerbR) {
who := map[string]zddc.Authority{}
for _, gv := range []struct {
letter string
v zddc.VerbSet
}{{"c", zddc.VerbC}, {"w", zddc.VerbW}, {"d", zddc.VerbD}} {
if verbs.Has(gv.v) {
continue
}
if a := zddc.WhoCan(chain, gv.v); !a.Empty() {
who[gv.letter] = a
}
}
if len(who) > 0 {
view.PathWhoCan = who
}
}
// 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)

View file

@ -0,0 +1,76 @@
package zddc
import "sort"
// Authority answers "who can do this here?" for a user who was denied an action.
// It is split so the UI can lead with the ROLE ("ask a document controller") and
// keep the specific People (admins + role members + direct email grants) as
// secondary detail. Both lists are sorted + de-duplicated.
//
// Safe to surface to anyone who can read the path: the .zddc cascade it is
// derived from is already governed by directory read-ACL, so this exposes
// nothing a reader couldn't already see in the .zddc itself.
type Authority struct {
Roles []string `json:"roles,omitempty"`
People []string `json:"people,omitempty"`
}
// Empty reports whether no authority was found (no one is named — the caller
// should fall back to "ask an administrator").
func (a Authority) Empty() bool { return len(a.Roles) == 0 && len(a.People) == 0 }
// WhoCan returns the principals that hold `verb` at the chain: every
// acl.permissions grantee across the cascade whose verb set includes it, plus
// every level's admins (who bypass the ACL entirely). Role grantees are recorded
// as Roles AND expanded to their member patterns in People; direct email/glob
// grantees go to People. Pure and side-effect-free.
func WhoCan(chain PolicyChain, verb VerbSet) Authority {
roleSet := map[string]struct{}{}
peopleSet := map[string]struct{}{}
add := func(principal string, levelIdx int) {
if principal == "" {
return
}
if IsPrincipalRole(principal) {
// A bare name is a role only if the cascade actually defines it;
// otherwise it's a wildcard pattern (e.g. "*") handled as a person.
if _, defined := lookupRoleMembers(chain, levelIdx, principal); defined {
roleSet[principal] = struct{}{}
for _, m := range RoleMembers(chain, levelIdx, principal) {
if m != "" {
peopleSet[m] = struct{}{}
}
}
return
}
}
peopleSet[principal] = struct{}{}
}
for i, level := range chain.Levels {
for principal, verbStr := range level.ACL.Permissions {
if v, _ := ParseVerbSet(verbStr); v.Has(verb) {
add(principal, i)
}
}
// Admins bypass the ACL clamp, so they can always do this verb.
for _, principal := range level.Admins {
add(principal, i)
}
}
return Authority{Roles: sortedSet(roleSet), People: sortedSet(peopleSet)}
}
func sortedSet(m map[string]struct{}) []string {
if len(m) == 0 {
return nil
}
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}

View file

@ -0,0 +1,63 @@
package zddc
import (
"reflect"
"testing"
)
func TestWhoCan(t *testing.T) {
chain := PolicyChain{
Levels: []ZddcFile{
{
ACL: ACLRules{Permissions: map[string]string{
"document_controller": "rwcda",
"carol@example.com": "rwc",
"*@example.com": "r", // read only — must NOT show up for 'c'
}},
Roles: map[string]Role{
"document_controller": {Members: []string{"alice@example.com", "bob@example.com"}},
},
Admins: []string{"super@example.com"},
},
},
HasAnyFile: true,
}
// create: the role (rwcda) + a direct grant (carol rwc) + the admin (bypass).
// The read-only *@example.com pattern is excluded.
got := WhoCan(chain, VerbC)
if want := []string{"document_controller"}; !reflect.DeepEqual(got.Roles, want) {
t.Errorf("VerbC Roles = %v, want %v", got.Roles, want)
}
if want := []string{"alice@example.com", "bob@example.com", "carol@example.com", "super@example.com"}; !reflect.DeepEqual(got.People, want) {
t.Errorf("VerbC People = %v, want %v", got.People, want)
}
// admin verb: only the role (rwcda) + admin bypass; carol (rwc) lacks 'a'.
got = WhoCan(chain, VerbA)
if wcContains(got.People, "carol@example.com") {
t.Errorf("VerbA People = %v, should not include carol (no 'a')", got.People)
}
if !wcContains(got.People, "super@example.com") {
t.Errorf("VerbA People = %v, want the admin", got.People)
}
// read: granted to the *@example.com pattern (a People entry, not a role).
if got = WhoCan(chain, VerbR); !wcContains(got.People, "*@example.com") {
t.Errorf("VerbR People = %v, want to include *@example.com", got.People)
}
// empty chain → nobody named.
if a := WhoCan(PolicyChain{}, VerbC); !a.Empty() {
t.Errorf("empty chain: got %+v, want Empty()", a)
}
}
func wcContains(ss []string, s string) bool {
for _, x := range ss {
if x == s {
return true
}
}
return false
}