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:
parent
3d553ce9d4
commit
7c0b66590c
12 changed files with 378 additions and 10 deletions
|
|
@ -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 can’t 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) + '/<party>/</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 don’t have create access here.');
|
||||
} else if (/\b409\b/.test(msg)) {
|
||||
statusError('Unknown party — register it first (document controller).');
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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
66
tests/cap.spec.js
Normal 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
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
76
zddc/internal/zddc/whocan.go
Normal file
76
zddc/internal/zddc/whocan.go
Normal 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
|
||||
}
|
||||
63
zddc/internal/zddc/whocan_test.go
Normal file
63
zddc/internal/zddc/whocan_test.go
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue