Rename the coarse gate from role to permission, matching RBAC
This commit is contained in:
@@ -9,13 +9,13 @@ const scheduling: Plugin = {
|
||||
apiVersion: "1.0.0",
|
||||
id: "scheduling",
|
||||
nav: [{
|
||||
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", role: "scheduling:read" }],
|
||||
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }],
|
||||
icon: "i-cal", id: "scheduling", label: "Scheduling",
|
||||
}],
|
||||
};
|
||||
// A plugin with a public nav node (reachable by anyone, signed in or not).
|
||||
const portal: Plugin = { apiVersion: "1.0.0", id: "portal", nav: [{ href: "/portal", id: "portal", label: "Portal", public: true }] };
|
||||
// A gated section fragment like the admin plugin's nav: the header carries the role, so
|
||||
// A gated section fragment like the admin plugin's nav: the header carries the permission, so
|
||||
// composeNav drops the whole subtree for a non-holder (the admin screens ship as a drop-in plugin).
|
||||
const adminLike: Plugin = {
|
||||
apiVersion: "1.0.0", id: "admin",
|
||||
@@ -24,7 +24,7 @@ const adminLike: Plugin = {
|
||||
{ href: "/admin/users", id: "users", label: "Users" },
|
||||
{ href: "/admin/groups", id: "groups", label: "Groups" },
|
||||
],
|
||||
icon: "i-shield", id: "admin", label: "Admin", role: "admin",
|
||||
icon: "i-shield", id: "admin", label: "Admin", permission: "admin",
|
||||
}],
|
||||
};
|
||||
|
||||
@@ -45,10 +45,10 @@ test("anonymous shell Sign-in link carries the current page as return_to", () =>
|
||||
assert.equal(buildPluginChrome({ currentPath: "/portal", menu: DEFAULT_MENU }).signInHref, "/login?return_to=%2Fportal");
|
||||
});
|
||||
|
||||
test("a role holder sees the Dashboard link + plugin nav; current path opens the active leaf", () => {
|
||||
test("a permission holder sees the Dashboard link + plugin nav; current path opens the active leaf", () => {
|
||||
const chrome = buildPluginChrome({
|
||||
currentPath: "/scheduling/shifts", menu: DEFAULT_MENU, plugins: [scheduling],
|
||||
identity: { email: "ada@x.io", id: "u1", roles: ["scheduling:read"] },
|
||||
identity: { email: "ada@x.io", id: "u1", permissions: ["scheduling:read"] },
|
||||
});
|
||||
assert.deepEqual(labels(chrome.nav), ["Dashboard", "Scheduling"]); // Dashboard shown to a signed-in user
|
||||
const section = chrome.nav.find((n) => n.label === "Scheduling")!;
|
||||
@@ -58,7 +58,7 @@ test("a role holder sees the Dashboard link + plugin nav; current path opens the
|
||||
});
|
||||
|
||||
test("a gated section (like the admin plugin) shows to a holder; a sub-path marks its base leaf current", () => {
|
||||
const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, plugins: [adminLike], identity: { email: "a@b.c", id: "u1", roles: ["admin"] } });
|
||||
const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, plugins: [adminLike], identity: { email: "a@b.c", id: "u1", permissions: ["admin"] } });
|
||||
const admin = chrome.nav.find((n) => n.label === "Admin")!;
|
||||
assert.ok(admin); // gated section visible to an admin
|
||||
assert.equal(admin.open, true); // ancestor of the current leaf opened
|
||||
|
||||
+3
-3
@@ -18,7 +18,7 @@ const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashbo
|
||||
export interface PageChrome {
|
||||
brand: { logo?: string; name: string; sub?: string };
|
||||
csrfToken: string; // double-submit token for the shell's Sign-out form + a plugin's own forms
|
||||
nav: NavNode[]; // global menu, composed + role-filtered + current-marked, ready for nav-tree.ejs
|
||||
nav: NavNode[]; // global menu, composed + permission-filtered + current-marked, ready for nav-tree.ejs
|
||||
signInHref: string; // where the shell's anonymous "Sign in" link points — carries this page as return_to
|
||||
theme?: string;
|
||||
user: ShellUser;
|
||||
@@ -39,8 +39,8 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
||||
const fragments: NavNode[][] = opts.identity ? [[DASHBOARD_NAV]] : [];
|
||||
for (const p of opts.plugins ?? []) if (p.nav?.length) fragments.push(p.nav);
|
||||
|
||||
const roles = opts.identity?.roles ?? [];
|
||||
const nav = composeNav(fragments, opts.menu.override, roles);
|
||||
const permissions = opts.identity?.permissions ?? [];
|
||||
const nav = composeNav(fragments, opts.menu.override, permissions);
|
||||
if (opts.currentPath) {
|
||||
// Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like
|
||||
// /admin/users/new marks the Users base leaf (/admin/users) and the dashboard marks Dashboard.
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { NavNode } from "./nav.ts";
|
||||
const NAV: NavNode[] = [{ href: "/dashboard", label: "Dashboard" }, { children: [{ href: "/admin/users", label: "Users" }], label: "Admin" }];
|
||||
|
||||
test("dashboard model: titled shell, passes the unified nav + csrf + user through", () => {
|
||||
const m = buildDashboardModel({ csrfToken: "tok.sig", identity: { email: "ada@x.io", id: "u1", roles: ["admin"] }, nav: NAV });
|
||||
const m = buildDashboardModel({ csrfToken: "tok.sig", identity: { email: "ada@x.io", id: "u1", permissions: ["admin"] }, nav: NAV });
|
||||
assert.equal(m.shell.title, "Dashboard");
|
||||
assert.equal(m.shell.csrfToken, "tok.sig");
|
||||
assert.equal(m.shell.user.name, "ada"); // real signed-in identity, not a demo profile
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Central menu config: config/menu.ts lets an operator set branding (app name, logo,
|
||||
// default theme) and reorder/rename/group/hide nav nodes across all plugins. The reorder/rename/
|
||||
// group/hide part is the NavOverride composeNav already applies (the override always wins, before
|
||||
// the per-user role filter). Authored as TypeScript (defineMenu types it); loaded once at
|
||||
// the per-user permission filter). Authored as TypeScript (defineMenu types it); loaded once at
|
||||
// boot — fail-loud on a malformed file, defaults when absent (clean clone needs no config).
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
+3
-3
@@ -18,8 +18,8 @@ test("menu renders trigger, positioning, the item matrix and check groups", asyn
|
||||
{ label: "Docs", href: "/docs" }, // link
|
||||
{ sep: true },
|
||||
{ label: "Sign out", icon: "i-logout", danger: true },
|
||||
{ group: { legend: "Role", name: "role", control: "radio", options: [
|
||||
{ value: "", label: "Any role", checked: true },
|
||||
{ group: { legend: "Permission", name: "permission", control: "radio", options: [
|
||||
{ value: "", label: "Any permission", checked: true },
|
||||
{ value: "admin", label: "Admin" },
|
||||
] } },
|
||||
{ group: { name: "col", options: [{ value: "name", label: "Name", checked: true }] } }, // checkbox default, no legend
|
||||
@@ -38,7 +38,7 @@ test("menu renders trigger, positioning, the item matrix and check groups", asyn
|
||||
assert.match(html, /<button class="menu-item danger" type="button"><svg class="ico"><use href="#i-logout"\s*\/?><\/svg>Sign out<\/button>/);
|
||||
|
||||
// Check group: radios reflect `checked`; legend optional; control defaults to checkbox.
|
||||
assert.match(html, /<fieldset class="menu-field"><legend class="menu-head">Role<\/legend><label class="menu-check"><input type="radio" name="role" value="" checked>Any role<\/label><label class="menu-check"><input type="radio" name="role" value="admin">Admin<\/label><\/fieldset>/);
|
||||
assert.match(html, /<fieldset class="menu-field"><legend class="menu-head">Permission<\/legend><label class="menu-check"><input type="radio" name="permission" value="" checked>Any permission<\/label><label class="menu-check"><input type="radio" name="permission" value="admin">Admin<\/label><\/fieldset>/);
|
||||
assert.match(html, /<fieldset class="menu-field"><label class="menu-check"><input type="checkbox" name="col" value="name" checked>Name<\/label><\/fieldset>/);
|
||||
});
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ const nodes = [
|
||||
{ label: "Webhooks (soon)" }, // leaf · static
|
||||
],
|
||||
},
|
||||
{ label: "Roles & Access", children: [{ label: "Roles", href: "/roles" }] }, // header · static · closed
|
||||
{ label: "Permissions & Access", children: [{ label: "Permissions", href: "/permissions" }] }, // header · static · closed
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -59,8 +59,8 @@ test("nav-tree renders the header/leaf × clickable/static matrix with counts, i
|
||||
assert.match(html, /<span class="nav-self"><span class="nav-label">Webhooks \(soon\)<\/span><\/span>/);
|
||||
|
||||
// Header · static · closed (no [open]) + label escaping in both label and aria-label.
|
||||
assert.match(html, /<details class="nav-disc"><summary class="nav-tog" aria-label="Toggle Roles & Access">/);
|
||||
assert.match(html, /<span class="nav-label">Roles & Access<\/span>/);
|
||||
assert.match(html, /<details class="nav-disc"><summary class="nav-tog" aria-label="Toggle Permissions & Access">/);
|
||||
assert.match(html, /<span class="nav-label">Permissions & Access<\/span>/);
|
||||
});
|
||||
|
||||
test("nav-tree renders an empty root list with no nodes and never throws", async () => {
|
||||
|
||||
+16
-16
@@ -2,32 +2,32 @@ import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { composeNav, type NavNode } from "./nav.ts";
|
||||
|
||||
// Two plugin fragments; ids let the override target nodes, `role` gates per role.
|
||||
// Two plugin fragments; ids let the override target nodes, `permission` gates per permission.
|
||||
const fragments: NavNode[][] = [
|
||||
[{
|
||||
icon: "i-cal", id: "sched", label: "Scheduling",
|
||||
children: [
|
||||
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", role: "scheduling:read" },
|
||||
{ href: "/scheduling/manage", id: "manage", label: "Manage", role: "scheduling:admin" },
|
||||
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" },
|
||||
{ href: "/scheduling/manage", id: "manage", label: "Manage", permission: "scheduling:admin" },
|
||||
],
|
||||
}],
|
||||
[{ href: "/reports", id: "reports", label: "Reports", role: "reports:read" }],
|
||||
[{ href: "/reports", id: "reports", label: "Reports", permission: "reports:read" }],
|
||||
];
|
||||
|
||||
test("composeNav merges fragments, filters by role, and emits clean render nodes", () => {
|
||||
test("composeNav merges fragments, filters by permission, and emits clean render nodes", () => {
|
||||
const tree = composeNav(fragments, {}, ["scheduling:read"]);
|
||||
|
||||
// Reports gone (no reports:read), Manage gone (no scheduling:admin), header kept with Shifts.
|
||||
// Output carries no `id`/`role` and omits absent fields — ready for nav-tree.ejs.
|
||||
// Output carries no `id`/`permission` and omits absent fields — ready for nav-tree.ejs.
|
||||
assert.deepEqual(tree, [
|
||||
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling/shifts", label: "Shifts" }] },
|
||||
]);
|
||||
});
|
||||
|
||||
test("composeNav drops gated subtrees, empty headers, and (with no roles) all gated nodes", () => {
|
||||
test("composeNav drops gated subtrees, empty headers, and (with no permissions) all gated nodes", () => {
|
||||
// A header the user can't reach takes its whole subtree, even visible children.
|
||||
const gatedHeader: NavNode[][] = [[
|
||||
{ id: "admin", label: "Admin", role: "admin", children: [{ href: "/u", id: "u", label: "Users" }] },
|
||||
{ id: "admin", label: "Admin", permission: "admin", children: [{ href: "/u", id: "u", label: "Users" }] },
|
||||
{ id: "free", label: "Free", children: [{ href: "/d", id: "d", label: "Docs" }] },
|
||||
]];
|
||||
assert.deepEqual(composeNav(gatedHeader, {}, []), [
|
||||
@@ -36,26 +36,26 @@ test("composeNav drops gated subtrees, empty headers, and (with no roles) all ga
|
||||
|
||||
// A pure header whose children are all filtered is dropped; a header with an href survives as a leaf.
|
||||
const emptyHeader: NavNode[][] = [[
|
||||
{ id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", role: "x" }] },
|
||||
{ href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", role: "y" }] },
|
||||
{ id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", permission: "x" }] },
|
||||
{ href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", permission: "y" }] },
|
||||
]];
|
||||
assert.deepEqual(composeNav(emptyHeader, {}, []), [{ href: "/hub", label: "Hub" }]);
|
||||
|
||||
// No fragments / no roles → empty tree, never throws.
|
||||
// No fragments / no permissions → empty tree, never throws.
|
||||
assert.deepEqual(composeNav(), []);
|
||||
});
|
||||
|
||||
test("composeNav keeps a node marked public for everyone — the blessed public alias", () => {
|
||||
// A header with one public child + one gated child: with no roles, the public child keeps the
|
||||
// A header with one public child + one gated child: with no permissions, the public child keeps the
|
||||
// header alive (the gated child is filtered out) — so a plugin can show a public menu option to all.
|
||||
const frag: NavNode[][] = [[{
|
||||
icon: "i-cal", id: "sched", label: "Scheduling",
|
||||
children: [
|
||||
{ href: "/scheduling", id: "overview", label: "Overview", public: true },
|
||||
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", role: "scheduling:read" },
|
||||
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" },
|
||||
],
|
||||
}]];
|
||||
// `public` is filter-only (like id/role) — never rendered into the output node.
|
||||
// `public` is filter-only (like id/permission) — never rendered into the output node.
|
||||
assert.deepEqual(composeNav(frag, {}, []), [
|
||||
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling", label: "Overview" }] },
|
||||
]);
|
||||
@@ -66,7 +66,7 @@ test("composeNav applies the override: rename, group, order, hide (then filters)
|
||||
{ href: "/a", id: "a", label: "Alpha" },
|
||||
{ href: "/b", id: "b", label: "Beta" },
|
||||
{ href: "/c", id: "c", label: "Gamma" },
|
||||
{ href: "/secret", id: "secret", label: "Secret", role: "root" },
|
||||
{ href: "/secret", id: "secret", label: "Secret", permission: "root" },
|
||||
]];
|
||||
|
||||
const tree = composeNav(base, {
|
||||
@@ -76,7 +76,7 @@ test("composeNav applies the override: rename, group, order, hide (then filters)
|
||||
hide: ["c"], // remove c from inside the group
|
||||
}, ["root"]);
|
||||
|
||||
// grp emitted (b only, c hidden), reordered before a; Secret kept now that role "root" is present.
|
||||
// grp emitted (b only, c hidden), reordered before a; Secret kept now that permission "root" is present.
|
||||
assert.deepEqual(tree, [
|
||||
{ icon: "i-box", label: "Group", open: true, children: [{ href: "/b", label: "Beta" }] },
|
||||
{ href: "/a", label: "First" },
|
||||
|
||||
+13
-13
@@ -1,10 +1,10 @@
|
||||
// composeNav: merge each plugin's nav fragment into one tree, apply the central
|
||||
// override, then role-filter per user. Pure and I/O-free — menu gating reads the JWT
|
||||
// `roles` claim (README "The menu system"), never Keto. A node is visible iff it is `public`, or
|
||||
// declares no `role`, or `roles` includes that role name; a gated header hides its whole
|
||||
// override, then permission-filter per user. Pure and I/O-free — menu gating reads the JWT
|
||||
// `permissions` claim (README "The menu system"), never Keto. A node is visible iff it is `public`, or
|
||||
// declares no `permission`, or `permissions` includes that permission name; a gated header hides its whole
|
||||
// subtree, and a pure header left with no children is dropped. The config/menu.ts supplies
|
||||
// the override (+ branding); this helper only transforms data, so its result is per-deployment
|
||||
// up to the final role filter and emits clean nodes ready for nav-tree.ejs (no id/role).
|
||||
// up to the final permission filter and emits clean nodes ready for nav-tree.ejs (no id/permission).
|
||||
|
||||
export interface NavNode {
|
||||
id?: string; // stable key for override targeting; stripped from the rendered tree
|
||||
@@ -15,12 +15,12 @@ export interface NavNode {
|
||||
icon?: string;
|
||||
label: string;
|
||||
open?: boolean;
|
||||
role?: string; // required role token; consumed by the filter, never rendered
|
||||
public?: boolean; // show to everyone, signed in or not — the blessed alias for "no role", stated outright; consumed by the filter, never rendered. Mutually exclusive with role (discovery refuses both).
|
||||
permission?: string; // required permission token; consumed by the filter, never rendered
|
||||
public?: boolean; // show to everyone, signed in or not — the blessed alias for "no permission", stated outright; consumed by the filter, never rendered. Mutually exclusive with permission (discovery refuses both).
|
||||
}
|
||||
|
||||
// Central override (config/menu.ts). Targets nodes by `id`; applied rename → group →
|
||||
// order → hide, then the per-user role filter runs last.
|
||||
// order → hide, then the per-user permission filter runs last.
|
||||
export interface NavOverride {
|
||||
groups?: NavGroupSpec[]; // wrap top-level nodes (by id) under a new header
|
||||
hide?: string[]; // remove nodes by id, at any depth (incl. a group's id)
|
||||
@@ -39,14 +39,14 @@ export interface NavGroupSpec {
|
||||
export function composeNav(
|
||||
fragments: NavNode[][] = [],
|
||||
override: NavOverride = {},
|
||||
roles: string[] = [],
|
||||
permissions: string[] = [],
|
||||
): NavNode[] {
|
||||
let nodes: NavNode[] = fragments.flat();
|
||||
if (override.rename) nodes = renameTree(nodes, override.rename);
|
||||
if (override.groups?.length) nodes = applyGroups(nodes, override.groups);
|
||||
if (override.order?.length) nodes = applyOrder(nodes, override.order);
|
||||
if (override.hide?.length) nodes = hideTree(nodes, new Set(override.hide));
|
||||
return filterByRoles(nodes, new Set(roles)).map(toRenderNode);
|
||||
return filterByRoles(nodes, new Set(permissions)).map(toRenderNode);
|
||||
}
|
||||
|
||||
function renameTree(nodes: NavNode[], rename: Record<string, string>): NavNode[] {
|
||||
@@ -103,19 +103,19 @@ function hideTree(nodes: NavNode[], hide: Set<string>): NavNode[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
function filterByRoles(nodes: NavNode[], roles: Set<string>): NavNode[] {
|
||||
function filterByRoles(nodes: NavNode[], permissions: Set<string>): NavNode[] {
|
||||
const out: NavNode[] = [];
|
||||
for (const n of nodes) {
|
||||
if (n.public !== true && n.role != null && !roles.has(n.role)) continue; // gated → drop node + subtree (public always shows)
|
||||
if (n.public !== true && n.permission != null && !permissions.has(n.permission)) continue; // gated → drop node + subtree (public always shows)
|
||||
if (!n.children) { out.push(n); continue; }
|
||||
const children = filterByRoles(n.children, roles);
|
||||
const children = filterByRoles(n.children, permissions);
|
||||
if (children.length === 0 && n.href == null) continue; // empty pure header → drop
|
||||
out.push({ ...n, children });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Strip the helper-only fields (id/role) and drop absent ones, so the tree is exactly
|
||||
// Strip the helper-only fields (id/permission) and drop absent ones, so the tree is exactly
|
||||
// what nav-tree.ejs reads.
|
||||
function toRenderNode(n: NavNode): NavNode {
|
||||
const out: NavNode = { label: n.label };
|
||||
|
||||
@@ -5,7 +5,7 @@ import { buildShellContext, shellUser } from "./shell-context.ts";
|
||||
test("shellUser derives the profile from the real user; anonymous → Guest", () => {
|
||||
assert.deepEqual(shellUser(null), { email: "", initials: "G", name: "Guest" });
|
||||
// Real user: name = email local part, email kept, initials = first two letters of the local part.
|
||||
assert.deepEqual(shellUser({ email: "ada@example.com", id: "u1", roles: [] }), { email: "ada@example.com", initials: "AD", name: "ada" });
|
||||
assert.deepEqual(shellUser({ email: "ada@example.com", id: "u1", permissions: [] }), { email: "ada@example.com", initials: "AD", name: "ada" });
|
||||
});
|
||||
|
||||
test("buildShellContext maps branding + breadcrumbs, omitting unset optional fields", () => {
|
||||
@@ -22,7 +22,7 @@ test("buildShellContext maps branding + breadcrumbs, omitting unset optional fie
|
||||
menu: { branding: { logo: "/l.svg", name: "Acme", sub: "Ops", theme: "dark" }, override: {} },
|
||||
signInHref: "/login?return_to=%2Fx",
|
||||
title: "Users",
|
||||
identity: { email: "a@b.c", id: "u1", roles: ["admin"] },
|
||||
identity: { email: "a@b.c", id: "u1", permissions: ["admin"] },
|
||||
});
|
||||
assert.deepEqual(full.brand, { logo: "/l.svg", name: "Acme", sub: "Ops" });
|
||||
assert.equal(full.theme, "dark");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Shell view-model builder: the brand/theme/user/title block every app-shell page
|
||||
// (the home dashboard, the built-in admin screens) hands to shell.ejs. Pure. Extracted so the
|
||||
// shell user is the *real* signed-in identity — no hardcoded demo profile — and branding is
|
||||
// read from one place. The User carries no display name (the JWT holds only id/email/roles), so
|
||||
// read from one place. The User carries no display name (the JWT holds only id/email/permissions), so
|
||||
// the profile shows the email's local part as the name with the full email beneath, initials from
|
||||
// the local part; anonymous ⇒ "Guest".
|
||||
|
||||
|
||||
Reference in New Issue
Block a user