Rename the coarse gate from role to permission, matching RBAC
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user