Rename the coarse gate from role to permission, matching RBAC

This commit is contained in:
2026-08-03 17:02:47 +02:00
parent 41c568796c
commit 925debbd51
79 changed files with 744 additions and 738 deletions
+2 -2
View File
@@ -15,7 +15,7 @@ What it demonstrates:
`POST /scheduling/shifts` CSRF-verifies it (`ctx.verifyCsrf`) and forwards the create upstream,
then POST-redirect-GET. The form body lives in the plugin's own `views/partials/shift-form.ejs`,
reusing the core `field` partial.
- **Role-gated nav** — the "Shifts" nav leaf and routes are gated on `scheduling:read` /
- **Permission-gated nav** — the "Shifts" nav leaf and routes are gated on `scheduling:read` /
`scheduling:write`; the whole "Scheduling" section is invisible to anyone without the grant.
The plugin holds **no state** — data lives upstream (README → *Stateless*). Handlers are thin and
@@ -46,6 +46,6 @@ cosmetically) — normalise to your backend's format there if it matters.
## Granting access
A user sees Scheduling once they hold the `scheduling:read` role in Keto (and `scheduling:write`
A user sees Scheduling once they hold the `scheduling:read` permission in Keto (and `scheduling:write`
to create). The one-command bootstrap grants both to the demo admin, so the seeded
`admin@plainpages.local` can use it immediately.
+8 -8
View File
@@ -1,5 +1,5 @@
// Reference plugin: a worked example of the contract — a list page that fetches upstream
// data, a CSRF-guarded form that forwards a write upstream, and role-gated nav. Copy this
// data, a CSRF-guarded form that forwards a write upstream, and permission-gated nav. Copy this
// folder, rename it, point it at your own backend. Full contract: README.md → Building plugins.
import { definePlugin } from "#plugin-api";
@@ -23,7 +23,7 @@ export default definePlugin({
nav: [{
children: [
{ href: SCHEDULING_PATH, id: "scheduling:overview", label: "Overview", public: true },
{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "Shifts", role: READ },
{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "Shifts", permission: READ },
],
icon: "i-cal",
id: "scheduling",
@@ -31,17 +31,17 @@ export default definePlugin({
}],
// Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
roles: [
permissions: [
{ description: "View shifts", name: READ },
{ description: "Create and edit shifts", name: WRITE },
],
// Mounted under /scheduling; `role` gates before the handler runs. The overview is `public`
// (anyone may reach /scheduling, signed in or not); the rest need a role.
// Mounted under /scheduling; `permission` gates before the handler runs. The overview is `public`
// (anyone may reach /scheduling, signed in or not); the rest need a permission.
routes: [
{ handler: overview(), method: "GET", path: "/", public: true },
{ handler: listShifts(upstream), method: "GET", path: "/shifts", role: READ },
{ handler: newShiftForm(), method: "GET", path: "/shifts/new", role: WRITE },
{ handler: createShift(upstream), method: "POST", path: "/shifts", role: WRITE },
{ handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ },
{ handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE },
{ handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE },
],
});
+7 -7
View File
@@ -12,12 +12,12 @@ import {
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } };
function fakeCtx(opts: { body?: string; roles?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
return {
chrome: CHROME, identity: null, log: new Log("none"), params: {}, query: url.searchParams, req, res: {} as ServerResponse,
roles: opts.roles ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true),
permissions: opts.permissions ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true),
};
}
@@ -93,8 +93,8 @@ test("readInput trims; validate requires title + assignee", () => {
// ---- list handler ----
test("listShifts renders the upstream rows; q filters; canWrite reflects the role", async () => {
const r = asView(await listShifts(fakeUpstream())(fakeCtx({ roles: ["scheduling:write"] })));
test("listShifts renders the upstream rows; q filters; canWrite reflects the permission", async () => {
const r = asView(await listShifts(fakeUpstream())(fakeCtx({ permissions: ["scheduling:write"] })));
assert.equal(r.view, "shifts");
const table = r.data["table"] as { rows: { name: string }[] };
assert.deepEqual(table.rows.map((x) => x.name), ["Morning desk", "Afternoon support"]);
@@ -112,15 +112,15 @@ test("listShifts degrades to a recoverable error page when the upstream is down
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []);
});
// ---- public overview handler (a page anyone can reach, gated data stays behind the role) ----
// ---- public overview handler (a page anyone can reach, gated data stays behind the permission) ----
test("overview renders a public page for anyone; it links straight to Shifts only for a reader", async () => {
const anon = asView(await overview()(fakeCtx())); // user null, no roles
const anon = asView(await overview()(fakeCtx())); // user null, no permissions
assert.equal(anon.view, "overview");
assert.equal(anon.data["chrome"], CHROME);
assert.equal(anon.data["canRead"], false); // anonymous → prompt to sign in, no shifts link
const reader = asView(await overview()(fakeCtx({ roles: ["scheduling:read"] })));
const reader = asView(await overview()(fakeCtx({ permissions: ["scheduling:read"] })));
assert.equal(reader.data["canRead"], true); // a reader gets a link straight to the shifts list
});
+3 -3
View File
@@ -10,8 +10,8 @@ import { can, CSRF_FIELD, GuardError, type PageChrome, parseListQuery, readFormB
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page
export const SHIFTS_PATH = "/scheduling/shifts";
export const READ = "scheduling:read"; // the role gating the list + nav
export const WRITE = "scheduling:write"; // the role gating create
export const READ = "scheduling:read"; // the permission gating the list + nav
export const WRITE = "scheduling:write"; // the permission gating create
export interface Shift {
id: string;
@@ -188,7 +188,7 @@ export function newShiftForm(): RouteHandler {
// Public overview: a page anyone may reach — its route + nav node are marked `public`, so the
// gate lets an anonymous visitor through and the menu option shows for everyone. The real data
// (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone
// else a prompt to sign in. ctx.identity may be null here, so read the role via can() (zero I/O).
// else a prompt to sign in. ctx.identity may be null here, so read the permission via can() (zero I/O).
export function overview(): RouteHandler {
return (ctx) => ({
data: { breadcrumbs: [{ label: "Overview" }], canRead: can(ctx, READ), chrome: ctx.chrome, shiftsHref: SHIFTS_PATH, title: "Scheduling" },
@@ -12,7 +12,7 @@
-%>
<%- include("partials/shell", {
actions: "",
body: '<div class="scheduling-page"><p>Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> role.</p>' + cta + '</div>',
body: '<div class="scheduling-page"><p>Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> permission.</p>' + cta + '</div>',
brand: chrome.brand,
breadcrumbs,
csrfToken: chrome.csrfToken,