diff --git a/AGENTS.md b/AGENTS.md index 9667fdd..f9f46af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,7 @@ them. Revisit only if the stated reason stops holding. `server.ts`/`config.ts`/`logger.ts` and the topology-guard `*.test.ts` stay at the root. Tests are co-located (`foo.test.ts` beside `foo.ts`). Add a new module to the folder that owns its concern rather than to the root; don't reintroduce a flat tree. The core ships **no domain - screens** — even the admin GUI (users/groups/roles) is a drop-in plugin (`examples/plugins/admin/`), + screens** — even the admin GUI (users/groups/permissions) is a drop-in plugin (`examples/plugins/admin/`), not `src/` code. - **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the base request context. It protects the I/O-free hot path on the public, bot-hit landing @@ -120,14 +120,15 @@ docker compose -f compose.yml up --build -d # production running **building plugins** comes first, then **configuring and securing** the system (Configuration, Auth); the **inner workings** (Architecture) and ops/runbooks are deliberately deferred — they're not top of mind when starting out. Concretely: Overview → - Users, groups & roles → Building plugins → menu/blocks/interactivity → Configuration → Auth → - Email → Architecture → Testing → Production → Observability → the JWT-rotation runbook → the - Project-layout file map → Extending. When adding a section, place it by this value (how - early an adopter needs it), not by where it sits in the stack. + Identities, groups & permissions → Building plugins → menu/blocks/interactivity → + Configuration → Auth → Email → Architecture → Testing → Production → Observability → the + JWT-rotation runbook → the Project-layout file map → Extending. When adding a section, place + it by this value (how early an adopter needs it), not by where it sits in the stack. - **Users, groups & roles precedes Building plugins** because a manifest's `role:` gate is - unreadable without the model, and operators need it as much as plugin authors. It is the one - home for that model — the plugin and auth sections link to it rather than restating it. + **Identities, groups & permissions precedes Building plugins** because a manifest's + `permission:` gate is unreadable without the model, and operators need it as much as plugin + authors. It is the one home for that model — the plugin and auth sections link to it rather + than restating it. When editing: put content in the section it belongs to (don't prepend rationale above Quick start); keep the ToC in sync when you add/rename/remove an `H2`/`H3`; and state each fact in diff --git a/README-dockerhub.md b/README-dockerhub.md index 001873c..6847430 100644 --- a/README-dockerhub.md +++ b/README-dockerhub.md @@ -190,4 +190,4 @@ export default definePlugin({ ``` Restart (`docker compose restart web`) and visit . Views, -forms, roles, and the runnable reference plugin: repo README → Building plugins. +forms, permissions, and the runnable reference plugin: repo README → Building plugins. diff --git a/README.md b/README.md index b230b0f..da80e57 100644 --- a/README.md +++ b/README.md @@ -28,14 +28,14 @@ docker compose up -d # http://localhost:3000, live-reloads on source chan **`admin@plainpages.local` / `admin`**. **3. Enable user & group admin (optional).** The core ships **no admin GUI** — the Users / Groups -/ Roles / OAuth2-clients screens are a drop-in plugin. Copy it in to mount them at `/admin/*`: +/ Permissions / OAuth2-clients screens are a drop-in plugin. Copy it in to mount them at `/admin/*`: ```bash cp -r examples/plugins/admin plugins/admin docker compose restart web ``` -The seeded admin already holds the `admin` role, so the **Admin** section now shows in the menu. +The seeded admin already holds the `admin` permission, so the **Admin** section now shows in the menu. See [`examples/plugins/admin/`](examples/plugins/admin/). **4. Add your first plugin.** The clone is bind-mounted into the container, so a new @@ -69,9 +69,9 @@ From here, render real pages against the app shell and fetch upstream data — s - [Overview](#overview) - [how it compares](#how-it-compares) -- [Users, groups & roles](#users-groups--roles) +- [Identities, groups & permissions](#identities-groups--permissions) - [a worked example](#a-worked-example) - - [granting a role](#granting-a-role) + - [granting a permission](#granting-a-permission) - [fine-grained, per-row access](#fine-grained-per-row-access) - [Building plugins](#building-plugins) - [anatomy](#anatomy-of-a-plugin) @@ -80,7 +80,7 @@ From here, render real pages against the app shell and fetch upstream data — s - [landing pages](#the-landing-pages-home--dashboard) - [RequestContext](#requestcontext) - [system capabilities (ctx.system)](#system-capabilities-the-ctxsystem-surface) - - [nav & role gates](#nav--role-gates) + - [nav & permission gates](#nav--permission-gates) - [versioning](#contract-versioning) - [conflict rules](#conflict-rules) - [hooks](#hooks) @@ -120,7 +120,7 @@ or gated**, so the same foundation serves a purely public site, a fully locked-d tool, or the common middle: a public front with an authenticated area behind it. Its **sweet spot** is the **back-office and operational tooling** you'd otherwise hand-roll for the tenth time, but nothing ties it to internal-only use. The core itself ships **no domain screens at -all** — even the screens for running the system (**users, groups, roles**) are a **drop-in +all** — even the screens for running the system (**users, groups, permissions**) are a **drop-in plugin** you opt into ([`examples/plugins/admin/`](examples/plugins/admin/)). Everything is a plugin. **Who it's for.** Experienced developers building server-rendered web products — back-office @@ -135,7 +135,7 @@ obvious rather than surprising, you're the audience. - **Included in the core:** themed sign-in / register / reset (Kratos-backed), the design system + app shell, the config-driven menu, sessions, and access control. No domain screens. -- **Opt-in admin plugin:** the **users, groups, roles, and OAuth2-clients** screens (users via +- **Opt-in admin plugin:** the **users, groups, permissions, and OAuth2-clients** screens (users via Kratos, the relationship graph via Keto, OAuth2 clients via Hydra) ship as [`examples/plugins/admin/`](examples/plugins/admin/) — copy it into `plugins/` to get a GUI for user & group admin. It's an ordinary plugin, using the privileged @@ -146,7 +146,7 @@ obvious rather than surprising, you're the audience. **Priorities (unchanged from day one):** **simplicity, few dependencies, strict TypeScript, no build step, Docker-only, environment-agnostic** (no `NODE_ENV` — every behaviour is an explicit config toggle). Heavy lifting that *isn't* simple to do well — -identity, sessions, SSO, OAuth2, role checks — is delegated to **Ory** sidecar +identity, sessions, SSO, OAuth2, permission checks — is delegated to **Ory** sidecar services rather than reinvented. "Simple" is about the *whole architecture* staying simple — not just at the start, but after you've dropped in 240 plugins and run it hard in production. The shape doesn't change as it grows: every plugin is the same self-contained @@ -198,22 +198,24 @@ server-rendered** design system, **[optional auth](#auth-sessions--access)** (an public or gated), **no app database**, and a **framework-light TypeScript** core with no build step. Each neighbour shares one trait and trades away the rest — Plainpages is the intersection. -## Users, groups & roles +## Identities, groups & permissions -Authorization here is **two hops, not three**: a user — directly, or through a group — is a -member of a **role**, and that role's *name* is exactly the string a plugin gates on. There is no -separate "permission" object to define, register, or wire up. +Authorization here is two hops: an **identity** — directly, or through a **group** — is granted a +**permission**, and that permission's *name* is exactly the string a plugin gates on. -- **Group** answers *who* — a reusable set of people. Optional: a role can be granted straight to a user. -- **Role** answers *what* — its **name is the string** you write in a manifest's `role:` gate. -- **A relation tuple** is the grant: `Role:#members@identity:`, or `@Group:#members`. +- **Group** answers *who* — a reusable set of people. Optional: a permission can be granted + straight to an identity. +- **Permission** answers *what* — its **name is the string** you write in a manifest's + `permission:` gate. +- **A relation tuple** is the grant: `Permission:#granted@identity:`, or + `@Group:#members`. - **Resource** answers *which row* — a live check, run only where a plugin explicitly asks for it. | Entity | Lives in | Answers | Example | | --- | --- | --- | --- | | **Identity** | Kratos | who you are | `identity:0198f2c1-…` | | **Group** | Keto | who — a reusable set | `Group:support` | -| **Role** | Keto | what you may do | `Role:scheduling:read` | +| **Permission** | Keto | what you may do | `Permission:scheduling:read` | | **Resource** | Keto | which specific row | `Resource:shift-4471` | Identities live in Kratos; every authorization edge is a Keto relation tuple. The app itself @@ -223,42 +225,43 @@ stores none of it — it is [stateless](#stateless). `namespace:object#relation@subject` — so the four namespaces above are *ours*, declared in `ory/keto/namespaces.keto.ts`; Keto only supplies the machinery that resolves them (including transitively, through nested groups). `Identity` is named to match Kratos, which owns that -record. `Group`, `Role` and `Resource` have no upstream counterpart to match, so they use the -ordinary words. +record. `Group`, `Permission` and `Resource` have no upstream counterpart to match, so they use +the ordinary words. -> **On the word "permission".** Ory uses it for the fine-grained `Resource` tier — the `permits` -> block (`view`/`edit`/`delete`). Plainpages therefore never uses it for the coarse tier: what a -> route or a menu item gates on is a **role**, always. +> **There is no `Role`.** In RBAC a permission is a single operation ("read shifts") and a role is +> a *bundle* of them ("IT Support staff"). A route gates on one operation, so it gates on a +> **permission**. When you want the bundle, make a group and grant it several — groups nest, so a +> group of groups works too. ### A worked example Alice works support and leads scheduling; Bob works support; Carol administers the system. ``` - people groups roles - ────── ────── ───── + people groups permissions + ────── ────── ─────────── alice ──┬─────────> Group:support ────┐ - │ ├──> Group:staff ──> Role:scheduling:read + │ ├──> Group:staff ──> Permission:scheduling:read bob ────┘ │ │ - alice ────────────> Group:sched-leads ┴──> Role:scheduling:write + alice ────────────> Group:sched-leads ┴──> Permission:scheduling:write - carol ───────────────────────────────────────────────> Role:admin + carol ───────────────────────────────────────────────> Permission:admin ``` -At login the host asks Keto which roles the user holds, walking those arrows transitively, and -bakes the answer into the session JWT (see [Login and the session +At login the host asks Keto which permissions the identity holds, walking those arrows +transitively, and bakes the answer into the session JWT (see [Login and the session JWT](#login-and-the-session-jwt)): ``` -alice → roles: ["scheduling:read", "scheduling:write"] -bob → roles: ["scheduling:read"] -carol → roles: ["admin"] +alice → permissions: ["scheduling:read", "scheduling:write"] +bob → permissions: ["scheduling:read"] +carol → permissions: ["admin"] ``` -Note what Carol does *not* have. **There is no role hierarchy and no superuser** — `admin` is -just another name, granting nothing except where a route gates on `admin` itself. +Note what Carol does *not* have. **Permissions do not nest, and there is no superuser** — `admin` +is just another name, granting nothing except where a route gates on `admin` itself. Against the reference plugins' actual routes: @@ -274,41 +277,42 @@ Bob reaches the shifts list with no direct grant: he is in `support`, support's `staff`, and staff holds `scheduling:read` — two hops, resolved by Keto at his login. He is refused the new-shift form because `scheduling:write` hangs off `sched-leads`, which he is not in. An anonymous visitor gets a **redirect**, not a 403, carrying `return_to` so signing in lands them -on the page they asked for; a signed-in user who merely lacks the role gets the 403 page, because -there is nothing to sign in *as* that would help. The menu is filtered by the same roles, so -nobody is shown a door they cannot open. +on the page they asked for; a signed-in user who merely lacks the permission gets the 403 page, +because there is nothing to sign in *as* that would help. The menu is filtered by the same +permissions, so nobody is shown a door they cannot open. -### Granting a role +### Granting a permission -Write the tuple. The admin plugin's **Groups** and **Roles** screens do exactly this, or use +Write the tuple. The admin plugin's **Groups** and **Permissions** screens do exactly this, or use Keto's write API directly: ```bash # everyone in sched-leads may write shifts curl -X PUT http://keto:4467/admin/relation-tuples -H 'content-type: application/json' -d '{ - "namespace": "Role", "object": "scheduling:write", "relation": "members", + "namespace": "Permission", "object": "scheduling:write", "relation": "granted", "subject_set": { "namespace": "Group", "object": "sched-leads", "relation": "members" } }' ``` -Roles are authored **only in Keto** — nothing else writes them. Role names are a shared global -namespace on purpose, so an operator grants `scheduling:read` once and every plugin referencing it -is gated consistently; namespace yours as `:`. +Permissions are authored **only in Keto** — nothing else writes them. Their names are a shared +global namespace on purpose, so an operator grants `scheduling:read` once and every plugin +referencing it is gated consistently; namespace yours as `:`. A change takes effect on the user's **next login or JWT re-mint** (~10 min) — see [Instant revoke](#instant-revoke-the-optional-denylist) when you need it sooner. ### Fine-grained, per-row access -The `Resource` namespace covers what a role cannot express: *this* row, shared with *this* person. -Its permissions nest — `owner` ⊇ `editor` ⊇ `viewer`. +The `Resource` namespace covers what a coarse permission cannot express: *this* row, shared with +*this* person. It is a separate mechanism — a `Resource` carries Keto `permits` (`view`, `edit`, +`delete`, which nest as `owner` ⊇ `editor` ⊇ `viewer`) and never appears in the JWT. -**A per-row grant never widens a coarse gate.** The route's `role` is checked *before* the handler -runs, so a user rejected there never reaches the check. Gate the route on something they hold, -then narrow inside the handler: +**A per-row grant never widens a coarse gate.** The route's `permission` is checked *before* the +handler runs, so a user rejected there never reaches the check. Gate the route on something they +hold, then narrow inside the handler: ```ts -{ method: "POST", path: "/shifts/:id", role: READ, handler: editShift } +{ method: "POST", path: "/shifts/:id", permission: READ, handler: editShift } async function editShift(ctx) { if (!(await check(keto, ctx, { namespace: "Resource", object: ctx.params.id, relation: "editors" }))) @@ -331,9 +335,9 @@ contract is **TypeScript** (`src/plugin-host/plugin.ts`), so the types there are source of truth; the sections below explain them, the guarantees around them, and the rules the host enforces. A complete, runnable example lives in **[`examples/plugins/scheduling/`](examples/plugins/scheduling/)** — a public overview page, a -role-gated list page fetching upstream data (it points `SCHEDULING_UPSTREAM` at its backend; +permission-gated list page fetching upstream data (it points `SCHEDULING_UPSTREAM` at its backend; the dev compose ships a tiny mock, `examples/shifts-upstream/`), a CSRF-guarded form forwarding -writes upstream, and a mix of public + role-gated nav. It is **not** pre-installed — `plugins/` +writes upstream, and a mix of public + permission-gated nav. It is **not** pre-installed — `plugins/` ships empty so you mount your own. To run it in dev, copy it in (`cp -r examples/plugins/scheduling plugins/scheduling`, then restart) — the dev compose already points `SCHEDULING_UPSTREAM` at its mock backend. Copy it to `plugins//` and adapt. @@ -364,7 +368,7 @@ single `plugin.ts`. must be **URL/path-safe** (`isValidPluginId`: lowercase `a–z`, digits, and dashes — dashes anywhere; no uppercase, underscores, dots, or slashes); the host rejects a malformed folder name at discovery. The id also namespaces the plugin's `views/`, its `/public//` assets, and (by -convention) its nav/role names. +convention) its nav/permission names. A handful of ids are **reserved** for the host's own first-party mounts — the gated `dashboard`, the Kratos auth flows (`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`), @@ -396,20 +400,20 @@ import { listThings, createThings } from "./handlers.ts"; export default definePlugin({ apiVersion: "1.0.0", // semver string of the host contract this plugin was built against (see Versioning) - // Nav fragment, merged into the global menu and role-filtered per user. + // Nav fragment, merged into the global menu and permission-filtered per user. // `icon` is a Lucide icon by its sprite id (src/ui/icons.ts). - nav: [{ href: "/things", icon: "i-cal", id: "things:list", label: "Things", role: "things:read" }], + nav: [{ href: "/things", icon: "i-cal", id: "things:list", label: "Things", permission: "things:read" }], - // Roles this plugin gates on. Optional — see Nav & role gates. - roles: [ + // Permissions this plugin gates on. Optional — see Nav & permission gates. + permissions: [ { description: "View things", name: "things:read" }, { description: "Create and edit things", name: "things:write" }, ], - // Route handlers, mounted under the plugin's path (/things). `role` gates first. + // Route handlers, mounted under the plugin's path (/things). `permission` gates first. routes: [ - { method: "GET", path: "/", role: "things:read", handler: listThings }, - { method: "POST", path: "/", role: "things:write", handler: createThings }, + { method: "GET", path: "/", permission: "things:read", handler: listThings }, + { method: "POST", path: "/", permission: "things:write", handler: createThings }, ], }); ``` @@ -426,7 +430,7 @@ there is **no `id` or `basePath`** in the manifest — both come from the folder | `home` | no | A `RouteHandler` that owns the **public** landing `/`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). | | `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). | | `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. | -| `roles` | no | Roles this plugin gates on. See [Nav & role gates](#nav--role-gates). | +| `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). | | `routes` | no | See [Routes & handlers](#routes--handlers). | | `hooks` | no | See [Hooks](#hooks). | @@ -434,14 +438,14 @@ A plugin may be routes-only, nav-only, or hooks-only — every collection field ### Routes & handlers -A route is `{ method, path, role?, public?, handler }`. `path` is **relative to the plugin's +A route is `{ method, path, permission?, public?, handler }`. `path` is **relative to the plugin's mount path `/`** (so `path: "/:id"` in the `things` plugin serves `/things/:id`); the host matches `method` + the resolved full path, extracts `:name` segments into `ctx.params.name`, -runs the `role` gate (a coarse JWT-claim check — see [Nav & role gates](#nav--role-gates)), +runs the `permission` gate (a coarse JWT-claim check — see [Nav & permission gates](#nav--permission-gates)), and only then calls the handler with the [request context](#requestcontext). When the gate fails, an **anonymous** visitor is redirected to `/login` to sign in; the requested page is preserved as `return_to`, so after signing in they land **back on the page they -asked for**, not the dashboard. A **signed-in** user who simply lacks the role gets the **403** page. +asked for**, not the dashboard. A **signed-in** user who simply lacks the permission gets the **403** page. A route marked **`public: true`** has no gate at all — anyone reaches it (see [Public pages & menu items](#public-pages--menu-items)). @@ -482,9 +486,9 @@ export async function listThings(ctx: RequestContext) { partials/subfolders to render a full page — exactly as the admin plugin's screens do. To load the plugin's own CSS, pass its `/public//x.css` href in the shell's `styles` slot (an array of extra stylesheet hrefs) — see the reference's `views/shifts.ejs`. -- **Finer authorization than the route `role`** uses the guards from `#plugin-api`: +- **Finer authorization than the route `permission`** uses the guards from `#plugin-api`: `requireSession(ctx)` (assert a session — throws a `GuardError` the host turns into a redirect - to sign in), `can(ctx, role)` (a coarse JWT-claim check, zero I/O), and `check(keto, ctx, + to sign in), `can(ctx, permission)` (a coarse JWT-claim check, zero I/O), and `check(keto, ctx, {namespace, object, relation})` (a live Keto check for relationship rules — the subject is the signed-in user, anonymous ⇒ denied). Throw `new GuardError(403, …)` after a failed `can`/`check` to render the 403 page. @@ -540,8 +544,8 @@ a signed-in visitor, or sign-in / register to an anonymous one). After login the points there. For the gated `dashboard`, the host enforces the session gate first, so `ctx.identity` is non-null; -branch on `ctx.roles` *inside* to tailor the page per role. Don't gate `dashboard` itself behind a -single role — there's no second dashboard to fall back to, so a user lacking it would land on a +branch on `ctx.permissions` *inside* to tailor the page per permission. Don't gate `dashboard` itself behind a +single permission — there's no second dashboard to fall back to, so a user lacking it would land on a 403. (Both slots answer `GET` and `HEAD`.) Only **one** plugin may own each slot: two declaring `home` (or two declaring `dashboard`) is a @@ -557,13 +561,13 @@ request: ```ts interface RequestContext { chrome: PageChrome; // brand/global-nav/user/theme/csrf for the native app shell - identity: SessionIdentity | null; // { id, email, roles } from the verified session JWT, or null + identity: SessionIdentity | null; // { id, email, permissions } from the verified session JWT, or null log: Log; // request-scoped logger, in this request's trace params: Record; // path params from the route match, e.g. /things/:id → { id } query: URLSearchParams; // alias of url.searchParams req: IncomingMessage; res: ServerResponse; - roles: string[]; // identity?.roles ?? [] — coarse gate without a null-check + permissions: string[]; // identity?.permissions ?? [] — coarse gate without a null-check system?: SystemCapabilities; // privileged Ory clients + instant-revoke, for a system plugin (see below); undefined unless the host wired them url: URL; verifyCsrf(submitted): boolean; // gate a form POST against the request's signed CSRF cookie @@ -574,7 +578,7 @@ interface RequestContext { theme, user }`. Hand it to `partials/shell` so a `view` result renders the **native app shell** (the same sidebar, branding, theme switch and signed-in profile every page uses); `chrome.nav` is the global menu — your plugin's nav fragment plus every other installed plugin's (the admin section among -them, when that plugin is present) — already composed, role-filtered, and current-marked for this +them, when that plugin is present) — already composed, permission-filtered, and current-marked for this request (the gated **Dashboard** link is omitted for an anonymous visitor). `chrome.signInHref` is where the shell's anonymous **Sign in** link points — the current page baked in as `return_to`. Map each `chrome.*` to the matching `partials/shell` local — @@ -587,7 +591,7 @@ secret and sets the cookie; the plugin never touches it. It is **opt-in per hand that never calls it has no CSRF guard at all. (See the reference: `examples/plugins/scheduling/`.) The same shell renders **every** page (the dashboard, your plugin pages — the admin plugin's included, and the -login/registration/front pages), so the menu looks identical signed in or out — it just role-filters. +login/registration/front pages), so the menu looks identical signed in or out — it just permission-filters. A page that wants a focused, chrome-free layout passes **`menu: false`** to `partials/shell` (drops the sidebar, single column); everything else still renders. @@ -604,7 +608,7 @@ OpenTelemetry Collector when `OTLP_ENDPOINT` is set). **Stability guarantee.** The fields above are the stable contract — present and non-breaking across a major `apiVersion`. New fields may be **added** within a major version (additive, never breaking). `req`/`res` are the raw Node objects and the full escape hatch; reading them is fine, -but prefer the typed fields so a handler keeps working as the host evolves. `user`/`roles` come +but prefer the typed fields so a handler keeps working as the host evolves. `user`/`permissions` come from the JWT middleware and are `null`/`[]` until a session exists. ### System capabilities (the `ctx.system` surface) @@ -618,7 +622,7 @@ needs the host's Ory admin clients and the instant-revoke hook instead. The host ```ts interface SystemCapabilities { // every field optional — present only when the host wired it hydra?: HydraAdmin; // OAuth2 client admin (register/list/delete Hydra clients) - keto?: KetoClient; // relationship read/write (groups, roles) + keto?: KetoClient; // relationship read/write (groups, permissions) kratosAdmin?: KratosAdmin; // identity admin (create/edit/deactivate/delete users) revoke?: (sub: string) => void; // instant-revoke a subject's live tokens (needs the denylist) } @@ -628,8 +632,8 @@ interface SystemCapabilities { // every field optional — present only Hydra configured, the [revocation denylist](#instant-revoke-the-optional-denylist) enabled). A system plugin treats every field as optional and **degrades when absent** — the host never fails a request over it. The built-in **admin plugin** ([`examples/plugins/admin/`](examples/plugins/admin/)) is the -reference consumer: its Users screen uses `ctx.system.kratosAdmin`, Groups/Roles use `ctx.system.keto`, -OAuth2 clients use `ctx.system.hydra`, and a deactivate/delete or user role-change calls +reference consumer: its Users screen uses `ctx.system.kratosAdmin`, Groups/Permissions use `ctx.system.keto`, +OAuth2 clients use `ctx.system.hydra`, and a deactivate/delete or user permission-change calls `ctx.system.revoke` so the change lands now instead of after the JWT TTL; where a capability is missing the screen renders a themed 503. @@ -637,11 +641,11 @@ This is a **privileged** surface — it hands a plugin the keys to identity and for first-party system plugins you author or vendor, the same trust level as any plugin (the host doesn't sandbox — [crash-isolation is a non-goal](#overview)). An ordinary domain plugin ignores it. -### Nav & role gates +### Nav & permission gates A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/ui/nav.ts`), which -applies the central override and then **filters per user** by the roles in the session JWT — a -node shows iff it is `public`, declares no `role`, or the user's roles include that name. Use +applies the central override and then **filters per user** by the permissions in the session JWT — a +node shows iff it is `public`, declares no `permission`, or the user's permissions include that name. Use arbitrary depth, counts, and icons; see `composeNav` for the node shape. A node's `icon` is a **Lucide icon**, referenced by its sprite id (e.g. `i-cal` → lucide `calendar`); the available ids are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name there. @@ -649,25 +653,25 @@ are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its luci #### Public pages & menu items A route or nav node may be marked **`public: true`** — reachable by **anyone, signed in or not**, -and the menu item shows for everyone. This is the same as omitting `role` (an ungated +and the menu item shows for everyone. This is the same as omitting `permission` (an ungated route/node is already open) but stated outright, so "public" is a **deliberate choice, not the -accident of a forgotten gate**. `public` and `role` are **mutually exclusive** — declaring +accident of a forgotten gate**. `public` and `permission` are **mutually exclusive** — declaring both is contradictory and discovery refuses the plugin at boot. A public page still renders in the native shell via `ctx.chrome`; for an anonymous visitor `ctx.identity` is `null`, the shell shows a **Sign in** link (`chrome.signInHref`, returning to this page) -in place of the profile/sign-out block, the gated **Dashboard** link is hidden, and `ctx.roles` is -empty (read a role with `can(ctx, …)` to branch). The reference plugin's `/scheduling` +in place of the profile/sign-out block, the gated **Dashboard** link is hidden, and `ctx.permissions` is +empty (read a permission with `can(ctx, …)` to branch). The reference plugin's `/scheduling` **Overview** is a worked example: it's `public`, so the "Scheduling" menu header shows for everyone, while the actual shifts list stays behind `scheduling:read`. -The gate passes iff the user's JWT `roles` include that name. How roles are granted, why their +The gate passes iff the user's JWT `permissions` include that name. How permissions are granted, why their names are a shared global namespace, and the fine-grained per-row tier are all covered in -[Users, groups & roles](#users-groups--roles). +[Identities, groups & permissions](#identities-groups--permissions). -Declaring the ones you gate on in `roles` is **optional but recommended**: it documents them, +Declaring the ones you gate on in `permissions` is **optional but recommended**: it documents them, feeds conflict detection, and lets the one-command bootstrap seed them — the demo admin is -granted every discovered plugin's declared roles, so a dropped-in plugin works out of the box +granted every discovered plugin's declared permissions, so a dropped-in plugin works out of the box without editing host config. ### Contract versioning @@ -703,15 +707,15 @@ with `findConflicts` and resolves them **loudly — never last-write-wins**. `er | `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/` prefix is unique), so this catches a plugin duplicating one of its own. | | `nav-id` | error | A nav node `id` is used more than once — the central override targets ids, so they must be unique. | | `home` / `dashboard` | error | More than one plugin declares `home` (or `dashboard`). Each landing page is a single slot, so only one may own it ([The landing pages](#the-landing-pages-home--dashboard)). | -| `role` | warn | A role name is declared by more than one plugin. Sharing is legitimate; namespace as `:` if unintended. | +| `permission` | warn | A permission name is declared by more than one plugin. Sharing is legitimate; namespace as `:` if unintended. | There is **no separate `basePath` rule**: the mount path is the derived `/`, so its -uniqueness follows from the id check. `role` is the one intentional overlap, so it warns +uniqueness follows from the id check. `permission` is the one intentional overlap, so it warns rather than aborts; everything else is an error an author fixes before the host will start. Beyond cross-plugin conflicts, discovery also rejects **per-manifest shape errors** at boot: a -non-array `nav`/`routes`/`roles`, a non-function `home`/`dashboard`, or a route/nav node that -sets both `public` and `role` (mutually exclusive — [Public pages](#public-pages--menu-items)). +non-array `nav`/`routes`/`permissions`, a non-function `home`/`dashboard`, or a route/nav node that +sets both `public` and `permission` (mutually exclusive — [Public pages](#public-pages--menu-items)). ### Hooks @@ -778,7 +782,7 @@ can't escape its own package scope, so it can't point at the host's file directl > Discovery — scanning `plugins/`, importing each `plugin.ts` default export, and > validating it (id, `apiVersion`, conflicts) — runs at boot (`src/plugin-host/discovery.ts`); a bad > plugin stops startup with a precise message. The router (`src/plugin-host/router.ts`) then mounts -> each route at `/`, resolves `:name` params, runs the role gate, and turns the +> each route at `/`, resolves `:name` params, runs the permission gate, and turns the > handler's `RouteResult` into the response; a `view` result renders > `plugins//views/.ejs` (`src/plugin-host/view-resolver.ts`), which may `include()` the core > building-block partials. A plugin's `public/` assets are served at `/public//` @@ -809,7 +813,7 @@ worked example: thin handlers bound to an injectable upstream client, unit-teste 3. **E2E the user-facing flow.** Per AGENTS.md §6, ship a side-effect-free Playwright test in `e2e-tests/` for each plugin page/form so the suite stays `fullyParallel`, run against the live `web` - service with the plugin mounted. The reference's role-gating is covered in `visual.spec.ts`; + service with the plugin mounted. The reference's permission-gating is covered in `visual.spec.ts`; its authenticated list/form happy-path is the full-E2E item (needs cross-host login infra). The validation an author hits is the same the host runs: bad `apiVersion` or a conflict @@ -837,13 +841,13 @@ The menu is **driven entirely by config** and assembled from two sources: export default defineMenu({ branding: { name: "Acme Ops" }, override: { hide: ["teams"] } }); ``` -Every nav item may carry a `role`; the rendered tree is **filtered per user** by -reading the roles in the session JWT (no per-request authz call — see +Every nav item may carry a `permission`; the rendered tree is **filtered per user** by +reading the permissions in the session JWT (no per-request authz call — see [Auth, sessions & access](#auth-sessions--access)), so the menu only ever shows what that person can reach. An item (or a whole page) may instead be marked **`public: true`** to show it to **everyone, signed in or not** — the blessed, explicit way to expose a public page and its menu entry (an ungated item is already public; `public` just -says so on purpose, and is mutually exclusive with `role`). The markup is the +says so on purpose, and is mutually exclusive with `permission`). The markup is the recursive, zero-JS nav tree from the design foundation (header/leaf × clickable/static, counts, arbitrary depth). Branding (name, logo, default theme) renders in the app shell — the sidebar brand shows the configured logo (else a default mark), and the theme sets the @@ -868,7 +872,7 @@ set of reusable EJS partials + TS helpers, fully styled and zero-JS: - **Helpers:** `composeNav` (menu from config), `parseListQuery` (`?q=…&status=…&sort=…&page=…` → filter/sort/pagination), `paginate` (page math), and the auth guards a handler calls to authorize (`src/auth/guards.ts`): `requireSession` (assert a - session — a `GuardError` the host turns into a redirect to sign in), `can(role)` (a coarse + session — a `GuardError` the host turns into a redirect to sign in), `can(permission)` (a coarse JWT-claim check, zero I/O), `check(relation, object)` (the one live Keto call, for relationship rules). @@ -916,7 +920,7 @@ The app is **environment-agnostic**: there is no `NODE_ENV`. Behaviour that used | `JWT_ISSUER` / `JWT_AUDIENCE` | _unset_ | optional: when set, the session JWT's `iss` / `aud` must match (the dev tokenizer sets neither) | | `JWT_CLOCK_SKEW_SEC` | `60` | exp/nbf leeway (s) for Kratos↔web clock drift (the auth E2E sets `0`) | | `ORY_TIMEOUT_SEC` | `5` | per-call timeout for outbound Kratos/Keto/Hydra (and http JWKS) fetches, so a hung Ory can't park a request | -| `REVOCATION_DENYLIST` | `false` | when `true`, enable the optional [instant role/session revoke denylist](#instant-revoke-the-optional-denylist) | +| `REVOCATION_DENYLIST` | `false` | when `true`, enable the optional [instant permission/session revoke denylist](#instant-revoke-the-optional-denylist) | | `REVOCATION_TTL_SEC` | `900` | how long a revoke entry lives; keep ≥ tokenizer TTL (10m) + clock skew | | `CSRF_SECRET` | dev throwaway | signs our double-submit CSRF token; enforced by `REQUIRE_SECURE_SECRETS` | @@ -989,7 +993,7 @@ blocks a clean clone: [Social sign-in (SSO)](#social-sign-in-sso)). Everything else is generated or seeded on first boot — Ory migrations, the dev signing key, -the demo admin identity and its Keto roles, the Keto OPL model — so there is nothing else to +the demo admin identity and its Keto permissions, the Keto OPL model — so there is nothing else to hand-configure. ### Social sign-in (SSO) @@ -1023,32 +1027,32 @@ the session for a signed JWT once** via the Kratos **session tokenizer** (`whoam ``` ── AT LOGIN / REFRESH (the only time Ory is on the path) ────────── Kratos verifies credentials - └─► app reads the user's roles from Keto (direct + transitive via groups) + └─► app reads the user's permissions from Keto (direct + transitive via groups) └─► app writes them as a derived projection on the identity (admin API) └─► whoami(tokenize_as: "plainpages") ─► signed JWT - claims: { sub, email, roles:[…from Keto], exp ≈ 10m } + claims: { sub, email, permissions:[…from Keto], exp ≈ 10m } └─► stored as the session cookie ── EVERY REQUEST (hot path — pure CPU, no I/O) ─────────────────── Browser ─cookie(JWT)─► web : verify signature (cached JWKS) - read claims.roles + read claims.permissions filter menu · gate routes ``` -**Keto is the single source of truth for roles.** Coarse roles are Keto relations (e.g. -`Role:admin#members@identity:alice`); the admin screens write them *only* to Keto. But the +**Keto is the single source of truth for permissions.** Coarse permissions are Keto relations (e.g. +`Permission:admin#members@identity:alice`); the admin screens write them *only* to Keto. But the tokenizer's claims mapper can read only the **identity**, not call Keto — so at login the -app reads the roles from Keto and refreshes a **derived projection**: a read-only copy +app reads the permissions from Keto and refreshes a **derived projection**: a read-only copy written onto the identity's `metadata_public` for the tokenizer to see, which the template -maps into the JWT `roles` claim. (It must be `metadata_public`, not `metadata_admin`: the +maps into the JWT `permissions` claim. (It must be `metadata_public`, not `metadata_admin`: the session Kratos hands the tokenizer carries only *public* metadata — and the user can already -read these coarse roles in their own JWT, so nothing is leaked.) That projection is a +read these coarse permissions in their own JWT, so nothing is leaked.) That projection is a per-login cache, authoritative nowhere; nothing edits it by hand, and a stale one self-heals on the next login. -A role can be granted to a user directly or to a **group** the user belongs to; login -resolves both (enumerate the defined roles, ask Keto to resolve each membership), so the JWT -`roles` match what the admin **Effective access** view shows. +A permission can be granted to a user directly or to a **group** the user belongs to; login +resolves both (enumerate the defined permissions, ask Keto to resolve each membership), so the JWT +`permissions` match what the admin **Effective access** view shows. Cost: **a handful of Keto reads + one identity refresh per login** — never per request. JWKS is cached, so even signature verification hits the network only on key rotation. The app @@ -1060,8 +1064,8 @@ recomputed from Keto. This design buys an I/O-free hot path that scales to **tens of thousands of concurrent users** on modest hardware. In return: -- **Role changes lag by up to one TTL (~10m).** Gating reads the JWT, not Keto, so a granted - or revoked role only takes effect when the token is next minted (re-login or TTL refresh). +- **Permission changes lag by up to one TTL (~10m).** Gating reads the JWT, not Keto, so a granted + or revoked permission only takes effect when the token is next minted (re-login or TTL refresh). For an admin tool this is intentional — the alternative is a Keto call per request, which we traded away. For instant revoke, turn on the optional [revocation denylist](#instant-revoke-the-optional-denylist) — it closes the gap for @@ -1074,12 +1078,12 @@ users** on modest hardware. In return: ### Instant revoke: the optional denylist Off by default; turn it on with `REVOCATION_DENYLIST=true` (`src/auth/denylist.ts`). For -security-critical revoke (offboarding, a compromised account) the ~10m role/session lag +security-critical revoke (offboarding, a compromised account) the ~10m permission/session lag above is too long. When enabled, an admin **deactivating** or **deleting** a user, or -**granting/revoking** a role to a *user*, records that subject as revoked-now; the hot path +**granting/revoking** a permission to a *user*, records that subject as revoked-now; the hot path then rejects every token for it minted **before** the revoke and forces a re-mint — which -re-reads roles from Keto, or clears a now-dead session. A fresh re-login (its JWT issued -*after* the revoke) passes, so a role downgrade lands immediately without locking the +re-reads permissions from Keto, or clears a now-dead session. A fresh re-login (its JWT issued +*after* the revoke) passes, so a permission downgrade lands immediately without locking the account. It's an in-memory, auto-evicting map — no database, like the JWKS cache, so it stays inside @@ -1089,11 +1093,11 @@ CPU — **Keto stays off the hot path**. Two deliberate bounds: it's instant on instance** that handled the revoke (across replicas/restarts the guarantee falls back to the token TTL — back the denylist with a shared store for hard multi-instance instant-revoke), and a **group** membership change is transitive across many users, so it's left to lag — -deactivate the user, or use a direct user-role change, for an instant effect. +deactivate the user, or use a direct user-permission change, for an instant effect. ### Three tiers of "may I?" -[Users, groups & roles](#users-groups--roles) covers *what* the entities are; this is where each +[Identities, groups & permissions](#identities-groups--permissions) covers *what* the entities are; this is where each **kind** of rule belongs. ``` @@ -1110,8 +1114,8 @@ deactivate the user, or use a direct user-role change, for an instant effect. is for. Reserve it for those; don't pay its tuple-sync cost for rules a service can already answer from its own data. -The admin plugin's users / groups / roles screens write authorization **only to Keto** — coarse -roles and fine-grained relationships alike. +The admin plugin's users / groups / permissions screens write authorization **only to Keto** — coarse +permissions and fine-grained relationships alike. ### OAuth2 provider (Hydra) @@ -1151,7 +1155,7 @@ ports (guarded by `src/compose.test.ts`); dev publishes only the two a browser m expose one, and never front one with a proxy that lacks its own auth. **The JWT is signed, not encrypted.** Claims are base64: a signed-in user can read their own -`sub`, `email` and `roles`. `HttpOnly` keeps page JavaScript out of the cookie, not the user. +`sub`, `email` and `permissions`. `HttpOnly` keeps page JavaScript out of the cookie, not the user. Never put anything in a claim you wouldn't show them. **The JWT's ~10m TTL is not the session lifetime.** The browser also holds Kratos' @@ -1160,12 +1164,12 @@ JWT. So a stolen cookie jar is worth 30 days of re-mintable access, not ten minu two cookies obey `SECURE_COOKIES`; the Kratos one takes its flags from Kratos' own config. **Offboarding is not instant by default.** An expired JWT re-mints off that live Kratos session, -re-reading roles from Keto — so a revoked role, or a deactivated identity, lands within one +re-reading permissions from Keto — so a revoked permission, or a deactivated identity, lands within one token TTL rather than immediately. With the [denylist](#instant-revoke-the-optional-denylist) on (it is off by default), both take effect at once, on the instance that handled the change. -**Not guaranteed** — accepted, and stated where each mechanism is: role changes +**Not guaranteed** — accepted, and stated where each mechanism is: permission changes [lag up to one token TTL and sign-in needs Ory up](#two-trade-offs--both-deliberate), and the denylist is [single-instance and skips group changes](#instant-revoke-the-optional-denylist). Hardening a real deploy is `REQUIRE_SECURE_SECRETS=true`, `SECURE_COOKIES=true`, and replacing @@ -1202,7 +1206,7 @@ docs for the full template-type list and the data each template receives. Plainpages runs as a small set of containers, orchestrated by Docker Compose: -| Container | Role | +| Container | Permission | | -------------- | ---- | | `web` | The Node 24 + TypeScript app: server-rendered EJS, the plugin host, the building-block partials. Stays tiny. | | `kratos` | **Ory Kratos** — identity: login, registration, password reset, SSO, sessions. | @@ -1212,7 +1216,7 @@ Plainpages runs as a small set of containers, orchestrated by Docker Compose: The `web` app is an Ory **relying party**: it never stores passwords. At login it turns the Kratos session into a short-lived, **locally-validated JWT** (the Kratos session -tokenizer) carrying the user's coarse roles — so every later request gates the menu and +tokenizer) carrying the user's coarse permissions — so every later request gates the menu and pages by **verifying the JWT in-process, with no per-request call to Ory**. Keto answers the rarer fine-grained checks; Hydra is used only when the app acts as an OAuth2 **login & consent provider** for other apps. It reaches the Ory services over their **REST APIs @@ -1261,7 +1265,7 @@ service — no Node/browsers on the host. There are five suites: **Visual + design system** (`visual.spec.ts`) — Ory-free, so it stays fast. It screenshots the live pages and asserts the rendered design system — the app shell, theme switch, mobile off-canvas layout, icon sprite, CSRF-guarded sign-out, the public landing, the 404 page, and -plugin role-gating — the last exercised by bind-mounting the reference example +plugin permission-gating — the last exercised by bind-mounting the reference example (`examples/plugins/scheduling/`) onto `/app/plugins/scheduling`. ```bash @@ -1273,7 +1277,7 @@ docker compose -f compose.yml -f e2e-tests/compose.visual.yml down -v boots the real Ory stack (Postgres + Kratos + Keto + bootstrap), shortens the session→JWT TTL to 8s (`ory/kratos/e2e.yml`) and sets `JWT_CLOCK_SKEW_SEC=0`, then logs in the seeded admin and proves the "stay signed in" hot path: the lapsed JWT is silently **re-minted** from the -live Kratos session (roles re-read from Keto), and once that session is revoked the stale +live Kratos session (permissions re-read from Keto), and once that session is revoked the stale cookie is **cleared**. ```bash @@ -1295,9 +1299,9 @@ docker compose -f compose.yml -f e2e-tests/compose.oauth.yml down -v **Full browser flow** (`full-flow.spec.ts`) — the real Playwright UI against the live stack: the themed **password login** and a **mocked-SSO** login (an in-network mock OIDC provider, -`e2e-tests/mock-oidc.ts`), **menu filtering by role**, the **users/groups/roles** admin CRUD, the +`e2e-tests/mock-oidc.ts`), **menu filtering by permission**, the **users/groups/permissions** admin CRUD, the **OAuth2-clients** admin screen (register → one-time secret → delete; Hydra is part of this stack -for it), a role-gated **plugin page**, and **logout**. Because the themed form posts straight to +for it), a permission-gated **plugin page**, and **logout**. Because the themed form posts straight to Kratos and cookies are host-scoped, a tiny same-origin gateway (`e2e-tests/proxy.ts`) fronts web + Kratos on one host (`ory/kratos/e2e-proxy.yml` points Kratos at it) — exactly as a production reverse proxy would. @@ -1341,7 +1345,7 @@ bash ci.sh ``` Each E2E suite **owns a clean stack** — never point two suites at one backend (auth-refresh -revokes the admin's sessions; full-flow writes users/groups/roles to Keto), which is why the +revokes the admin's sessions; full-flow writes users/groups/permissions to Keto), which is why the gate runs them serially, one stack up/down per suite. ## CI/CD @@ -1491,8 +1495,8 @@ The server drains in-flight requests on `SIGTERM`/`SIGINT` rather than cutting t mid-response, so container restarts are clean. The first-boot **bootstrap** is idempotent and runs on every `up` — it generates the JWT -signing key if absent, creates the demo admin in Kratos, and grants it the `admin` role plus -every discovered plugin's declared role names in Keto, so role checks (and any +signing key if absent, creates the demo admin in Kratos, and grants it the `admin` permission plus +every discovered plugin's declared permission names in Keto, so permission checks (and any dropped-in plugin) resolve out of the box. The web app waits for Kratos + Keto to be healthy *and* the bootstrap to finish before starting. **Change the demo admin before production.** @@ -1513,7 +1517,7 @@ whole handler (an `AsyncLocalStorage`), so logs and traces correlate. Three expl Every request emits one access line (`method`, `path` — the query is dropped, it can carry tokens — `status`, `ms`, `requestId`); login/logout, admin writes (who-did-what), and -missing-role/CSRF rejections log at `info`/`warn`, and the catch-all 500 + the +missing-permission/CSRF rejections log at `info`/`warn`, and the catch-all 500 + the Ory-unreachable re-mint at `error`/`warn`. An inbound W3C `traceparent` is **adopted**, so a request continues a trace started by an upstream proxy/gateway. @@ -1625,20 +1629,20 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *. auth/ Identity, the session-JWT hot path, guards, and the Ory REST clients jwt.ts JWS signature verify via node:crypto, no jose (decode + verify a compact JWS against one JWK) - jwt-middleware.ts resolveSession()/authenticate(): per-request session-JWT verify — key by kid → signature → exp/nbf/iss/aud (clock skew) → ctx.identity/roles; flags a lapsed token for re-mint + jwt-middleware.ts resolveSession()/authenticate(): per-request session-JWT verify — key by kid → signature → exp/nbf/iss/aud (clock skew) → ctx.identity/permissions; flags a lapsed token for re-mint jwks.ts JwksProvider — resolve the verify key by kid; createJwksProvider() picks by scheme: staticJwks (base64) or cachingJwks (file/http: TTL cache + rotation-on-miss reload) gen-jwks.ts generateJwks()/rotateJwks() + CLI (mint · --prepend · --prune): the ES256 session-tokenizer signing JWKS; see JWT signing key & rotation - login.ts completeLogin()/remintSession(): login completion + TTL re-mint — roles from Keto → metadata_public projection → tokenize → session JWT cookie - guards.ts requireSession()/can()/check(): in-handler authorization — the imperative counterpart to the route role gate; GuardError → 303 /login or 403; check() is the one live Keto "may I?" call + login.ts completeLogin()/remintSession(): login completion + TTL re-mint — permissions from Keto → metadata_public projection → tokenize → session JWT cookie + guards.ts requireSession()/can()/check(): in-handler authorization — the imperative counterpart to the route permission gate; GuardError → 303 /login or 403; check() is the one live Keto "may I?" call csrf.ts CSRF for our own POST forms: signed double-submit token — issue/verify, cookie, request gate denylist.ts Optional instant-revoke denylist: in-memory, auto-evicting; hot path rejects a revoked subject's pre-revoke tokens (REVOCATION_DENYLIST) flow-view.ts buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs oauth-login.ts resolveLoginChallenge(): authenticate a Hydra login challenge via the Kratos session → accept, or bounce to /login oauth-consent.ts resolveConsentChallenge()/acceptConsent()/rejectConsent(): auto-accept first-party, else show the consent screen → grant scopes routes.ts buildAuthRoutes(): the built-in auth/OAuth2 endpoints as named handlers on the internal route table — themed flow pages, /oauth2/* challenges, /auth/complete, POST /logout, /error; only what the wired clients support is registered - bootstrap.ts One-command bootstrap: idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin role in Keto + bootstrap.ts One-command bootstrap: idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin permission in Keto kratos-public.ts createKratosPublic(): Kratos public-API fetch client — self-service flow init/get/submit, browser logout, whoami, session→JWT tokenize - kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public update (login role projection) + kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public update (login permission projection) keto-client.ts createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API) hydra-admin.ts createHydraAdmin(): Hydra admin-API fetch client — OAuth2 login + consent challenge get/accept/reject + OAuth2 client CRUD fetch-timeout.ts withTimeout(): bound every outbound Ory call — wrap the injected fetch so each request aborts after a deadline unless the caller passed its own signal; server.ts wires it into the Kratos/Keto/Hydra clients @@ -1648,7 +1652,7 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *. plugin-api.ts Stable plugin author barrel — the one module a plugin imports, as `#plugin-api` (definePlugin, ctx/result types, guards, body/CSRF/list-query/paginate helpers, and the ctx.system Ory client types) system.ts SystemCapabilities: the privileged ctx.system surface (Ory admin clients + instant-revoke) a system plugin uses; the host populates it from the wired clients, the admin plugin consumes it discovery.ts discoverPlugins(): scan plugins/, import + validate each plugin.ts default export, fail loud at boot - router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, role gate + router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate hooks.ts runBootHooks()/runRequestHooks()/runResponseHooks(): invoke a plugin's optional lifecycle hooks in discovery order; no sandbox (a throwing hook fails loud), skipped when no plugin declares one view-resolver.ts renderPluginView(): render plugins//views/.ejs; plugin views can include() core partials @@ -1656,19 +1660,19 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *. chrome.ts buildPluginChrome(): the one global menu + brand/user/theme/csrf every page renders the shell from (unified across all pages) — exposed on ctx.chrome shell-context.ts buildShellContext(): brand/theme/user view-model for the dashboard shell (real signed-in user, no demo profile) dashboard.ts buildDashboardModel(): the gated "/dashboard" app home — a short instructional starter (replace it with a plugin `dashboard` handler); "/" is the public landing (a plugin `home` handler). Both render the one unified menu (ctx.chrome) - nav.ts composeNav(): merge plugin nav fragments + central override, role-filter → nav-tree model + nav.ts composeNav(): merge plugin nav fragments + central override, permission-filter → nav-tree model menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding, imported as `#menu-config`), validated at boot icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs) list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize } paginate.ts paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs -views/ Core EJS templates, all in the one app shell: home (public "/" landing), index (instructional /dashboard), auth (themed Kratos flows), oauth-consent (OAuth2 consent), error (flow-error sink → /error), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, landing/flow/consent bodies, menu/popover, theme switch, icon sprite). Domain screens live in plugins, not here — the admin plugin ships its own views/ (incl. its Users/Groups/Roles/Clients + confirm bodies) +views/ Core EJS templates, all in the one app shell: home (public "/" landing), index (instructional /dashboard), auth (themed Kratos flows), oauth-consent (OAuth2 consent), error (flow-error sink → /error), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, landing/flow/consent bodies, menu/popover, theme switch, icon sprite). Domain screens live in plugins, not here — the admin plugin ships its own views/ (incl. its Users/Groups/Permissions/Clients + confirm bodies) public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt) config/ Drop-in mount point for the central menu override + branding (config/menu.ts). Ships empty (.gitkeep, git-ignored otherwise) — mount your own or copy the template from examples/config/; defaults apply when absent -ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — role/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs → /oauth2/*) + storage init (postgres/init/init.sql: one DB per service) +ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — permission/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs → /oauth2/*) + storage init (postgres/init/init.sql: one DB per service) plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in). Ships empty (.gitkeep, git-ignored otherwise) — mount your own; the E2E suites bind-mount the example plugins onto /app/plugins/scheduling and /app/plugins/admin -examples/ Copy-in reference material, mirroring the mount dirs: plugins/scheduling/ (the reference plugin — list/form over an upstream + role-gated nav), plugins/admin/ (the system-admin plugin — Users/Groups/Roles/OAuth2-clients over Ory via ctx.system), both copied into plugins/; and config/menu.ts (the menu/branding template copied into config/); shifts-upstream/ is the dev mock backend the scheduling plugin reads/writes (stand-in for your real service) -e2e-tests/ Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint) + oauth-login.spec (OAuth2 login + consent) + full-flow.spec (browser UI: password/SSO login, menu-by-role, admin CRUD, plugin page, logout) + devstack-login.spec (regression: login works from the banner's localhost URL and 127.0.0.1 is canonicalised, on the plain `docker compose up` topology); proxy.ts (same-origin gateway) + mock-oidc.ts (mock SSO provider) back full-flow. e2e-tests/Dockerfile + e2e-tests/compose.{visual,auth,oauth,full,devstack}.yml run them +examples/ Copy-in reference material, mirroring the mount dirs: plugins/scheduling/ (the reference plugin — list/form over an upstream + permission-gated nav), plugins/admin/ (the system-admin plugin — Users/Groups/Permissions/OAuth2-clients over Ory via ctx.system), both copied into plugins/; and config/menu.ts (the menu/branding template copied into config/); shifts-upstream/ is the dev mock backend the scheduling plugin reads/writes (stand-in for your real service) +e2e-tests/ Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint) + oauth-login.spec (OAuth2 login + consent) + full-flow.spec (browser UI: password/SSO login, menu-by-permission, admin CRUD, plugin page, logout) + devstack-login.spec (regression: login works from the banner's localhost URL and 127.0.0.1 is canonicalised, on the plain `docker compose up` topology); proxy.ts (same-origin gateway) + mock-oidc.ts (mock SSO provider) back full-flow. e2e-tests/Dockerfile + e2e-tests/compose.{visual,auth,oauth,full,devstack}.yml run them ci.sh The full CI gate: typecheck → unit tests → every E2E suite, each on a fresh, always-torn-down stack (`bash ci.sh`) .gitea/workflows/ Gitea Actions: ci.yml — the full gate (ci.sh) on every branch push except main; mirror.yml — force-sync main + tags to the GitHub mirror; see CI/CD diff --git a/compose.yml b/compose.yml index 488ef0d..b94e056 100644 --- a/compose.yml +++ b/compose.yml @@ -130,9 +130,9 @@ services: environment: ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin} - # Base roles for the demo admin; bootstrap also grants every discovered plugin's declared - # role names (so the reference plugin — and any drop-in — works out of the box). - ADMIN_ROLES: ${ADMIN_ROLES:-admin} + # Base permissions for the demo admin; bootstrap also grants every discovered plugin's declared + # permission names (so the reference plugin — and any drop-in — works out of the box). + ADMIN_PERMISSIONS: ${ADMIN_PERMISSIONS:-admin} APP_URL: ${APP_URL:-http://localhost:3000} # printed in the first-run login banner JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json KETO_WRITE_URL: http://keto:4467 diff --git a/e2e-tests/auth-refresh.spec.ts b/e2e-tests/auth-refresh.spec.ts index fdbfb80..2b1606f 100644 --- a/e2e-tests/auth-refresh.spec.ts +++ b/e2e-tests/auth-refresh.spec.ts @@ -9,7 +9,7 @@ import { expect, test } from "@playwright/test"; const WEB = process.env.BASE_URL ?? "http://web:3000"; const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433"; const KRATOS_ADMIN = process.env.KRATOS_ADMIN_URL ?? "http://kratos:4434"; -const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap; admin role granted in Keto +const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap; admin permission granted in Keto const ADMIN_PASSWORD = "admin"; const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); @@ -29,8 +29,8 @@ function relayCookies(res: Response): string { .filter((kv) => kv.split("=")[1] !== "") .join("; "); } -// Read a JWT's claims without verifying (web already verified it; we only inspect exp/roles). -function jwtClaims(jwt: string): { email: string; exp: number; roles: string[]; sub: string } { +// Read a JWT's claims without verifying (web already verified it; we only inspect exp/permissions). +function jwtClaims(jwt: string): { email: string; exp: number; permissions: string[]; sub: string } { return JSON.parse(Buffer.from(jwt.split(".")[1]!, "base64url").toString()); } @@ -72,7 +72,7 @@ async function awaitJwtSetCookie(session: string, jwt: string): Promise test("an expired session JWT is silently re-minted while Kratos lives, then cleared once it dies", async () => { test.setTimeout(90_000); // two short-TTL windows (8s each) + Ory round-trips - // 1. Log in for real, then complete login on web → our session JWT (roles read from Keto). + // 1. Log in for real, then complete login on web → our session JWT (permissions read from Keto). const session = await kratosLogin(); const complete = await fetch(`${WEB}/auth/complete`, { headers: { cookie: `plainpages_session=${session}` }, redirect: "manual" }); expect(complete.status, "auth/complete redirects home").toBe(303); @@ -83,7 +83,7 @@ test("an expired session JWT is silently re-minted while Kratos lives, then clea const claims1 = jwtClaims(jwt1); expect(claims1.email).toBe(ADMIN_EMAIL); expect(claims1.sub, "sub is the Kratos identity id").toBeTruthy(); - expect(claims1.roles, "roles are projected from Keto").toContain("admin"); + expect(claims1.permissions, "permissions are projected from Keto").toContain("admin"); // 2. Token timeout → refresh: once the 8s TTL lapses, the next request re-mints a fresh JWT. const jwt2Line = await awaitJwtSetCookie(session, jwt1); @@ -91,7 +91,7 @@ test("an expired session JWT is silently re-minted while Kratos lives, then clea expect(jwt2, "a different token was minted").not.toBe(jwt1); const claims2 = jwtClaims(jwt2); expect(claims2.exp, "the new token expires later").toBeGreaterThan(claims1.exp); - expect(claims2.roles, "re-mint re-reads roles from Keto").toContain("admin"); + expect(claims2.permissions, "re-mint re-reads permissions from Keto").toContain("admin"); // 3. Kill the Kratos session: now the lapsed token cannot refresh — the cookie is cleared. const revoke = await fetch(`${KRATOS_ADMIN}/admin/identities/${claims1.sub}/sessions`, { method: "DELETE" }); diff --git a/e2e-tests/compose.full.yml b/e2e-tests/compose.full.yml index bcd125f..8684d7b 100644 --- a/e2e-tests/compose.full.yml +++ b/e2e-tests/compose.full.yml @@ -1,5 +1,5 @@ # Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO -# login, menu filtering by role, users/groups/roles/OAuth2-clients CRUD, a plugin page, logout. A +# login, menu filtering by permission, users/groups/permissions/OAuth2-clients CRUD, a plugin page, logout. A # tiny same-origin gateway (proxy, e2e-tests/proxy.ts) fronts web + Kratos on one host so the browser's cookies # round-trip (ory/kratos/e2e-proxy.yml points Kratos at it); a mock OIDC provider backs the SSO test. # docker compose -f compose.yml -f e2e-tests/compose.full.yml run --build --rm e2e @@ -30,7 +30,7 @@ services: - ./examples/plugins/scheduling:/app/plugins/scheduling:ro - ./examples/plugins/admin:/app/plugins/admin:ro - # bootstrap grants the demo admin every discovered plugin's role names, so it needs the + # bootstrap grants the demo admin every discovered plugin's permission names, so it needs the # example plugins present too — else the admin lacks scheduling:read/write and the gated pages 403. bootstrap: volumes: diff --git a/e2e-tests/full-flow.spec.ts b/e2e-tests/full-flow.spec.ts index 4c45f91..229f36a 100644 --- a/e2e-tests/full-flow.spec.ts +++ b/e2e-tests/full-flow.spec.ts @@ -9,7 +9,7 @@ import { randomUUID } from "node:crypto"; // journey and the standalone SSO test run in parallel (fullyParallel) but stay independent: each // uses its own browser context, and only the SSO test writes the mock-OIDC identity — keep it so // (no cross-group shared backend writes) or serialise the file if that ever changes. -const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap, holds the admin role in Keto +const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap, holds the admin permission in Keto const ADMIN_PASSWORD = "admin"; const SSO_EMAIL = "sso-user@plainpages.local"; // minted by the mock OIDC provider on first SSO login const suffix = randomUUID().slice(0, 8); // unique per run so re-runs don't collide on names @@ -36,7 +36,7 @@ test.describe.serial("authenticated admin journey", () => { }); test.afterAll(async () => { await page.context().close(); }); - test("menu filters by role: an admin sees the gated Admin section + the plugin", async () => { + test("menu filters by permission: an admin sees the gated Admin section + the plugin", async () => { // The signed-in admin holds admin + scheduling:read/write, so both gated sections are present // in the menu (collapsed by default → assert they're in the DOM, not necessarily visible). await page.goto("/dashboard"); @@ -65,7 +65,7 @@ test.describe.serial("authenticated admin journey", () => { await expect(page.locator("tr", { hasText: email })).toHaveCount(0); }); - test("groups + roles CRUD: create one of each (writes go to Keto) and see them listed", async () => { + test("groups + permissions CRUD: create one of each (writes go to Keto) and see them listed", async () => { // A Keto set exists only while it has ≥1 member, so create needs a first member (the form // enforces it); pick the first option (a user) from the required picker. const group = `e2e-grp-${suffix}`; @@ -76,13 +76,13 @@ test.describe.serial("authenticated admin journey", () => { await expect(page).toHaveURL(/\/admin\/groups(\?|\/|$)/); await expect(page.locator("main")).toContainText(group); - const role = `e2e-role-${suffix}`; - await page.goto("/admin/roles/new"); - await page.fill('input[name="name"]', role); + const permission = `e2e-permission-${suffix}`; + await page.goto("/admin/permissions/new"); + await page.fill('input[name="name"]', permission); await page.locator('select[name="member"]').selectOption({ index: 1 }); await page.locator('.form-card button[type="submit"]').click(); - await expect(page).toHaveURL(/\/admin\/roles(\?|\/|$)/); - await expect(page.locator("main")).toContainText(role); + await expect(page).toHaveURL(/\/admin\/permissions(\?|\/|$)/); + await expect(page.locator("main")).toContainText(permission); }); test("OAuth2 clients CRUD: register a client (writes go to Hydra), see the one-time secret once, then delete it via the confirm step", async () => { @@ -153,6 +153,6 @@ test("mocked SSO login: the provider button signs a user in via OIDC", async ({ await page.locator(".sso-btn").click(); // Mock OIDC auto-approves → Kratos creates the identity → /auth/complete → dashboard, signed in. await expect(page.locator(".profile-mail")).toHaveText(SSO_EMAIL); - // A fresh SSO identity holds no roles, so the gated Admin section stays hidden. + // A fresh SSO identity holds no permissions, so the gated Admin section stays hidden. await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0); }); diff --git a/e2e-tests/visual.spec.ts b/e2e-tests/visual.spec.ts index 9e03271..6be79f7 100644 --- a/e2e-tests/visual.spec.ts +++ b/e2e-tests/visual.spec.ts @@ -13,19 +13,19 @@ const shot = (page: Page, name: string): Promise => // Sign a session JWT with the committed dev tokenizer key (bind-mounted at /repo/jwks.json), so the // gated dashboard renders for a "signed-in" user without standing up Ory — web verifies it // with the same key by `kid`, exactly as it verifies a real Kratos-tokenizer JWT. -function devSession(roles: string[] = []): string { +function devSession(permissions: string[] = []): string { const jwk = JSON.parse(readFileSync("/repo/jwks.json", "utf8")).keys[0]; const key = createPrivateKey({ format: "jwk", key: jwk }); const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url"); const now = Math.floor(Date.now() / 1000); - const input = `${b64({ alg: "ES256", kid: jwk.kid, typ: "JWT" })}.${b64({ email: "demo@plainpages.local", exp: now + 3600, iat: now, roles, sub: "visual-demo" })}`; + const input = `${b64({ alg: "ES256", kid: jwk.kid, typ: "JWT" })}.${b64({ email: "demo@plainpages.local", exp: now + 3600, iat: now, permissions, sub: "visual-demo" })}`; return `${input}.${sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key }).toString("base64url")}`; } test.beforeAll(async () => { await mkdir(SHOTS, { recursive: true }); }); // The dashboard is gated: a page navigation needs a session. Plant one per test — a plain -// member (no roles) so the gated scheduling nav stays filtered out. +// member (no permissions) so the gated scheduling nav stays filtered out. test.beforeEach(async ({ context }) => { await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]); }); @@ -99,7 +99,7 @@ test("the public landing at / is ungated and links to sign in + register", async await context.clearCookies(); // visit "/" as a logged-out visitor (drop the beforeEach session) await page.goto("/"); await expect(page.locator(".landing")).toBeVisible(); - // the same app shell every page renders — the menu shows even signed out (role-filtered). + // the same app shell every page renders — the menu shows even signed out (permission-filtered). await expect(page.locator(".sidebar")).toBeVisible(); await expect(page.getByRole("link", { name: "Log in" })).toHaveAttribute("href", "/login"); await expect(page.getByRole("link", { name: "Create account" })).toHaveAttribute("href", "/registration"); @@ -114,7 +114,7 @@ test("unknown routes serve the 404 page (a real user-facing flow, covered end-to }); // The reference plugin (plugins/scheduling) ships discovered in the image. Its public Overview is -// reachable by anyone and its menu header shows for everyone; the shifts list stays role-gated, +// reachable by anyone and its menu header shows for everyone; the shifts list stays permission-gated, // so an anonymous visitor is bounced to sign in. The authenticated list/form flow is the full // E2E (full-flow.spec). Side-effect-free. test("the reference plugin: public Overview is open to all, the gated Shifts redirects to /login", async ({ page, request }) => { @@ -136,7 +136,7 @@ test("the reference plugin: public Overview is open to all, the gated Shifts red expect(res.status()).toBe(303); expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts"); - // The signed-in member (no scheduling role) sees the public Scheduling → Overview leaf in the nav, + // The signed-in member (no scheduling permission) sees the public Scheduling → Overview leaf in the nav, // but the gated Shifts leaf is filtered out. await page.goto("/dashboard"); await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders diff --git a/examples/README.md b/examples/README.md index 75f3165..bbc3ff3 100644 --- a/examples/README.md +++ b/examples/README.md @@ -5,7 +5,7 @@ across (or bind-mount your own) and restart. | Path | Copy into | Example of | | --- | --- | --- | -| [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and role-gated nav — built from the core building blocks, holding no state. Imports the host surface as `#plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). | -| [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Roles / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). | +| [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and permission-gated nav — built from the core building blocks, holding no state. Imports the host surface as `#plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). | +| [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). | | [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). | | [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `SCHEDULING_UPSTREAM` at the real thing instead. | diff --git a/examples/config/menu.ts b/examples/config/menu.ts index 4cc7955..d625e02 100644 --- a/examples/config/menu.ts +++ b/examples/config/menu.ts @@ -3,7 +3,7 @@ // config/ ships empty; mount your own or copy this in. Absent config = built-in defaults. // // Brand the app and reorder/rename/group/hide nav nodes (by their `id`) across all plugins — -// the override always wins, applied before the per-user role filter. Every field is +// the override always wins, applied before the per-user permission filter. Every field is // optional; delete one to fall back to the default. // See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README.md (The menu system). @@ -20,7 +20,7 @@ export default defineMenu({ // Operator override (rename → group → order → hide), keyed by node id. override: { // rename: { people: "Staff" }, // node id → new label - // groups: [{ id: "admin", label: "Admin", children: ["users", "roles"] }], + // groups: [{ id: "admin", label: "Admin", children: ["users", "permissions"] }], // order: ["people", "reports"], // top-level order by id // hide: ["teams"], // remove nodes (any depth) }, diff --git a/examples/plugins/admin/README.md b/examples/plugins/admin/README.md index 5d693e8..7ec84b9 100644 --- a/examples/plugins/admin/README.md +++ b/examples/plugins/admin/README.md @@ -1,6 +1,6 @@ # Admin — the system-administration plugin -The Users / Groups / Roles / OAuth2-clients screens for running Plainpages itself. These used to be +The Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. These used to be built into the core; they now ship as a **drop-in example plugin** so a fresh clone has no admin GUI until you opt in. Copy this folder into `plugins/` (it keeps the id and mount path `admin`, so the screens live at `/admin/*`) and restart: @@ -10,7 +10,7 @@ cp -r examples/plugins/admin plugins/admin docker compose restart web ``` -The seeded `admin@plainpages.local` already holds the `admin` role, so the section appears in the +The seeded `admin@plainpages.local` already holds the `admin` permission, so the section appears in the menu and the screens work immediately. ## What it demonstrates — a *system* plugin @@ -20,21 +20,21 @@ reference](../scheduling/README.md)). The admin screens instead administer **Pla stack**, so they use the privileged **`ctx.system`** surface the host exposes to a system plugin: - **`ctx.system.kratosAdmin`** — create/edit/deactivate/delete Kratos identities (Users). -- **`ctx.system.keto`** — read/write the Keto relationship graph (Groups, Roles). +- **`ctx.system.keto`** — read/write the Keto relationship graph (Groups, Permissions). - **`ctx.system.hydra`** — register/list/delete Ory Hydra OAuth2 clients. - **`ctx.system.revoke(sub)`** — the optional instant-revoke hook: a deactivate/delete or a - user's role change kills that subject's live tokens at once instead of waiting out the JWT TTL. + user's permission change kills that subject's live tokens at once instead of waiting out the JWT TTL. `ctx.system` is populated only when the host wired those services (the dev stack wires Kratos + Keto, and Hydra when configured). Where a capability is absent the screen degrades to a themed 503 rather than crashing — see `admin-shared.ts`. Everything else is an ordinary plugin: folder-discovered, -gated per route by `role: "admin"`, rendering the core building blocks in `views/`. +gated per route by `permission: "admin"`, rendering the core building blocks in `views/`. ## Layout -- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` role, and the - route table — one thin handler per method+path, all gated by `role: "admin"`. -- `admin-users.ts` · `admin-groups.ts` · `admin-roles.ts` · `admin-clients.ts` — each a set of pure +- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` permission, and the + route table — one thin handler per method+path, all gated by `permission: "admin"`. +- `admin-users.ts` · `admin-groups.ts` · `admin-permissions.ts` · `admin-clients.ts` — each a set of pure view-model builders (unit-tested in the matching `*.test.ts`) plus thin per-route handlers keyed on `ctx.params` (the host extracts `:id`/`:name`), sharing a small `withX` wrapper that resolves the admin gate + the needed `ctx.system` clients once. diff --git a/examples/plugins/admin/admin-roles.test.ts b/examples/plugins/admin/admin-permissions.test.ts similarity index 50% rename from examples/plugins/admin/admin-roles.test.ts rename to examples/plugins/admin/admin-permissions.test.ts index e8c01ff..01fe687 100644 --- a/examples/plugins/admin/admin-roles.test.ts +++ b/examples/plugins/admin/admin-permissions.test.ts @@ -1,34 +1,34 @@ -// Built-in Roles admin screen: the pure view-model + Keto builders. A role is a -// Keto subject set (Role:#members); members are users (subject_id) or groups (subject_set) — -// "assign roles to users/groups". The "effective access" view flattens a Keto `expand` tree into the -// distinct set of users who hold the role directly or transitively via a group. The HTTP +// Built-in Roles admin screen: the pure view-model + Keto builders. A permission is a +// Keto subject set (Permission:#members); members are users (subject_id) or groups (subject_set) — +// "assign permissions to users/groups". The "effective access" view flattens a Keto `expand` tree into the +// distinct set of users who hold the permission directly or transitively via a group. The HTTP // routing/gate/CSRF + live Keto/Kratos calls are exercised over HTTP in app.test.ts. import assert from "node:assert/strict"; import { test } from "node:test"; import { memberView } from "./admin-groups.ts"; import { - buildRoleDetailModel, - buildRoleFormModel, - buildRolesListModel, + buildPermissionDetailModel, + buildPermissionFormModel, + buildPermissionsListModel, expandToEffectiveUsers, isValidRoleName, - roleMemberTuple, -} from "./admin-roles.ts"; + permissionGrantTuple, +} from "./admin-permissions.ts"; import type { ExpandTree, RelationTuple } from "#plugin-api"; const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`; -const userTuple = (role: string, n: number): RelationTuple => - ({ namespace: "Role", object: role, relation: "members", subject_id: `identity:${uid(n)}` }); -const groupTuple = (role: string, group: string): RelationTuple => - ({ namespace: "Role", object: role, relation: "members", subject_set: { namespace: "Group", object: group, relation: "members" } }); +const userTuple = (permission: string, n: number): RelationTuple => + ({ namespace: "Permission", object: permission, relation: "granted", subject_id: `identity:${uid(n)}` }); +const groupTuple = (permission: string, group: string): RelationTuple => + ({ namespace: "Permission", object: permission, relation: "granted", subject_set: { namespace: "Group", object: group, relation: "members" } }); -test("isValidRoleName + roleMemberTuple map the form value to a Role tuple over a user/group (else null)", () => { +test("isValidRoleName + permissionGrantTuple map the form value to a Permission tuple over a user/group (else null)", () => { for (const ok of ["admin", "editor", "team-a", "a1_b9"]) assert.equal(isValidRoleName(ok), true, ok); for (const bad of ["", "Admin", "a b", "-bad", "a".repeat(65)]) assert.equal(isValidRoleName(bad), false, bad); - assert.deepEqual(roleMemberTuple("editor", `identity:${uid(2)}`), { namespace: "Role", object: "editor", relation: "members", subject_id: `identity:${uid(2)}` }); - assert.deepEqual(roleMemberTuple("editor", "group:eng"), { namespace: "Role", object: "editor", relation: "members", subject_set: { namespace: "Group", object: "eng", relation: "members" } }); - for (const bad of ["", "identity:not-a-uuid", "group:Bad Name", "nope:x"]) assert.equal(roleMemberTuple("editor", bad), null, bad); + assert.deepEqual(permissionGrantTuple("editor", `identity:${uid(2)}`), { namespace: "Permission", object: "editor", relation: "granted", subject_id: `identity:${uid(2)}` }); + assert.deepEqual(permissionGrantTuple("editor", "group:eng"), { namespace: "Permission", object: "editor", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } }); + for (const bad of ["", "identity:not-a-uuid", "group:Bad Name", "nope:x"]) assert.equal(permissionGrantTuple("editor", bad), null, bad); }); test("expandToEffectiveUsers flattens an expand tree → sorted distinct user ids, transitive through groups", () => { @@ -43,50 +43,50 @@ test("expandToEffectiveUsers flattens an expand tree → sorted distinct user id type: "union", }, ], - tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Role", object: "admin", relation: "members" } }, + tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Permission", object: "admin", relation: "granted" } }, type: "union", }; assert.deepEqual(expandToEffectiveUsers(tree), [uid(1), uid(2)]); assert.deepEqual(expandToEffectiveUsers(null), []); - assert.deepEqual(expandToEffectiveUsers({ type: "leaf" }), []); // an empty role + assert.deepEqual(expandToEffectiveUsers({ type: "leaf" }), []); // an empty permission }); -test("buildRolesListModel filters by search, sorts, paginates; the name links to the detail page", () => { - const roles = Array.from({ length: 30 }, (_, i) => ({ memberCount: i + 1, name: `role-${String(i).padStart(2, "0")}` })); +test("buildPermissionsListModel filters by search, sorts, paginates; the name links to the detail page", () => { + const permissions = Array.from({ length: 30 }, (_, i) => ({ memberCount: i + 1, name: `permission-${String(i).padStart(2, "0")}` })); - const all = buildRolesListModel({ roles, url: "http://x/admin/roles" }); + const all = buildPermissionsListModel({ permissions, url: "http://x/admin/permissions" }); assert.equal(all.pagination.summary.total, 30); assert.equal(all.table.rows.length, 25); // default page size - assert.equal(all.title, "Roles"); + assert.equal(all.title, "Permissions"); const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } }; - assert.equal(first.rowHeader.text, "role-00"); - assert.equal(first.rowHeader.href, "/admin/roles/role-00"); + assert.equal(first.rowHeader.text, "permission-00"); + assert.equal(first.rowHeader.href, "/admin/permissions/permission-00"); - const one = buildRolesListModel({ roles, url: "http://x/admin/roles?q=role-07" }); + const one = buildPermissionsListModel({ permissions, url: "http://x/admin/permissions?q=permission-07" }); assert.equal(one.pagination.summary.total, 1); assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]); - const desc = buildRolesListModel({ roles, url: "http://x/admin/roles?sort=-members" }); - assert.equal((desc.table.rows[0]!.cells[0] as { rowHeader: { text: string } }).rowHeader.text, "role-29"); + const desc = buildPermissionsListModel({ permissions, url: "http://x/admin/permissions?sort=-members" }); + assert.equal((desc.table.rows[0]!.cells[0] as { rowHeader: { text: string } }).rowHeader.text, "permission-29"); }); -test("buildRoleFormModel: a create form with a required name field + member options (user or group)", () => { +test("buildPermissionFormModel: a create form with a required name field + member options (user or group)", () => { const options = [{ label: "ada@example.com", value: `identity:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }]; - const m = buildRoleFormModel({ csrfToken: "tok.sig", memberOptions: options }); - assert.equal(m.title, "New role"); - assert.equal(m.form.action, "/admin/roles"); - assert.equal(m.form.submitLabel, "Create role"); + const m = buildPermissionFormModel({ csrfToken: "tok.sig", memberOptions: options }); + assert.equal(m.title, "New permission"); + assert.equal(m.form.action, "/admin/permissions"); + assert.equal(m.form.submitLabel, "Create permission"); assert.equal(m.form.csrfToken, "tok.sig"); assert.equal(m.form.nameField.required, true); assert.deepEqual(m.form.memberOptions, options); - const err = buildRoleFormModel({ error: "That name is taken.", memberOptions: options, values: { member: "group:eng", name: "Admin" } }); + const err = buildPermissionFormModel({ error: "That name is taken.", memberOptions: options, values: { member: "group:eng", name: "Admin" } }); assert.equal(err.error, "That name is taken."); assert.equal(err.form.nameField.value, "Admin"); assert.equal(err.form.selectedMember, "group:eng"); }); -test("buildRoleDetailModel: members → rows, add-options exclude current members, effective access listed, actions wired", () => { +test("buildPermissionDetailModel: members → rows, add-options exclude current members, effective access listed, actions wired", () => { const members = [memberView(userTuple("admin", 1), new Map([[uid(1), "ada@example.com"]])), memberView(groupTuple("admin", "eng"), new Map())]; const candidates = [ { label: "ada@example.com", value: `identity:${uid(1)}` }, // already a member → excluded @@ -95,12 +95,12 @@ test("buildRoleDetailModel: members → rows, add-options exclude current member { label: "ops (group)", value: "group:ops" }, ]; const effective = [{ label: "ada@example.com" }, { label: "grace@example.com" }]; // ada direct, grace via eng - const m = buildRoleDetailModel({ candidates, effective, members, role: { name: "admin" } }); + const m = buildPermissionDetailModel({ candidates, effective, members, permission: { name: "admin" } }); assert.equal(m.title, "admin"); assert.equal(m.members.rows.length, 2); - assert.equal(m.members.action, "/admin/roles/admin/members/delete"); - assert.equal(m.add.action, "/admin/roles/admin/members"); + assert.equal(m.members.action, "/admin/permissions/admin/members/delete"); + assert.equal(m.add.action, "/admin/permissions/admin/members"); assert.deepEqual(m.add.options.map((o) => o.value), [`identity:${uid(2)}`, "group:ops"]); assert.deepEqual(m.effective.map((e) => e.label), ["ada@example.com", "grace@example.com"]); - assert.equal(m.delete.action, "/admin/roles/admin/delete"); + assert.equal(m.delete.action, "/admin/permissions/admin/delete"); }); diff --git a/examples/plugins/admin/admin-roles.ts b/examples/plugins/admin/admin-permissions.ts similarity index 61% rename from examples/plugins/admin/admin-roles.ts rename to examples/plugins/admin/admin-permissions.ts index 93ca812..d152ab1 100644 --- a/examples/plugins/admin/admin-roles.ts +++ b/examples/plugins/admin/admin-permissions.ts @@ -1,15 +1,15 @@ -// Roles admin screen: list / create / delete Keto roles and assign -// them to users and groups. A role is a Keto subject set `Role:#members` (OPL: members are users -// or groups, resolved transitively) — the source of truth for the JWT `roles` claim. It shares the +// Permissions admin screen: list / create / delete Keto permissions and assign +// them to users and groups. A permission is a Keto subject set `Permission:#members` (OPL: members are users +// or groups, resolved transitively) — the source of truth for the JWT `permissions` claim. It shares the // Groups screen's membership model, so the pure helpers (parseSubject, member pickers, tuple paging) -// are reused from admin-groups. The role-specific piece is the **effective access** view: -// `keto.expand(Role:#members)` flattened to the distinct users who hold the role directly or via -// a group — matching what login projects into the JWT (login.ts readRoles). Writes go only to Keto; +// are reused from admin-groups. The permission-specific piece is the **effective access** view: +// `keto.expand(Permission:#members)` flattened to the distinct users who hold the permission directly or via +// a group — matching what login projects into the JWT (login.ts readPermissions). Writes go only to Keto; // Kratos is read only to label members. Below the builders are thin per-route handlers (keyed on // ctx.params) over a shared `withRoles` gate — admin-only, CSRF-guarded. import { type ExpandTree, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SessionIdentity } from "#plugin-api"; -import { ADMIN_ROLE, ADMIN_ROLES_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts"; +import { ADMIN_PERMISSION, ADMIN_PERMISSIONS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts"; import { type GroupView, groupsFromTuples, @@ -23,29 +23,29 @@ import { } from "./admin-groups.ts"; import type { FieldConfig } from "./admin-users.ts"; -const ROLE_NS = "Role"; -const MEMBERS = "members"; +const PERMISSION_NS = "Permission"; +const GRANTED = "granted"; const DEFAULT_PAGE_SIZE = 25; const PAGE_SIZES = [25, 50, 100]; // Expand far past any sane group-nesting depth so the effective-access view never silently // under-reports the deepest members (Keto's own default is shallow). const EXPAND_MAX_DEPTH = 50; -// A role and a group share the URL-safe name rule and the user|group membership model. -export type RoleView = GroupView; +// A permission and a group share the URL-safe name rule and the user|group membership model. +export type PermissionView = GroupView; export const isValidRoleName = isValidGroupName; -export const rolesFromTuples = groupsFromTuples; +export const permissionsFromTuples = groupsFromTuples; export interface EffectiveUser { label: string; // email (or the raw id when unresolved) } -// The full membership tuple for assigning/revoking `value` to/from `role` (null if value is invalid). -export function roleMemberTuple(role: string, value: string): RelationTuple | null { +// The full membership tuple for assigning/revoking `value` to/from `permission` (null if value is invalid). +export function permissionGrantTuple(permission: string, value: string): RelationTuple | null { const subject = parseSubject(value); - return subject ? { namespace: ROLE_NS, object: role, relation: MEMBERS, ...subject } : null; + return subject ? { namespace: PERMISSION_NS, object: permission, relation: GRANTED, ...subject } : null; } -// Flatten a Keto `expand` tree → the sorted, distinct user ids that effectively hold the role +// Flatten a Keto `expand` tree → the sorted, distinct user ids that effectively hold the permission // (direct leaves + users reached through member groups, any depth). The subject rides on each // node's `tuple`; subject-set nodes (the groups) contribute nothing directly — their members // surface as leaves under them. @@ -70,17 +70,17 @@ interface ListState { sort: string | null; } -const SORT: Record number | string> = { +const SORT: Record number | string> = { members: (r) => r.memberCount, name: (r) => r.name, }; const COLUMNS = [ - { key: "name", label: "Role" }, + { key: "name", label: "Permission" }, { key: "members", label: "Members" }, ]; function detailHref(name: string): string { - return `${ADMIN_ROLES_BASE}/${encodeURIComponent(name)}`; + return `${ADMIN_PERMISSIONS_BASE}/${encodeURIComponent(name)}`; } function listHref(state: ListState, overrides: Partial = {}): string { @@ -91,12 +91,12 @@ function listHref(state: ListState, overrides: Partial = {}): string if (s.page > 1) p.set("page", String(s.page)); if (s.pageSize !== DEFAULT_PAGE_SIZE) p.set("pageSize", String(s.pageSize)); const qs = p.toString(); - return qs ? `${ADMIN_ROLES_BASE}?${qs}` : ADMIN_ROLES_BASE; + return qs ? `${ADMIN_PERMISSIONS_BASE}?${qs}` : ADMIN_PERMISSIONS_BASE; } -export function buildRolesListModel(opts: { +export function buildPermissionsListModel(opts: { csrfToken?: string; - roles: RoleView[]; + permissions: PermissionView[]; url: URL | URLSearchParams | string; }) { const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE }); @@ -104,7 +104,7 @@ export function buildRolesListModel(opts: { const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null; const needle = query.q.toLowerCase(); - let list = opts.roles.filter((r) => !needle || r.name.toLowerCase().includes(needle)); + let list = opts.permissions.filter((r) => !needle || r.name.toLowerCase().includes(needle)); if (sort) { const get = SORT[sort.field]!; const dir = sort.dir === "desc" ? -1 : 1; @@ -121,17 +121,17 @@ export function buildRolesListModel(opts: { const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken }; return { - breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Admin" }, { label: "Roles" }], + breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Admin" }, { label: "Permissions" }], filterBar: listFilterBar(state), pagination: listPagination(state, page), table: listTable(rows, state, sort), - title: "Roles", + title: "Permissions", }; } -function listTable(rows: RoleView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null) { +function listTable(rows: PermissionView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null) { return { - caption: "Roles", + caption: "Permissions", columns: COLUMNS.map((c) => { const dir = sort && sort.field === c.key ? sort.dir : undefined; const next = dir === "asc" ? `-${c.key}` : c.key; @@ -149,11 +149,11 @@ function listFilterBar(state: ListState) { if (state.q) pills.push({ label: "Search", remove: listHref(state, { page: 1, q: "" }), value: state.q }); return { applyLabel: "Apply", - clearHref: ADMIN_ROLES_BASE, - label: "Filter roles", + clearHref: ADMIN_PERMISSIONS_BASE, + label: "Filter permissions", pills, rows: [[ - { label: "Search roles", name: "q", placeholder: "Search role name…", type: "search", value: state.q }, + { label: "Search permissions", name: "q", placeholder: "Search permission name…", type: "search", value: state.q }, { type: "spacer" }, ]], }; @@ -178,7 +178,7 @@ function listPagination(state: ListState, page: ReturnType) { // ---- create form + detail view models ---- -export function buildRoleFormModel(opts: { +export function buildPermissionFormModel(opts: { csrfToken?: string; error?: string; memberOptions: MemberOption[]; @@ -186,69 +186,69 @@ export function buildRoleFormModel(opts: { }) { const nameField: FieldConfig = { autocomplete: "off", hint: "Lowercase letters, digits, dashes and underscores.", icon: "i-shield", - id: "name", label: "Role name", name: "name", required: true, value: opts.values?.name ?? "", + id: "name", label: "Permission name", name: "name", required: true, value: opts.values?.name ?? "", }; return { - breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: "New" }], + breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Permissions" }, { label: "New" }], error: opts.error, form: { - action: ADMIN_ROLES_BASE, - cancelHref: ADMIN_ROLES_BASE, + action: ADMIN_PERMISSIONS_BASE, + cancelHref: ADMIN_PERMISSIONS_BASE, csrfToken: opts.csrfToken ?? "", memberOptions: opts.memberOptions, nameField, selectedMember: opts.values?.member ?? "", - submitLabel: "Create role", + submitLabel: "Create permission", }, - title: "New role", + title: "New permission", }; } -export function buildRoleDetailModel(opts: { +export function buildPermissionDetailModel(opts: { candidates: MemberOption[]; csrfToken?: string; effective: EffectiveUser[]; error?: string; members: MemberView[]; - role: { name: string }; + permission: { name: string }; }) { - const name = opts.role.name; + const name = opts.permission.name; const base = detailHref(name); const taken = new Set(opts.members.map((m) => m.subject)); - const options = opts.candidates.filter((c) => !taken.has(c.value)); // members are users/groups, never the role itself + const options = opts.candidates.filter((c) => !taken.has(c.value)); // members are users/groups, never the permission itself return { add: { action: `${base}/members`, options }, - breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: name }], + breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Permissions" }, { label: name }], csrfToken: opts.csrfToken ?? "", delete: { action: `${base}/delete` }, effective: opts.effective, error: opts.error, members: { action: `${base}/members/delete`, rows: opts.members }, - role: { name }, + permission: { name }, title: name, }; } // ---- request handler (imperative shell) ---- -// instant-revoke: a role change for a `identity:` member must take effect now, so revoke that -// user's live tokens (a re-mint then re-reads roles from Keto). A `group:` change is +// instant-revoke: a permission change for a `identity:` member must take effect now, so revoke that +// user's live tokens (a re-mint then re-reads permissions from Keto). A `group:` change is // transitive across many users — left to lag (documented), so only direct user members revoke. function revokeUserMember(revoke: ((sub: string) => void) | undefined, member: string): void { if (revoke && member.startsWith("identity:")) revoke(member.slice("identity:".length)); } -// A role exists exactly while it has ≥1 member (Keto has no create-object). +// A permission exists exactly while it has ≥1 member (Keto has no create-object). async function roleExists(keto: KetoClient, name: string): Promise { - const page = await keto.listRelations({ namespace: ROLE_NS, object: name, relation: MEMBERS, pageSize: 1 }); + const page = await keto.listRelations({ namespace: PERMISSION_NS, object: name, relation: GRANTED, pageSize: 1 }); return page.tuples.length > 0; } -// The distinct users who effectively hold the role (expand → flatten → label by email). Skipped for -// an empty role (no member tuples) so we don't expand a non-existent Keto object. +// The distinct users who effectively hold the permission (expand → flatten → label by email). Skipped for +// an empty permission (no member tuples) so we don't expand a non-existent Keto object. async function effectiveUsers(keto: KetoClient, name: string, hasMembers: boolean, emailById: Map): Promise { if (!hasMembers) return []; - const tree = await keto.expand({ namespace: ROLE_NS, object: name, relation: MEMBERS }, { maxDepth: EXPAND_MAX_DEPTH }); + const tree = await keto.expand({ namespace: PERMISSION_NS, object: name, relation: GRANTED }, { maxDepth: EXPAND_MAX_DEPTH }); return expandToEffectiveUsers(tree) .map((id) => ({ label: emailById.get(id) ?? `identity:${id}` })) .sort((a, b) => a.label.localeCompare(b.label)); @@ -268,7 +268,7 @@ function withRoles(inner: (deps: RolesDeps) => Promise): RouteHandl }; } -// Same, plus the validated :name from ctx.params (an invalid role name → themed 404). +// Same, plus the validated :name from ctx.params (an invalid permission name → themed 404). function withRoleName(inner: (deps: RolesDeps, name: string) => Promise): RouteHandler { return withRoles((deps) => { const name = deps.ctx.params["name"] ?? ""; @@ -279,89 +279,89 @@ function withRoleName(inner: (deps: RolesDeps, name: string) => Promise => { const { options } = await memberCandidates(deps.keto, deps.kratosAdmin); - return { data: { chrome: deps.ctx.chrome, model: buildRoleFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, ...extra }) }, view: "role-form" }; + return { data: { chrome: deps.ctx.chrome, model: buildPermissionFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, ...extra }) }, view: "permission-form" }; }; -// The role detail (members + effective access). With `error` set it's a 400 (a rejected action). +// The permission detail (members + effective access). With `error` set it's a 400 (a rejected action). const roleDetailResult = async (deps: RolesDeps, name: string, error?: string): Promise => { const { emailById, options } = await memberCandidates(deps.keto, deps.kratosAdmin); - const tuples = await pagedTuples(deps.keto, { namespace: ROLE_NS, object: name, relation: MEMBERS }); + const tuples = await pagedTuples(deps.keto, { namespace: PERMISSION_NS, object: name, relation: GRANTED }); const members = tuples.map((t) => memberView(t, emailById)); const effective = await effectiveUsers(deps.keto, name, tuples.length > 0, emailById); - const result: RouteResult = { data: { chrome: deps.ctx.chrome, model: buildRoleDetailModel({ candidates: options, csrfToken: deps.ctx.chrome.csrfToken, effective, members, role: { name }, ...(error ? { error } : {}) }) }, view: "role-detail" }; + const result: RouteResult = { data: { chrome: deps.ctx.chrome, model: buildPermissionDetailModel({ candidates: options, csrfToken: deps.ctx.chrome.csrfToken, effective, members, permission: { name }, ...(error ? { error } : {}) }) }, view: "permission-detail" }; return error ? { ...result, status: 400 } : result; }; -// GET /admin/roles — the list. +// GET /admin/permissions — the list. export const rolesList = withRoles(async ({ ctx, keto }) => { - const roles = rolesFromTuples(await pagedTuples(keto, { namespace: ROLE_NS, relation: MEMBERS })); - return { data: { chrome: ctx.chrome, model: buildRolesListModel({ csrfToken: ctx.chrome.csrfToken, roles, url: ctx.url }) }, view: "roles" }; + const permissions = permissionsFromTuples(await pagedTuples(keto, { namespace: PERMISSION_NS, relation: GRANTED })); + return { data: { chrome: ctx.chrome, model: buildPermissionsListModel({ csrfToken: ctx.chrome.csrfToken, permissions, url: ctx.url }) }, view: "permissions" }; }); -// POST /admin/roles — create + assign the first member (a *user* grant revokes their live tokens). +// POST /admin/permissions — create + assign the first member (a *user* grant revokes their live tokens). export const rolesCreate = withRoles(async (deps) => { const { ctx, keto, revoke, user } = deps; const form = (await guardedForm(ctx))!; const name = (form.get("name") ?? "").trim(); const member = (form.get("member") ?? "").trim(); - const tuple = roleMemberTuple(name, member); + const tuple = permissionGrantTuple(name, member); const reject = async (error: string): Promise => ({ ...(await roleFormResult(deps, { error, values: { member, name } })), status: 400 }); - if (!isValidRoleName(name)) return reject("Role names use lowercase letters, digits, dashes and underscores."); - if (!tuple) return reject("Pick a user or group to assign the role to."); - if (await roleExists(keto, name)) return reject("A role with that name already exists."); + if (!isValidRoleName(name)) return reject("Permission names use lowercase letters, digits, dashes and underscores."); + if (!tuple) return reject("Pick a user or group to assign the permission to."); + if (await roleExists(keto, name)) return reject("A permission with that name already exists."); await keto.writeTuple(tuple); revokeUserMember(revoke, member); - ctx.log.info("admin: role created + first member assigned", { actor: user.id, member, role: name }); + ctx.log.info("admin: permission created + first member assigned", { actor: user.id, member, permission: name }); return { redirect: detailHref(name) }; }); -// GET /admin/roles/new — the create form. +// GET /admin/permissions/new — the create form. export const rolesNewForm = withRoles((deps) => roleFormResult(deps, {})); -// GET /admin/roles/:name — the detail (members + effective access via Keto expand). +// GET /admin/permissions/:name — the detail (members + effective access via Keto expand). export const rolesDetail = withRoleName((deps, name) => roleDetailResult(deps, name)); -// POST /admin/roles/:name/members — assign a user/group; a *user* grant revokes their live tokens. +// POST /admin/permissions/:name/members — assign a user/group; a *user* grant revokes their live tokens. export const rolesAddMember = withRoleName(async (deps, name) => { const { ctx, keto, revoke, user } = deps; const form = (await guardedForm(ctx))!; const member = (form.get("member") ?? "").trim(); - const tuple = roleMemberTuple(name, member); // the picker only offers real users/groups - if (tuple) { await keto.writeTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: role assigned", { actor: user.id, member, role: name }); } + const tuple = permissionGrantTuple(name, member); // the picker only offers real users/groups + if (tuple) { await keto.writeTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: permission assigned", { actor: user.id, member, permission: name }); } return { redirect: detailHref(name) }; }); -// GET /admin/roles/:name/delete — confirm, except the admin role can't be deleted. +// GET /admin/permissions/:name/delete — confirm, except the admin permission can't be deleted. export const rolesDeleteConfirm = withRoleName((deps, name) => { - if (name === ADMIN_ROLE) return roleDetailResult(deps, name, "The admin role can't be deleted — it would remove all admin access."); + if (name === ADMIN_PERMISSION) return roleDetailResult(deps, name, "The admin permission can't be deleted — it would remove all admin access."); const base = detailHref(name); return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({ - breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { href: base, label: name }, { label: "Delete" }], - cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete role", - message: `Delete role ${name}? This revokes it from everyone it's assigned to.`, title: "Delete role", + breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Permissions" }, { href: base, label: name }, { label: "Delete" }], + cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete permission", + message: `Delete permission ${name}? This revokes it from everyone it's assigned to.`, title: "Delete permission", }) }, view: "confirm" }); }); -// POST /admin/roles/:name/delete — remove every member tuple (a whole-role delete lags per the -// documented instant-revoke tradeoff; the admin role is protected). +// POST /admin/permissions/:name/delete — remove every member tuple (a whole-permission delete lags per the +// documented instant-revoke tradeoff; the admin permission is protected). export const rolesDelete = withRoleName(async (deps, name) => { const { ctx, keto, user } = deps; await guardedForm(ctx); // CSRF-verify the POST - if (name === ADMIN_ROLE) return roleDetailResult(deps, name, "The admin role can't be deleted — it would remove all admin access."); - await keto.deleteTuple({ namespace: ROLE_NS, object: name, relation: MEMBERS }); - ctx.log.info("admin: role deleted", { actor: user.id, role: name }); - return { redirect: ADMIN_ROLES_BASE }; + if (name === ADMIN_PERMISSION) return roleDetailResult(deps, name, "The admin permission can't be deleted — it would remove all admin access."); + await keto.deleteTuple({ namespace: PERMISSION_NS, object: name, relation: GRANTED }); + ctx.log.info("admin: permission deleted", { actor: user.id, permission: name }); + return { redirect: ADMIN_PERMISSIONS_BASE }; }); -// POST /admin/roles/:name/members/delete — unassign; a *user* unassign revokes their live tokens. +// POST /admin/permissions/:name/members/delete — unassign; a *user* unassign revokes their live tokens. // Self-protection: an admin can't revoke their own *direct* admin grant (a group-held admin isn't // covered — the robust "last effective admin" check is deferred). export const rolesRemoveMember = withRoleName(async (deps, name) => { const { ctx, keto, revoke, user } = deps; const form = (await guardedForm(ctx))!; const member = (form.get("member") ?? "").trim(); - if (name === ADMIN_ROLE && member === `identity:${user.id}`) return roleDetailResult(deps, name, "You can't revoke your own admin access."); - const tuple = roleMemberTuple(name, member); - if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: role unassigned", { actor: user.id, member, role: name }); } + if (name === ADMIN_PERMISSION && member === `identity:${user.id}`) return roleDetailResult(deps, name, "You can't revoke your own admin access."); + const tuple = permissionGrantTuple(name, member); + if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: permission unassigned", { actor: user.id, member, permission: name }); } return { redirect: detailHref(name) }; }); diff --git a/examples/plugins/admin/admin-shared.test.ts b/examples/plugins/admin/admin-shared.test.ts index a976ed6..fb5add1 100644 --- a/examples/plugins/admin/admin-shared.test.ts +++ b/examples/plugins/admin/admin-shared.test.ts @@ -7,10 +7,10 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { Readable } from "node:stream"; import { test } from "node:test"; import { GuardError, type Log, type PageChrome, type RequestContext, type SessionIdentity } from "#plugin-api"; -import { ADMIN_NAV, ADMIN_ROLE, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts"; +import { ADMIN_NAV, ADMIN_PERMISSION, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts"; -const admin: SessionIdentity = { email: "ada@x.io", id: "u1", roles: ["admin"] }; -const member: SessionIdentity = { email: "bo@x.io", id: "u2", roles: ["scheduling:read"] }; +const admin: SessionIdentity = { email: "ada@x.io", id: "u1", permissions: ["admin"] }; +const member: SessionIdentity = { email: "bo@x.io", id: "u2", permissions: ["scheduling:read"] }; const CHROME = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } } as PageChrome; function fakeCtx(opts: { body?: string; method?: string; user?: SessionIdentity | null; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext { @@ -19,7 +19,7 @@ function fakeCtx(opts: { body?: string; method?: string; user?: SessionIdentity req.method = opts.method ?? "GET"; return { chrome: CHROME, identity: opts.user ?? null, log: {} as Log, params: {}, query: url.searchParams, req, res: {} as ServerResponse, - roles: opts.user?.roles ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true), + permissions: opts.user?.permissions ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true), }; } @@ -27,11 +27,11 @@ function fakeCtx(opts: { body?: string; method?: string; user?: SessionIdentity test("ADMIN_NAV: a gated Admin header over the four screens; no per-request current/open state", () => { assert.equal(ADMIN_NAV.id, "admin"); - assert.equal(ADMIN_NAV.role, ADMIN_ROLE); // gate on the header ⇒ composeNav drops the whole subtree for a non-admin + assert.equal(ADMIN_NAV.permission, ADMIN_PERMISSION); // gate on the header ⇒ composeNav drops the whole subtree for a non-admin assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static - assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/roles", "/admin/clients"]); - assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["Users", "Groups", "Roles", "OAuth2 clients"]); - assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined && c.role === undefined)); // the header's gate covers the subtree + assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/permissions", "/admin/clients"]); + assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["Users", "Groups", "Permissions", "OAuth2 clients"]); + assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined && c.permission === undefined)); // the header's gate covers the subtree }); // ---- auth gates ---- diff --git a/examples/plugins/admin/admin-shared.ts b/examples/plugins/admin/admin-shared.ts index aa614ba..eabb0c9 100644 --- a/examples/plugins/admin/admin-shared.ts +++ b/examples/plugins/admin/admin-shared.ts @@ -5,36 +5,36 @@ import { can, CSRF_FIELD, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type SessionIdentity } from "#plugin-api"; -export const ADMIN_ROLE = "admin"; // the role gating the whole admin section +export const ADMIN_PERMISSION = "admin"; // the permission gating the whole admin section export const ADMIN_USERS_BASE = "/admin/users"; export const ADMIN_GROUPS_BASE = "/admin/groups"; -export const ADMIN_ROLES_BASE = "/admin/roles"; +export const ADMIN_PERMISSIONS_BASE = "/admin/permissions"; export const ADMIN_CLIENTS_BASE = "/admin/clients"; -export type AdminScreen = "clients" | "groups" | "roles" | "users"; +export type AdminScreen = "clients" | "groups" | "permissions" | "users"; // The plugin's nav fragment: the gated "Admin" header + its four screens. The host composes it into -// the one global menu, filters per user (the header's `role` drops the whole subtree for a +// the one global menu, filters per user (the header's `permission` drops the whole subtree for a // non-admin), and current-marks the active item — so there is no `current`/`open` state here. export const ADMIN_NAV: NavNode = { children: [ { href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "Users" }, { href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "Groups" }, - { href: ADMIN_ROLES_BASE, icon: "i-shield", id: "roles", label: "Roles" }, + { href: ADMIN_PERMISSIONS_BASE, icon: "i-shield", id: "permissions", label: "Permissions" }, { href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "OAuth2 clients" }, ], icon: "i-shield", id: "admin", label: "Admin", - role: ADMIN_ROLE, + permission: ADMIN_PERMISSION, }; -// The admin gate: a signed-in admin only. Each route already declares `role: "admin"`, so the +// The admin gate: a signed-in admin only. Each route already declares `permission: "admin"`, so the // host enforces this before the handler runs; this is defence-in-depth and what a direct unit test // relies on. Returns the (non-null) user for the handler to thread on. GuardError → /login or 403. export function requireAdmin(ctx: RequestContext): SessionIdentity { const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept) - if (!can(ctx, ADMIN_ROLE)) throw new GuardError(403, "admin role required"); + if (!can(ctx, ADMIN_PERMISSION)) throw new GuardError(403, "admin permission required"); return user; } diff --git a/examples/plugins/admin/admin-users.ts b/examples/plugins/admin/admin-users.ts index 5d1a22e..1498a3b 100644 --- a/examples/plugins/admin/admin-users.ts +++ b/examples/plugins/admin/admin-users.ts @@ -268,7 +268,7 @@ function readUserInput(form: URLSearchParams): UserInput { // the Kratos capability (else a themed 503). Each route below is a thin handler over these. interface UsersDeps { ctx: RequestContext; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: SessionIdentity; } -// Resolve the shared deps, then run `inner`. The route's `role: "admin"` already gated at the +// Resolve the shared deps, then run `inner`. The route's `permission: "admin"` already gated at the // host; `requireAdmin` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it. function withUser(inner: (deps: UsersDeps) => Promise): RouteHandler { return async (ctx) => { diff --git a/examples/plugins/admin/plugin.ts b/examples/plugins/admin/plugin.ts index 1ec59bb..2ba2623 100644 --- a/examples/plugins/admin/plugin.ts +++ b/examples/plugins/admin/plugin.ts @@ -9,21 +9,21 @@ import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "#plugin-api"; import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts"; import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsRemoveMember } from "./admin-groups.ts"; -import { rolesAddMember, rolesCreate, rolesDelete, rolesDeleteConfirm, rolesDetail, rolesList, rolesNewForm, rolesRemoveMember } from "./admin-roles.ts"; +import { rolesAddMember, rolesCreate, rolesDelete, rolesDeleteConfirm, rolesDetail, rolesList, rolesNewForm, rolesRemoveMember } from "./admin-permissions.ts"; import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersRecovery, usersState, usersUpdate } from "./admin-users.ts"; -import { ADMIN_NAV, ADMIN_ROLE } from "./admin-shared.ts"; +import { ADMIN_NAV, ADMIN_PERMISSION } from "./admin-shared.ts"; -// Every admin route is gated by the one `admin` role — the host redirects an anonymous visitor +// Every admin route is gated by the one `admin` permission — the host redirects an anonymous visitor // to /login, gives a signed-in non-admin the 403 page, and filters the nav the same way. Handlers are // thin and keyed on ctx.params (the host extracts :id / :name), the idiomatic per-route style. -const r = (method: HttpMethod, path: string, handler: RouteHandler): Route => ({ handler, method, path, role: ADMIN_ROLE }); +const r = (method: HttpMethod, path: string, handler: RouteHandler): Route => ({ handler, method, path, permission: ADMIN_PERMISSION }); export default definePlugin({ apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION nav: [ADMIN_NAV], - roles: [{ description: "Administer users, groups, roles, and OAuth2 clients", name: ADMIN_ROLE }], + permissions: [{ description: "Administer users, groups, permissions, and OAuth2 clients", name: ADMIN_PERMISSION }], routes: [ // Users @@ -46,14 +46,14 @@ export default definePlugin({ r("POST", "/groups/:name/delete", groupsDelete), r("POST", "/groups/:name/members/delete", groupsRemoveMember), // Roles - r("GET", "/roles", rolesList), - r("POST", "/roles", rolesCreate), - r("GET", "/roles/new", rolesNewForm), - r("GET", "/roles/:name", rolesDetail), - r("POST", "/roles/:name/members", rolesAddMember), - r("GET", "/roles/:name/delete", rolesDeleteConfirm), - r("POST", "/roles/:name/delete", rolesDelete), - r("POST", "/roles/:name/members/delete", rolesRemoveMember), + r("GET", "/permissions", rolesList), + r("POST", "/permissions", rolesCreate), + r("GET", "/permissions/new", rolesNewForm), + r("GET", "/permissions/:name", rolesDetail), + r("POST", "/permissions/:name/members", rolesAddMember), + r("GET", "/permissions/:name/delete", rolesDeleteConfirm), + r("POST", "/permissions/:name/delete", rolesDelete), + r("POST", "/permissions/:name/members/delete", rolesRemoveMember), // OAuth2 clients r("GET", "/clients", clientsList), r("POST", "/clients", clientsCreate), diff --git a/examples/plugins/admin/views/clients.ejs b/examples/plugins/admin/views/clients.ejs index ddcd34f..0228df0 100644 --- a/examples/plugins/admin/views/clients.ejs +++ b/examples/plugins/admin/views/clients.ejs @@ -1,6 +1,6 @@ <%# OAuth2 clients admin list: apps that log in *through* us (Hydra). Same building blocks as - the Roles screen, around the shell, backed by live Hydra OAuth2 clients (admin-clients.ts). + the Permissions screen, around the shell, backed by live Hydra OAuth2 clients (admin-clients.ts). %><% const nav = include("partials/nav-tree", { nodes: chrome.nav }); const filters = include("partials/filter-bar", model.filterBar); diff --git a/examples/plugins/admin/views/partials/role-detail-body.ejs b/examples/plugins/admin/views/partials/permission-detail-body.ejs similarity index 77% rename from examples/plugins/admin/views/partials/role-detail-body.ejs rename to examples/plugins/admin/views/partials/permission-detail-body.ejs index 78775f2..030a312 100644 --- a/examples/plugins/admin/views/partials/role-detail-body.ejs +++ b/examples/plugins/admin/views/partials/permission-detail-body.ejs @@ -1,13 +1,13 @@ <%# - Admin role detail body, captured into the shell content slot. Config: - role { name } + Admin permission detail body, captured into the shell content slot. Config: + permission { name } members { action, rows: { kind:"group"|"identity", label, subject }[] } action = revoke endpoint - effective { label }[] users who hold the role (expand) + effective { label }[] users who hold the permission (expand) add { action, options: {label,value}[] } action = assign endpoint - del { action } delete the whole role + del { action } delete the whole permission csrfToken, error? %><% - const role = locals.role; + const permission = locals.permission; const members = locals.members; const effective = locals.effective; const add = locals.add; @@ -21,7 +21,7 @@

Assigned to

<% if (members.rows.length) { -%> -
+
Members of <%= role.name %>
MemberTypeActions
<% members.rows.forEach((m) => { -%> <% }) -%> @@ -32,7 +32,7 @@

Effective access

-

Everyone who holds this role — directly or through a group (resolved by Keto).

+

Everyone who holds this permission — directly or through a group (resolved by Keto).

<% if (effective.length) { -%>
    <% effective.forEach((u) => { -%> @@ -40,18 +40,18 @@ <% }) -%>
<% } else { -%> -

No users hold this role yet.

+

No users hold this permission yet.

<% } -%>
-

Assign the role

+

Assign the permission

<% if (add.options.length) { -%>
<% } else { -%> -

All users and groups already have this role.

+

All users and groups already have this permission.

<% } -%>
-
- Delete role +
+ Delete permission
diff --git a/examples/plugins/admin/views/partials/role-form-body.ejs b/examples/plugins/admin/views/partials/permission-form-body.ejs similarity index 85% rename from examples/plugins/admin/views/partials/role-form-body.ejs rename to examples/plugins/admin/views/partials/permission-form-body.ejs index 9582cd9..63cab01 100644 --- a/examples/plugins/admin/views/partials/role-form-body.ejs +++ b/examples/plugins/admin/views/partials/permission-form-body.ejs @@ -1,5 +1,5 @@ <%# - Admin role create form body, captured into the shell content slot. Config: + Admin permission create form body, captured into the shell content slot. Config: form { action, csrfToken, submitLabel, cancelHref, nameField: field.ejs config, memberOptions: {label,value}[], selectedMember } error? string shown when a write was rejected @@ -16,7 +16,7 @@
- A role exists once assigned; add more users or groups after creating it. + A permission exists once assigned; add more users or groups after creating it.
Cancel diff --git a/examples/plugins/admin/views/permission-detail.ejs b/examples/plugins/admin/views/permission-detail.ejs new file mode 100644 index 0000000..3ceed2d --- /dev/null +++ b/examples/plugins/admin/views/permission-detail.ejs @@ -0,0 +1,16 @@ +<%# + Permission admin detail page: the permission-detail body (members · effective access) in the shell. +%><% + const nav = include("partials/nav-tree", { nodes: chrome.nav }); + const body = include("partials/permission-detail-body", { add: model.add, csrfToken: model.csrfToken, del: model.delete, effective: model.effective, error: model.error, members: model.members, permission: model.permission }); +-%> +<%- include("partials/shell", { + body, + brand: chrome.brand, + breadcrumbs: model.breadcrumbs, + csrfToken: chrome.csrfToken, + nav, + theme: chrome.theme, + title: model.title, + user: chrome.user, +}) %> diff --git a/examples/plugins/admin/views/role-form.ejs b/examples/plugins/admin/views/permission-form.ejs similarity index 60% rename from examples/plugins/admin/views/role-form.ejs rename to examples/plugins/admin/views/permission-form.ejs index 56b1a58..89fb8cf 100644 --- a/examples/plugins/admin/views/role-form.ejs +++ b/examples/plugins/admin/views/permission-form.ejs @@ -1,8 +1,8 @@ <%# - Role admin create page: the role-form body captured into the app shell. + Permission admin create page: the permission-form body captured into the app shell. %><% const nav = include("partials/nav-tree", { nodes: chrome.nav }); - const body = include("partials/role-form-body", { error: model.error, form: model.form }); + const body = include("partials/permission-form-body", { error: model.error, form: model.form }); -%> <%- include("partials/shell", { body, diff --git a/examples/plugins/admin/views/roles.ejs b/examples/plugins/admin/views/permissions.ejs similarity index 58% rename from examples/plugins/admin/views/roles.ejs rename to examples/plugins/admin/views/permissions.ejs index 091f1a0..8eff683 100644 --- a/examples/plugins/admin/views/roles.ejs +++ b/examples/plugins/admin/views/permissions.ejs @@ -1,12 +1,12 @@ <%# - Roles admin list: the same building blocks as the Groups screen, around the shell, backed - by live Keto Role subject sets (admin-roles.ts). Filter/sort/page round-trip the URL. + Permissions admin list: the same building blocks as the Groups screen, around the shell, backed + by live Keto Permission subject sets (admin-permissions.ts). Filter/sort/page round-trip the URL. %><% const nav = include("partials/nav-tree", { nodes: chrome.nav }); const filters = include("partials/filter-bar", model.filterBar); const table = include("partials/data-table", model.table); const pager = include("partials/pagination", model.pagination); - const actions = 'Add role'; + const actions = 'Add permission'; -%> <%- include("partials/shell", { actions, diff --git a/examples/plugins/admin/views/role-detail.ejs b/examples/plugins/admin/views/role-detail.ejs deleted file mode 100644 index 2534908..0000000 --- a/examples/plugins/admin/views/role-detail.ejs +++ /dev/null @@ -1,16 +0,0 @@ -<%# - Role admin detail page: the role-detail body (members · effective access) in the shell. -%><% - const nav = include("partials/nav-tree", { nodes: chrome.nav }); - const body = include("partials/role-detail-body", { add: model.add, csrfToken: model.csrfToken, del: model.delete, effective: model.effective, error: model.error, members: model.members, role: model.role }); --%> -<%- include("partials/shell", { - body, - brand: chrome.brand, - breadcrumbs: model.breadcrumbs, - csrfToken: chrome.csrfToken, - nav, - theme: chrome.theme, - title: model.title, - user: chrome.user, -}) %> diff --git a/examples/plugins/scheduling/README.md b/examples/plugins/scheduling/README.md index e5f56cb..8586401 100644 --- a/examples/plugins/scheduling/README.md +++ b/examples/plugins/scheduling/README.md @@ -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. diff --git a/examples/plugins/scheduling/plugin.ts b/examples/plugins/scheduling/plugin.ts index c80522f..a7282ff 100644 --- a/examples/plugins/scheduling/plugin.ts +++ b/examples/plugins/scheduling/plugin.ts @@ -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 `:`. - 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 }, ], }); diff --git a/examples/plugins/scheduling/shifts.test.ts b/examples/plugins/scheduling/shifts.test.ts index b22abcd..966c9eb 100644 --- a/examples/plugins/scheduling/shifts.test.ts +++ b/examples/plugins/scheduling/shifts.test.ts @@ -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 }); diff --git a/examples/plugins/scheduling/shifts.ts b/examples/plugins/scheduling/shifts.ts index 85c1882..4b78741 100644 --- a/examples/plugins/scheduling/shifts.ts +++ b/examples/plugins/scheduling/shifts.ts @@ -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" }, diff --git a/examples/plugins/scheduling/views/overview.ejs b/examples/plugins/scheduling/views/overview.ejs index 8858c17..444d21e 100644 --- a/examples/plugins/scheduling/views/overview.ejs +++ b/examples/plugins/scheduling/views/overview.ejs @@ -12,7 +12,7 @@ -%> <%- include("partials/shell", { actions: "", - body: '

Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the scheduling:read role.

' + cta + '
', + body: '

Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the scheduling:read permission.

' + cta + '
', brand: chrome.brand, breadcrumbs, csrfToken: chrome.csrfToken, diff --git a/ory/keto/keto.yml b/ory/keto/keto.yml index b33ea12..4ab897d 100644 --- a/ory/keto/keto.yml +++ b/ory/keto/keto.yml @@ -1,4 +1,4 @@ -# Ory Keto — authorization (ReBAC), the source of truth for roles/groups and the rare +# Ory Keto — authorization (ReBAC), the source of truth for permissions/groups and the rare # fine-grained check (README: three tiers of "may I?"). The permission model lives in # namespaces.keto.ts (OPL); DSN comes from the env (the per-service keto DB). The web # app never connects directly — it calls the read (4466) / write (4467) APIs, the ports diff --git a/ory/keto/namespaces.keto.ts b/ory/keto/namespaces.keto.ts index 2849c78..f12530e 100644 --- a/ory/keto/namespaces.keto.ts +++ b/ory/keto/namespaces.keto.ts @@ -7,26 +7,27 @@ import { Context, Namespace, SubjectSet } from "@ory/keto-namespace-types" // A Kratos identity. Subjects are written as `identity:`. class Identity implements Namespace {} -// A subject set: a named collection of users (and nested groups), resolved transitively. -// The admin "Groups" screen manages membership; checks expand it automatically. +// A named set of identities (and nested groups), resolved transitively. The admin "Groups" +// screen manages membership; checks expand it automatically. class Group implements Namespace { related: { members: (Identity | SubjectSet)[] } } -// A coarse role — the source of truth for the JWT `roles` claim. At login the app reads -// `Role:#members@identity:` from Keto and projects the result into the token -// (README: Login → session JWT). A group can hold a role, so members can be users or groups. -class Role implements Namespace { +// A coarse permission — an operation a route or menu item gates on, and the source of truth +// for the JWT `permissions` claim. At login the app reads `Permission:#granted@identity:` +// from Keto and projects the result into the token (README: Login → session JWT). A group can +// hold a permission, so grants go to an identity or to a whole group. +class Permission implements Namespace { related: { - members: (Identity | SubjectSet)[] + granted: (Identity | SubjectSet)[] } } // A fine-grained, relationship-checked resource — README's third "may I?" tier, the rare -// live Keto check (e.g. sharing/delegation). Permissions nest: owner ⊇ editor ⊇ viewer. -// Grants accept a user directly or any member of a group. +// live Keto check (e.g. sharing/delegation). Permits nest: owner ⊇ editor ⊇ viewer. +// Grants accept an identity directly or any member of a group. class Resource implements Namespace { related: { owners: (Identity | SubjectSet)[] diff --git a/ory/kratos/kratos.yml b/ory/kratos/kratos.yml index 1c0cd86..ca0e5c8 100644 --- a/ory/kratos/kratos.yml +++ b/ory/kratos/kratos.yml @@ -41,7 +41,7 @@ selfservice: ui_url: http://localhost:3000/login after: # After authenticating, land on our completion route — it mints the session JWT - # (roles from Keto → metadata_public projection → tokenize) and sets our cookie. + # (permissions from Keto → metadata_public projection → tokenize) and sets our cookie. default_browser_return_url: http://localhost:3000/auth/complete registration: ui_url: http://localhost:3000/registration @@ -94,7 +94,7 @@ session: same_site: Lax # Session→JWT tokenizer: whoami(tokenize_as: plainpages) mints a short-lived, # locally-verifiable JWT so the hot path never calls Ory. Claims come from the - # committed Jsonnet mapper (sub = identity id, email from traits, roles from the + # committed Jsonnet mapper (sub = identity id, email from traits, permissions from the # metadata_public projection); signed with tokenizer/jwks.json. whoami: tokenizer: diff --git a/ory/kratos/tokenizer/plainpages.jsonnet b/ory/kratos/tokenizer/plainpages.jsonnet index ddf5973..594f9e0 100644 --- a/ory/kratos/tokenizer/plainpages.jsonnet +++ b/ory/kratos/tokenizer/plainpages.jsonnet @@ -1,7 +1,7 @@ // Session→JWT claims mapper for the `plainpages` tokenizer. Kratos exposes the // session as `session`; `sub` is set from the identity id (subject_source: id) and -// can't be overridden here. roles come from metadata_public — the per-login projection -// of Keto roles the app refreshes at login (metadata_admin is NOT carried in the session +// can't be overridden here. permissions come from metadata_public — the per-login projection +// of Keto permissions the app refreshes at login (metadata_admin is NOT carried in the session // the tokenizer sees; metadata_public is). Absent on a fresh identity ⇒ empty list. local session = std.extVar('session'); local meta = @@ -12,6 +12,6 @@ local meta = { claims: { email: session.identity.traits.email, - roles: if std.objectHas(meta, 'roles') then meta.roles else [], + permissions: if std.objectHas(meta, 'permissions') then meta.permissions else [], }, } diff --git a/src/auth/bootstrap.test.ts b/src/auth/bootstrap.test.ts index aa32cc0..a85bbef 100644 --- a/src/auth/bootstrap.test.ts +++ b/src/auth/bootstrap.test.ts @@ -1,11 +1,11 @@ // One-command bootstrap: idempotent first-boot seeding. Guards the pure payload -// builders (Kratos create-identity body + Keto role tuple), the idempotent seedAdmin +// builders (Kratos create-identity body + Keto permission tuple), the idempotent seedAdmin // orchestration (fresh 201 vs existing 409 → reuse id), and the JWKS generate-if-absent // safety net. Live boot is verified by running the stack; these catch contract drift. import { test } from "node:test"; import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; -import { ensureJwks, firstRunBanner, identityPayload, roleTuple, seedAdmin, seedRoles } from "./bootstrap.ts"; +import { ensureJwks, firstRunBanner, identityPayload, permissionTuple, seedAdmin, seedPermissions } from "./bootstrap.ts"; const json = (status: number, body?: unknown) => new Response(body === undefined ? null : JSON.stringify(body), { @@ -20,27 +20,27 @@ test("identityPayload is a valid Kratos create-identity body with a password cre assert.equal(body.credentials.password.config.password, "admin"); }); -test("roleTuple grants a role to identity: in the Role namespace", () => { +test("permissionTuple grants a permission to identity: in the Permission namespace", () => { const id = randomUUID(); - assert.deepEqual(roleTuple(id, "admin"), { - namespace: "Role", + assert.deepEqual(permissionTuple(id, "admin"), { + namespace: "Permission", object: "admin", - relation: "members", + relation: "granted", subject_id: `identity:${id}`, }); }); -test("seedRoles unions ADMIN_ROLES (default 'admin') with the discovered plugins' declared roles", () => { - // Clean clone: no ADMIN_ROLES, the scheduling plugin declares its two tokens → the demo admin +test("seedPermissions unions ADMIN_PERMISSIONS (default 'admin') with the discovered plugins' declared permissions", () => { + // Clean clone: no ADMIN_PERMISSIONS, the scheduling plugin declares its two tokens → the demo admin // gets exactly today's behaviour, but derived from discovery, not hardcoded in the host. - assert.deepEqual(seedRoles(undefined, ["scheduling:read", "scheduling:write"]), ["admin", "scheduling:read", "scheduling:write"]); - assert.deepEqual(seedRoles(undefined, []), ["admin"]); // no plugins → just the base admin role - assert.deepEqual(seedRoles("admin, ops ", ["inventory:read"]), ["admin", "ops", "inventory:read"]); // env trimmed + extended - assert.deepEqual(seedRoles("admin,scheduling:read", ["scheduling:read"]), ["admin", "scheduling:read"]); // dedup, no double grant - assert.deepEqual(seedRoles("admin,, ", [" scheduling:read ", ""]), ["admin", "scheduling:read"]); // blanks dropped, tokens trimmed (both sides) + assert.deepEqual(seedPermissions(undefined, ["scheduling:read", "scheduling:write"]), ["admin", "scheduling:read", "scheduling:write"]); + assert.deepEqual(seedPermissions(undefined, []), ["admin"]); // no plugins → just the base admin permission + assert.deepEqual(seedPermissions("admin, ops ", ["inventory:read"]), ["admin", "ops", "inventory:read"]); // env trimmed + extended + assert.deepEqual(seedPermissions("admin,scheduling:read", ["scheduling:read"]), ["admin", "scheduling:read"]); // dedup, no double grant + assert.deepEqual(seedPermissions("admin,, ", [" scheduling:read ", ""]), ["admin", "scheduling:read"]); // blanks dropped, tokens trimmed (both sides) }); -test("seedAdmin on a fresh stack creates the identity and grants every role (one tuple each)", async () => { +test("seedAdmin on a fresh stack creates the identity and grants every permission (one tuple each)", async () => { const id = randomUUID(); const calls: { method: string; url: string; body?: unknown }[] = []; const fetchImpl = (async (url, init) => { @@ -57,20 +57,20 @@ test("seedAdmin on a fresh stack creates the identity and grants every role (one ketoWriteUrl: "http://keto:4467", kratosAdminUrl: "http://kratos:4434", password: "admin", - roles: ["admin", "scheduling:read"], + permissions: ["admin", "scheduling:read"], }); - assert.deepEqual(result, { created: true, id, roles: ["admin", "scheduling:read"] }); + assert.deepEqual(result, { created: true, id, permissions: ["admin", "scheduling:read"] }); const puts = calls.filter((c) => c.url.includes("relation-tuples")); - assert.equal(puts.length, 2); // one grant per role + assert.equal(puts.length, 2); // one grant per permission assert.ok(puts.every((p) => p.method === "PUT")); assert.deepEqual(puts.map((p) => p.body), [ - { namespace: "Role", object: "admin", relation: "members", subject_id: `identity:${id}` }, - { namespace: "Role", object: "scheduling:read", relation: "members", subject_id: `identity:${id}` }, + { namespace: "Permission", object: "admin", relation: "granted", subject_id: `identity:${id}` }, + { namespace: "Permission", object: "scheduling:read", relation: "granted", subject_id: `identity:${id}` }, ]); }); -test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants the role", async () => { +test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants the permission", async () => { const id = randomUUID(); let granted: unknown; const fetchImpl = (async (url, init) => { @@ -90,11 +90,11 @@ test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants ketoWriteUrl: "http://keto:4467", kratosAdminUrl: "http://kratos:4434", password: "admin", - roles: ["admin"], + permissions: ["admin"], }); - assert.deepEqual(result, { created: false, id, roles: ["admin"] }); - assert.deepEqual(granted, { namespace: "Role", object: "admin", relation: "members", subject_id: `identity:${id}` }); + assert.deepEqual(result, { created: false, id, permissions: ["admin"] }); + assert.deepEqual(granted, { namespace: "Permission", object: "admin", relation: "granted", subject_id: `identity:${id}` }); }); test("seedAdmin fails loud on an unexpected Kratos error", async () => { @@ -106,7 +106,7 @@ test("seedAdmin fails loud on an unexpected Kratos error", async () => { ketoWriteUrl: "http://keto:4467", kratosAdminUrl: "http://kratos:4434", password: "admin", - roles: ["admin"], + permissions: ["admin"], }), /Kratos/, ); diff --git a/src/auth/bootstrap.ts b/src/auth/bootstrap.ts index fab120e..9219c85 100644 --- a/src/auth/bootstrap.ts +++ b/src/auth/bootstrap.ts @@ -2,8 +2,8 @@ // kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`: // 1. generate the JWKS signing key if absent (committed dev key makes this a safety net); // 2. seed a demo admin (admin@plainpages.local / admin) in Kratos; -// 3. grant it its roles in Keto so menu/role checks resolve out of the box — `admin` plus -// every discovered plugin's declared role names, so a dropped-in plugin is usable by +// 3. grant it its permissions in Keto so menu/permission checks resolve out of the box — `admin` plus +// every discovered plugin's declared permission names, so a dropped-in plugin is usable by // the demo admin with no host config edit (the host stays plugin-agnostic). // Then prints a first-run banner; fails loud on any unexpected upstream error. import { existsSync, writeFileSync } from "node:fs"; @@ -22,19 +22,19 @@ export function identityPayload(email: string, password: string) { }; } -// Coarse-role grant: `Role:#members@identity:`. Subject ids are `identity:` -// (namespaces.keto.ts) — the source of truth the login flow projects into the JWT roles. -export function roleTuple(identityId: string, role: string) { - return { namespace: "Role", object: role, relation: "members", subject_id: `identity:${identityId}` }; +// Coarse-permission grant: `Permission:#members@identity:`. Subject ids are `identity:` +// (namespaces.keto.ts) — the source of truth the login flow projects into the JWT permissions. +export function permissionTuple(identityId: string, permission: string) { + return { namespace: "Permission", object: permission, relation: "granted", subject_id: `identity:${identityId}` }; } -// The roles to grant the demo admin = the configured base (ADMIN_ROLES, default just `admin`) -// unioned with every discovered plugin's declared role names (a route/nav `role` is a -// coarse role — granted as a Keto `Role:#members` tuple). So the host names no plugin, yet a +// The permissions to grant the demo admin = the configured base (ADMIN_PERMISSIONS, default just `admin`) +// unioned with every discovered plugin's declared permission names (a route/nav `permission` is a +// coarse permission — granted as a Keto `Permission:#members` tuple). So the host names no plugin, yet a // dropped-in plugin's tokens are seeded out of the box. Deduped, order-stable, blanks dropped. -export function seedRoles(adminRolesEnv: string | undefined, declaredRoles: string[]): string[] { +export function seedPermissions(adminRolesEnv: string | undefined, declaredPermissions: string[]): string[] { const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean); - return [...new Set([...clean((adminRolesEnv ?? "admin").split(",")), ...clean(declaredRoles)])]; + return [...new Set([...clean((adminRolesEnv ?? "admin").split(",")), ...clean(declaredPermissions)])]; } // --- JWKS safety net ----------------------------------------------------------------- @@ -63,13 +63,13 @@ export interface SeedOptions { ketoWriteUrl: string; kratosAdminUrl: string; password: string; - roles: string[]; + permissions: string[]; } export interface SeedResult { created: boolean; id: string; - roles: string[]; + permissions: string[]; } export async function seedAdmin(opts: SeedOptions): Promise { @@ -93,17 +93,17 @@ export async function seedAdmin(opts: SeedOptions): Promise { throw new Error(`bootstrap: Kratos create identity failed (${res.status}): ${await res.text()}`); } - // Grant each role in Keto. PUT is idempotent — re-running just re-asserts the tuple. - for (const role of opts.roles) { + // Grant each permission in Keto. PUT is idempotent — re-running just re-asserts the tuple. + for (const permission of opts.permissions) { const grant = await http(`${opts.ketoWriteUrl}/admin/relation-tuples`, { - body: JSON.stringify(roleTuple(id, role)), + body: JSON.stringify(permissionTuple(id, permission)), headers: { "content-type": "application/json" }, method: "PUT", }); - if (!grant.ok) throw new Error(`bootstrap: Keto grant role "${role}" failed (${grant.status}): ${await grant.text()}`); + if (!grant.ok) throw new Error(`bootstrap: Keto grant permission "${permission}" failed (${grant.status}): ${await grant.text()}`); } - return { created, id, roles: opts.roles }; + return { created, id, permissions: opts.permissions }; } async function findIdentityId(http: typeof fetch, adminUrl: string, email: string): Promise { @@ -143,10 +143,10 @@ async function main() { await runWithLog(log, async () => { if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key"); - // Seed `admin` (or ADMIN_ROLES) + every discovered plugin's declared role names, so the + // Seed `admin` (or ADMIN_PERMISSIONS) + every discovered plugin's declared permission names, so the // shipped example — and any dropped-in plugin — works for the demo admin without a host edit. - const declared = (await discoverPlugins()).flatMap((p) => (p.roles ?? []).map((d) => d.name)); - const roles = seedRoles(env["ADMIN_ROLES"], declared); + const declared = (await discoverPlugins()).flatMap((p) => (p.permissions ?? []).map((d) => d.name)); + const permissions = seedPermissions(env["ADMIN_PERMISSIONS"], declared); const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local"; const password = env["ADMIN_PASSWORD"] ?? "admin"; const result = await seedAdmin({ @@ -155,9 +155,9 @@ async function main() { ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467", kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434", password, - roles, + permissions, }); - log.info("admin seeded", { created: result.created, id: result.id, roles: result.roles.join(", ") }); + log.info("admin seeded", { created: result.created, id: result.id, permissions: result.permissions.join(", ") }); // The banner is human-facing UX (the first-run "you're ready" block), not a log event — print raw. console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password })); }); diff --git a/src/auth/denylist.ts b/src/auth/denylist.ts index 4919f7a..b1accf7 100644 --- a/src/auth/denylist.ts +++ b/src/auth/denylist.ts @@ -1,12 +1,12 @@ -// Optional revocation denylist: instant role/session revoke without putting Keto +// Optional revocation denylist: instant permission/session revoke without putting Keto // back on the hot path. Off by default — enable with REVOCATION_DENYLIST=true. // -// The hot path verifies a short-lived (~10m) session JWT in-process, so a revoked role or a +// The hot path verifies a short-lived (~10m) session JWT in-process, so a revoked permission or a // killed session only takes effect when the token is next minted (re-login / TTL refresh) — // up to one token TTL of lag. For security-critical revoke (offboarding, a compromised // account) that lag is too long. An admin action records the subject as revoked-now and the // hot path then rejects that subject's pre-revoke tokens at once, forcing a re-mint (which -// re-reads roles from Keto, or clears a now-dead session). +// re-reads permissions from Keto, or clears a now-dead session). // // Cost & scope: an in-memory, auto-evicting Map — no database, like the JWKS cache, so it // stays inside the stateless model. A token carries `iat`, so a *fresh* re-login (iat after diff --git a/src/auth/gen-jwks.test.ts b/src/auth/gen-jwks.test.ts index 33fec6a..b129805 100644 --- a/src/auth/gen-jwks.test.ts +++ b/src/auth/gen-jwks.test.ts @@ -48,7 +48,7 @@ test("rotateJwks --prune keeps only the newest (first) key, dropping superseded test("a JWS signed with a generated key verifies via our own verifier (reads what Kratos signs)", () => { const key = generateJwks().keys[0]!; const head = b64url(JSON.stringify({ alg: "ES256", kid: key.kid })); - const body = b64url(JSON.stringify({ email: "a@b.c", roles: [], sub: key.kid })); + const body = b64url(JSON.stringify({ email: "a@b.c", permissions: [], sub: key.kid })); const sig = sign("SHA256", Buffer.from(`${head}.${body}`), { dsaEncoding: "ieee-p1363", key: createPrivateKey({ key: key as unknown as JsonWebKey, format: "jwk" }) }); const token = `${head}.${body}.${sig.toString("base64url")}`; diff --git a/src/auth/guards.test.ts b/src/auth/guards.test.ts index 6b378e2..99b7154 100644 --- a/src/auth/guards.test.ts +++ b/src/auth/guards.test.ts @@ -12,7 +12,7 @@ function ctxFor(user: SessionIdentity | null, url = "/"): RequestContext { return buildContext(req, new ServerResponse(req), { identity: user }); } -const alice: SessionIdentity = { email: "a@b.c", id: "u1", roles: ["admin", "scheduling:read"] }; +const alice: SessionIdentity = { email: "a@b.c", id: "u1", permissions: ["admin", "scheduling:read"] }; test("requireSession returns the user, or throws GuardError(401)→/login (preserving return_to) when anonymous", () => { assert.equal(requireSession(ctxFor(alice)), alice); @@ -30,7 +30,7 @@ test("requireSession returns the user, or throws GuardError(401)→/login (prese err instanceof GuardError && err.location === "/login?return_to=%2Fscheduling%2Fshifts%3Fq%3D1"); }); -test("can reads a coarse role from the JWT claims; anonymous has none", () => { +test("can reads a coarse permission from the JWT claims; anonymous has none", () => { assert.equal(can(ctxFor(alice), "admin"), true); assert.equal(can(ctxFor(alice), "billing:write"), false); assert.equal(can(ctxFor(null), "admin"), false); diff --git a/src/auth/guards.ts b/src/auth/guards.ts index 7ed8284..e46cde7 100644 --- a/src/auth/guards.ts +++ b/src/auth/guards.ts @@ -1,5 +1,5 @@ // Auth guards: in-handler authorization, the imperative counterpart to the -// declarative route `role` gate. The middleware already verified the session JWT and put +// declarative route `permission` gate. The middleware already verified the session JWT and put // the User on ctx; these read it. `requireSession` asserts (throws GuardError, which app.ts maps // to a response); `can`/`check` are predicates a handler branches on. `check` is the one live // Keto call — the fine-grained "may I?" tier (README), reserved for relationship rules. @@ -37,9 +37,9 @@ export function requireSession(ctx: RequestContext): SessionIdentity { return ctx.identity; } -// Coarse role check straight from the JWT claims — in-process, zero I/O. Anonymous ⇒ false. -export function can(ctx: RequestContext, role: string): boolean { - return ctx.roles.includes(role); +// Coarse permission check straight from the JWT claims — in-process, zero I/O. Anonymous ⇒ false. +export function can(ctx: RequestContext, permission: string): boolean { + return ctx.permissions.includes(permission); } // Live Keto relationship check at the point of action. The subject is the current user; diff --git a/src/auth/jwt-middleware.test.ts b/src/auth/jwt-middleware.test.ts index 7666c31..9ca496a 100644 --- a/src/auth/jwt-middleware.test.ts +++ b/src/auth/jwt-middleware.test.ts @@ -22,11 +22,11 @@ const jwk2: JsonWebKey = { ...(k2.publicKey.export({ format: "jwk" }) as JsonWeb const jwks = staticJwks([jwk1, jwk2]); // rotated set: two live keys const NOW = 1_700_000_000; // fixed clock for deterministic exp/nbf checks -const valid = { email: "a@b.c", exp: NOW + 600, roles: ["admin"], sub: "u1" }; +const valid = { email: "a@b.c", exp: NOW + 600, permissions: ["admin"], sub: "u1" }; test("verifyToken: a valid token → User, selecting the verify key by kid across a rotated set", async () => { const user = await verifyToken(mint(k2.privateKey, "k2", valid), jwks, { now: NOW }); - assert.deepEqual(user, { email: "a@b.c", id: "u1", roles: ["admin"] }); + assert.deepEqual(user, { email: "a@b.c", id: "u1", permissions: ["admin"] }); }); test("verifyToken requires exp, rejects expiry and future nbf, with clock-skew leeway", async () => { @@ -59,18 +59,18 @@ test("verifyToken rejects a bad signature and an unknown kid", async () => { await assert.rejects(verifyToken(mint(k1.privateKey, "nope", valid), jwks, { now: NOW }), /no JWKS key/); }); -test("claimsToIdentity requires sub + email, defaults roles to [], keeps only string roles", () => { +test("claimsToIdentity requires sub + email, defaults permissions to [], keeps only string permissions", () => { assert.throws(() => claimsToIdentity({ email: "a@b.c", exp: NOW }), /sub/); assert.throws(() => claimsToIdentity({ email: "a@b.c", exp: NOW, sub: "" }), /sub/); // empty sub rejected too assert.throws(() => claimsToIdentity({ exp: NOW, sub: "u" }), /email/); assert.throws(() => claimsToIdentity({ email: "", exp: NOW, sub: "u" }), /email/); // empty email rejected (the shell keys signed-in vs anonymous off it) - assert.deepEqual(claimsToIdentity({ email: "a@b.c", sub: "u" }).roles, []); // roles absent - assert.deepEqual(claimsToIdentity({ email: "a@b.c", roles: ["a", 1, "b"], sub: "u" }).roles, ["a", "b"]); + assert.deepEqual(claimsToIdentity({ email: "a@b.c", sub: "u" }).permissions, []); // permissions absent + assert.deepEqual(claimsToIdentity({ email: "a@b.c", permissions: ["a", 1, "b"], sub: "u" }).permissions, ["a", "b"]); }); test("resolveSession classifies the cookie; authenticate is its fail-closed identity projection", async () => { const cookie = (extra: Record = {}, kid = "k1") => `${SESSION_COOKIE}=${mint(k1.privateKey, kid, { ...valid, ...extra })}`; - const identity = { email: "a@b.c", id: "u1", roles: ["admin"] }; + const identity = { email: "a@b.c", id: "u1", permissions: ["admin"] }; // A valid token → the user, not expired. assert.deepEqual(await resolveSession(cookie(), jwks, { now: NOW }), { expired: false, identity }); @@ -96,6 +96,6 @@ test("verifyToken honours an optional denylist: a revoked subject's token reject await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 }), jwks, { denylist, now: NOW }), /revoked/); assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=${mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 })}`, jwks, { denylist, now: NOW }), { expired: true, identity: null }); // A token minted after the revoke (fresh login) is accepted; a different subject is untouched. - assert.deepEqual(await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW + 5 }), jwks, { denylist, now: NOW }), { email: "a@b.c", id: "u1", roles: ["admin"] }); + assert.deepEqual(await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW + 5 }), jwks, { denylist, now: NOW }), { email: "a@b.c", id: "u1", permissions: ["admin"] }); await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5, sub: "u2" }), jwks, { denylist, now: NOW }); }); diff --git a/src/auth/jwt-middleware.ts b/src/auth/jwt-middleware.ts index a179fe0..6f1aed4 100644 --- a/src/auth/jwt-middleware.ts +++ b/src/auth/jwt-middleware.ts @@ -2,7 +2,7 @@ // the hot path that never calls Ory. Select the verify key by `kid` from the cached JWKS, // check the signature (src/auth/jwt.ts), validate the time/issuer/audience claims, project the // User onto the request context. `authenticate` fails closed: any bad/expired token ⇒ null -// (anonymous), so the route renders signed-out and the role gate denies. +// (anonymous), so the route renders signed-out and the permission gate denies. import type { SessionIdentity } from "../http/context.ts"; import { parseCookies } from "../http/cookie.ts"; import type { Denylist } from "./denylist.ts"; @@ -59,15 +59,15 @@ export function validateClaims(payload: Record, options: Verify } // Map verified claims → the request User. sub/email are required and non-empty (the tokenizer -// always sets them; an empty email would read as anonymous in the shell); roles defaults to [] and +// always sets them; an empty email would read as anonymous in the shell); permissions defaults to [] and // keeps only string entries (defensive). export function claimsToIdentity(payload: Record): SessionIdentity { const sub = payload["sub"]; if (typeof sub !== "string" || sub === "") throw new TokenError("token missing sub"); const email = payload["email"]; if (typeof email !== "string" || email === "") throw new TokenError("token missing email"); - const roles = payload["roles"]; - return { email, id: sub, roles: Array.isArray(roles) ? roles.filter((r): r is string => typeof r === "string") : [] }; + const permissions = payload["permissions"]; + return { email, id: sub, permissions: Array.isArray(permissions) ? permissions.filter((r): r is string => typeof r === "string") : [] }; } // Verify a session JWT end-to-end: select the key by `kid`, check the signature, validate @@ -80,7 +80,7 @@ export async function verifyToken(token: string, jwks: JwksProvider, options: Ve validateClaims(verified.payload, options); const user = claimsToIdentity(verified.payload); // Instant revoke: a denylisted subject's pre-revoke token is rejected as *expired* so - // resolveSession routes it through the re-mint (fresh roles from Keto, or a cleared session). + // resolveSession routes it through the re-mint (fresh permissions from Keto, or a cleared session). if (options.denylist?.isRevoked(user.id, num(verified.payload, "iat"))) throw new TokenError("token revoked", true); return user; } diff --git a/src/auth/jwt.test.ts b/src/auth/jwt.test.ts index 5e231fc..83d8f64 100644 --- a/src/auth/jwt.test.ts +++ b/src/auth/jwt.test.ts @@ -22,10 +22,10 @@ const rsaJwk = rsa.publicKey.export({ format: "jwk" }) as JsonWebKey; const ecJwk = ec.publicKey.export({ format: "jwk" }) as JsonWebKey; test("verifies an RS256 token, returning the decoded header + payload", () => { - const token = makeJws("RS256", rsa.privateKey, { roles: ["admin"], sub: "u" }); + const token = makeJws("RS256", rsa.privateKey, { permissions: ["admin"], sub: "u" }); const verified = verifyJws(token, rsaJwk); assert.equal(verified.header.alg, "RS256"); - assert.deepEqual(verified.payload, { roles: ["admin"], sub: "u" }); + assert.deepEqual(verified.payload, { permissions: ["admin"], sub: "u" }); }); test("verifies an ES256 token (raw r‖s signature)", () => { @@ -35,10 +35,10 @@ test("verifies an ES256 token (raw r‖s signature)", () => { // All three reach and fail the signature check itself, not an earlier structural guard. test("rejects a signature that fails verification (tampered payload, wrong key, empty)", () => { - const token = makeJws("RS256", rsa.privateKey, { roles: ["user"], sub: "u" }); + const token = makeJws("RS256", rsa.privateKey, { permissions: ["user"], sub: "u" }); const [header, payload, signature] = token.split("."); - const forged = `${header}.${b64url(JSON.stringify({ roles: ["admin"], sub: "u" }))}.${signature}`; + const forged = `${header}.${b64url(JSON.stringify({ permissions: ["admin"], sub: "u" }))}.${signature}`; assert.throws(() => verifyJws(forged, rsaJwk), /invalid signature/); const otherJwk = generateKeyPairSync("rsa", { modulusLength: 2048 }).publicKey.export({ format: "jwk" }) as JsonWebKey; diff --git a/src/auth/keto-client.test.ts b/src/auth/keto-client.test.ts index ea86d7a..f1eedbb 100644 --- a/src/auth/keto-client.test.ts +++ b/src/auth/keto-client.test.ts @@ -29,13 +29,13 @@ const keto = (fetchImpl: typeof fetch) => createKetoClient({ fetchImpl, readUrl: test("check GETs the read API and returns the allowed boolean (true and false)", async () => { const allow = recorder(() => res(200, { allowed: true })); - assert.equal(await keto(allow.fetchImpl).check({ namespace: "Role", object: "admin", relation: "members", subject_id: USER }), true); + assert.equal(await keto(allow.fetchImpl).check({ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER }), true); assert.match(allow.calls[0]!.url, /^http:\/\/keto:4466\/relation-tuples\/check\?/); - assert.match(allow.calls[0]!.url, /namespace=Role&object=admin&relation=members/); + assert.match(allow.calls[0]!.url, /namespace=Permission&object=admin&relation=granted/); assert.match(allow.calls[0]!.url, new RegExp(`subject_id=${encodeURIComponent(USER).replace(/[.]/g, "\\.")}`)); // A denied check is 403 {allowed:false} (not a 200) — both statuses carry the verdict. const deny = recorder(() => res(403, { allowed: false })); - assert.equal(await keto(deny.fetchImpl).check({ namespace: "Role", object: "admin", relation: "members", subject_id: "identity:nobody" }), false); + assert.equal(await keto(deny.fetchImpl).check({ namespace: "Permission", object: "admin", relation: "granted", subject_id: "identity:nobody" }), false); }); test("check on a subject_set builds subject_set.* params and forwards max-depth", async () => { @@ -51,20 +51,20 @@ test("check on a subject_set builds subject_set.* params and forwards max-depth" test("check throws a KetoError carrying the status on an unexpected response", async () => { await assert.rejects( - keto((async () => res(400, { error: "bad" })) as typeof fetch).check({ namespace: "Role", object: "admin", relation: "members", subject_id: USER }), + keto((async () => res(400, { error: "bad" })) as typeof fetch).check({ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER }), (e: unknown) => e instanceof KetoError && e.status === 400, ); }); test("listRelations builds the filter query + pagination and parses next_page_token", async () => { - const tuples = [{ namespace: "Role", object: "admin", relation: "members", subject_id: USER }]; + const tuples = [{ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER }]; const { calls, fetchImpl } = recorder(() => res(200, { next_page_token: "NEXT", relation_tuples: tuples })); - const out = await keto(fetchImpl).listRelations({ namespace: "Role", object: "admin", pageSize: 10, pageToken: "CUR", relation: "members" }); + const out = await keto(fetchImpl).listRelations({ namespace: "Permission", object: "admin", pageSize: 10, pageToken: "CUR", relation: "granted" }); assert.deepEqual(out.tuples, tuples); assert.equal(out.nextPageToken, "NEXT"); const url = calls[0]!.url; assert.match(url, /^http:\/\/keto:4466\/relation-tuples\?/); - assert.match(url, /namespace=Role&object=admin&relation=members/); + assert.match(url, /namespace=Permission&object=admin&relation=granted/); assert.match(url, /page_size=10&page_token=CUR/); // No Link header / token in the body ⇒ null, empty list ⇒ []. const empty = await keto((async () => res(200, {})) as typeof fetch).listRelations(); @@ -72,16 +72,16 @@ test("listRelations builds the filter query + pagination and parses next_page_to }); test("expand GETs the read API for a subject set and returns the tree (with max-depth)", async () => { - const tree = { children: [{ tuple: { namespace: "", object: "", relation: "", subject_id: USER }, type: "leaf" }], tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Role", object: "admin", relation: "members" } }, type: "union" }; + const tree = { children: [{ tuple: { namespace: "", object: "", relation: "", subject_id: USER }, type: "leaf" }], tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Permission", object: "admin", relation: "granted" } }, type: "union" }; const { calls, fetchImpl } = recorder(() => res(200, tree)); - const out = await keto(fetchImpl).expand({ namespace: "Role", object: "admin", relation: "members" }, { maxDepth: 3 }); + const out = await keto(fetchImpl).expand({ namespace: "Permission", object: "admin", relation: "granted" }, { maxDepth: 3 }); assert.deepEqual(out, tree); assert.match(calls[0]!.url, /^http:\/\/keto:4466\/relation-tuples\/expand\?/); - assert.match(calls[0]!.url, /namespace=Role&object=admin&relation=members&max-depth=3/); + assert.match(calls[0]!.url, /namespace=Permission&object=admin&relation=granted&max-depth=3/); }); test("writeTuple PUTs the tuple as JSON to the write API (idempotent; non-2xx throws)", async () => { - const tuple = { namespace: "Role", object: "admin", relation: "members", subject_id: USER }; + const tuple = { namespace: "Permission", object: "admin", relation: "granted", subject_id: USER }; const { calls, fetchImpl } = recorder(() => res(201, tuple)); await keto(fetchImpl).writeTuple(tuple); assert.equal(calls[0]!.method, "PUT"); @@ -95,12 +95,12 @@ test("writeTuple PUTs the tuple as JSON to the write API (idempotent; non-2xx th test("deleteTuple DELETEs the write API by query params (204 resolves; non-204 throws)", async () => { const { calls, fetchImpl } = recorder(() => res(204)); - await keto(fetchImpl).deleteTuple({ namespace: "Role", object: "admin", relation: "members", subject_id: USER }); + await keto(fetchImpl).deleteTuple({ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER }); assert.equal(calls[0]!.method, "DELETE"); assert.match(calls[0]!.url, /^http:\/\/keto:4467\/admin\/relation-tuples\?/); - assert.match(calls[0]!.url, /namespace=Role&object=admin&relation=members/); + assert.match(calls[0]!.url, /namespace=Permission&object=admin&relation=granted/); await assert.rejects( - keto((async () => res(404)) as typeof fetch).deleteTuple({ namespace: "Role", object: "x", relation: "members", subject_id: USER }), + keto((async () => res(404)) as typeof fetch).deleteTuple({ namespace: "Permission", object: "x", relation: "granted", subject_id: USER }), (e: unknown) => e instanceof KetoError && e.status === 404, ); }); diff --git a/src/auth/keto-client.ts b/src/auth/keto-client.ts index 2782caf..98852e0 100644 --- a/src/auth/keto-client.ts +++ b/src/auth/keto-client.ts @@ -13,7 +13,7 @@ export interface SubjectSet { } // A relationship tuple — the wire shape for writes and the filter shape for reads. Subject -// is `subject_id` xor `subject_set` (never both). Mirrors bootstrap.ts's roleTuple. +// is `subject_id` xor `subject_set` (never both). Mirrors bootstrap.ts's permissionTuple. export interface RelationTuple { namespace: string; object: string; diff --git a/src/auth/kratos-admin.test.ts b/src/auth/kratos-admin.test.ts index 06d6db4..69a7278 100644 --- a/src/auth/kratos-admin.test.ts +++ b/src/auth/kratos-admin.test.ts @@ -1,5 +1,5 @@ // Kratos admin-API client: typed fetch wrappers over Ory Kratos' admin endpoints — -// identity CRUD + the surgical metadata_public update the login flow projects roles into. +// identity CRUD + the surgical metadata_public update the login flow projects permissions into. // Guards the request contracts (URLs, method, JSON-Patch body, query/pagination) and the // result mapping (201/200/404/4xx). Live wiring is verified by login completion. import { test } from "node:test"; @@ -90,13 +90,13 @@ test("updateIdentity PUTs the full body to /admin/identities/ and returns th }); test("updateMetadataPublic PATCHes a JSON-Patch `add /metadata_public` so it never clobbers traits", async () => { - const identity = { id: ID, metadata_public: { roles: ["admin"] } }; + const identity = { id: ID, metadata_public: { permissions: ["admin"] } }; const { calls, fetchImpl } = recorder(() => res(200, identity)); - const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).updateMetadataPublic(ID, { roles: ["admin"] }); + const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).updateMetadataPublic(ID, { permissions: ["admin"] }); assert.deepEqual(out, identity); assert.equal(calls[0]!.method, "PATCH"); assert.match(calls[0]!.url, new RegExp(`/admin/identities/${ID}$`)); - assert.deepEqual(JSON.parse(calls[0]!.body!), [{ op: "add", path: "/metadata_public", value: { roles: ["admin"] } }]); + assert.deepEqual(JSON.parse(calls[0]!.body!), [{ op: "add", path: "/metadata_public", value: { permissions: ["admin"] } }]); }); test("createRecoveryCode POSTs the identity id to /admin/recovery/code → { code, link }", async () => { diff --git a/src/auth/kratos-admin.ts b/src/auth/kratos-admin.ts index aec998a..16b6578 100644 --- a/src/auth/kratos-admin.ts +++ b/src/auth/kratos-admin.ts @@ -1,6 +1,6 @@ // Kratos admin-API client: typed `fetch` wrappers over Ory Kratos' admin endpoints // (internal-only admin port) — identity CRUD + the surgical `metadata_public` update login -// completion projects Keto roles into (README). Built-in `fetch` only, no SDK dep (AGENTS.md); +// completion projects Keto permissions into (README). Built-in `fetch` only, no SDK dep (AGENTS.md); // `fetchImpl`-injectable, reuses kratos-public.ts's `KratosError` (branch on `.status`). import { KratosError } from "./kratos-public.ts"; @@ -106,7 +106,7 @@ export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof }, // JSON Patch `add` sets metadata_public whether it's currently absent, null, or set, and - // touches nothing else — so the login role projection never clobbers traits/state. + // touches nothing else — so the login permission projection never clobbers traits/state. // (metadata_public, not _admin: the session the tokenizer sees carries only public metadata.) async updateMetadataPublic(id, metadata) { const patch = [{ op: "add", path: "/metadata_public", value: metadata }]; diff --git a/src/auth/login.test.ts b/src/auth/login.test.ts index a1cf2d5..021333e 100644 --- a/src/auth/login.test.ts +++ b/src/auth/login.test.ts @@ -1,4 +1,4 @@ -// Login completion: turn a Kratos session into our session JWT — read roles from Keto, +// Login completion: turn a Kratos session into our session JWT — read permissions from Keto, // project them onto the identity, tokenize, build the cookie. Fakes the three Ory clients; // the live, full-stack login is verified by the Playwright E2E. import { test } from "node:test"; @@ -6,10 +6,10 @@ import assert from "node:assert/strict"; import type { KetoClient, RelationTuple } from "./keto-client.ts"; import type { Identity, KratosAdmin } from "./kratos-admin.ts"; import type { KratosPublic, Session } from "./kratos-public.ts"; -import { completeLogin, readRoles, remintSession, SESSION_COOKIE, sessionCookie } from "./login.ts"; +import { completeLogin, readPermissions, remintSession, SESSION_COOKIE, sessionCookie } from "./login.ts"; const ID = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55"; -const roleTuple = (object: string): RelationTuple => ({ namespace: "Role", object, relation: "members", subject_id: `identity:${ID}` }); +const permissionTuple = (object: string): RelationTuple => ({ namespace: "Permission", object, relation: "granted", subject_id: `identity:${ID}` }); const ketoStub = (over: Partial = {}): KetoClient => ({ check: async () => false, @@ -40,32 +40,32 @@ const publicStub = (over: Partial = {}): KratosPublic => ({ ...over, }); -test("readRoles returns roles held directly OR transitively (enumerate defined roles → Keto-check each)", async () => { +test("readPermissions returns permissions held directly OR transitively (enumerate defined permissions → Keto-check each)", async () => { const listQ: unknown[] = []; const checked: string[] = []; - const role = (object: string, subject: Partial): RelationTuple => ({ namespace: "Role", object, relation: "members", ...subject }); + const permission = (object: string, subject: Partial): RelationTuple => ({ namespace: "Permission", object, relation: "granted", ...subject }); const keto = ketoStub({ - // Enumerate every Role tuple (paged, no subject filter) to find the distinct role names — + // Enumerate every Permission tuple (paged, no subject filter) to find the distinct permission names — // subjects vary (a direct user, a group) and a name repeats across pages → de-duped. listRelations: async (q) => { listQ.push(q); - if (q?.pageToken === "p2") return { nextPageToken: null, tuples: [role("editor", { subject_id: "identity:other" })] }; + if (q?.pageToken === "p2") return { nextPageToken: null, tuples: [permission("editor", { subject_id: "identity:other" })] }; return { nextPageToken: "p2", tuples: [ - role("editor", { subject_set: { namespace: "Group", object: "eng", relation: "members" } }), - role("admin", { subject_id: `identity:${ID}` }), - role("viewer", { subject_id: "identity:stranger" }), + permission("editor", { subject_set: { namespace: "Group", object: "eng", relation: "members" } }), + permission("admin", { subject_id: `identity:${ID}` }), + permission("viewer", { subject_id: "identity:stranger" }), ] }; }, // Keto resolves transitively: the user holds editor (via a group) + admin (direct), not viewer. check: async (t) => { checked.push(t.object); return t.object === "admin" || t.object === "editor"; }, }); - assert.deepEqual(await readRoles(keto, ID), ["admin", "editor"]); - assert.deepEqual(listQ[0], { namespace: "Role", relation: "members" }); // enumerate, not subject-filtered + assert.deepEqual(await readPermissions(keto, ID), ["admin", "editor"]); + assert.deepEqual(listQ[0], { namespace: "Permission", relation: "granted" }); // enumerate, not subject-filtered assert.equal((listQ[1] as { pageToken?: string }).pageToken, "p2"); // second page follows the cursor - assert.deepEqual(checked.sort(), ["admin", "editor", "viewer"]); // every distinct role checked for the user + assert.deepEqual(checked.sort(), ["admin", "editor", "viewer"]); // every distinct permission checked for the user }); -test("completeLogin: read roles → project onto metadata_public → tokenize → JWT (in that order)", async () => { +test("completeLogin: read permissions → project onto metadata_public → tokenize → JWT (in that order)", async () => { const events: string[] = []; let projected: unknown; const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } }; @@ -76,11 +76,11 @@ test("completeLogin: read roles → project onto metadata_public → tokenize }, }); const kratosAdmin = adminStub({ updateMetadataPublic: async (_id, meta) => { events.push("project"); projected = meta; return identity; } }); - const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [roleTuple("admin")] }) }); + const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [permissionTuple("admin")] }) }); const out = await completeLogin({ keto, kratosAdmin, kratosPublic }, "plainpages_session=s"); - assert.deepEqual(out, { email: "admin@plainpages.local", identityId: ID, jwt: "h.p.s", roles: ["admin"] }); - assert.deepEqual(projected, { roles: ["admin"] }); // Keto roles, projected for the tokenizer + assert.deepEqual(out, { email: "admin@plainpages.local", identityId: ID, jwt: "h.p.s", permissions: ["admin"] }); + assert.deepEqual(projected, { permissions: ["admin"] }); // Keto permissions, projected for the tokenizer assert.deepEqual(events, ["whoami", "project", "tokenize"]); // projection MUST precede tokenize }); @@ -101,11 +101,11 @@ test("completeLogin maps a missing email trait to null and throws if the tokeniz test("remintSession: a live Kratos session → fresh cookie + refreshed user; a dead session → a clearing cookie + null", async () => { const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } }; const kratosPublic = publicStub({ whoami: async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session }); - const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [roleTuple("admin")] }) }); + const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [permissionTuple("admin")] }) }); - // TTL lapsed but the Kratos session lives → re-read roles from Keto, re-tokenize, fresh cookie. + // TTL lapsed but the Kratos session lives → re-read permissions from Keto, re-tokenize, fresh cookie. const live = await remintSession({ keto, kratosAdmin: adminStub(), kratosPublic }, "plainpages_session=s"); - assert.deepEqual(live.identity, { email: "admin@plainpages.local", id: ID, roles: ["admin"] }); + assert.deepEqual(live.identity, { email: "admin@plainpages.local", id: ID, permissions: ["admin"] }); assert.match(live.setCookie, /^plainpages_jwt=h\.p\.s;.*Max-Age=2592000.*HttpOnly/); // Kratos session also gone → clear the stale JWT so the next request falls through to anonymous. diff --git a/src/auth/login.ts b/src/auth/login.ts index 7e3805f..be8800e 100644 --- a/src/auth/login.ts +++ b/src/auth/login.ts @@ -1,9 +1,9 @@ // Login completion: turn a fresh Kratos session into our locally-verifiable // session JWT — the one moment Ory is on the path (README: Login → session JWT): // 1. whoami(cookie) → the identity (id, email); no active session ⇒ null -// 2. read roles from Keto → the source of truth for the `roles` claim +// 2. read permissions from Keto → the source of truth for the `permissions` claim // 3. project onto metadata_public (admin API) so the tokenizer's mapper can read them -// 4. whoami(tokenize_as) → the signed JWT { sub, email, roles }, stored as our cookie +// 4. whoami(tokenize_as) → the signed JWT { sub, email, permissions }, stored as our cookie // Order matters: the projection is written before tokenizing, because the claims mapper // reads only the identity, never Keto. import type { SessionIdentity } from "../http/context.ts"; @@ -34,26 +34,26 @@ export interface CompletedLogin { email: string | null; identityId: string; jwt: string; - roles: string[]; + permissions: string[]; } -// The coarse roles a user holds — directly (`Role:#members@identity:`) or transitively via a -// group that is a member of the role. Enumerates the defined roles (the distinct objects in the Role -// namespace) and asks Keto to resolve each membership, so a role granted to a group reaches the JWT — +// The coarse permissions a user holds — directly (`Permission:#members@identity:`) or transitively via a +// group that is a member of the permission. Enumerates the defined permissions (the distinct objects in the Permission +// namespace) and asks Keto to resolve each membership, so a permission granted to a group reaches the JWT — // matching the OPL model and the admin "Effective access" view. At login/refresh only, never per -// request; role count is small, so the per-role checks are cheap and run in parallel. -export async function readRoles(keto: KetoClient, identityId: string): Promise { +// request; permission count is small, so the per-permission checks are cheap and run in parallel. +export async function readPermissions(keto: KetoClient, identityId: string): Promise { const subject_id = `identity:${identityId}`; const names = new Set(); let pageToken: string | undefined; do { - const page = await keto.listRelations({ namespace: "Role", relation: "members", ...(pageToken ? { pageToken } : {}) }); + const page = await keto.listRelations({ namespace: "Permission", relation: "granted", ...(pageToken ? { pageToken } : {}) }); for (const t of page.tuples) names.add(t.object); pageToken = page.nextPageToken ?? undefined; } while (pageToken); - const roles = [...names]; - const held = await Promise.all(roles.map((object) => keto.check({ namespace: "Role", object, relation: "members", subject_id }))); - return roles.filter((_, i) => held[i]).sort(); + const permissions = [...names]; + const held = await Promise.all(permissions.map((object) => keto.check({ namespace: "Permission", object, relation: "granted", subject_id }))); + return permissions.filter((_, i) => held[i]).sort(); } export async function completeLogin(deps: LoginDeps, cookie: string | undefined): Promise { @@ -63,15 +63,15 @@ export async function completeLogin(deps: LoginDeps, cookie: string | undefined) const emailTrait = session.identity.traits?.["email"]; const email = typeof emailTrait === "string" ? emailTrait : null; - const roles = await readRoles(deps.keto, identityId); - await deps.kratosAdmin.updateMetadataPublic(identityId, { roles }); + const permissions = await readPermissions(deps.keto, identityId); + await deps.kratosAdmin.updateMetadataPublic(identityId, { permissions }); const tokenized = await deps.kratosPublic.whoami({ ...(cookie ? { cookie } : {}), tokenizeAs: TOKENIZE_AS }); const jwt = tokenized?.tokenized; if (!jwt) throw new Error("login completion: Kratos tokenizer returned no JWT"); - currentLog()?.info("session minted", { roles: roles.join(","), sub: identityId }); // login or TTL re-mint - return { email, identityId, jwt, roles }; + currentLog()?.info("session minted", { permissions: permissions.join(","), sub: identityId }); // login or TTL re-mint + return { email, identityId, jwt, permissions }; } export interface Reminted { @@ -80,14 +80,14 @@ export interface Reminted { } // Re-mint the session JWT on TTL expiry — "stay signed in" (README): the ~10m token lapsed but -// the long-lived Kratos session may still be live. A live session ⇒ re-read roles from Keto, +// the long-lived Kratos session may still be live. A live session ⇒ re-read permissions from Keto, // re-tokenize, fresh cookie + the refreshed user (the one moment authz recomputes). A dead // session ⇒ a cookie that *clears* the stale JWT, so later requests fall straight through to // anonymous instead of re-hitting Ory on every one. export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise { const completed = await completeLogin(deps, cookie); if (!completed) return { setCookie: clearSessionCookie(options), identity: null }; - return { setCookie: sessionCookie(completed.jwt, options), identity: { email: completed.email ?? "", id: completed.identityId, roles: completed.roles } }; + return { setCookie: sessionCookie(completed.jwt, options), identity: { email: completed.email ?? "", id: completed.identityId, permissions: completed.permissions } }; } // Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is diff --git a/src/auth/oauth-consent.ts b/src/auth/oauth-consent.ts index 104a6b7..47465f6 100644 --- a/src/auth/oauth-consent.ts +++ b/src/auth/oauth-consent.ts @@ -2,7 +2,7 @@ // /oauth2/consent?consent_challenge=… (hydra.yml urls.consent). A first-party client (or one // Hydra already skipped) is auto-granted the requested scopes; a third-party client shows the // themed consent screen, then accept (allow) / reject (deny). id_token claims (email/name) come -// from the Kratos identity. OAuth2-provider role only — no first-party page needs this (README). +// from the Kratos identity. OAuth2-provider permission only — no first-party page needs this (README). import type { AcceptConsent, ConsentRequest, HydraAdmin, OAuth2Client } from "./hydra-admin.ts"; import type { KratosPublic } from "./kratos-public.ts"; diff --git a/src/auth/oauth-login.ts b/src/auth/oauth-login.ts index a5bc789..cfb6d5b 100644 --- a/src/auth/oauth-login.ts +++ b/src/auth/oauth-login.ts @@ -2,7 +2,7 @@ // Hydra hands the browser to /oauth2/login?login_challenge=… (hydra.yml urls.login). We // authenticate the user with their existing Kratos session and accept the request; Hydra then // proceeds to consent and mints the tokens. No first-party page needs this — it's the OAuth2 -// provider role only (README). +// provider permission only (README). import type { HydraAdmin } from "./hydra-admin.ts"; import type { KratosPublic } from "./kratos-public.ts"; diff --git a/src/auth/routes.ts b/src/auth/routes.ts index c939903..35748e6 100644 --- a/src/auth/routes.ts +++ b/src/auth/routes.ts @@ -192,7 +192,7 @@ function oauthLogout(hydra: HydraAdmin): BuiltinRoute["handler"] { } // Login completion: where Kratos lands the browser after authenticating (kratos.yml). Mint our -// session JWT — read roles from Keto, project onto the identity, tokenize — and store it as the +// session JWT — read permissions from Keto, project onto the identity, tokenize — and store it as the // cookie; no active session bounces back to sign in. function completeAuth(deps: { keto: KetoClient; kratosAdmin: KratosAdmin; kratosPublic: KratosPublic }, secureCookies: boolean): BuiltinRoute["handler"] { return async (ctx: RequestContext): Promise => { diff --git a/src/config.ts b/src/config.ts index 4a51b67..682cd00 100644 --- a/src/config.ts +++ b/src/config.ts @@ -31,7 +31,7 @@ export interface Config { otlpEndpoint: string | undefined; // OTLP/HTTP collector base URI; unset ⇒ console-only (no export) otlpProtocol: "http/json" | "http/protobuf"; // OTLP wire format (protobuf for json-averse collectors) port: number; - revocationDenylist: boolean; // enable the optional instant role/session revoke denylist + revocationDenylist: boolean; // enable the optional instant permission/session revoke denylist revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew secureCookies: boolean; serviceName: string; // OTLP service.name — an implementer brands their own logs/traces @@ -157,7 +157,7 @@ export function loadConfig(env: Env = process.env): Config { otlpEndpoint: readOptionalUrl(env, "OTLP_ENDPOINT"), otlpProtocol: readEnum(env, "OTLP_PROTOCOL", ["http/json", "http/protobuf"] as const, "http/json"), port: readPort(env), - // Optional instant-revoke, off by default. When on, an admin deactivate/delete or role + // Optional instant-revoke, off by default. When on, an admin deactivate/delete or permission // change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m // tokenizer TTL + skew, so it outlasts any pre-revoke token). revocationDenylist: readBool(env, "REVOCATION_DENYLIST", false), diff --git a/src/http/app.test.ts b/src/http/app.test.ts index 6b50e02..6eac62c 100644 --- a/src/http/app.test.ts +++ b/src/http/app.test.ts @@ -40,9 +40,9 @@ function mintJwt(payload: Record): string { const input = `${b64url(JSON.stringify({ alg: "ES256", kid: "test-kid", typ: "JWT" }))}.${b64url(JSON.stringify(payload))}`; return `${input}.${b64url(sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key: ec.privateKey }))}`; } -// A session cookie carrying `roles`, valid for 10 min — the auth most tests need to reach a gated page. -const session = (roles: string[] = []): string => - `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, roles, sub: "u1" })}`; +// A session cookie carrying `permissions`, valid for 10 min — the auth most tests need to reach a gated page. +const session = (permissions: string[] = []): string => + `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, permissions, sub: "u1" })}`; const server = createApp({ jwks: staticJwks([ecJwk]) }); let base = ""; @@ -83,7 +83,7 @@ test("/ is the public landing: anonymous → 200 with intro + sign-in/register l const html = await res.text(); assert.match(html, /href="\/login"/); // a prominent path to sign in assert.match(html, /href="\/registration"/); // and to register - // the same app shell every page renders — the menu shows even when signed out (role-filtered). + // the same app shell every page renders — the menu shows even when signed out (permission-filtered). assert.match(html, /
Members of <%= permission.name %>
MemberTypeActions
<%= m.label %><%= m.kind === "group" ? "Group" : "User" %>