Rename the coarse gate from role to permission, matching RBAC

This commit is contained in:
2026-08-03 17:02:47 +02:00
parent 41c568796c
commit 925debbd51
79 changed files with 744 additions and 738 deletions
+9 -8
View File
@@ -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 `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 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 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. not `src/` code.
- **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the - **`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 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 running **building plugins** comes first, then **configuring and securing** the system
(Configuration, Auth); the **inner workings** (Architecture) and ops/runbooks are (Configuration, Auth); the **inner workings** (Architecture) and ops/runbooks are
deliberately deferred — they're not top of mind when starting out. Concretely: Overview → deliberately deferred — they're not top of mind when starting out. Concretely: Overview →
Users, groups & roles → Building plugins → menu/blocks/interactivity → Configuration → Auth → Identities, groups & permissions → Building plugins → menu/blocks/interactivity →
Email → Architecture → Testing → Production → Observability → the JWT-rotation runbook → the Configuration → Auth → Email → Architecture → Testing → Production → Observability → the
Project-layout file map → Extending. When adding a section, place it by this value (how JWT-rotation runbook → the Project-layout file map → Extending. When adding a section, place
early an adopter needs it), not by where it sits in the stack. 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 **Identities, groups & permissions precedes Building plugins** because a manifest's
unreadable without the model, and operators need it as much as plugin authors. It is the one `permission:` gate is unreadable without the model, and operators need it as much as plugin
home for that model — the plugin and auth sections link to it rather than restating it. 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 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 start); keep the ToC in sync when you add/rename/remove an `H2`/`H3`; and state each fact in
+1 -1
View File
@@ -190,4 +190,4 @@ export default definePlugin({
``` ```
Restart (`docker compose restart web`) and visit <http://localhost:3000/hello>. Views, Restart (`docker compose restart web`) and visit <http://localhost:3000/hello>. Views,
forms, roles, and the runnable reference plugin: repo README → Building plugins. forms, permissions, and the runnable reference plugin: repo README → Building plugins.
+146 -142
View File
@@ -28,14 +28,14 @@ docker compose up -d # http://localhost:3000, live-reloads on source chan
**`admin@plainpages.local` / `admin`**. **`admin@plainpages.local` / `admin`**.
**3. Enable user & group admin (optional).** The core ships **no admin GUI** — the Users / Groups **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 ```bash
cp -r examples/plugins/admin plugins/admin cp -r examples/plugins/admin plugins/admin
docker compose restart web 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/). See [`examples/plugins/admin/`](examples/plugins/admin/).
**4. Add your first plugin.** The clone is bind-mounted into the container, so a new **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) - [Overview](#overview)
- [how it compares](#how-it-compares) - [how it compares](#how-it-compares)
- [Users, groups & roles](#users-groups--roles) - [Identities, groups & permissions](#identities-groups--permissions)
- [a worked example](#a-worked-example) - [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) - [fine-grained, per-row access](#fine-grained-per-row-access)
- [Building plugins](#building-plugins) - [Building plugins](#building-plugins)
- [anatomy](#anatomy-of-a-plugin) - [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) - [landing pages](#the-landing-pages-home--dashboard)
- [RequestContext](#requestcontext) - [RequestContext](#requestcontext)
- [system capabilities (ctx.system)](#system-capabilities-the-ctxsystem-surface) - [system capabilities (ctx.system)](#system-capabilities-the-ctxsystem-surface)
- [nav & role gates](#nav--role-gates) - [nav & permission gates](#nav--permission-gates)
- [versioning](#contract-versioning) - [versioning](#contract-versioning)
- [conflict rules](#conflict-rules) - [conflict rules](#conflict-rules)
- [hooks](#hooks) - [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 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 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 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. 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 **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 - **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. 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 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 [`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 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 **Priorities (unchanged from day one):** **simplicity, few dependencies, strict
TypeScript, no build step, Docker-only, environment-agnostic** (no `NODE_ENV` — every 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 — 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 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 — 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 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 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. 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 Authorization here is two hops: an **identity** — directly, or through a **group** — is granted a
member of a **role**, and that role's *name* is exactly the string a plugin gates on. There is no **permission**, and that permission's *name* is exactly the string a plugin gates on.
separate "permission" object to define, register, or wire up.
- **Group** answers *who* — a reusable set of people. Optional: a role can be granted straight to a user. - **Group** answers *who* — a reusable set of people. Optional: a permission can be granted
- **Role** answers *what* — its **name is the string** you write in a manifest's `role:` gate. straight to an identity.
- **A relation tuple** is the grant: `Role:<name>#members@identity:<id>`, or `@Group:<name>#members`. - **Permission** answers *what* its **name is the string** you write in a manifest's
`permission:` gate.
- **A relation tuple** is the grant: `Permission:<name>#granted@identity:<id>`, or
`@Group:<name>#members`.
- **Resource** answers *which row* — a live check, run only where a plugin explicitly asks for it. - **Resource** answers *which row* — a live check, run only where a plugin explicitly asks for it.
| Entity | Lives in | Answers | Example | | Entity | Lives in | Answers | Example |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| **Identity** | Kratos | who you are | `identity:0198f2c1-…` | | **Identity** | Kratos | who you are | `identity:0198f2c1-…` |
| **Group** | Keto | who — a reusable set | `Group:support` | | **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` | | **Resource** | Keto | which specific row | `Resource:shift-4471` |
Identities live in Kratos; every authorization edge is a Keto relation tuple. The app itself 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 `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 `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 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 record. `Group`, `Permission` and `Resource` have no upstream counterpart to match, so they use
ordinary words. the ordinary words.
> **On the word "permission".** Ory uses it for the fine-grained `Resource` tier — the `permits` > **There is no `Role`.** In RBAC a permission is a single operation ("read shifts") and a role is
> block (`view`/`edit`/`delete`). Plainpages therefore never uses it for the coarse tier: what a > a *bundle* of them ("IT Support staff"). A route gates on one operation, so it gates on a
> route or a menu item gates on is a **role**, always. > **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 ### A worked example
Alice works support and leads scheduling; Bob works support; Carol administers the system. Alice works support and leads scheduling; Bob works support; Carol administers the system.
``` ```
people groups roles people groups permissions
────── ────── ───── ────── ────── ───────────
alice ──┬─────────> Group:support ────┐ alice ──┬─────────> Group:support ────┐
│ ├──> Group:staff ──> Role:scheduling:read │ ├──> Group:staff ──> Permission:scheduling:read
bob ────┘ │ 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 At login the host asks Keto which permissions the identity holds, walking those arrows
bakes the answer into the session JWT (see [Login and the session transitively, and bakes the answer into the session JWT (see [Login and the session
JWT](#login-and-the-session-jwt)): JWT](#login-and-the-session-jwt)):
``` ```
alice → roles: ["scheduling:read", "scheduling:write"] alice → permissions: ["scheduling:read", "scheduling:write"]
bob → roles: ["scheduling:read"] bob → permissions: ["scheduling:read"]
carol → roles: ["admin"] carol → permissions: ["admin"]
``` ```
Note what Carol does *not* have. **There is no role hierarchy and no superuser**`admin` is Note what Carol does *not* have. **Permissions do not nest, and there is no superuser**`admin`
just another name, granting nothing except where a route gates on `admin` itself. is just another name, granting nothing except where a route gates on `admin` itself.
Against the reference plugins' actual routes: 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 `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. 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 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 on the page they asked for; a signed-in user who merely lacks the permission gets the 403 page,
there is nothing to sign in *as* that would help. The menu is filtered by the same roles, so because there is nothing to sign in *as* that would help. The menu is filtered by the same
nobody is shown a door they cannot open. 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: Keto's write API directly:
```bash ```bash
# everyone in sched-leads may write shifts # everyone in sched-leads may write shifts
curl -X PUT http://keto:4467/admin/relation-tuples -H 'content-type: application/json' -d '{ 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" } "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 Permissions are authored **only in Keto** — nothing else writes them. Their names are a shared
namespace on purpose, so an operator grants `scheduling:read` once and every plugin referencing it global namespace on purpose, so an operator grants `scheduling:read` once and every plugin
is gated consistently; namespace yours as `<id>:<action>`. referencing it is gated consistently; namespace yours as `<id>:<action>`.
A change takes effect on the user's **next login or JWT re-mint** (~10 min) — see [Instant 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. revoke](#instant-revoke-the-optional-denylist) when you need it sooner.
### Fine-grained, per-row access ### Fine-grained, per-row access
The `Resource` namespace covers what a role cannot express: *this* row, shared with *this* person. The `Resource` namespace covers what a coarse permission cannot express: *this* row, shared with
Its permissions nest — `owner``editor` `viewer`. *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 **A per-row grant never widens a coarse gate.** The route's `permission` is checked *before* the
runs, so a user rejected there never reaches the check. Gate the route on something they hold, handler runs, so a user rejected there never reaches the check. Gate the route on something they
then narrow inside the handler: hold, then narrow inside the handler:
```ts ```ts
{ method: "POST", path: "/shifts/:id", role: READ, handler: editShift } { method: "POST", path: "/shifts/:id", permission: READ, handler: editShift }
async function editShift(ctx) { async function editShift(ctx) {
if (!(await check(keto, ctx, { namespace: "Resource", object: ctx.params.id, relation: "editors" }))) 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 source of truth; the sections below explain them, the guarantees around them, and the rules
the host enforces. A complete, runnable example lives in the host enforces. A complete, runnable example lives in
**[`examples/plugins/scheduling/`](examples/plugins/scheduling/)** — a public overview page, a **[`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 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 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 (`cp -r examples/plugins/scheduling plugins/scheduling`, then restart) — the dev compose already
points `SCHEDULING_UPSTREAM` at its mock backend. Copy it to `plugins/<id>/` and adapt. points `SCHEDULING_UPSTREAM` at its mock backend. Copy it to `plugins/<id>/` and adapt.
@@ -364,7 +368,7 @@ single `plugin.ts`.
must be **URL/path-safe** (`isValidPluginId`: lowercase `az`, digits, and dashes — dashes must be **URL/path-safe** (`isValidPluginId`: lowercase `az`, digits, and dashes — dashes
anywhere; no uppercase, underscores, dots, or slashes); the host rejects a malformed folder name 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/<id>/` assets, and (by at discovery. The id also namespaces the plugin's `views/`, its `/public/<id>/` 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 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`), Kratos auth flows (`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`),
@@ -396,20 +400,20 @@ import { listThings, createThings } from "./handlers.ts";
export default definePlugin({ export default definePlugin({
apiVersion: "1.0.0", // semver string of the host contract this plugin was built against (see Versioning) 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). // `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. // Permissions this plugin gates on. Optional — see Nav & permission gates.
roles: [ permissions: [
{ description: "View things", name: "things:read" }, { description: "View things", name: "things:read" },
{ description: "Create and edit things", name: "things:write" }, { 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: [ routes: [
{ method: "GET", path: "/", role: "things:read", handler: listThings }, { method: "GET", path: "/", permission: "things:read", handler: listThings },
{ method: "POST", path: "/", role: "things:write", handler: createThings }, { 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). | | `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). | | `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. | | `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). | | `routes` | no | See [Routes & handlers](#routes--handlers). |
| `hooks` | no | See [Hooks](#hooks). | | `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 ### 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 `/<id>`** (so `path: "/:id"` in the `things` plugin serves `/things/:id`); the host mount path `/<id>`** (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`, 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 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 **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 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 A route marked **`public: true`** has no gate at all — anyone reaches it (see [Public pages & menu
items](#public-pages--menu-items)). 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 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/<id>/x.css` href in the shell's `styles` slot (an array of plugin's own CSS, pass its `/public/<id>/x.css` href in the shell's `styles` slot (an array of
extra stylesheet hrefs) — see the reference's `views/shifts.ejs`. 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 `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 {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` signed-in user, anonymous ⇒ denied). Throw `new GuardError(403, …)` after a failed `can`/`check`
to render the 403 page. 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. points there.
For the gated `dashboard`, the host enforces the session gate first, so `ctx.identity` is non-null; 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 branch on `ctx.permissions` *inside* to tailor the page per permission. 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 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`.) 403. (Both slots answer `GET` and `HEAD`.)
Only **one** plugin may own each slot: two declaring `home` (or two declaring `dashboard`) is a Only **one** plugin may own each slot: two declaring `home` (or two declaring `dashboard`) is a
@@ -557,13 +561,13 @@ request:
```ts ```ts
interface RequestContext { interface RequestContext {
chrome: PageChrome; // brand/global-nav/user/theme/csrf for the native app shell 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 log: Log; // request-scoped logger, in this request's trace
params: Record<string, string>; // path params from the route match, e.g. /things/:id → { id } params: Record<string, string>; // path params from the route match, e.g. /things/:id → { id }
query: URLSearchParams; // alias of url.searchParams query: URLSearchParams; // alias of url.searchParams
req: IncomingMessage; req: IncomingMessage;
res: ServerResponse; 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 system?: SystemCapabilities; // privileged Ory clients + instant-revoke, for a system plugin (see below); undefined unless the host wired them
url: URL; url: URL;
verifyCsrf(submitted): boolean; // gate a form POST against the request's signed CSRF cookie 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 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 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 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 request (the gated **Dashboard** link is omitted for an
anonymous visitor). `chrome.signInHref` is where the shell's anonymous **Sign in** link points — the 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 — 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/`.) 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 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 A page that wants a focused, chrome-free layout passes **`menu: false`** to `partials/shell` (drops the
sidebar, single column); everything else still renders. 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 **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 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, 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. from the JWT middleware and are `null`/`[]` until a session exists.
### System capabilities (the `ctx.system` surface) ### 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 ```ts
interface SystemCapabilities { // every field optional — present only when the host wired it interface SystemCapabilities { // every field optional — present only when the host wired it
hydra?: HydraAdmin; // OAuth2 client admin (register/list/delete Hydra clients) 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) kratosAdmin?: KratosAdmin; // identity admin (create/edit/deactivate/delete users)
revoke?: (sub: string) => void; // instant-revoke a subject's live tokens (needs the denylist) 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 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 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 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`, 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 role-change calls 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 `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. 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 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. 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 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 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 `role`, or the user's roles include that name. Use 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 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 **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. 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 #### Public pages & menu items
A route or nav node may be marked **`public: true`** — reachable by **anyone, signed in or not**, 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 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. 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 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) `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 in place of the profile/sign-out block, the gated **Dashboard** link is hidden, and `ctx.permissions` is
empty (read a role with `can(ctx, …)` to branch). The reference plugin's `/scheduling` 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, **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`. 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 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 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. without editing host config.
### Contract versioning ### 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 `/<id>` prefix is unique), so this catches a plugin duplicating one of its own. | | `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/<id>` 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. | | `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)). | | `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 `<id>:<action>` if unintended. | | `permission` | warn | A permission name is declared by more than one plugin. Sharing is legitimate; namespace as `<id>:<action>` if unintended. |
There is **no separate `basePath` rule**: the mount path is the derived `/<id>`, so its There is **no separate `basePath` rule**: the mount path is the derived `/<id>`, 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. 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 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 non-array `nav`/`routes`/`permissions`, a non-function `home`/`dashboard`, or a route/nav node that
sets both `public` and `role` (mutually exclusive — [Public pages](#public-pages--menu-items)). sets both `public` and `permission` (mutually exclusive — [Public pages](#public-pages--menu-items)).
### Hooks ### 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 > 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 > 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 > plugin stops startup with a precise message. The router (`src/plugin-host/router.ts`) then mounts
> each route at `/<id>`, resolves `:name` params, runs the role gate, and turns the > each route at `/<id>`, resolves `:name` params, runs the permission gate, and turns the
> handler's `RouteResult` into the response; a `view` result renders > handler's `RouteResult` into the response; a `view` result renders
> `plugins/<id>/views/<view>.ejs` (`src/plugin-host/view-resolver.ts`), which may `include()` the core > `plugins/<id>/views/<view>.ejs` (`src/plugin-host/view-resolver.ts`), which may `include()` the core
> building-block partials. A plugin's `public/` assets are served at `/public/<id>/` > building-block partials. A plugin's `public/` assets are served at `/public/<id>/`
@@ -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 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` `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). 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 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"] } }); 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 Every nav item may carry a `permission`; the rendered tree is **filtered per user** by
reading the roles in the session JWT (no per-request authz call — see 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 [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: 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 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 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, 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 — 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 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` - **Helpers:** `composeNav` (menu from config), `parseListQuery`
(`?q=…&status=…&sort=…&page=…` → filter/sort/pagination), `paginate` (page math), and the (`?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 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 JWT-claim check, zero I/O), `check(relation, object)` (the one live Keto call, for
relationship rules). 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_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`) | | `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 | | `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 | | `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` | | `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)). [Social sign-in (SSO)](#social-sign-in-sso)).
Everything else is generated or seeded on first boot — Ory migrations, the dev signing key, 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. hand-configure.
### Social sign-in (SSO) ### 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) ────────── ── AT LOGIN / REFRESH (the only time Ory is on the path) ──────────
Kratos verifies credentials 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) └─► app writes them as a derived projection on the identity (admin API)
└─► whoami(tokenize_as: "plainpages") ─► signed JWT └─► 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 └─► stored as the session cookie
── EVERY REQUEST (hot path — pure CPU, no I/O) ─────────────────── ── EVERY REQUEST (hot path — pure CPU, no I/O) ───────────────────
Browser ─cookie(JWT)─► web : verify signature (cached JWKS) Browser ─cookie(JWT)─► web : verify signature (cached JWKS)
read claims.roles read claims.permissions
filter menu · gate routes filter menu · gate routes
``` ```
**Keto is the single source of truth for roles.** Coarse roles are Keto relations (e.g. **Keto is the single source of truth for permissions.** Coarse permissions are Keto relations (e.g.
`Role:admin#members@identity:alice`); the admin screens write them *only* to Keto. But the `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 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 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 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 per-login cache, authoritative nowhere; nothing edits it by hand, and a stale one self-heals
on the next login. on the next login.
A role can be granted to a user directly or to a **group** the user belongs to; login A permission 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 resolves both (enumerate the defined permissions, ask Keto to resolve each membership), so the JWT
`roles` match what the admin **Effective access** view shows. `permissions` match what the admin **Effective access** view shows.
Cost: **a handful of Keto reads + one identity refresh per login** — never per request. JWKS 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 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 This design buys an I/O-free hot path that scales to **tens of thousands of concurrent
users** on modest hardware. In return: users** on modest hardware. In return:
- **Role changes lag by up to one TTL (~10m).** Gating reads the JWT, not Keto, so a granted - **Permission 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). 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 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 we traded away. For instant revoke, turn on the optional
[revocation denylist](#instant-revoke-the-optional-denylist) — it closes the gap for [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 ### Instant revoke: the optional denylist
Off by default; turn it on with `REVOCATION_DENYLIST=true` (`src/auth/denylist.ts`). For 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 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 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 re-reads permissions 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 *after* the revoke) passes, so a permission downgrade lands immediately without locking the
account. account.
It's an in-memory, auto-evicting map — no database, like the JWKS cache, so it stays inside 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 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), 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 — 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?" ### 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. **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 is for. Reserve it for those; don't pay its tuple-sync cost for rules a service can already
answer from its own data. answer from its own data.
The admin plugin's users / groups / roles screens write authorization **only to Keto** — coarse The admin plugin's users / groups / permissions screens write authorization **only to Keto** — coarse
roles and fine-grained relationships alike. permissions and fine-grained relationships alike.
### OAuth2 provider (Hydra) ### 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. 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 **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. 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' **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. 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, **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 token TTL rather than immediately. With the
[denylist](#instant-revoke-the-optional-denylist) on (it is off by default), both take effect at [denylist](#instant-revoke-the-optional-denylist) on (it is off by default), both take effect at
once, on the instance that handled the change. 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 [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). 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 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: 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. | | `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. | | `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 `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 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 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 & 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 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 **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 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 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`. (`examples/plugins/scheduling/`) onto `/app/plugins/scheduling`.
```bash ```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 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 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 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**. cookie is **cleared**.
```bash ```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: **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, 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 **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 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 Kratos on one host (`ory/kratos/e2e-proxy.yml` points Kratos at it) — exactly as a production
reverse proxy would. 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 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. gate runs them serially, one stack up/down per suite.
## CI/CD ## 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. mid-response, so container restarts are clean.
The first-boot **bootstrap** is idempotent and runs on every `up` — it generates the JWT 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 signing key if absent, creates the demo admin in Kratos, and grants it the `admin` permission plus
every discovered plugin's declared role names in Keto, so role checks (and any 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 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.** *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 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 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 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. 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 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.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) 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 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 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 role gate; GuardError → 303 /login or 403; check() is the one live Keto "may I?" call 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 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) 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 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-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 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 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-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) 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 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 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) 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 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 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 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/<id>/views/<view>.ejs; plugin views can include() core partials view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.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 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) 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) 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 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) 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 } 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 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) 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 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 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) 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-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 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`) 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; .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 mirror.yml — force-sync main + tags to the GitHub mirror; see CI/CD
+3 -3
View File
@@ -130,9 +130,9 @@ services:
environment: environment:
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local} ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
# Base roles for the demo admin; bootstrap also grants every discovered plugin's declared # Base permissions 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). # permission names (so the reference plugin — and any drop-in — works out of the box).
ADMIN_ROLES: ${ADMIN_ROLES:-admin} ADMIN_PERMISSIONS: ${ADMIN_PERMISSIONS:-admin}
APP_URL: ${APP_URL:-http://localhost:3000} # printed in the first-run login banner APP_URL: ${APP_URL:-http://localhost:3000} # printed in the first-run login banner
JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json
KETO_WRITE_URL: http://keto:4467 KETO_WRITE_URL: http://keto:4467
+6 -6
View File
@@ -9,7 +9,7 @@ import { expect, test } from "@playwright/test";
const WEB = process.env.BASE_URL ?? "http://web:3000"; const WEB = process.env.BASE_URL ?? "http://web:3000";
const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433"; const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433";
const KRATOS_ADMIN = process.env.KRATOS_ADMIN_URL ?? "http://kratos:4434"; 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 ADMIN_PASSWORD = "admin";
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms)); const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
@@ -29,8 +29,8 @@ function relayCookies(res: Response): string {
.filter((kv) => kv.split("=")[1] !== "") .filter((kv) => kv.split("=")[1] !== "")
.join("; "); .join("; ");
} }
// Read a JWT's claims without verifying (web already verified it; we only inspect exp/roles). // Read a JWT's claims without verifying (web already verified it; we only inspect exp/permissions).
function jwtClaims(jwt: string): { email: string; exp: number; roles: string[]; sub: string } { function jwtClaims(jwt: string): { email: string; exp: number; permissions: string[]; sub: string } {
return JSON.parse(Buffer.from(jwt.split(".")[1]!, "base64url").toString()); return JSON.parse(Buffer.from(jwt.split(".")[1]!, "base64url").toString());
} }
@@ -72,7 +72,7 @@ async function awaitJwtSetCookie(session: string, jwt: string): Promise<string>
test("an expired session JWT is silently re-minted while Kratos lives, then cleared once it dies", async () => { 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 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 session = await kratosLogin();
const complete = await fetch(`${WEB}/auth/complete`, { headers: { cookie: `plainpages_session=${session}` }, redirect: "manual" }); const complete = await fetch(`${WEB}/auth/complete`, { headers: { cookie: `plainpages_session=${session}` }, redirect: "manual" });
expect(complete.status, "auth/complete redirects home").toBe(303); 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); const claims1 = jwtClaims(jwt1);
expect(claims1.email).toBe(ADMIN_EMAIL); expect(claims1.email).toBe(ADMIN_EMAIL);
expect(claims1.sub, "sub is the Kratos identity id").toBeTruthy(); 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. // 2. Token timeout → refresh: once the 8s TTL lapses, the next request re-mints a fresh JWT.
const jwt2Line = await awaitJwtSetCookie(session, jwt1); 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); expect(jwt2, "a different token was minted").not.toBe(jwt1);
const claims2 = jwtClaims(jwt2); const claims2 = jwtClaims(jwt2);
expect(claims2.exp, "the new token expires later").toBeGreaterThan(claims1.exp); 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. // 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" }); const revoke = await fetch(`${KRATOS_ADMIN}/admin/identities/${claims1.sub}/sessions`, { method: "DELETE" });
+2 -2
View File
@@ -1,5 +1,5 @@
# Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO # 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 # 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. # 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 # 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/scheduling:/app/plugins/scheduling:ro
- ./examples/plugins/admin:/app/plugins/admin: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. # example plugins present too — else the admin lacks scheduling:read/write and the gated pages 403.
bootstrap: bootstrap:
volumes: volumes:
+9 -9
View File
@@ -9,7 +9,7 @@ import { randomUUID } from "node:crypto";
// journey and the standalone SSO test run in parallel (fullyParallel) but stay independent: each // 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 // 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. // (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 ADMIN_PASSWORD = "admin";
const SSO_EMAIL = "sso-user@plainpages.local"; // minted by the mock OIDC provider on first SSO login 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 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.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 // 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). // in the menu (collapsed by default → assert they're in the DOM, not necessarily visible).
await page.goto("/dashboard"); await page.goto("/dashboard");
@@ -65,7 +65,7 @@ test.describe.serial("authenticated admin journey", () => {
await expect(page.locator("tr", { hasText: email })).toHaveCount(0); 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 // 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. // enforces it); pick the first option (a user) from the required picker.
const group = `e2e-grp-${suffix}`; const group = `e2e-grp-${suffix}`;
@@ -76,13 +76,13 @@ test.describe.serial("authenticated admin journey", () => {
await expect(page).toHaveURL(/\/admin\/groups(\?|\/|$)/); await expect(page).toHaveURL(/\/admin\/groups(\?|\/|$)/);
await expect(page.locator("main")).toContainText(group); await expect(page.locator("main")).toContainText(group);
const role = `e2e-role-${suffix}`; const permission = `e2e-permission-${suffix}`;
await page.goto("/admin/roles/new"); await page.goto("/admin/permissions/new");
await page.fill('input[name="name"]', role); await page.fill('input[name="name"]', permission);
await page.locator('select[name="member"]').selectOption({ index: 1 }); await page.locator('select[name="member"]').selectOption({ index: 1 });
await page.locator('.form-card button[type="submit"]').click(); await page.locator('.form-card button[type="submit"]').click();
await expect(page).toHaveURL(/\/admin\/roles(\?|\/|$)/); await expect(page).toHaveURL(/\/admin\/permissions(\?|\/|$)/);
await expect(page.locator("main")).toContainText(role); 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 () => { 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(); await page.locator(".sso-btn").click();
// Mock OIDC auto-approves → Kratos creates the identity → /auth/complete → dashboard, signed in. // Mock OIDC auto-approves → Kratos creates the identity → /auth/complete → dashboard, signed in.
await expect(page.locator(".profile-mail")).toHaveText(SSO_EMAIL); 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); await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0);
}); });
+6 -6
View File
@@ -13,19 +13,19 @@ const shot = (page: Page, name: string): Promise<Buffer> =>
// Sign a session JWT with the committed dev tokenizer key (bind-mounted at /repo/jwks.json), so the // 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 // 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. // 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 jwk = JSON.parse(readFileSync("/repo/jwks.json", "utf8")).keys[0];
const key = createPrivateKey({ format: "jwk", key: jwk }); const key = createPrivateKey({ format: "jwk", key: jwk });
const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url"); const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url");
const now = Math.floor(Date.now() / 1000); 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")}`; return `${input}.${sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key }).toString("base64url")}`;
} }
test.beforeAll(async () => { await mkdir(SHOTS, { recursive: true }); }); test.beforeAll(async () => { await mkdir(SHOTS, { recursive: true }); });
// The dashboard is gated: a page navigation needs a session. Plant one per test — a plain // 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 }) => { test.beforeEach(async ({ context }) => {
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]); 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 context.clearCookies(); // visit "/" as a logged-out visitor (drop the beforeEach session)
await page.goto("/"); await page.goto("/");
await expect(page.locator(".landing")).toBeVisible(); 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.locator(".sidebar")).toBeVisible();
await expect(page.getByRole("link", { name: "Log in" })).toHaveAttribute("href", "/login"); await expect(page.getByRole("link", { name: "Log in" })).toHaveAttribute("href", "/login");
await expect(page.getByRole("link", { name: "Create account" })).toHaveAttribute("href", "/registration"); 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 // 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 // so an anonymous visitor is bounced to sign in. The authenticated list/form flow is the full
// E2E (full-flow.spec). Side-effect-free. // 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 }) => { 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.status()).toBe(303);
expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts"); 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. // but the gated Shifts leaf is filtered out.
await page.goto("/dashboard"); await page.goto("/dashboard");
await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders
+2 -2
View File
@@ -5,7 +5,7 @@ across (or bind-mount your own) and restart.
| Path | Copy into | Example of | | 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/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 / 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/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). | | [`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. | | [`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. |
+2 -2
View File
@@ -3,7 +3,7 @@
// config/ ships empty; mount your own or copy this in. Absent config = built-in defaults. // 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 — // 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. // 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). // 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. // Operator override (rename → group → order → hide), keyed by node id.
override: { override: {
// rename: { people: "Staff" }, // node id → new label // 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 // order: ["people", "reports"], // top-level order by id
// hide: ["teams"], // remove nodes (any depth) // hide: ["teams"], // remove nodes (any depth)
}, },
+8 -8
View File
@@ -1,6 +1,6 @@
# Admin — the system-administration plugin # 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 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 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: screens live at `/admin/*`) and restart:
@@ -10,7 +10,7 @@ cp -r examples/plugins/admin plugins/admin
docker compose restart web 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. menu and the screens work immediately.
## What it demonstrates — a *system* plugin ## 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: 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.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.hydra`** — register/list/delete Ory Hydra OAuth2 clients.
- **`ctx.system.revoke(sub)`** — the optional instant-revoke hook: a deactivate/delete or a - **`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, `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 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, 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 ## Layout
- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` role, and the - `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 `role: "admin"`. route table — one thin handler per method+path, all gated by `permission: "admin"`.
- `admin-users.ts` · `admin-groups.ts` · `admin-roles.ts` · `admin-clients.ts` — each a set of pure - `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 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 `ctx.params` (the host extracts `:id`/`:name`), sharing a small `withX` wrapper that resolves the
admin gate + the needed `ctx.system` clients once. admin gate + the needed `ctx.system` clients once.
@@ -1,34 +1,34 @@
// Built-in Roles admin screen: the pure view-model + Keto builders. A role is a // Built-in Roles admin screen: the pure view-model + Keto builders. A permission is a
// Keto subject set (Role:<name>#members); members are users (subject_id) or groups (subject_set) — // Keto subject set (Permission:<name>#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 // "assign permissions 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 // 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. // routing/gate/CSRF + live Keto/Kratos calls are exercised over HTTP in app.test.ts.
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { memberView } from "./admin-groups.ts"; import { memberView } from "./admin-groups.ts";
import { import {
buildRoleDetailModel, buildPermissionDetailModel,
buildRoleFormModel, buildPermissionFormModel,
buildRolesListModel, buildPermissionsListModel,
expandToEffectiveUsers, expandToEffectiveUsers,
isValidRoleName, isValidRoleName,
roleMemberTuple, permissionGrantTuple,
} from "./admin-roles.ts"; } from "./admin-permissions.ts";
import type { ExpandTree, RelationTuple } from "#plugin-api"; import type { ExpandTree, RelationTuple } from "#plugin-api";
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`; const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
const userTuple = (role: string, n: number): RelationTuple => const userTuple = (permission: string, n: number): RelationTuple =>
({ namespace: "Role", object: role, relation: "members", subject_id: `identity:${uid(n)}` }); ({ namespace: "Permission", object: permission, relation: "granted", subject_id: `identity:${uid(n)}` });
const groupTuple = (role: string, group: string): RelationTuple => const groupTuple = (permission: string, group: string): RelationTuple =>
({ namespace: "Role", object: role, relation: "members", subject_set: { namespace: "Group", object: group, relation: "members" } }); ({ 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 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); 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(permissionGrantTuple("editor", `identity:${uid(2)}`), { namespace: "Permission", object: "editor", relation: "granted", 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" } }); 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(roleMemberTuple("editor", bad), null, bad); 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", () => { 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", 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", type: "union",
}; };
assert.deepEqual(expandToEffectiveUsers(tree), [uid(1), uid(2)]); assert.deepEqual(expandToEffectiveUsers(tree), [uid(1), uid(2)]);
assert.deepEqual(expandToEffectiveUsers(null), []); 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", () => { test("buildPermissionsListModel 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")}` })); 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.pagination.summary.total, 30);
assert.equal(all.table.rows.length, 25); // default page size 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 } }; 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.text, "permission-00");
assert.equal(first.rowHeader.href, "/admin/roles/role-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.equal(one.pagination.summary.total, 1);
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]); assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
const desc = buildRolesListModel({ roles, url: "http://x/admin/roles?sort=-members" }); 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, "role-29"); 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 options = [{ label: "ada@example.com", value: `identity:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
const m = buildRoleFormModel({ csrfToken: "tok.sig", memberOptions: options }); const m = buildPermissionFormModel({ csrfToken: "tok.sig", memberOptions: options });
assert.equal(m.title, "New role"); assert.equal(m.title, "New permission");
assert.equal(m.form.action, "/admin/roles"); assert.equal(m.form.action, "/admin/permissions");
assert.equal(m.form.submitLabel, "Create role"); assert.equal(m.form.submitLabel, "Create permission");
assert.equal(m.form.csrfToken, "tok.sig"); assert.equal(m.form.csrfToken, "tok.sig");
assert.equal(m.form.nameField.required, true); assert.equal(m.form.nameField.required, true);
assert.deepEqual(m.form.memberOptions, options); 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.error, "That name is taken.");
assert.equal(err.form.nameField.value, "Admin"); assert.equal(err.form.nameField.value, "Admin");
assert.equal(err.form.selectedMember, "group:eng"); 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 members = [memberView(userTuple("admin", 1), new Map([[uid(1), "ada@example.com"]])), memberView(groupTuple("admin", "eng"), new Map())];
const candidates = [ const candidates = [
{ label: "ada@example.com", value: `identity:${uid(1)}` }, // already a member → excluded { 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" }, { label: "ops (group)", value: "group:ops" },
]; ];
const effective = [{ label: "ada@example.com" }, { label: "grace@example.com" }]; // ada direct, grace via eng 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.title, "admin");
assert.equal(m.members.rows.length, 2); assert.equal(m.members.rows.length, 2);
assert.equal(m.members.action, "/admin/roles/admin/members/delete"); assert.equal(m.members.action, "/admin/permissions/admin/members/delete");
assert.equal(m.add.action, "/admin/roles/admin/members"); 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.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.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");
}); });
@@ -1,15 +1,15 @@
// Roles admin screen: list / create / delete Keto roles and assign // Permissions admin screen: list / create / delete Keto permissions and assign
// them to users and groups. A role is a Keto subject set `Role:<name>#members` (OPL: members are users // them to users and groups. A permission is a Keto subject set `Permission:<name>#members` (OPL: members are users
// or groups, resolved transitively) — the source of truth for the JWT `roles` claim. It shares the // 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) // 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: // are reused from admin-groups. The permission-specific piece is the **effective access** view:
// `keto.expand(Role:<name>#members)` flattened to the distinct users who hold the role directly or via // `keto.expand(Permission:<name>#members)` flattened to the distinct users who hold the permission directly or via
// a group — matching what login projects into the JWT (login.ts readRoles). Writes go only to Keto; // 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 // 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. // 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 { 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 { import {
type GroupView, type GroupView,
groupsFromTuples, groupsFromTuples,
@@ -23,29 +23,29 @@ import {
} from "./admin-groups.ts"; } from "./admin-groups.ts";
import type { FieldConfig } from "./admin-users.ts"; import type { FieldConfig } from "./admin-users.ts";
const ROLE_NS = "Role"; const PERMISSION_NS = "Permission";
const MEMBERS = "members"; const GRANTED = "granted";
const DEFAULT_PAGE_SIZE = 25; const DEFAULT_PAGE_SIZE = 25;
const PAGE_SIZES = [25, 50, 100]; const PAGE_SIZES = [25, 50, 100];
// Expand far past any sane group-nesting depth so the effective-access view never silently // 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). // under-reports the deepest members (Keto's own default is shallow).
const EXPAND_MAX_DEPTH = 50; const EXPAND_MAX_DEPTH = 50;
// A role and a group share the URL-safe name rule and the user|group membership model. // A permission and a group share the URL-safe name rule and the user|group membership model.
export type RoleView = GroupView; export type PermissionView = GroupView;
export const isValidRoleName = isValidGroupName; export const isValidRoleName = isValidGroupName;
export const rolesFromTuples = groupsFromTuples; export const permissionsFromTuples = groupsFromTuples;
export interface EffectiveUser { export interface EffectiveUser {
label: string; // email (or the raw id when unresolved) 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). // The full membership tuple for assigning/revoking `value` to/from `permission` (null if value is invalid).
export function roleMemberTuple(role: string, value: string): RelationTuple | null { export function permissionGrantTuple(permission: string, value: string): RelationTuple | null {
const subject = parseSubject(value); 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 // (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 // node's `tuple`; subject-set nodes (the groups) contribute nothing directly — their members
// surface as leaves under them. // surface as leaves under them.
@@ -70,17 +70,17 @@ interface ListState {
sort: string | null; sort: string | null;
} }
const SORT: Record<string, (r: RoleView) => number | string> = { const SORT: Record<string, (r: PermissionView) => number | string> = {
members: (r) => r.memberCount, members: (r) => r.memberCount,
name: (r) => r.name, name: (r) => r.name,
}; };
const COLUMNS = [ const COLUMNS = [
{ key: "name", label: "Role" }, { key: "name", label: "Permission" },
{ key: "members", label: "Members" }, { key: "members", label: "Members" },
]; ];
function detailHref(name: string): string { function detailHref(name: string): string {
return `${ADMIN_ROLES_BASE}/${encodeURIComponent(name)}`; return `${ADMIN_PERMISSIONS_BASE}/${encodeURIComponent(name)}`;
} }
function listHref(state: ListState, overrides: Partial<ListState> = {}): string { function listHref(state: ListState, overrides: Partial<ListState> = {}): string {
@@ -91,12 +91,12 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
if (s.page > 1) p.set("page", String(s.page)); if (s.page > 1) p.set("page", String(s.page));
if (s.pageSize !== DEFAULT_PAGE_SIZE) p.set("pageSize", String(s.pageSize)); if (s.pageSize !== DEFAULT_PAGE_SIZE) p.set("pageSize", String(s.pageSize));
const qs = p.toString(); 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; csrfToken?: string;
roles: RoleView[]; permissions: PermissionView[];
url: URL | URLSearchParams | string; url: URL | URLSearchParams | string;
}) { }) {
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE }); 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 sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
const needle = query.q.toLowerCase(); 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) { if (sort) {
const get = SORT[sort.field]!; const get = SORT[sort.field]!;
const dir = sort.dir === "desc" ? -1 : 1; 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 }; const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken };
return { return {
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Admin" }, { label: "Roles" }], breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Admin" }, { label: "Permissions" }],
filterBar: listFilterBar(state), filterBar: listFilterBar(state),
pagination: listPagination(state, page), pagination: listPagination(state, page),
table: listTable(rows, state, sort), 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 { return {
caption: "Roles", caption: "Permissions",
columns: COLUMNS.map((c) => { columns: COLUMNS.map((c) => {
const dir = sort && sort.field === c.key ? sort.dir : undefined; const dir = sort && sort.field === c.key ? sort.dir : undefined;
const next = dir === "asc" ? `-${c.key}` : c.key; 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 }); if (state.q) pills.push({ label: "Search", remove: listHref(state, { page: 1, q: "" }), value: state.q });
return { return {
applyLabel: "Apply", applyLabel: "Apply",
clearHref: ADMIN_ROLES_BASE, clearHref: ADMIN_PERMISSIONS_BASE,
label: "Filter roles", label: "Filter permissions",
pills, pills,
rows: [[ 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" }, { type: "spacer" },
]], ]],
}; };
@@ -178,7 +178,7 @@ function listPagination(state: ListState, page: ReturnType<typeof paginate>) {
// ---- create form + detail view models ---- // ---- create form + detail view models ----
export function buildRoleFormModel(opts: { export function buildPermissionFormModel(opts: {
csrfToken?: string; csrfToken?: string;
error?: string; error?: string;
memberOptions: MemberOption[]; memberOptions: MemberOption[];
@@ -186,69 +186,69 @@ export function buildRoleFormModel(opts: {
}) { }) {
const nameField: FieldConfig = { const nameField: FieldConfig = {
autocomplete: "off", hint: "Lowercase letters, digits, dashes and underscores.", icon: "i-shield", 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 { return {
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: "New" }], breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Permissions" }, { label: "New" }],
error: opts.error, error: opts.error,
form: { form: {
action: ADMIN_ROLES_BASE, action: ADMIN_PERMISSIONS_BASE,
cancelHref: ADMIN_ROLES_BASE, cancelHref: ADMIN_PERMISSIONS_BASE,
csrfToken: opts.csrfToken ?? "", csrfToken: opts.csrfToken ?? "",
memberOptions: opts.memberOptions, memberOptions: opts.memberOptions,
nameField, nameField,
selectedMember: opts.values?.member ?? "", 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[]; candidates: MemberOption[];
csrfToken?: string; csrfToken?: string;
effective: EffectiveUser[]; effective: EffectiveUser[];
error?: string; error?: string;
members: MemberView[]; members: MemberView[];
role: { name: string }; permission: { name: string };
}) { }) {
const name = opts.role.name; const name = opts.permission.name;
const base = detailHref(name); const base = detailHref(name);
const taken = new Set(opts.members.map((m) => m.subject)); 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 { return {
add: { action: `${base}/members`, options }, 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 ?? "", csrfToken: opts.csrfToken ?? "",
delete: { action: `${base}/delete` }, delete: { action: `${base}/delete` },
effective: opts.effective, effective: opts.effective,
error: opts.error, error: opts.error,
members: { action: `${base}/members/delete`, rows: opts.members }, members: { action: `${base}/members/delete`, rows: opts.members },
role: { name }, permission: { name },
title: name, title: name,
}; };
} }
// ---- request handler (imperative shell) ---- // ---- request handler (imperative shell) ----
// instant-revoke: a role change for a `identity:<id>` member must take effect now, so revoke that // instant-revoke: a permission change for a `identity:<id>` member must take effect now, so revoke that
// user's live tokens (a re-mint then re-reads roles from Keto). A `group:<name>` change is // user's live tokens (a re-mint then re-reads permissions from Keto). A `group:<name>` change is
// transitive across many users — left to lag (documented), so only direct user members revoke. // transitive across many users — left to lag (documented), so only direct user members revoke.
function revokeUserMember(revoke: ((sub: string) => void) | undefined, member: string): void { function revokeUserMember(revoke: ((sub: string) => void) | undefined, member: string): void {
if (revoke && member.startsWith("identity:")) revoke(member.slice("identity:".length)); 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<boolean> { async function roleExists(keto: KetoClient, name: string): Promise<boolean> {
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; return page.tuples.length > 0;
} }
// The distinct users who effectively hold the role (expand → flatten → label by email). Skipped for // The distinct users who effectively hold the permission (expand → flatten → label by email). Skipped for
// an empty role (no member tuples) so we don't expand a non-existent Keto object. // 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<string, string>): Promise<EffectiveUser[]> { async function effectiveUsers(keto: KetoClient, name: string, hasMembers: boolean, emailById: Map<string, string>): Promise<EffectiveUser[]> {
if (!hasMembers) return []; 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) return expandToEffectiveUsers(tree)
.map((id) => ({ label: emailById.get(id) ?? `identity:${id}` })) .map((id) => ({ label: emailById.get(id) ?? `identity:${id}` }))
.sort((a, b) => a.label.localeCompare(b.label)); .sort((a, b) => a.label.localeCompare(b.label));
@@ -268,7 +268,7 @@ function withRoles(inner: (deps: RolesDeps) => Promise<RouteResult>): 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<RouteResult>): RouteHandler { function withRoleName(inner: (deps: RolesDeps, name: string) => Promise<RouteResult>): RouteHandler {
return withRoles((deps) => { return withRoles((deps) => {
const name = deps.ctx.params["name"] ?? ""; const name = deps.ctx.params["name"] ?? "";
@@ -279,89 +279,89 @@ function withRoleName(inner: (deps: RolesDeps, name: string) => Promise<RouteRes
const roleFormResult = async (deps: RolesDeps, extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => { const roleFormResult = async (deps: RolesDeps, extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
const { options } = await memberCandidates(deps.keto, deps.kratosAdmin); 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<RouteResult> => { const roleDetailResult = async (deps: RolesDeps, name: string, error?: string): Promise<RouteResult> => {
const { emailById, options } = await memberCandidates(deps.keto, deps.kratosAdmin); 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 members = tuples.map((t) => memberView(t, emailById));
const effective = await effectiveUsers(deps.keto, name, tuples.length > 0, 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; return error ? { ...result, status: 400 } : result;
}; };
// GET /admin/roles — the list. // GET /admin/permissions — the list.
export const rolesList = withRoles(async ({ ctx, keto }) => { export const rolesList = withRoles(async ({ ctx, keto }) => {
const roles = rolesFromTuples(await pagedTuples(keto, { namespace: ROLE_NS, relation: MEMBERS })); const permissions = permissionsFromTuples(await pagedTuples(keto, { namespace: PERMISSION_NS, relation: GRANTED }));
return { data: { chrome: ctx.chrome, model: buildRolesListModel({ csrfToken: ctx.chrome.csrfToken, roles, url: ctx.url }) }, view: "roles" }; 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) => { export const rolesCreate = withRoles(async (deps) => {
const { ctx, keto, revoke, user } = deps; const { ctx, keto, revoke, user } = deps;
const form = (await guardedForm(ctx))!; const form = (await guardedForm(ctx))!;
const name = (form.get("name") ?? "").trim(); const name = (form.get("name") ?? "").trim();
const member = (form.get("member") ?? "").trim(); const member = (form.get("member") ?? "").trim();
const tuple = roleMemberTuple(name, member); const tuple = permissionGrantTuple(name, member);
const reject = async (error: string): Promise<RouteResult> => ({ ...(await roleFormResult(deps, { error, values: { member, name } })), status: 400 }); const reject = async (error: string): Promise<RouteResult> => ({ ...(await roleFormResult(deps, { error, values: { member, name } })), status: 400 });
if (!isValidRoleName(name)) return reject("Role names use lowercase letters, digits, dashes and underscores."); 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 role to."); if (!tuple) return reject("Pick a user or group to assign the permission to.");
if (await roleExists(keto, name)) return reject("A role with that name already exists."); if (await roleExists(keto, name)) return reject("A permission with that name already exists.");
await keto.writeTuple(tuple); await keto.writeTuple(tuple);
revokeUserMember(revoke, member); 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) }; 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, {})); 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)); 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) => { export const rolesAddMember = withRoleName(async (deps, name) => {
const { ctx, keto, revoke, user } = deps; const { ctx, keto, revoke, user } = deps;
const form = (await guardedForm(ctx))!; const form = (await guardedForm(ctx))!;
const member = (form.get("member") ?? "").trim(); const member = (form.get("member") ?? "").trim();
const tuple = roleMemberTuple(name, member); // the picker only offers real users/groups 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: role assigned", { actor: user.id, member, role: name }); } 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) }; 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) => { 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); const base = detailHref(name);
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({ return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { href: base, label: name }, { label: "Delete" }], breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Permissions" }, { href: base, label: name }, { label: "Delete" }],
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete role", cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete permission",
message: `Delete role ${name}? This revokes it from everyone it's assigned to.`, title: "Delete role", message: `Delete permission ${name}? This revokes it from everyone it's assigned to.`, title: "Delete permission",
}) }, view: "confirm" }); }) }, view: "confirm" });
}); });
// POST /admin/roles/:name/delete — remove every member tuple (a whole-role delete lags per the // POST /admin/permissions/:name/delete — remove every member tuple (a whole-permission delete lags per the
// documented instant-revoke tradeoff; the admin role is protected). // documented instant-revoke tradeoff; the admin permission is protected).
export const rolesDelete = withRoleName(async (deps, name) => { export const rolesDelete = withRoleName(async (deps, name) => {
const { ctx, keto, user } = deps; const { ctx, keto, user } = deps;
await guardedForm(ctx); // CSRF-verify the POST 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."); 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: ROLE_NS, object: name, relation: MEMBERS }); await keto.deleteTuple({ namespace: PERMISSION_NS, object: name, relation: GRANTED });
ctx.log.info("admin: role deleted", { actor: user.id, role: name }); ctx.log.info("admin: permission deleted", { actor: user.id, permission: name });
return { redirect: ADMIN_ROLES_BASE }; 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 // 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). // covered — the robust "last effective admin" check is deferred).
export const rolesRemoveMember = withRoleName(async (deps, name) => { export const rolesRemoveMember = withRoleName(async (deps, name) => {
const { ctx, keto, revoke, user } = deps; const { ctx, keto, revoke, user } = deps;
const form = (await guardedForm(ctx))!; const form = (await guardedForm(ctx))!;
const member = (form.get("member") ?? "").trim(); 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."); if (name === ADMIN_PERMISSION && member === `identity:${user.id}`) return roleDetailResult(deps, name, "You can't revoke your own admin access.");
const tuple = roleMemberTuple(name, member); const tuple = permissionGrantTuple(name, member);
if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: role unassigned", { actor: user.id, member, role: name }); } 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) }; return { redirect: detailHref(name) };
}); });
+8 -8
View File
@@ -7,10 +7,10 @@ import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import { test } from "node:test"; import { test } from "node:test";
import { GuardError, type Log, type PageChrome, type RequestContext, type SessionIdentity } from "#plugin-api"; 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 admin: SessionIdentity = { email: "ada@x.io", id: "u1", permissions: ["admin"] };
const member: SessionIdentity = { email: "bo@x.io", id: "u2", roles: ["scheduling:read"] }; 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; 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 { 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"; req.method = opts.method ?? "GET";
return { return {
chrome: CHROME, identity: opts.user ?? null, log: {} as Log, params: {}, query: url.searchParams, req, res: {} as ServerResponse, 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", () => { 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.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.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.href), ["/admin/users", "/admin/groups", "/admin/permissions", "/admin/clients"]);
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["Users", "Groups", "Roles", "OAuth2 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.role === undefined)); // the header's gate covers the subtree assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined && c.permission === undefined)); // the header's gate covers the subtree
}); });
// ---- auth gates ---- // ---- auth gates ----
+8 -8
View File
@@ -5,36 +5,36 @@
import { can, CSRF_FIELD, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type SessionIdentity } from "#plugin-api"; 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_USERS_BASE = "/admin/users";
export const ADMIN_GROUPS_BASE = "/admin/groups"; 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 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 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. // non-admin), and current-marks the active item — so there is no `current`/`open` state here.
export const ADMIN_NAV: NavNode = { export const ADMIN_NAV: NavNode = {
children: [ children: [
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "Users" }, { href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "Users" },
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "Groups" }, { 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" }, { href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "OAuth2 clients" },
], ],
icon: "i-shield", icon: "i-shield",
id: "admin", id: "admin",
label: "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 // 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. // relies on. Returns the (non-null) user for the handler to thread on. GuardError → /login or 403.
export function requireAdmin(ctx: RequestContext): SessionIdentity { export function requireAdmin(ctx: RequestContext): SessionIdentity {
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept) 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; return user;
} }
+1 -1
View File
@@ -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. // 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; } 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. // host; `requireAdmin` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it.
function withUser(inner: (deps: UsersDeps) => Promise<RouteResult>): RouteHandler { function withUser(inner: (deps: UsersDeps) => Promise<RouteResult>): RouteHandler {
return async (ctx) => { return async (ctx) => {
+13 -13
View File
@@ -9,21 +9,21 @@
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "#plugin-api"; import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "#plugin-api";
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts"; 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 { 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 { 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 // 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. // 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({ export default definePlugin({
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
nav: [ADMIN_NAV], 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: [ routes: [
// Users // Users
@@ -46,14 +46,14 @@ export default definePlugin({
r("POST", "/groups/:name/delete", groupsDelete), r("POST", "/groups/:name/delete", groupsDelete),
r("POST", "/groups/:name/members/delete", groupsRemoveMember), r("POST", "/groups/:name/members/delete", groupsRemoveMember),
// Roles // Roles
r("GET", "/roles", rolesList), r("GET", "/permissions", rolesList),
r("POST", "/roles", rolesCreate), r("POST", "/permissions", rolesCreate),
r("GET", "/roles/new", rolesNewForm), r("GET", "/permissions/new", rolesNewForm),
r("GET", "/roles/:name", rolesDetail), r("GET", "/permissions/:name", rolesDetail),
r("POST", "/roles/:name/members", rolesAddMember), r("POST", "/permissions/:name/members", rolesAddMember),
r("GET", "/roles/:name/delete", rolesDeleteConfirm), r("GET", "/permissions/:name/delete", rolesDeleteConfirm),
r("POST", "/roles/:name/delete", rolesDelete), r("POST", "/permissions/:name/delete", rolesDelete),
r("POST", "/roles/:name/members/delete", rolesRemoveMember), r("POST", "/permissions/:name/members/delete", rolesRemoveMember),
// OAuth2 clients // OAuth2 clients
r("GET", "/clients", clientsList), r("GET", "/clients", clientsList),
r("POST", "/clients", clientsCreate), r("POST", "/clients", clientsCreate),
+1 -1
View File
@@ -1,6 +1,6 @@
<%# <%#
OAuth2 clients admin list: apps that log in *through* us (Hydra). Same building blocks as 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 nav = include("partials/nav-tree", { nodes: chrome.nav });
const filters = include("partials/filter-bar", model.filterBar); const filters = include("partials/filter-bar", model.filterBar);
@@ -1,13 +1,13 @@
<%# <%#
Admin role detail body, captured into the shell content slot. Config: Admin permission detail body, captured into the shell content slot. Config:
role { name } permission { name }
members { action, rows: { kind:"group"|"identity", label, subject }[] } action = revoke endpoint 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 add { action, options: {label,value}[] } action = assign endpoint
del { action } delete the whole role del { action } delete the whole permission
csrfToken, error? csrfToken, error?
%><% %><%
const role = locals.role; const permission = locals.permission;
const members = locals.members; const members = locals.members;
const effective = locals.effective; const effective = locals.effective;
const add = locals.add; const add = locals.add;
@@ -21,7 +21,7 @@
<section class="form-card" aria-labelledby="members-h"> <section class="form-card" aria-labelledby="members-h">
<h2 class="card-title" id="members-h">Assigned to</h2> <h2 class="card-title" id="members-h">Assigned to</h2>
<% if (members.rows.length) { -%> <% if (members.rows.length) { -%>
<div class="table-wrap"><table class="table"><caption class="sr-only">Members of <%= role.name %></caption><thead><tr><th scope="col">Member</th><th scope="col">Type</th><th class="col-actions" scope="col"><span class="sr-only">Actions</span></th></tr></thead><tbody> <div class="table-wrap"><table class="table"><caption class="sr-only">Members of <%= permission.name %></caption><thead><tr><th scope="col">Member</th><th scope="col">Type</th><th class="col-actions" scope="col"><span class="sr-only">Actions</span></th></tr></thead><tbody>
<% members.rows.forEach((m) => { -%> <% members.rows.forEach((m) => { -%>
<tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? "Group" : "User" %></span></td><td class="col-actions"><form method="post" action="<%= members.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg>Revoke</button></form></td></tr> <tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? "Group" : "User" %></span></td><td class="col-actions"><form method="post" action="<%= members.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg>Revoke</button></form></td></tr>
<% }) -%> <% }) -%>
@@ -32,7 +32,7 @@
</section> </section>
<section class="form-card" aria-labelledby="effective-h"> <section class="form-card" aria-labelledby="effective-h">
<h2 class="card-title" id="effective-h">Effective access</h2> <h2 class="card-title" id="effective-h">Effective access</h2>
<p class="field-hint">Everyone who holds this role — directly or through a group (resolved by Keto).</p> <p class="field-hint">Everyone who holds this permission — directly or through a group (resolved by Keto).</p>
<% if (effective.length) { -%> <% if (effective.length) { -%>
<ul class="plain-list"> <ul class="plain-list">
<% effective.forEach((u) => { -%> <% effective.forEach((u) => { -%>
@@ -40,18 +40,18 @@
<% }) -%> <% }) -%>
</ul> </ul>
<% } else { -%> <% } else { -%>
<p class="cell-muted">No users hold this role yet.</p> <p class="cell-muted">No users hold this permission yet.</p>
<% } -%> <% } -%>
</section> </section>
<section class="form-card" aria-labelledby="add-h"> <section class="form-card" aria-labelledby="add-h">
<h2 class="card-title" id="add-h">Assign the role</h2> <h2 class="card-title" id="add-h">Assign the permission</h2>
<% if (add.options.length) { -%> <% if (add.options.length) { -%>
<form class="inline-form" method="post" action="<%= add.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member">Member</label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected>Choose a user or group…</option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Assign</button></form> <form class="inline-form" method="post" action="<%= add.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member">Member</label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected>Choose a user or group…</option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Assign</button></form>
<% } else { -%> <% } else { -%>
<p class="cell-muted">All users and groups already have this role.</p> <p class="cell-muted">All users and groups already have this permission.</p>
<% } -%> <% } -%>
</section> </section>
<section class="form-card admin-actions" aria-label="Role actions"> <section class="form-card admin-actions" aria-label="Permission actions">
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete role</a> <a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete permission</a>
</section> </section>
</div> </div>
@@ -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, form { action, csrfToken, submitLabel, cancelHref, nameField: field.ejs config,
memberOptions: {label,value}[], selectedMember } memberOptions: {label,value}[], selectedMember }
error? string shown when a write was rejected error? string shown when a write was rejected
@@ -16,7 +16,7 @@
<div class="field"> <div class="field">
<label for="member">Assign to</label> <label for="member">Assign to</label>
<span class="select"><select id="member" name="member" required><option value="" disabled<% if (!form.selectedMember) { %> selected<% } %>>Choose a user or group…</option><% form.memberOptions.forEach((o) => { %><option value="<%= o.value %>"<% if (form.selectedMember === o.value) { %> selected<% } %>><%= o.label %></option><% }) %></select></span> <span class="select"><select id="member" name="member" required><option value="" disabled<% if (!form.selectedMember) { %> selected<% } %>>Choose a user or group…</option><% form.memberOptions.forEach((o) => { %><option value="<%= o.value %>"<% if (form.selectedMember === o.value) { %> selected<% } %>><%= o.label %></option><% }) %></select></span>
<span class="field-hint">A role exists once assigned; add more users or groups after creating it.</span> <span class="field-hint">A permission exists once assigned; add more users or groups after creating it.</span>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<a class="btn" href="<%= form.cancelHref %>">Cancel</a> <a class="btn" href="<%= form.cancelHref %>">Cancel</a>
@@ -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,
}) %>
@@ -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 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", { <%- include("partials/shell", {
body, body,
@@ -1,12 +1,12 @@
<%# <%#
Roles admin list: the same building blocks as the Groups screen, around the shell, backed Permissions 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. 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 nav = include("partials/nav-tree", { nodes: chrome.nav });
const filters = include("partials/filter-bar", model.filterBar); const filters = include("partials/filter-bar", model.filterBar);
const table = include("partials/data-table", model.table); const table = include("partials/data-table", model.table);
const pager = include("partials/pagination", model.pagination); const pager = include("partials/pagination", model.pagination);
const actions = '<a class="btn btn-primary" href="/admin/roles/new"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Add role</a>'; const actions = '<a class="btn btn-primary" href="/admin/permissions/new"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Add permission</a>';
-%> -%>
<%- include("partials/shell", { <%- include("partials/shell", {
actions, actions,
@@ -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,
}) %>
+2 -2
View File
@@ -15,7 +15,7 @@ What it demonstrates:
`POST /scheduling/shifts` CSRF-verifies it (`ctx.verifyCsrf`) and forwards the create upstream, `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`, then POST-redirect-GET. The form body lives in the plugin's own `views/partials/shift-form.ejs`,
reusing the core `field` partial. 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. `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 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 ## 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 to create). The one-command bootstrap grants both to the demo admin, so the seeded
`admin@plainpages.local` can use it immediately. `admin@plainpages.local` can use it immediately.
+8 -8
View File
@@ -1,5 +1,5 @@
// Reference plugin: a worked example of the contract — a list page that fetches upstream // 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. // folder, rename it, point it at your own backend. Full contract: README.md → Building plugins.
import { definePlugin } from "#plugin-api"; import { definePlugin } from "#plugin-api";
@@ -23,7 +23,7 @@ export default definePlugin({
nav: [{ nav: [{
children: [ children: [
{ href: SCHEDULING_PATH, id: "scheduling:overview", label: "Overview", public: true }, { 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", icon: "i-cal",
id: "scheduling", id: "scheduling",
@@ -31,17 +31,17 @@ export default definePlugin({
}], }],
// Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`. // Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
roles: [ permissions: [
{ description: "View shifts", name: READ }, { description: "View shifts", name: READ },
{ description: "Create and edit shifts", name: WRITE }, { description: "Create and edit shifts", name: WRITE },
], ],
// Mounted under /scheduling; `role` gates before the handler runs. The overview is `public` // 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 role. // (anyone may reach /scheduling, signed in or not); the rest need a permission.
routes: [ routes: [
{ handler: overview(), method: "GET", path: "/", public: true }, { handler: overview(), method: "GET", path: "/", public: true },
{ handler: listShifts(upstream), method: "GET", path: "/shifts", role: READ }, { handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ },
{ handler: newShiftForm(), method: "GET", path: "/shifts/new", role: WRITE }, { handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE },
{ handler: createShift(upstream), method: "POST", path: "/shifts", role: WRITE }, { handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE },
], ],
}); });
+7 -7
View File
@@ -12,12 +12,12 @@ import {
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } }; 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 url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage; const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
return { return {
chrome: CHROME, identity: null, log: new Log("none"), params: {}, query: url.searchParams, req, res: {} as ServerResponse, 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 ---- // ---- list handler ----
test("listShifts renders the upstream rows; q filters; canWrite reflects the role", async () => { test("listShifts renders the upstream rows; q filters; canWrite reflects the permission", async () => {
const r = asView(await listShifts(fakeUpstream())(fakeCtx({ roles: ["scheduling:write"] }))); const r = asView(await listShifts(fakeUpstream())(fakeCtx({ permissions: ["scheduling:write"] })));
assert.equal(r.view, "shifts"); assert.equal(r.view, "shifts");
const table = r.data["table"] as { rows: { name: string }[] }; const table = r.data["table"] as { rows: { name: string }[] };
assert.deepEqual(table.rows.map((x) => x.name), ["Morning desk", "Afternoon support"]); 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, []); 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 () => { 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.view, "overview");
assert.equal(anon.data["chrome"], CHROME); assert.equal(anon.data["chrome"], CHROME);
assert.equal(anon.data["canRead"], false); // anonymous → prompt to sign in, no shifts link 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 assert.equal(reader.data["canRead"], true); // a reader gets a link straight to the shifts list
}); });
+3 -3
View File
@@ -10,8 +10,8 @@ import { can, CSRF_FIELD, GuardError, type PageChrome, parseListQuery, readFormB
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page
export const SHIFTS_PATH = "/scheduling/shifts"; export const SHIFTS_PATH = "/scheduling/shifts";
export const READ = "scheduling:read"; // the role gating the list + nav export const READ = "scheduling:read"; // the permission gating the list + nav
export const WRITE = "scheduling:write"; // the role gating create export const WRITE = "scheduling:write"; // the permission gating create
export interface Shift { export interface Shift {
id: string; 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 // 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 // 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 // (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 { export function overview(): RouteHandler {
return (ctx) => ({ return (ctx) => ({
data: { breadcrumbs: [{ label: "Overview" }], canRead: can(ctx, READ), chrome: ctx.chrome, shiftsHref: SHIFTS_PATH, title: "Scheduling" }, data: { breadcrumbs: [{ label: "Overview" }], canRead: can(ctx, READ), chrome: ctx.chrome, shiftsHref: SHIFTS_PATH, title: "Scheduling" },
@@ -12,7 +12,7 @@
-%> -%>
<%- include("partials/shell", { <%- include("partials/shell", {
actions: "", actions: "",
body: '<div class="scheduling-page"><p>Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> role.</p>' + cta + '</div>', body: '<div class="scheduling-page"><p>Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> permission.</p>' + cta + '</div>',
brand: chrome.brand, brand: chrome.brand,
breadcrumbs, breadcrumbs,
csrfToken: chrome.csrfToken, csrfToken: chrome.csrfToken,
+1 -1
View File
@@ -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 # 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 # 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 # app never connects directly — it calls the read (4466) / write (4467) APIs, the ports
+10 -9
View File
@@ -7,26 +7,27 @@ import { Context, Namespace, SubjectSet } from "@ory/keto-namespace-types"
// A Kratos identity. Subjects are written as `identity:<kratos-identity-id>`. // A Kratos identity. Subjects are written as `identity:<kratos-identity-id>`.
class Identity implements Namespace {} class Identity implements Namespace {}
// A subject set: a named collection of users (and nested groups), resolved transitively. // A named set of identities (and nested groups), resolved transitively. The admin "Groups"
// The admin "Groups" screen manages membership; checks expand it automatically. // screen manages membership; checks expand it automatically.
class Group implements Namespace { class Group implements Namespace {
related: { related: {
members: (Identity | SubjectSet<Group, "members">)[] members: (Identity | SubjectSet<Group, "members">)[]
} }
} }
// A coarse role — the source of truth for the JWT `roles` claim. At login the app reads // A coarse permission — an operation a route or menu item gates on, and the source of truth
// `Role:<name>#members@identity:<id>` from Keto and projects the result into the token // for the JWT `permissions` claim. At login the app reads `Permission:<name>#granted@identity:<id>`
// (README: Login → session JWT). A group can hold a role, so members can be users or groups. // from Keto and projects the result into the token (README: Login → session JWT). A group can
class Role implements Namespace { // hold a permission, so grants go to an identity or to a whole group.
class Permission implements Namespace {
related: { related: {
members: (Identity | SubjectSet<Group, "members">)[] granted: (Identity | SubjectSet<Group, "members">)[]
} }
} }
// A fine-grained, relationship-checked resource — README's third "may I?" tier, the rare // 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. // live Keto check (e.g. sharing/delegation). Permits nest: owner ⊇ editor ⊇ viewer.
// Grants accept a user directly or any member of a group. // Grants accept an identity directly or any member of a group.
class Resource implements Namespace { class Resource implements Namespace {
related: { related: {
owners: (Identity | SubjectSet<Group, "members">)[] owners: (Identity | SubjectSet<Group, "members">)[]
+2 -2
View File
@@ -41,7 +41,7 @@ selfservice:
ui_url: http://localhost:3000/login ui_url: http://localhost:3000/login
after: after:
# After authenticating, land on our completion route — it mints the session JWT # 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 default_browser_return_url: http://localhost:3000/auth/complete
registration: registration:
ui_url: http://localhost:3000/registration ui_url: http://localhost:3000/registration
@@ -94,7 +94,7 @@ session:
same_site: Lax same_site: Lax
# Session→JWT tokenizer: whoami(tokenize_as: plainpages) mints a short-lived, # 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 # 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. # metadata_public projection); signed with tokenizer/jwks.json.
whoami: whoami:
tokenizer: tokenizer:
+3 -3
View File
@@ -1,7 +1,7 @@
// Session→JWT claims mapper for the `plainpages` tokenizer. Kratos exposes the // 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 // 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 // can't be overridden here. permissions come from metadata_public — the per-login projection
// of Keto roles the app refreshes at login (metadata_admin is NOT carried in the session // 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. // the tokenizer sees; metadata_public is). Absent on a fresh identity ⇒ empty list.
local session = std.extVar('session'); local session = std.extVar('session');
local meta = local meta =
@@ -12,6 +12,6 @@ local meta =
{ {
claims: { claims: {
email: session.identity.traits.email, 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 [],
}, },
} }
+24 -24
View File
@@ -1,11 +1,11 @@
// One-command bootstrap: idempotent first-boot seeding. Guards the pure payload // 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 // 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. // safety net. Live boot is verified by running the stack; these catch contract drift.
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { randomUUID } from "node:crypto"; 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) => const json = (status: number, body?: unknown) =>
new Response(body === undefined ? null : JSON.stringify(body), { 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"); assert.equal(body.credentials.password.config.password, "admin");
}); });
test("roleTuple grants a role to identity:<id> in the Role namespace", () => { test("permissionTuple grants a permission to identity:<id> in the Permission namespace", () => {
const id = randomUUID(); const id = randomUUID();
assert.deepEqual(roleTuple(id, "admin"), { assert.deepEqual(permissionTuple(id, "admin"), {
namespace: "Role", namespace: "Permission",
object: "admin", object: "admin",
relation: "members", relation: "granted",
subject_id: `identity:${id}`, subject_id: `identity:${id}`,
}); });
}); });
test("seedRoles unions ADMIN_ROLES (default 'admin') with the discovered plugins' declared roles", () => { test("seedPermissions unions ADMIN_PERMISSIONS (default 'admin') with the discovered plugins' declared permissions", () => {
// Clean clone: no ADMIN_ROLES, the scheduling plugin declares its two tokens → the demo admin // 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. // 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(seedPermissions(undefined, ["scheduling:read", "scheduling:write"]), ["admin", "scheduling:read", "scheduling:write"]);
assert.deepEqual(seedRoles(undefined, []), ["admin"]); // no plugins → just the base admin role assert.deepEqual(seedPermissions(undefined, []), ["admin"]); // no plugins → just the base admin permission
assert.deepEqual(seedRoles("admin, ops ", ["inventory:read"]), ["admin", "ops", "inventory:read"]); // env trimmed + extended assert.deepEqual(seedPermissions("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(seedPermissions("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("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 id = randomUUID();
const calls: { method: string; url: string; body?: unknown }[] = []; const calls: { method: string; url: string; body?: unknown }[] = [];
const fetchImpl = (async (url, init) => { 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", ketoWriteUrl: "http://keto:4467",
kratosAdminUrl: "http://kratos:4434", kratosAdminUrl: "http://kratos:4434",
password: "admin", 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")); 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.ok(puts.every((p) => p.method === "PUT"));
assert.deepEqual(puts.map((p) => p.body), [ assert.deepEqual(puts.map((p) => p.body), [
{ namespace: "Role", object: "admin", relation: "members", subject_id: `identity:${id}` }, { namespace: "Permission", object: "admin", relation: "granted", subject_id: `identity:${id}` },
{ namespace: "Role", object: "scheduling:read", relation: "members", 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(); const id = randomUUID();
let granted: unknown; let granted: unknown;
const fetchImpl = (async (url, init) => { 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", ketoWriteUrl: "http://keto:4467",
kratosAdminUrl: "http://kratos:4434", kratosAdminUrl: "http://kratos:4434",
password: "admin", password: "admin",
roles: ["admin"], permissions: ["admin"],
}); });
assert.deepEqual(result, { created: false, id, roles: ["admin"] }); assert.deepEqual(result, { created: false, id, permissions: ["admin"] });
assert.deepEqual(granted, { namespace: "Role", object: "admin", relation: "members", subject_id: `identity:${id}` }); assert.deepEqual(granted, { namespace: "Permission", object: "admin", relation: "granted", subject_id: `identity:${id}` });
}); });
test("seedAdmin fails loud on an unexpected Kratos error", async () => { 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", ketoWriteUrl: "http://keto:4467",
kratosAdminUrl: "http://kratos:4434", kratosAdminUrl: "http://kratos:4434",
password: "admin", password: "admin",
roles: ["admin"], permissions: ["admin"],
}), }),
/Kratos/, /Kratos/,
); );
+23 -23
View File
@@ -2,8 +2,8 @@
// kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`: // 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); // 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; // 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 // 3. grant it its permissions in Keto so menu/permission checks resolve out of the box — `admin` plus
// every discovered plugin's declared role names, so a dropped-in plugin is usable by // 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). // 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. // Then prints a first-run banner; fails loud on any unexpected upstream error.
import { existsSync, writeFileSync } from "node:fs"; import { existsSync, writeFileSync } from "node:fs";
@@ -22,19 +22,19 @@ export function identityPayload(email: string, password: string) {
}; };
} }
// Coarse-role grant: `Role:<role>#members@identity:<id>`. Subject ids are `identity:<kratos-id>` // Coarse-permission grant: `Permission:<permission>#members@identity:<id>`. Subject ids are `identity:<kratos-id>`
// (namespaces.keto.ts) — the source of truth the login flow projects into the JWT roles. // (namespaces.keto.ts) — the source of truth the login flow projects into the JWT permissions.
export function roleTuple(identityId: string, role: string) { export function permissionTuple(identityId: string, permission: string) {
return { namespace: "Role", object: role, relation: "members", subject_id: `identity:${identityId}` }; 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`) // The permissions to grant the demo admin = the configured base (ADMIN_PERMISSIONS, default just `admin`)
// unioned with every discovered plugin's declared role names (a route/nav `role` is a // unioned with every discovered plugin's declared permission names (a route/nav `permission` is a
// coarse role — granted as a Keto `Role:<token>#members` tuple). So the host names no plugin, yet a // coarse permission — granted as a Keto `Permission:<token>#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. // 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); 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 ----------------------------------------------------------------- // --- JWKS safety net -----------------------------------------------------------------
@@ -63,13 +63,13 @@ export interface SeedOptions {
ketoWriteUrl: string; ketoWriteUrl: string;
kratosAdminUrl: string; kratosAdminUrl: string;
password: string; password: string;
roles: string[]; permissions: string[];
} }
export interface SeedResult { export interface SeedResult {
created: boolean; created: boolean;
id: string; id: string;
roles: string[]; permissions: string[];
} }
export async function seedAdmin(opts: SeedOptions): Promise<SeedResult> { export async function seedAdmin(opts: SeedOptions): Promise<SeedResult> {
@@ -93,17 +93,17 @@ export async function seedAdmin(opts: SeedOptions): Promise<SeedResult> {
throw new Error(`bootstrap: Kratos create identity failed (${res.status}): ${await res.text()}`); 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. // Grant each permission in Keto. PUT is idempotent — re-running just re-asserts the tuple.
for (const role of opts.roles) { for (const permission of opts.permissions) {
const grant = await http(`${opts.ketoWriteUrl}/admin/relation-tuples`, { 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" }, headers: { "content-type": "application/json" },
method: "PUT", 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<string> { async function findIdentityId(http: typeof fetch, adminUrl: string, email: string): Promise<string> {
@@ -143,10 +143,10 @@ async function main() {
await runWithLog(log, async () => { await runWithLog(log, async () => {
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key"); 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. // 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 declared = (await discoverPlugins()).flatMap((p) => (p.permissions ?? []).map((d) => d.name));
const roles = seedRoles(env["ADMIN_ROLES"], declared); const permissions = seedPermissions(env["ADMIN_PERMISSIONS"], declared);
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local"; const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
const password = env["ADMIN_PASSWORD"] ?? "admin"; const password = env["ADMIN_PASSWORD"] ?? "admin";
const result = await seedAdmin({ const result = await seedAdmin({
@@ -155,9 +155,9 @@ async function main() {
ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467", ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467",
kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434", kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434",
password, 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. // 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 })); console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password }));
}); });
+3 -3
View File
@@ -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. // 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) — // 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 // 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 // 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 // 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 // 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 // stays inside the stateless model. A token carries `iat`, so a *fresh* re-login (iat after
+1 -1
View File
@@ -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)", () => { test("a JWS signed with a generated key verifies via our own verifier (reads what Kratos signs)", () => {
const key = generateJwks().keys[0]!; const key = generateJwks().keys[0]!;
const head = b64url(JSON.stringify({ alg: "ES256", kid: key.kid })); 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 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")}`; const token = `${head}.${body}.${sig.toString("base64url")}`;
+2 -2
View File
@@ -12,7 +12,7 @@ function ctxFor(user: SessionIdentity | null, url = "/"): RequestContext {
return buildContext(req, new ServerResponse(req), { identity: user }); 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", () => { test("requireSession returns the user, or throws GuardError(401)→/login (preserving return_to) when anonymous", () => {
assert.equal(requireSession(ctxFor(alice)), alice); 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"); 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), "admin"), true);
assert.equal(can(ctxFor(alice), "billing:write"), false); assert.equal(can(ctxFor(alice), "billing:write"), false);
assert.equal(can(ctxFor(null), "admin"), false); assert.equal(can(ctxFor(null), "admin"), false);
+4 -4
View File
@@ -1,5 +1,5 @@
// Auth guards: in-handler authorization, the imperative counterpart to the // 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 // 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 // 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. // 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; return ctx.identity;
} }
// Coarse role check straight from the JWT claims — in-process, zero I/O. Anonymous ⇒ false. // Coarse permission check straight from the JWT claims — in-process, zero I/O. Anonymous ⇒ false.
export function can(ctx: RequestContext, role: string): boolean { export function can(ctx: RequestContext, permission: string): boolean {
return ctx.roles.includes(role); return ctx.permissions.includes(permission);
} }
// Live Keto relationship check at the point of action. The subject is the current user; // Live Keto relationship check at the point of action. The subject is the current user;
+7 -7
View File
@@ -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 jwks = staticJwks([jwk1, jwk2]); // rotated set: two live keys
const NOW = 1_700_000_000; // fixed clock for deterministic exp/nbf checks 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 () => { 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 }); 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 () => { 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/); 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/);
assert.throws(() => claimsToIdentity({ email: "a@b.c", exp: NOW, sub: "" }), /sub/); // empty sub rejected too 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({ 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.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", sub: "u" }).permissions, []); // permissions absent
assert.deepEqual(claimsToIdentity({ email: "a@b.c", roles: ["a", 1, "b"], sub: "u" }).roles, ["a", "b"]); 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 () => { test("resolveSession classifies the cookie; authenticate is its fail-closed identity projection", async () => {
const cookie = (extra: Record<string, unknown> = {}, kid = "k1") => `${SESSION_COOKIE}=${mint(k1.privateKey, kid, { ...valid, ...extra })}`; const cookie = (extra: Record<string, unknown> = {}, 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. // A valid token → the user, not expired.
assert.deepEqual(await resolveSession(cookie(), jwks, { now: NOW }), { expired: false, identity }); 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/); 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 }); 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. // 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 }); await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5, sub: "u2" }), jwks, { denylist, now: NOW });
}); });
+5 -5
View File
@@ -2,7 +2,7 @@
// the hot path that never calls Ory. Select the verify key by `kid` from the cached JWKS, // 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 // 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 // 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 type { SessionIdentity } from "../http/context.ts";
import { parseCookies } from "../http/cookie.ts"; import { parseCookies } from "../http/cookie.ts";
import type { Denylist } from "./denylist.ts"; import type { Denylist } from "./denylist.ts";
@@ -59,15 +59,15 @@ export function validateClaims(payload: Record<string, unknown>, options: Verify
} }
// Map verified claims → the request User. sub/email are required and non-empty (the tokenizer // 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). // keeps only string entries (defensive).
export function claimsToIdentity(payload: Record<string, unknown>): SessionIdentity { export function claimsToIdentity(payload: Record<string, unknown>): SessionIdentity {
const sub = payload["sub"]; const sub = payload["sub"];
if (typeof sub !== "string" || sub === "") throw new TokenError("token missing sub"); if (typeof sub !== "string" || sub === "") throw new TokenError("token missing sub");
const email = payload["email"]; const email = payload["email"];
if (typeof email !== "string" || email === "") throw new TokenError("token missing email"); if (typeof email !== "string" || email === "") throw new TokenError("token missing email");
const roles = payload["roles"]; const permissions = payload["permissions"];
return { email, id: sub, roles: Array.isArray(roles) ? roles.filter((r): r is string => typeof r === "string") : [] }; 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 // 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); validateClaims(verified.payload, options);
const user = claimsToIdentity(verified.payload); const user = claimsToIdentity(verified.payload);
// Instant revoke: a denylisted subject's pre-revoke token is rejected as *expired* so // 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); if (options.denylist?.isRevoked(user.id, num(verified.payload, "iat"))) throw new TokenError("token revoked", true);
return user; return user;
} }
+4 -4
View File
@@ -22,10 +22,10 @@ const rsaJwk = rsa.publicKey.export({ format: "jwk" }) as JsonWebKey;
const ecJwk = ec.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", () => { 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); const verified = verifyJws(token, rsaJwk);
assert.equal(verified.header.alg, "RS256"); 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)", () => { 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. // 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)", () => { 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 [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/); assert.throws(() => verifyJws(forged, rsaJwk), /invalid signature/);
const otherJwk = generateKeyPairSync("rsa", { modulusLength: 2048 }).publicKey.export({ format: "jwk" }) as JsonWebKey; const otherJwk = generateKeyPairSync("rsa", { modulusLength: 2048 }).publicKey.export({ format: "jwk" }) as JsonWebKey;
+14 -14
View File
@@ -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 () => { test("check GETs the read API and returns the allowed boolean (true and false)", async () => {
const allow = recorder(() => res(200, { allowed: true })); 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, /^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, "\\.")}`)); 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. // A denied check is 403 {allowed:false} (not a 200) — both statuses carry the verdict.
const deny = recorder(() => res(403, { allowed: false })); 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 () => { 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 () => { test("check throws a KetoError carrying the status on an unexpected response", async () => {
await assert.rejects( 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, (e: unknown) => e instanceof KetoError && e.status === 400,
); );
}); });
test("listRelations builds the filter query + pagination and parses next_page_token", async () => { 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 { 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.deepEqual(out.tuples, tuples);
assert.equal(out.nextPageToken, "NEXT"); assert.equal(out.nextPageToken, "NEXT");
const url = calls[0]!.url; const url = calls[0]!.url;
assert.match(url, /^http:\/\/keto:4466\/relation-tuples\?/); 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/); assert.match(url, /page_size=10&page_token=CUR/);
// No Link header / token in the body ⇒ null, empty list ⇒ []. // No Link header / token in the body ⇒ null, empty list ⇒ [].
const empty = await keto((async () => res(200, {})) as typeof fetch).listRelations(); 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 () => { 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 { 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.deepEqual(out, tree);
assert.match(calls[0]!.url, /^http:\/\/keto:4466\/relation-tuples\/expand\?/); 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 () => { 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)); const { calls, fetchImpl } = recorder(() => res(201, tuple));
await keto(fetchImpl).writeTuple(tuple); await keto(fetchImpl).writeTuple(tuple);
assert.equal(calls[0]!.method, "PUT"); 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 () => { test("deleteTuple DELETEs the write API by query params (204 resolves; non-204 throws)", async () => {
const { calls, fetchImpl } = recorder(() => res(204)); 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.equal(calls[0]!.method, "DELETE");
assert.match(calls[0]!.url, /^http:\/\/keto:4467\/admin\/relation-tuples\?/); 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( 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, (e: unknown) => e instanceof KetoError && e.status === 404,
); );
}); });
+1 -1
View File
@@ -13,7 +13,7 @@ export interface SubjectSet {
} }
// A relationship tuple — the wire shape for writes and the filter shape for reads. Subject // 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 { export interface RelationTuple {
namespace: string; namespace: string;
object: string; object: string;
+4 -4
View File
@@ -1,5 +1,5 @@
// Kratos admin-API client: typed fetch wrappers over Ory Kratos' admin endpoints — // 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 // 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. // result mapping (201/200/404/4xx). Live wiring is verified by login completion.
import { test } from "node:test"; import { test } from "node:test";
@@ -90,13 +90,13 @@ test("updateIdentity PUTs the full body to /admin/identities/<id> and returns th
}); });
test("updateMetadataPublic PATCHes a JSON-Patch `add /metadata_public` so it never clobbers traits", async () => { 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 { 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.deepEqual(out, identity);
assert.equal(calls[0]!.method, "PATCH"); assert.equal(calls[0]!.method, "PATCH");
assert.match(calls[0]!.url, new RegExp(`/admin/identities/${ID}$`)); 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 () => { test("createRecoveryCode POSTs the identity id to /admin/recovery/code → { code, link }", async () => {
+2 -2
View File
@@ -1,6 +1,6 @@
// Kratos admin-API client: typed `fetch` wrappers over Ory Kratos' admin endpoints // Kratos admin-API client: typed `fetch` wrappers over Ory Kratos' admin endpoints
// (internal-only admin port) — identity CRUD + the surgical `metadata_public` update login // (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`). // `fetchImpl`-injectable, reuses kratos-public.ts's `KratosError` (branch on `.status`).
import { KratosError } from "./kratos-public.ts"; 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 // 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.) // (metadata_public, not _admin: the session the tokenizer sees carries only public metadata.)
async updateMetadataPublic(id, metadata) { async updateMetadataPublic(id, metadata) {
const patch = [{ op: "add", path: "/metadata_public", value: metadata }]; const patch = [{ op: "add", path: "/metadata_public", value: metadata }];
+20 -20
View File
@@ -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; // 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. // the live, full-stack login is verified by the Playwright E2E.
import { test } from "node:test"; 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 { KetoClient, RelationTuple } from "./keto-client.ts";
import type { Identity, KratosAdmin } from "./kratos-admin.ts"; import type { Identity, KratosAdmin } from "./kratos-admin.ts";
import type { KratosPublic, Session } from "./kratos-public.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 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> = {}): KetoClient => ({ const ketoStub = (over: Partial<KetoClient> = {}): KetoClient => ({
check: async () => false, check: async () => false,
@@ -40,32 +40,32 @@ const publicStub = (over: Partial<KratosPublic> = {}): KratosPublic => ({
...over, ...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 listQ: unknown[] = [];
const checked: string[] = []; const checked: string[] = [];
const role = (object: string, subject: Partial<RelationTuple>): RelationTuple => ({ namespace: "Role", object, relation: "members", ...subject }); const permission = (object: string, subject: Partial<RelationTuple>): RelationTuple => ({ namespace: "Permission", object, relation: "granted", ...subject });
const keto = ketoStub({ 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. // subjects vary (a direct user, a group) and a name repeats across pages → de-duped.
listRelations: async (q) => { listRelations: async (q) => {
listQ.push(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: [ return { nextPageToken: "p2", tuples: [
role("editor", { subject_set: { namespace: "Group", object: "eng", relation: "members" } }), permission("editor", { subject_set: { namespace: "Group", object: "eng", relation: "members" } }),
role("admin", { subject_id: `identity:${ID}` }), permission("admin", { subject_id: `identity:${ID}` }),
role("viewer", { subject_id: "identity:stranger" }), permission("viewer", { subject_id: "identity:stranger" }),
] }; ] };
}, },
// Keto resolves transitively: the user holds editor (via a group) + admin (direct), not viewer. // 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"; }, check: async (t) => { checked.push(t.object); return t.object === "admin" || t.object === "editor"; },
}); });
assert.deepEqual(await readRoles(keto, ID), ["admin", "editor"]); assert.deepEqual(await readPermissions(keto, ID), ["admin", "editor"]);
assert.deepEqual(listQ[0], { namespace: "Role", relation: "members" }); // enumerate, not subject-filtered 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.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[] = []; const events: string[] = [];
let projected: unknown; let projected: unknown;
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } }; 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 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"); 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(out, { email: "admin@plainpages.local", identityId: ID, jwt: "h.p.s", permissions: ["admin"] });
assert.deepEqual(projected, { roles: ["admin"] }); // Keto roles, projected for the tokenizer assert.deepEqual(projected, { permissions: ["admin"] }); // Keto permissions, projected for the tokenizer
assert.deepEqual(events, ["whoami", "project", "tokenize"]); // projection MUST precede tokenize 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 () => { 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 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 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"); 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/); 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. // Kratos session also gone → clear the stale JWT so the next request falls through to anonymous.
+18 -18
View File
@@ -1,9 +1,9 @@
// Login completion: turn a fresh Kratos session into our locally-verifiable // 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): // 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 // 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 // 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 // Order matters: the projection is written before tokenizing, because the claims mapper
// reads only the identity, never Keto. // reads only the identity, never Keto.
import type { SessionIdentity } from "../http/context.ts"; import type { SessionIdentity } from "../http/context.ts";
@@ -34,26 +34,26 @@ export interface CompletedLogin {
email: string | null; email: string | null;
identityId: string; identityId: string;
jwt: string; jwt: string;
roles: string[]; permissions: string[];
} }
// The coarse roles a user holds — directly (`Role:<name>#members@identity:<id>`) or transitively via a // The coarse permissions a user holds — directly (`Permission:<name>#members@identity:<id>`) or transitively via a
// group that is a member of the role. Enumerates the defined roles (the distinct objects in the Role // 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 role granted to a group reaches the JWT — // 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 // 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. // request; permission count is small, so the per-permission checks are cheap and run in parallel.
export async function readRoles(keto: KetoClient, identityId: string): Promise<string[]> { export async function readPermissions(keto: KetoClient, identityId: string): Promise<string[]> {
const subject_id = `identity:${identityId}`; const subject_id = `identity:${identityId}`;
const names = new Set<string>(); const names = new Set<string>();
let pageToken: string | undefined; let pageToken: string | undefined;
do { 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); for (const t of page.tuples) names.add(t.object);
pageToken = page.nextPageToken ?? undefined; pageToken = page.nextPageToken ?? undefined;
} while (pageToken); } while (pageToken);
const roles = [...names]; const permissions = [...names];
const held = await Promise.all(roles.map((object) => keto.check({ namespace: "Role", object, relation: "members", subject_id }))); const held = await Promise.all(permissions.map((object) => keto.check({ namespace: "Permission", object, relation: "granted", subject_id })));
return roles.filter((_, i) => held[i]).sort(); return permissions.filter((_, i) => held[i]).sort();
} }
export async function completeLogin(deps: LoginDeps, cookie: string | undefined): Promise<CompletedLogin | null> { export async function completeLogin(deps: LoginDeps, cookie: string | undefined): Promise<CompletedLogin | null> {
@@ -63,15 +63,15 @@ export async function completeLogin(deps: LoginDeps, cookie: string | undefined)
const emailTrait = session.identity.traits?.["email"]; const emailTrait = session.identity.traits?.["email"];
const email = typeof emailTrait === "string" ? emailTrait : null; const email = typeof emailTrait === "string" ? emailTrait : null;
const roles = await readRoles(deps.keto, identityId); const permissions = await readPermissions(deps.keto, identityId);
await deps.kratosAdmin.updateMetadataPublic(identityId, { roles }); await deps.kratosAdmin.updateMetadataPublic(identityId, { permissions });
const tokenized = await deps.kratosPublic.whoami({ ...(cookie ? { cookie } : {}), tokenizeAs: TOKENIZE_AS }); const tokenized = await deps.kratosPublic.whoami({ ...(cookie ? { cookie } : {}), tokenizeAs: TOKENIZE_AS });
const jwt = tokenized?.tokenized; const jwt = tokenized?.tokenized;
if (!jwt) throw new Error("login completion: Kratos tokenizer returned no JWT"); 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 currentLog()?.info("session minted", { permissions: permissions.join(","), sub: identityId }); // login or TTL re-mint
return { email, identityId, jwt, roles }; return { email, identityId, jwt, permissions };
} }
export interface Reminted { 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 // 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 // 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 // session ⇒ a cookie that *clears* the stale JWT, so later requests fall straight through to
// anonymous instead of re-hitting Ory on every one. // anonymous instead of re-hitting Ory on every one.
export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise<Reminted> { export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise<Reminted> {
const completed = await completeLogin(deps, cookie); const completed = await completeLogin(deps, cookie);
if (!completed) return { setCookie: clearSessionCookie(options), identity: null }; 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 // Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is
+1 -1
View File
@@ -2,7 +2,7 @@
// /oauth2/consent?consent_challenge=… (hydra.yml urls.consent). A first-party client (or one // /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 // 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 // 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 { AcceptConsent, ConsentRequest, HydraAdmin, OAuth2Client } from "./hydra-admin.ts";
import type { KratosPublic } from "./kratos-public.ts"; import type { KratosPublic } from "./kratos-public.ts";
+1 -1
View File
@@ -2,7 +2,7 @@
// Hydra hands the browser to /oauth2/login?login_challenge=… (hydra.yml urls.login). We // 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 // 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 // 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 { HydraAdmin } from "./hydra-admin.ts";
import type { KratosPublic } from "./kratos-public.ts"; import type { KratosPublic } from "./kratos-public.ts";
+1 -1
View File
@@ -192,7 +192,7 @@ function oauthLogout(hydra: HydraAdmin): BuiltinRoute["handler"] {
} }
// Login completion: where Kratos lands the browser after authenticating (kratos.yml). Mint our // 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. // cookie; no active session bounces back to sign in.
function completeAuth(deps: { keto: KetoClient; kratosAdmin: KratosAdmin; kratosPublic: KratosPublic }, secureCookies: boolean): BuiltinRoute["handler"] { function completeAuth(deps: { keto: KetoClient; kratosAdmin: KratosAdmin; kratosPublic: KratosPublic }, secureCookies: boolean): BuiltinRoute["handler"] {
return async (ctx: RequestContext): Promise<RouteResult> => { return async (ctx: RequestContext): Promise<RouteResult> => {
+2 -2
View File
@@ -31,7 +31,7 @@ export interface Config {
otlpEndpoint: string | undefined; // OTLP/HTTP collector base URI; unset ⇒ console-only (no export) 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) otlpProtocol: "http/json" | "http/protobuf"; // OTLP wire format (protobuf for json-averse collectors)
port: number; 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 revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew
secureCookies: boolean; secureCookies: boolean;
serviceName: string; // OTLP service.name — an implementer brands their own logs/traces 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"), otlpEndpoint: readOptionalUrl(env, "OTLP_ENDPOINT"),
otlpProtocol: readEnum(env, "OTLP_PROTOCOL", ["http/json", "http/protobuf"] as const, "http/json"), otlpProtocol: readEnum(env, "OTLP_PROTOCOL", ["http/json", "http/protobuf"] as const, "http/json"),
port: readPort(env), 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 // 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). // tokenizer TTL + skew, so it outlasts any pre-revoke token).
revocationDenylist: readBool(env, "REVOCATION_DENYLIST", false), revocationDenylist: readBool(env, "REVOCATION_DENYLIST", false),
+68 -68
View File
@@ -40,9 +40,9 @@ function mintJwt(payload: Record<string, unknown>): string {
const input = `${b64url(JSON.stringify({ alg: "ES256", kid: "test-kid", typ: "JWT" }))}.${b64url(JSON.stringify(payload))}`; 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 }))}`; 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. // A session cookie carrying `permissions`, valid for 10 min — the auth most tests need to reach a gated page.
const session = (roles: string[] = []): string => const session = (permissions: string[] = []): string =>
`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, roles, sub: "u1" })}`; `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, permissions, sub: "u1" })}`;
const server = createApp({ jwks: staticJwks([ecJwk]) }); const server = createApp({ jwks: staticJwks([ecJwk]) });
let base = ""; let base = "";
@@ -83,7 +83,7 @@ test("/ is the public landing: anonymous → 200 with intro + sign-in/register l
const html = await res.text(); const html = await res.text();
assert.match(html, /href="\/login"/); // a prominent path to sign in assert.match(html, /href="\/login"/); // a prominent path to sign in
assert.match(html, /href="\/registration"/); // and to register 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, /<aside class="sidebar"/); assert.match(html, /<aside class="sidebar"/);
assert.match(html, /class="landing-title"/); // the landing hero owns the page's single <h1> assert.match(html, /class="landing-title"/); // the landing hero owns the page's single <h1>
}); });
@@ -385,7 +385,7 @@ test("renders the 500 HTML page when a handler throws", async () => {
} }
}); });
// A test plugin exercising each RouteResult shape, a path param, and the role gate. // A test plugin exercising each RouteResult shape, a path param, and the permission gate.
const demoPlugin: Plugin = { const demoPlugin: Plugin = {
apiVersion: "1.0.0", apiVersion: "1.0.0",
id: "demo", id: "demo",
@@ -393,7 +393,7 @@ const demoPlugin: Plugin = {
{ handler: (ctx) => ({ html: `<p>Hi ${ctx.params.name}</p>` }), method: "GET", path: "/hello/:name" }, { handler: (ctx) => ({ html: `<p>Hi ${ctx.params.name}</p>` }), method: "GET", path: "/hello/:name" },
{ handler: () => ({ json: { ok: true } }), method: "GET", path: "/data" }, { handler: () => ({ json: { ok: true } }), method: "GET", path: "/data" },
{ handler: () => ({ redirect: "/demo/hello/world" }), method: "POST", path: "/go" }, { handler: () => ({ redirect: "/demo/hello/world" }), method: "POST", path: "/go" },
{ handler: () => ({ html: "secret" }), method: "GET", path: "/secret", role: "demo:read" }, { handler: () => ({ html: "secret" }), method: "GET", path: "/secret", permission: "demo:read" },
{ handler: () => ({ html: "open to all" }), method: "GET", path: "/public-page", public: true }, // blessed public { handler: () => ({ html: "open to all" }), method: "GET", path: "/public-page", public: true }, // blessed public
{ handler: () => ({ data: { who: "Plainpages" }, view: "page" }), method: "GET", path: "/page" }, { handler: () => ({ data: { who: "Plainpages" }, view: "page" }), method: "GET", path: "/page" },
], ],
@@ -406,7 +406,7 @@ async function startApp(t: TestContext, plugins: Plugin[], pluginsDir?: string):
return `http://localhost:${(app.address() as AddressInfo).port}`; return `http://localhost:${(app.address() as AddressInfo).port}`;
} }
test("mounts plugin routes: params, html/json/redirect/view results, and the role gate", async (t) => { test("mounts plugin routes: params, html/json/redirect/view results, and the permission gate", async (t) => {
const dir = mkdtempSync(join(tmpdir(), "pp-plugins-")); const dir = mkdtempSync(join(tmpdir(), "pp-plugins-"));
mkdirSync(join(dir, "demo", "views"), { recursive: true }); mkdirSync(join(dir, "demo", "views"), { recursive: true });
mkdirSync(join(dir, "demo", "public"), { recursive: true }); mkdirSync(join(dir, "demo", "public"), { recursive: true });
@@ -516,9 +516,9 @@ test("a plugin view renders the native chrome; its forms are CSRF-guarded via ct
assert.equal(ok.status, 303); assert.equal(ok.status, 303);
}); });
// JWT middleware: a verified session cookie populates ctx.identity/roles, which the gate reads. // JWT middleware: a verified session cookie populates ctx.identity/permissions, which the gate reads.
// The key + mintJwt + session() helper are hoisted above the shared `server` (top of file). // The key + mintJwt + session() helper are hoisted above the shared `server` (top of file).
test("a verified session JWT authorizes a role-gated route; no cookie / expired token → sign in", async (t) => { test("a verified session JWT authorizes a permission-gated route; no cookie / expired token → sign in", async (t) => {
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [demoPlugin] }); const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [demoPlugin] });
await new Promise<void>((r) => app.listen(0, r)); await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close()); t.after(() => app.close());
@@ -526,8 +526,8 @@ test("a verified session JWT authorizes a role-gated route; no cookie / expired
const nowSec = Math.floor(Date.now() / 1000); const nowSec = Math.floor(Date.now() / 1000);
const secret = (cookie?: string) => fetch(url + "/demo/secret", { redirect: "manual", ...(cookie ? { headers: { cookie } } : {}) }); const secret = (cookie?: string) => fetch(url + "/demo/secret", { redirect: "manual", ...(cookie ? { headers: { cookie } } : {}) });
// Token carrying the gating role → the handler runs (200). // Token carrying the gating permission → the handler runs (200).
const ok = await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["demo:read"], sub: "u1" })}`); const ok = await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions: ["demo:read"], sub: "u1" })}`);
assert.equal(ok.status, 200); assert.equal(ok.status, 200);
assert.equal(await ok.text(), "secret"); assert.equal(await ok.text(), "secret");
@@ -536,12 +536,12 @@ test("a verified session JWT authorizes a role-gated route; no cookie / expired
const noCookie = await secret(); const noCookie = await secret();
assert.equal(noCookie.status, 303); assert.equal(noCookie.status, 303);
assert.equal(noCookie.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret"); assert.equal(noCookie.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret");
assert.equal((await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}`)).status, 303); assert.equal((await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, permissions: ["demo:read"], sub: "u1" })}`)).status, 303);
// The gated dashboard renders for any signed-in user; anonymous is bounced to sign in before any // The gated dashboard renders for any signed-in user; anonymous is bounced to sign in before any
// page renders (gate on /dashboard). The Admin section links come from the admin plugin — its nav // page renders (gate on /dashboard). The Admin section links come from the admin plugin — its nav
// composition + role-filtering is covered in the admin-screen tests below. // composition + permission-filtering is covered in the admin-screen tests below.
const dash = await fetch(url + "/dashboard", { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["admin"], sub: "u1" })}` } }); const dash = await fetch(url + "/dashboard", { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions: ["admin"], sub: "u1" })}` } });
assert.equal(dash.status, 200); assert.equal(dash.status, 200);
const anonDash = await fetch(url + "/dashboard", { redirect: "manual" }); const anonDash = await fetch(url + "/dashboard", { redirect: "manual" });
assert.equal(anonDash.status, 303); assert.equal(anonDash.status, 303);
@@ -555,7 +555,7 @@ test("revocation denylist: a revoked subject's token stops authorizing on the ho
t.after(() => app.close()); t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`; const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const nowSec = Math.floor(Date.now() / 1000); const nowSec = Math.floor(Date.now() / 1000);
const secret = (iat: number) => fetch(url + "/demo/secret", { redirect: "manual", headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, iat, roles: ["demo:read"], sub: "u1" })}` } }); const secret = (iat: number) => fetch(url + "/demo/secret", { redirect: "manual", headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, iat, permissions: ["demo:read"], sub: "u1" })}` } });
assert.equal((await secret(nowSec)).status, 200); // before any revoke, the token authorizes assert.equal((await secret(nowSec)).status, 200); // before any revoke, the token authorizes
@@ -567,10 +567,10 @@ test("revocation denylist: a revoked subject's token stops authorizing on the ho
test("session re-mint: an expired JWT backed by a live Kratos session is silently re-minted; a dead session clears it", async (t) => { test("session re-mint: an expired JWT backed by a live Kratos session is silently re-minted; a dead session clears it", async (t) => {
const identity: Identity = { id: "u1", traits: { email: "a@b.c" } }; const identity: Identity = { id: "u1", traits: { email: "a@b.c" } };
const nowSec = Math.floor(Date.now() / 1000); const nowSec = Math.floor(Date.now() / 1000);
const freshJwt = mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["demo:read"], sub: "u1" }); const freshJwt = mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions: ["demo:read"], sub: "u1" });
const live = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: freshJwt } : { active: true, identity }) as Session); const live = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: freshJwt } : { active: true, identity }) as Session);
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "demo:read", relation: "members", subject_id: "identity:u1" }] }) }); const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Permission", object: "demo:read", relation: "granted", subject_id: "identity:u1" }] }) });
const expired = `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}; plainpages_session=s`; const expired = `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, permissions: ["demo:read"], sub: "u1" })}; plainpages_session=s`;
// Live Kratos session: the lapsed token is re-minted — the gated route runs AND a fresh cookie rides the response. // Live Kratos session: the lapsed token is re-minted — the gated route runs AND a fresh cookie rides the response.
const app = createApp({ jwks: staticJwks([ecJwk]), keto, kratos: live, kratosAdmin: stubAdmin({}), plugins: [demoPlugin] }); const app = createApp({ jwks: staticJwks([ecJwk]), keto, kratos: live, kratosAdmin: stubAdmin({}), plugins: [demoPlugin] });
@@ -610,7 +610,7 @@ test("guards map to responses: requireSession → /login, a failed can/check →
{ handler: (ctx) => ({ html: `hi ${requireSession(ctx).email}` }), method: "GET", path: "/me" }, { handler: (ctx) => ({ html: `hi ${requireSession(ctx).email}` }), method: "GET", path: "/me" },
{ handler: (ctx) => { if (!can(ctx, "admin")) throw new GuardError(403, "no"); return { html: "ok" }; }, method: "GET", path: "/admin-only" }, { handler: (ctx) => { if (!can(ctx, "admin")) throw new GuardError(403, "no"); return { html: "ok" }; }, method: "GET", path: "/admin-only" },
{ handler: async (ctx) => { if (!(await check(keto, ctx, { namespace: "Resource", object: ctx.params.id ?? "", relation: "view" }))) throw new GuardError(403, "no"); return { html: "seen" }; }, method: "GET", path: "/doc/:id" }, { handler: async (ctx) => { if (!(await check(keto, ctx, { namespace: "Resource", object: ctx.params.id ?? "", relation: "view" }))) throw new GuardError(403, "no"); return { html: "seen" }; }, method: "GET", path: "/doc/:id" },
{ handler: () => ({ html: "gated" }), method: "GET", path: "/gated", role: "secret:read" }, // declarative route gate { handler: () => ({ html: "gated" }), method: "GET", path: "/gated", permission: "secret:read" }, // declarative route gate
], ],
}; };
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [guarded] }); const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [guarded] });
@@ -618,7 +618,7 @@ test("guards map to responses: requireSession → /login, a failed can/check →
t.after(() => app.close()); t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`; const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const nowSec = Math.floor(Date.now() / 1000); const nowSec = Math.floor(Date.now() / 1000);
const auth = (roles: string[]) => ({ headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles, sub: "u1" })}` } }); const auth = (permissions: string[]) => ({ headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions, sub: "u1" })}` } });
// requireSession: anonymous bounces to /login (remembering the page); a signed-in user reaches the handler. // requireSession: anonymous bounces to /login (remembering the page); a signed-in user reaches the handler.
const anon = await fetch(url + "/guarded/me", { redirect: "manual" }); const anon = await fetch(url + "/guarded/me", { redirect: "manual" });
@@ -628,7 +628,7 @@ test("guards map to responses: requireSession → /login, a failed can/check →
assert.equal(me.status, 200); assert.equal(me.status, 200);
assert.match(await me.text(), /hi a@b\.c/); assert.match(await me.text(), /hi a@b\.c/);
// can: signed-in but lacking the role → 403 page; carrying it → 200. // can: signed-in but lacking the permission → 403 page; carrying it → 200.
assert.equal((await fetch(url + "/guarded/admin-only", auth([]))).status, 403); assert.equal((await fetch(url + "/guarded/admin-only", auth([]))).status, 403);
assert.equal((await fetch(url + "/guarded/admin-only", auth(["admin"]))).status, 200); assert.equal((await fetch(url + "/guarded/admin-only", auth(["admin"]))).status, 200);
@@ -636,7 +636,7 @@ test("guards map to responses: requireSession → /login, a failed can/check →
assert.equal((await fetch(url + "/guarded/doc/open", auth([]))).status, 200); assert.equal((await fetch(url + "/guarded/doc/open", auth([]))).status, 200);
assert.equal((await fetch(url + "/guarded/doc/shut", auth([]))).status, 403); assert.equal((await fetch(url + "/guarded/doc/shut", auth([]))).status, 403);
// declarative route `role` gate: anonymous → sign in, signed-in-without-role → the 403 page, with → 200. // declarative route `permission` gate: anonymous → sign in, signed-in-without-permission → the 403 page, with → 200.
const gAnon = await fetch(url + "/guarded/gated", { redirect: "manual" }); const gAnon = await fetch(url + "/guarded/gated", { redirect: "manual" });
assert.equal(gAnon.status, 303); assert.equal(gAnon.status, 303);
assert.equal(gAnon.headers.get("location"), "/login?return_to=%2Fguarded%2Fgated"); assert.equal(gAnon.headers.get("location"), "/login?return_to=%2Fguarded%2Fgated");
@@ -717,7 +717,7 @@ test("themed auth GET: anonymous inits a flow (CSRF relay, stale→restart); a s
assert.equal(stale.headers.get("location"), "/login"); assert.equal(stale.headers.get("location"), "/login");
// Already signed in → /login + /registration short-circuit to the app dashboard; /settings stays reachable. // Already signed in → /login + /registration short-circuit to the app dashboard; /settings stays reachable.
const signedIn = { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, roles: [], sub: "u1" })}` }, redirect: "manual" as const }; const signedIn = { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, permissions: [], sub: "u1" })}` }, redirect: "manual" as const };
for (const path of ["/login", "/registration"]) { for (const path of ["/login", "/registration"]) {
const res = await fetch(url + path, signedIn); const res = await fetch(url + path, signedIn);
assert.equal(res.status, 303, `${path} while signed in → 303`); assert.equal(res.status, 303, `${path} while signed in → 303`);
@@ -856,7 +856,7 @@ const fakeKeto = (tuples: RelationTuple[] = [], over: Partial<KetoClient> = {}):
const withWhoami = (whoami: KratosPublic["whoami"]): KratosPublic => ({ ...mockKratos(async () => { throw new Error("unused"); }), whoami }); const withWhoami = (whoami: KratosPublic["whoami"]): KratosPublic => ({ ...mockKratos(async () => { throw new Error("unused"); }), whoami });
// Shared harness for the admin-screen HTTP tests: an app on a random port with an admin JWT + // Shared harness for the admin-screen HTTP tests: an app on a random port with an admin JWT +
// CSRF cookie. get(path, roles)/post(path, body) carry them; `token` is the matching CSRF field. // CSRF cookie. get(path, permissions)/post(path, body) carry them; `token` is the matching CSRF field.
const ADMIN_CSRF = "admin-secret"; const ADMIN_CSRF = "admin-secret";
async function adminHarness(t: TestContext, opts: AppOptions = {}) { async function adminHarness(t: TestContext, opts: AppOptions = {}) {
const app = createApp({ csrfSecret: ADMIN_CSRF, jwks: staticJwks([ecJwk]), pluginsDir: examplesPluginsDir, plugins: [adminPlugin], ...opts }); const app = createApp({ csrfSecret: ADMIN_CSRF, jwks: staticJwks([ecJwk]), pluginsDir: examplesPluginsDir, plugins: [adminPlugin], ...opts });
@@ -865,14 +865,14 @@ async function adminHarness(t: TestContext, opts: AppOptions = {}) {
const url = `http://localhost:${(app.address() as AddressInfo).port}`; const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const token = issueCsrfToken(ADMIN_CSRF); const token = issueCsrfToken(ADMIN_CSRF);
const nowSec = Math.floor(Date.now() / 1000); const nowSec = Math.floor(Date.now() / 1000);
const cookie = (roles: string[]) => `${SESSION_COOKIE}=${mintJwt({ email: "admin@x", exp: nowSec + 600, roles, sub: "admin1" })}; ${CSRF_COOKIE}=${token}`; const cookie = (permissions: string[]) => `${SESSION_COOKIE}=${mintJwt({ email: "admin@x", exp: nowSec + 600, permissions, sub: "admin1" })}; ${CSRF_COOKIE}=${token}`;
const get = (path: string, roles: string[] = ["admin"]) => fetch(url + path, { headers: { cookie: cookie(roles) }, redirect: "manual" }); const get = (path: string, permissions: string[] = ["admin"]) => fetch(url + path, { headers: { cookie: cookie(permissions) }, redirect: "manual" });
const post = (path: string, body: string) => const post = (path: string, body: string) =>
fetch(url + path, { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookie(["admin"]) }, method: "POST", redirect: "manual" }); fetch(url + path, { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookie(["admin"]) }, method: "POST", redirect: "manual" });
return { get, post, token, url }; return { get, post, token, url };
} }
// Every admin route is gated: anonymous → /login, a signed-in non-admin → 403. // Every admin route is gated: anonymous → /login, a signed-in non-admin → 403.
async function assertAdminGate(url: string, get: (path: string, roles?: string[]) => Promise<Response>, path: string) { async function assertAdminGate(url: string, get: (path: string, permissions?: string[]) => Promise<Response>, path: string) {
const anon = await fetch(url + path, { redirect: "manual" }); const anon = await fetch(url + path, { redirect: "manual" });
assert.equal(anon.status, 303); assert.equal(anon.status, 303);
assert.equal(anon.headers.get("location"), `/login?return_to=${encodeURIComponent(path)}`); // remembers the page assert.equal(anon.headers.get("location"), `/login?return_to=${encodeURIComponent(path)}`); // remembers the page
@@ -884,7 +884,7 @@ test("login completion (/auth/complete): a live session mints the JWT cookie; no
let projected: unknown; let projected: unknown;
const kratos = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session); const kratos = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session);
const kratosAdmin = stubAdmin({ updateMetadataPublic: async (_id, meta) => { projected = meta; return identity; } }); const kratosAdmin = stubAdmin({ updateMetadataPublic: async (_id, meta) => { projected = meta; return identity; } });
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "admin", relation: "members", subject_id: `identity:${identity.id}` }] }) }); const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Permission", object: "admin", relation: "granted", subject_id: `identity:${identity.id}` }] }) });
const complete = async (app: ReturnType<typeof createApp>, cookie?: string, returnTo?: string) => { const complete = async (app: ReturnType<typeof createApp>, cookie?: string, returnTo?: string) => {
await new Promise<void>((r) => app.listen(0, r)); await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close()); t.after(() => app.close());
@@ -892,12 +892,12 @@ test("login completion (/auth/complete): a live session mints the JWT cookie; no
return fetch(`http://localhost:${(app.address() as AddressInfo).port}/auth/complete${q}`, { headers: cookie ? { cookie } : {}, redirect: "manual" }); return fetch(`http://localhost:${(app.address() as AddressInfo).port}/auth/complete${q}`, { headers: cookie ? { cookie } : {}, redirect: "manual" });
}; };
// Live Kratos session: roles from Keto → projection → tokenize → JWT cookie, land on the dashboard. // Live Kratos session: permissions from Keto → projection → tokenize → JWT cookie, land on the dashboard.
const ok = await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s"); const ok = await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s");
assert.equal(ok.status, 303); assert.equal(ok.status, 303);
assert.equal(ok.headers.get("location"), "/dashboard"); assert.equal(ok.headers.get("location"), "/dashboard");
assert.match(ok.headers.get("set-cookie") ?? "", /^plainpages_jwt=h\.p\.s;.*HttpOnly/); assert.match(ok.headers.get("set-cookie") ?? "", /^plainpages_jwt=h\.p\.s;.*HttpOnly/);
assert.deepEqual(projected, { roles: ["admin"] }); // Keto roles projected onto the identity for the tokenizer assert.deepEqual(projected, { permissions: ["admin"] }); // Keto permissions projected onto the identity for the tokenizer
// return_to: a safe host-relative target lands the user back where they were headed; an // return_to: a safe host-relative target lands the user back where they were headed; an
// off-origin one is ignored (open-redirect guard) and falls back to the dashboard. // off-origin one is ignored (open-redirect guard) and falls back to the dashboard.
@@ -1164,7 +1164,7 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
assert.equal((await post(`/admin/users/admin1/state`, `_csrf=${token}`)).status, 400); assert.equal((await post(`/admin/users/admin1/state`, `_csrf=${token}`)).status, 400);
assert.equal(store.find((x) => x.id === "admin1")!.state, "active"); assert.equal(store.find((x) => x.id === "admin1")!.state, "active");
// Unknown id → 404; malformed %-encoding → 404 (not a 500), matching groups/roles/clients. // Unknown id → 404; malformed %-encoding → 404 (not a 500), matching groups/permissions/clients.
assert.equal((await get(`/admin/users/${randomUUID()}`)).status, 404); assert.equal((await get(`/admin/users/${randomUUID()}`)).status, 404);
assert.equal((await get("/admin/users/%ZZ")).status, 404); assert.equal((await get("/admin/users/%ZZ")).status, 404);
}); });
@@ -1235,10 +1235,10 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
{ id: ada, schema_id: "default", state: "active", traits: { email: "ada@example.com" } }, { id: ada, schema_id: "default", state: "active", traits: { email: "ada@example.com" } },
{ id: grace, schema_id: "default", state: "active", traits: { email: "grace@example.com" } }, { id: grace, schema_id: "default", state: "active", traits: { email: "grace@example.com" } },
]; ];
// grace is in the `eng` group; `editor` is an existing role whose only direct member is ada. // grace is in the `eng` group; `editor` is an existing permission whose only direct member is ada.
const tuples: RelationTuple[] = [ const tuples: RelationTuple[] = [
{ namespace: "Group", object: "eng", relation: "members", subject_id: `identity:${grace}` }, { namespace: "Group", object: "eng", relation: "members", subject_id: `identity:${grace}` },
{ namespace: "Role", object: "editor", relation: "members", subject_id: `identity:${ada}` }, { namespace: "Permission", object: "editor", relation: "granted", subject_id: `identity:${ada}` },
]; ];
// Mirror Keto's expand shape: the subject rides on `tuple`, set nodes carry members as children. // Mirror Keto's expand shape: the subject rides on `tuple`, set nodes carry members as children.
const expandSet = (set: SubjectSet): ExpandTree => ({ const expandSet = (set: SubjectSet): ExpandTree => ({
@@ -1250,70 +1250,70 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
}); });
const keto = fakeKeto(tuples, { expand: async (set) => expandSet(set) }); const keto = fakeKeto(tuples, { expand: async (set) => expandSet(set) });
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) }); const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
const denylist = createDenylist(); // granting/revoking a *user's* role revokes their live tokens (a group change is transitive → left to lag) const denylist = createDenylist(); // granting/revoking a *user's* permission revokes their live tokens (a group change is transitive → left to lag)
const { get, post, token, url } = await adminHarness(t, { denylist, keto, kratosAdmin }); const { get, post, token, url } = await adminHarness(t, { denylist, keto, kratosAdmin });
await assertAdminGate(url, get, "/admin/roles"); await assertAdminGate(url, get, "/admin/permissions");
// List: the existing role shows + the "add" link. // List: the existing permission shows + the "add" link.
const listHtml = await (await get("/admin/roles")).text(); const listHtml = await (await get("/admin/permissions")).text();
assert.match(listHtml, /href="\/admin\/roles\/editor"/); assert.match(listHtml, /href="\/admin\/permissions\/editor"/);
assert.match(listHtml, /href="\/admin\/roles\/new"/); assert.match(listHtml, /href="\/admin\/permissions\/new"/);
// Create: a valid post writes the first-member tuple and redirects to the detail. // Create: a valid post writes the first-member tuple and redirects to the detail.
assert.match(await (await get("/admin/roles/new")).text(), /Create role/); assert.match(await (await get("/admin/permissions/new")).text(), /Create permission/);
const created = await post("/admin/roles", `_csrf=${token}&name=viewer&member=identity:${ada}`); const created = await post("/admin/permissions", `_csrf=${token}&name=viewer&member=identity:${ada}`);
assert.equal(created.status, 303); assert.equal(created.status, 303);
assert.equal(created.headers.get("location"), "/admin/roles/viewer"); assert.equal(created.headers.get("location"), "/admin/permissions/viewer");
assert.ok(tuples.some((tp) => tp.namespace === "Role" && tp.object === "viewer" && tp.subject_id === `identity:${ada}`)); assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "viewer" && tp.subject_id === `identity:${ada}`));
assert.equal(denylist.isRevoked(ada, 0), true); // assigning a role to a user revokes their stale token so the grant lands now assert.equal(denylist.isRevoked(ada, 0), true); // assigning a permission to a user revokes their stale token so the grant lands now
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written. // An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written.
const before = tuples.length; const before = tuples.length;
assert.equal((await post("/admin/roles", `_csrf=${token}&name=Bad Name&member=identity:${ada}`)).status, 400); assert.equal((await post("/admin/permissions", `_csrf=${token}&name=Bad Name&member=identity:${ada}`)).status, 400);
assert.equal((await post("/admin/roles", `_csrf=${token}&name=editor&member=identity:${ada}`)).status, 400); // already exists assert.equal((await post("/admin/permissions", `_csrf=${token}&name=editor&member=identity:${ada}`)).status, 400); // already exists
assert.equal((await post("/admin/roles", `name=x&member=identity:${ada}`)).status, 403); assert.equal((await post("/admin/permissions", `name=x&member=identity:${ada}`)).status, 403);
assert.equal(tuples.length, before); assert.equal(tuples.length, before);
// Detail: ada (direct) is in the effective-access list; grace (only reachable via a group) is not // Detail: ada (direct) is in the effective-access list; grace (only reachable via a group) is not
// yet — though grace appears elsewhere as an assignable candidate, so target the effective <li>. // yet — though grace appears elsewhere as an assignable candidate, so target the effective <li>.
const effectiveLi = (email: string) => new RegExp(`<li><span class="cell-strong">${email.replace(".", "\\.")}`); const effectiveLi = (email: string) => new RegExp(`<li><span class="cell-strong">${email.replace(".", "\\.")}`);
const detail = await (await get("/admin/roles/editor")).text(); const detail = await (await get("/admin/permissions/editor")).text();
assert.match(detail, effectiveLi("ada@example.com")); assert.match(detail, effectiveLi("ada@example.com"));
assert.doesNotMatch(detail, effectiveLi("grace@example.com")); assert.doesNotMatch(detail, effectiveLi("grace@example.com"));
// Assign the `eng` group to the role → grace now holds it transitively (effective access via expand). // Assign the `eng` group to the permission → grace now holds it transitively (effective access via expand).
await post("/admin/roles/editor/members", `_csrf=${token}&member=group:eng`); await post("/admin/permissions/editor/members", `_csrf=${token}&member=group:eng`);
assert.ok(tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor" && tp.subject_set?.object === "eng")); assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "editor" && tp.subject_set?.object === "eng"));
const withGroup = await (await get("/admin/roles/editor")).text(); const withGroup = await (await get("/admin/permissions/editor")).text();
assert.match(withGroup, effectiveLi("grace@example.com")); assert.match(withGroup, effectiveLi("grace@example.com"));
// Revoke the group membership. // Revoke the group membership.
await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=group:eng`); await post("/admin/permissions/editor/members/delete", `_csrf=${token}&member=group:eng`);
assert.ok(!tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor" && tp.subject_set?.object === "eng")); assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.object === "editor" && tp.subject_set?.object === "eng"));
// Unassigning a *user* membership likewise revokes that user's live token, so the loss of access is immediate. // Unassigning a *user* membership likewise revokes that user's live token, so the loss of access is immediate.
await post("/admin/roles/editor/members", `_csrf=${token}&member=identity:${grace}`); await post("/admin/permissions/editor/members", `_csrf=${token}&member=identity:${grace}`);
await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=identity:${grace}`); await post("/admin/permissions/editor/members/delete", `_csrf=${token}&member=identity:${grace}`);
assert.equal(denylist.isRevoked(grace, 0), true); assert.equal(denylist.isRevoked(grace, 0), true);
// Delete the role: a confirm step (GET) then the POST removes every member tuple, back to the list. // Delete the permission: a confirm step (GET) then the POST removes every member tuple, back to the list.
assert.match(await (await get("/admin/roles/editor/delete")).text(), /Cancel/); assert.match(await (await get("/admin/permissions/editor/delete")).text(), /Cancel/);
const del = await post("/admin/roles/editor/delete", `_csrf=${token}`); const del = await post("/admin/permissions/editor/delete", `_csrf=${token}`);
assert.equal(del.status, 303); assert.equal(del.status, 303);
assert.equal(del.headers.get("location"), "/admin/roles"); assert.equal(del.headers.get("location"), "/admin/permissions");
assert.ok(!tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor")); assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.object === "editor"));
// Self-protection: the admin role can't be deleted, nor can you revoke your own admin (sub admin1). // Self-protection: the admin permission can't be deleted, nor can you revoke your own admin (sub admin1).
tuples.push({ namespace: "Role", object: "admin", relation: "members", subject_id: "identity:admin1" }); tuples.push({ namespace: "Permission", object: "admin", relation: "granted", subject_id: "identity:admin1" });
assert.equal((await post("/admin/roles/admin/delete", `_csrf=${token}`)).status, 400); assert.equal((await post("/admin/permissions/admin/delete", `_csrf=${token}`)).status, 400);
assert.ok(tuples.some((tp) => tp.object === "admin")); assert.ok(tuples.some((tp) => tp.object === "admin"));
assert.equal((await post("/admin/roles/admin/members/delete", `_csrf=${token}&member=identity:admin1`)).status, 400); assert.equal((await post("/admin/permissions/admin/members/delete", `_csrf=${token}&member=identity:admin1`)).status, 400);
assert.ok(tuples.some((tp) => tp.object === "admin" && tp.subject_id === "identity:admin1")); assert.ok(tuples.some((tp) => tp.object === "admin" && tp.subject_id === "identity:admin1"));
// An invalid role name in the path → 404; malformed %-encoding doesn't 500. // An invalid permission name in the path → 404; malformed %-encoding doesn't 500.
assert.equal((await get("/admin/roles/Bad%20Name")).status, 404); assert.equal((await get("/admin/permissions/Bad%20Name")).status, 404);
assert.equal((await get("/admin/roles/%ZZ")).status, 404); assert.equal((await get("/admin/permissions/%ZZ")).status, 404);
}); });
// Built-in OAuth2 clients admin screen: gate + list/register/detail/delete over HTTP against an // Built-in OAuth2 clients admin screen: gate + list/register/detail/delete over HTTP against an
+7 -7
View File
@@ -40,7 +40,7 @@ export interface AppOptions {
csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted
denylist?: Denylist; // optional instant-revoke; the hot path rejects revoked subjects, admin writes record revokes denylist?: Denylist; // optional instant-revoke; the hot path rejects revoked subjects, admin writes record revokes
hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge
jwks?: JwksProvider; // verify the session JWT → ctx.identity/roles; absent ⇒ always anonymous jwks?: JwksProvider; // verify the session JWT → ctx.identity/permissions; absent ⇒ always anonymous
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes
kratosAdmin?: KratosAdmin; // Kratos admin client; with kratos+keto enables login completion kratosAdmin?: KratosAdmin; // Kratos admin client; with kratos+keto enables login completion
@@ -186,9 +186,9 @@ export function createApp(options: AppOptions = {}): Server {
} }
} }
// Verify the session JWT once (cached JWKS) → ctx.identity/roles; none/invalid ⇒ anonymous. // Verify the session JWT once (cached JWKS) → ctx.identity/permissions; none/invalid ⇒ anonymous.
// If the token has lapsed but a live Kratos session still backs it (and we have the Ory // If the token has lapsed but a live Kratos session still backs it (and we have the Ory
// clients), silently re-mint it — "stay signed in": re-read roles from Keto, re-tokenize, // clients), silently re-mint it — "stay signed in": re-read permissions from Keto, re-tokenize,
// and set the fresh cookie via setHeader so it rides whatever response this request produces // and set the fresh cookie via setHeader so it rides whatever response this request produces
// (a dead session clears the stale cookie). This is the only place the hot path touches Ory. // (a dead session clears the stale cookie). This is the only place the hot path touches Ory.
let user: SessionIdentity | null = null; let user: SessionIdentity | null = null;
@@ -240,17 +240,17 @@ export function createApp(options: AppOptions = {}): Server {
} }
} }
// Plugin routes (any method): gate on the route's role, then run the handler. The // Plugin routes (any method): gate on the route's permission, then run the handler. The
// handler gets ctx.chrome (native app shell) + ctx.verifyCsrf (guard its own forms); a fresh // handler gets ctx.chrome (native app shell) + ctx.verifyCsrf (guard its own forms); a fresh
// CSRF cookie is set so those forms have a valid double-submit token. // CSRF cookie is set so those forms have a valid double-submit token.
const match = matchRoute(plugins, method, pathname); const match = matchRoute(plugins, method, pathname);
if (match) { if (match) {
const routeCtx = buildContext(req, res, { chrome, identity: user, log: reqLog, params: match.params, verifyCsrf, ...(system ? { system } : {}) }); const routeCtx = buildContext(req, res, { chrome, identity: user, log: reqLog, params: match.params, verifyCsrf, ...(system ? { system } : {}) });
if (!isAuthorized(match.route, routeCtx.roles)) { if (!isAuthorized(match.route, routeCtx.permissions)) {
// Anonymous → sign in (like the built-in screens' requireSession), remembering the page as // Anonymous → sign in (like the built-in screens' requireSession), remembering the page as
// return_to; a signed-in user who simply lacks the role gets the 403 page. // return_to; a signed-in user who simply lacks the permission gets the 403 page.
if (!routeCtx.identity) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; } if (!routeCtx.identity) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
reqLog.warn("forbidden: missing role", { path: pathname, required: match.route.role ?? "", sub: routeCtx.identity.id }); reqLog.warn("forbidden: missing permission", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.identity.id });
sendHtml(res, 403, await render("403", { title: "Forbidden" })); sendHtml(res, 403, await render("403", { title: "Forbidden" }));
return; return;
} }
+4 -4
View File
@@ -23,7 +23,7 @@ test("buildContext parses the URL, exposes query, and defaults to an anonymous u
assert.equal(ctx.query.get("q"), "ann"); assert.equal(ctx.query.get("q"), "ann");
assert.equal(ctx.query.get("page"), "2"); assert.equal(ctx.query.get("page"), "2");
assert.equal(ctx.identity, null); assert.equal(ctx.identity, null);
assert.deepEqual(ctx.roles, []); assert.deepEqual(ctx.permissions, []);
assert.deepEqual(ctx.params, {}); assert.deepEqual(ctx.params, {});
}); });
@@ -33,12 +33,12 @@ test("buildContext threads path params supplied by the router", () => {
assert.equal(ctx.params.id, "42"); assert.equal(ctx.params.id, "42");
}); });
test("buildContext threads the user and derives roles from it", () => { test("buildContext threads the user and derives permissions from it", () => {
const { req, res } = reqRes("/"); const { req, res } = reqRes("/");
const user: SessionIdentity = { email: "a@b.c", id: "u1", roles: ["admin", "editor"] }; const user: SessionIdentity = { email: "a@b.c", id: "u1", permissions: ["admin", "editor"] };
const ctx = buildContext(req, res, { identity: user }); const ctx = buildContext(req, res, { identity: user });
assert.equal(ctx.identity, user); assert.equal(ctx.identity, user);
assert.equal(ctx.roles, user.roles); // same reference, never a divergent copy — buildContext is the only writer assert.equal(ctx.permissions, user.permissions); // same reference, never a divergent copy — buildContext is the only writer
}); });
test("buildContext defaults a missing request URL to /", () => { test("buildContext defaults a missing request URL to /", () => {
+4 -4
View File
@@ -8,11 +8,11 @@ import { createLogger, type Log } from "../logger.ts";
// middleware supplies `identity` (null until then). The host's single handler argument. // middleware supplies `identity` (null until then). The host's single handler argument.
// The authenticated Kratos identity, projected from verified session JWT claims: // The authenticated Kratos identity, projected from verified session JWT claims:
// `id` = `sub`, plus `email` and the coarse `roles` carried in the token. // `id` = `sub`, plus `email` and the coarse `permissions` carried in the token.
export interface SessionIdentity { export interface SessionIdentity {
email: string; email: string;
id: string; id: string;
roles: string[]; permissions: string[];
} }
export interface RequestContext { export interface RequestContext {
@@ -29,7 +29,7 @@ export interface RequestContext {
query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q") query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q")
req: IncomingMessage; req: IncomingMessage;
res: ServerResponse; res: ServerResponse;
roles: string[]; // identity?.roles ?? [] — coarse gate without a null-check permissions: string[]; // identity?.permissions ?? [] — coarse gate without a null-check
// Privileged host services (Ory admin clients + instant-revoke) for a system plugin. Undefined // Privileged host services (Ory admin clients + instant-revoke) for a system plugin. Undefined
// unless the host wired them; every field optional. Ordinary domain plugins ignore it. // unless the host wired them; every field optional. Ordinary domain plugins ignore it.
system?: SystemCapabilities; system?: SystemCapabilities;
@@ -74,7 +74,7 @@ export function buildContext(
query: url.searchParams, query: url.searchParams,
req, req,
res, res,
roles: identity?.roles ?? [], permissions: identity?.permissions ?? [],
...(options.system ? { system: options.system } : {}), ...(options.system ? { system: options.system } : {}),
url, url,
verifyCsrf: options.verifyCsrf ?? (() => false), // fail-closed unless the host binds the secret verifyCsrf: options.verifyCsrf ?? (() => false), // fail-closed unless the host binds the secret
+6 -6
View File
@@ -1,6 +1,6 @@
// Guards the Ory Keto config: migrations run before the server (keto-migrate → // Guards the Ory Keto config: migrations run before the server (keto-migrate →
// keto), the DSN targets the keto database, read/write APIs serve on the ports config.ts // keto), the DSN targets the keto database, read/write APIs serve on the ports config.ts
// points at, and the OPL declares the identity/role/group/resource namespaces. Version pinning is // points at, and the OPL declares the identity/permission/group/resource namespaces. Version pinning is
// in compose.test.ts. Real boot is verified by running the stack; this catches edits. // in compose.test.ts. Real boot is verified by running the stack; this catches edits.
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
@@ -35,12 +35,12 @@ test("keto loads the OPL namespaces from the mounted file", () => {
"namespaces come from the committed OPL"); "namespaces come from the committed OPL");
}); });
test("the OPL declares role, group and a resource namespace over identity subjects", () => { test("the OPL declares permission, group and a resource namespace over identity subjects", () => {
for (const ns of ["Identity", "Group", "Role", "Resource"]) for (const ns of ["Identity", "Group", "Permission", "Resource"])
assert.match(opl, new RegExp(`class ${ns} implements Namespace`), `defines ${ns}`); assert.match(opl, new RegExp(`class ${ns} implements Namespace`), `defines ${ns}`);
// role + group are subject sets read at login → JWT roles claim (README). // permission + group are subject sets read at login → JWT permissions claim (README).
assert.match(opl, /class Role implements Namespace\s*{\s*related:\s*{\s*members:/, assert.match(opl, /class Permission implements Namespace\s*{\s*related:\s*{\s*granted:/,
"Role has a members relation"); "Permission has a granted relation");
assert.match(opl, /class Group implements Namespace\s*{\s*related:\s*{\s*members:/, assert.match(opl, /class Group implements Namespace\s*{\s*related:\s*{\s*members:/,
"Group has a members relation"); "Group has a members relation");
}); });
+4 -4
View File
@@ -52,7 +52,7 @@ test("self-service flows return to our themed pages (on the localhost dev host)"
test("after a successful login Kratos returns to our /auth/complete route to mint the JWT", () => { test("after a successful login Kratos returns to our /auth/complete route to mint the JWT", () => {
assert.match(kratosYml, /default_browser_return_url:\s*http:\/\/localhost:3000\/auth\/complete/, assert.match(kratosYml, /default_browser_return_url:\s*http:\/\/localhost:3000\/auth\/complete/,
"login completion (read roles → project → tokenize → set cookie) runs at /auth/complete"); "login completion (read permissions → project → tokenize → set cookie) runs at /auth/complete");
}); });
test("recovery + verification run on email code, delivered by a courier", () => { test("recovery + verification run on email code, delivered by a courier", () => {
@@ -79,12 +79,12 @@ test("session tokenizer template 'plainpages' mints a short-lived signed JWT", (
"claims via the committed mapper"); "claims via the committed mapper");
}); });
test("the tokenizer claims mapper emits email + roles from the metadata_public projection", () => { test("the tokenizer claims mapper emits email + permissions from the metadata_public projection", () => {
// metadata_public, not _admin: the session Kratos hands the tokenizer carries only public // metadata_public, not _admin: the session Kratos hands the tokenizer carries only public
// metadata (admin metadata is stripped), so the roles projection must live in metadata_public. // metadata (admin metadata is stripped), so the permissions projection must live in metadata_public.
const mapper = read("ory/kratos/tokenizer/plainpages.jsonnet"); const mapper = read("ory/kratos/tokenizer/plainpages.jsonnet");
assert.match(mapper, /email:\s*session\.identity\.traits\.email/, "email ← identity trait"); assert.match(mapper, /email:\s*session\.identity\.traits\.email/, "email ← identity trait");
assert.match(mapper, /metadata_public/, "roles ← metadata_public (the per-login Keto projection)"); assert.match(mapper, /metadata_public/, "permissions ← metadata_public (the per-login Keto projection)");
}); });
test("social sign-in is off by default — a clean clone stays password-only", () => { test("social sign-in is off by default — a clean clone stays password-only", () => {
+5 -5
View File
@@ -50,8 +50,8 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
{ name: "non-function dashboard", files: { "weirddash/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: "nope" };` }, match: /weirddash.*dashboard.*function/s }, { name: "non-function dashboard", files: { "weirddash/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: "nope" };` }, match: /weirddash.*dashboard.*function/s },
{ name: "reserved dashboard id shadows the gated dashboard", files: { "dashboard/plugin.ts": full("dashboard") }, match: /dashboard.*reserved/s }, { name: "reserved dashboard id shadows the gated dashboard", files: { "dashboard/plugin.ts": full("dashboard") }, match: /dashboard.*reserved/s },
{ name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ }, { name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ },
{ name: "a route marked public AND role is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", public: true, role: "x", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*role/s }, { name: "a route marked public AND permission is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", public: true, permission: "x", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*permission/s },
{ name: "a nav node marked public AND role is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", public: true, role: "x" }] };` }, match: /contranav.*public.*role/s }, { name: "a nav node marked public AND permission is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", public: true, permission: "x" }] };` }, match: /contranav.*public.*permission/s },
{ name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "b" }) };` }, match: /home/ }, { name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "b" }) };` }, match: /home/ },
{ name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ }, { name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ },
]; ];
@@ -85,12 +85,12 @@ test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard)
assert.equal(typeof plugins[0]?.dashboard, "function"); assert.equal(typeof plugins[0]?.dashboard, "function");
}); });
test("a shared role name only warns — both plugins still load", async (t) => { test("a shared permission name only warns — both plugins still load", async (t) => {
const shared = `export default { apiVersion: "1.0.0", roles: [{ name: "shared:read" }] };`; const shared = `export default { apiVersion: "1.0.0", permissions: [{ name: "shared:read" }] };`;
const dir = scaffold(t, { "x/plugin.ts": shared, "y/plugin.ts": shared }); const dir = scaffold(t, { "x/plugin.ts": shared, "y/plugin.ts": shared });
const warnings: string[] = []; const warnings: string[] = [];
const plugins = await discoverPlugins({ dir, logger: { warn: (m) => warnings.push(String(m)) } }); const plugins = await discoverPlugins({ dir, logger: { warn: (m) => warnings.push(String(m)) } });
assert.equal(plugins.length, 2); assert.equal(plugins.length, 2);
assert.ok(warnings.some((w) => /shared:read/.test(w)), "expected a role-conflict warning"); assert.ok(warnings.some((w) => /shared:read/.test(w)), "expected a permission-conflict warning");
}); });
+7 -7
View File
@@ -2,7 +2,7 @@
// validate it, assemble the loaded Plugin[]. The imperative shell over plugin.ts's pure rules // validate it, assemble the loaded Plugin[]. The imperative shell over plugin.ts's pure rules
// (isValidPluginId, checkApiVersion, findConflicts). Fails loud: every per-plugin problem and // (isValidPluginId, checkApiVersion, findConflicts). Fails loud: every per-plugin problem and
// error-level conflict is collected into one boot-stopping Error; warn-level diagnostics // error-level conflict is collected into one boot-stopping Error; warn-level diagnostics
// (older-minor apiVersion, shared role name) log and load continues. Folder name = id. // (older-minor apiVersion, shared permission name) log and load continues. Folder name = id.
import { existsSync, readdirSync } from "node:fs"; import { existsSync, readdirSync } from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
@@ -85,7 +85,7 @@ function asManifest(value: unknown): PluginManifest | null {
// The collection fields feed findConflicts, which iterates them — a non-array crashes it opaquely. // The collection fields feed findConflicts, which iterates them — a non-array crashes it opaquely.
function shapeError(manifest: PluginManifest): string | null { function shapeError(manifest: PluginManifest): string | null {
for (const field of ["nav", "roles", "routes"] as const) { for (const field of ["nav", "permissions", "routes"] as const) {
if (manifest[field] !== undefined && !Array.isArray(manifest[field])) return `"${field}" must be an array`; if (manifest[field] !== undefined && !Array.isArray(manifest[field])) return `"${field}" must be an array`;
} }
// `home` / `dashboard` (the landing-page overrides) are route handlers; the host calls them, so // `home` / `dashboard` (the landing-page overrides) are route handlers; the host calls them, so
@@ -93,20 +93,20 @@ function shapeError(manifest: PluginManifest): string | null {
for (const slot of ["home", "dashboard"] as const) { for (const slot of ["home", "dashboard"] as const) {
if (manifest[slot] !== undefined && typeof manifest[slot] !== "function") return `"${slot}" must be a function (a route handler)`; if (manifest[slot] !== undefined && typeof manifest[slot] !== "function") return `"${slot}" must be a function (a route handler)`;
} }
// `public` and `role` are contradictory on the same route/nav node — "open to all" vs // `public` and `permission` are contradictory on the same route/nav node — "open to all" vs
// "needs this role". Refuse rather than silently pick one, so the author's intent is unambiguous. // "needs this permission". Refuse rather than silently pick one, so the author's intent is unambiguous.
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) { for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
if (route?.public === true && route.role != null) return `route "${route.method} ${route.path}" sets both public and role — they are mutually exclusive`; if (route?.public === true && route.permission != null) return `route "${route.method} ${route.path}" sets both public and permission — they are mutually exclusive`;
} }
const navContradiction = findPublicNavContradiction(manifest.nav); const navContradiction = findPublicNavContradiction(manifest.nav);
if (navContradiction) return navContradiction; if (navContradiction) return navContradiction;
return null; return null;
} }
// Recurse the nav fragment: a node that is both `public` and `role`-gated is contradictory. // Recurse the nav fragment: a node that is both `public` and `permission`-gated is contradictory.
function findPublicNavContradiction(nodes: PluginManifest["nav"]): string | null { function findPublicNavContradiction(nodes: PluginManifest["nav"]): string | null {
for (const node of Array.isArray(nodes) ? nodes : []) { for (const node of Array.isArray(nodes) ? nodes : []) {
if (node?.public === true && node.role != null) return `nav node "${node.label ?? node.id ?? "?"}" sets both public and role — they are mutually exclusive`; if (node?.public === true && node.permission != null) return `nav node "${node.label ?? node.id ?? "?"}" sets both public and permission — they are mutually exclusive`;
const inChild = findPublicNavContradiction(node?.children); const inChild = findPublicNavContradiction(node?.children);
if (inChild) return inChild; if (inChild) return inChild;
} }
+1 -1
View File
@@ -5,7 +5,7 @@
// a plugin should import from here, never reach into deeper modules. See README.md → Building plugins. // a plugin should import from here, never reach into deeper modules. See README.md → Building plugins.
export { definePlugin } from "./plugin.ts"; export { definePlugin } from "./plugin.ts";
export type { HttpMethod, Plugin, PluginHooks, PluginManifest, RoleDecl, Route, RouteHandler, RouteResult } from "./plugin.ts"; export type { HttpMethod, Plugin, PluginHooks, PluginManifest, PermissionDecl, Route, RouteHandler, RouteResult } from "./plugin.ts";
export type { RequestContext, SessionIdentity } from "../http/context.ts"; export type { RequestContext, SessionIdentity } from "../http/context.ts";
export type { PageChrome } from "../ui/chrome.ts"; export type { PageChrome } from "../ui/chrome.ts";
export type { NavNode } from "../ui/nav.ts"; export type { NavNode } from "../ui/nav.ts";
+10 -10
View File
@@ -21,13 +21,13 @@ const scheduling: PluginManifest = definePlugin({
apiVersion: "1.0.0", apiVersion: "1.0.0",
hooks: { onBoot: () => {} }, hooks: { onBoot: () => {} },
nav: [{ nav: [{
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", role: "scheduling:read" }], children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }],
icon: "i-cal", id: "scheduling:root", label: "Scheduling", icon: "i-cal", id: "scheduling:root", label: "Scheduling",
}], }],
roles: [{ description: "View shifts", name: "scheduling:read" }], permissions: [{ description: "View shifts", name: "scheduling:read" }],
routes: [ routes: [
{ handler: () => ({ data: { rows: [] }, view: "shifts" }), method: "GET", path: "/shifts", role: "scheduling:read" }, { handler: () => ({ data: { rows: [] }, view: "shifts" }), method: "GET", path: "/shifts", permission: "scheduling:read" },
{ handler: () => ({ redirect: "/scheduling/shifts" }), method: "POST", path: "/shifts", role: "scheduling:write" }, { handler: () => ({ redirect: "/scheduling/shifts" }), method: "POST", path: "/shifts", permission: "scheduling:write" },
{ handler: (ctx) => void ctx.res.end("raw"), method: "GET", path: "/raw" }, // void = handler wrote res itself { handler: (ctx) => void ctx.res.end("raw"), method: "GET", path: "/raw" }, // void = handler wrote res itself
], ],
}); });
@@ -87,19 +87,19 @@ test("findConflicts: a duplicate id and a colliding route are loud errors", () =
assert.ok(dupRoute.some((c) => c.kind === "route" && c.level === "error" && c.message.includes("/a/t"))); assert.ok(dupRoute.some((c) => c.kind === "route" && c.level === "error" && c.message.includes("/a/t")));
}); });
test("findConflicts: duplicate nav id is an error, a shared role name only warns", () => { test("findConflicts: duplicate nav id is an error, a shared permission name only warns", () => {
const navDup = findConflicts([ const navDup = findConflicts([
p({ id: "a", nav: [{ id: "dup", label: "A" }] }), p({ id: "a", nav: [{ id: "dup", label: "A" }] }),
p({ id: "b", nav: [{ id: "dup", label: "B" }] }), p({ id: "b", nav: [{ id: "dup", label: "B" }] }),
]); ]);
assert.ok(navDup.some((c) => c.kind === "nav-id" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b"))); assert.ok(navDup.some((c) => c.kind === "nav-id" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
// Sharing a role across plugins is legitimate → warn, not error. // Sharing a permission across plugins is legitimate → warn, not error.
const roleDup = findConflicts([ const permissionDup = findConflicts([
p({ id: "a", roles: [{ name: "shared:read" }] }), p({ id: "a", permissions: [{ name: "shared:read" }] }),
p({ id: "b", roles: [{ name: "shared:read" }] }), p({ id: "b", permissions: [{ name: "shared:read" }] }),
]); ]);
assert.ok(roleDup.some((c) => c.kind === "role" && c.level === "warn")); assert.ok(permissionDup.some((c) => c.kind === "permission" && c.level === "warn"));
}); });
test("findConflicts: each single slot (`home`/`dashboard`) may have one owner — two is a loud error", () => { test("findConflicts: each single slot (`home`/`dashboard`) may have one owner — two is a loud error", () => {
+10 -10
View File
@@ -29,16 +29,16 @@ export interface Route {
handler: RouteHandler; handler: RouteHandler;
method: HttpMethod; method: HttpMethod;
path: string; // relative to the plugin's mount path `/<id>`; ":name" segments → ctx.params.name path: string; // relative to the plugin's mount path `/<id>`; ":name" segments → ctx.params.name
role?: string; // coarse gate — the Keto Role the caller must hold; checked before the handler runs permission?: string; // coarse gate — the Keto Permission the caller must hold; checked before the handler runs
// Mark the page reachable by anyone, signed in or not. The same as omitting `role` // Mark the page reachable by anyone, signed in or not. The same as omitting `permission`
// — an ungated route is already open — but stated outright, so "public" is a deliberate // — an ungated route is already open — but stated outright, so "public" is a deliberate
// choice, not an accident. Mutually exclusive with `role` (discovery refuses both). // choice, not an accident. Mutually exclusive with `permission` (discovery refuses both).
public?: boolean; public?: boolean;
} }
// A Keto Role this plugin gates on — declared for docs/seeding. Role names are a shared // A Keto Permission this plugin gates on — declared for docs/seeding. Permission names are a shared
// global namespace (so an operator grants them once in Keto); namespace as `<id>:<action>`. // global namespace (so an operator grants them once in Keto); namespace as `<id>:<action>`.
export interface RoleDecl { export interface PermissionDecl {
description?: string; description?: string;
name: string; name: string;
} }
@@ -63,7 +63,7 @@ export interface PluginManifest {
home?: RouteHandler; home?: RouteHandler;
hooks?: PluginHooks; hooks?: PluginHooks;
nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/ui/icons.ts), node ids must be globally unique nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/ui/icons.ts), node ids must be globally unique
roles?: RoleDecl[]; permissions?: PermissionDecl[];
routes?: Route[]; routes?: Route[];
} }
@@ -147,7 +147,7 @@ export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HO
} }
export interface PluginConflict { export interface PluginConflict {
kind: "dashboard" | "home" | "id" | "nav-id" | "role" | "route"; kind: "dashboard" | "home" | "id" | "nav-id" | "permission" | "route";
level: "error" | "warn"; level: "error" | "warn";
message: string; message: string;
plugins: string[]; // unique ids involved plugins: string[]; // unique ids involved
@@ -155,7 +155,7 @@ export interface PluginConflict {
// The conflict rules: defined, loud resolution — never last-write-wins. Pure over the discovered // The conflict rules: defined, loud resolution — never last-write-wins. Pure over the discovered
// plugins; discovery throws on any "error" and logs every "warn". Mount-path (`/<id>`) uniqueness // plugins; discovery throws on any "error" and logs every "warn". Mount-path (`/<id>`) uniqueness
// is structural — it follows from the id check, so it needs no rule of its own. Shared role // is structural — it follows from the id check, so it needs no rule of its own. Shared permission
// names are the one intentional overlap, so they warn rather than error. // names are the one intentional overlap, so they warn rather than error.
export function findConflicts(plugins: Plugin[]): PluginConflict[] { export function findConflicts(plugins: Plugin[]): PluginConflict[] {
const out: PluginConflict[] = []; const out: PluginConflict[] = [];
@@ -184,9 +184,9 @@ export function findConflicts(plugins: Plugin[]): PluginConflict[] {
}); });
collect(plugins, (plugin, push) => { collect(plugins, (plugin, push) => {
for (const decl of plugin.roles ?? []) push(decl.name); for (const decl of plugin.permissions ?? []) push(decl.name);
}).forEach((owners, name) => { }).forEach((owners, name) => {
if (owners.length > 1) out.push({ kind: "role", level: "warn", message: `role "${name}" declared by ${uniq(owners).length} plugins; namespace as "<id>:<action>" unless shared on purpose`, plugins: uniq(owners) }); if (owners.length > 1) out.push({ kind: "permission", level: "warn", message: `permission "${name}" declared by ${uniq(owners).length} plugins; namespace as "<id>:<action>" unless shared on purpose`, plugins: uniq(owners) });
}); });
return out; return out;
+3 -3
View File
@@ -55,13 +55,13 @@ test("allowedMethods lists methods at a path (GET implies HEAD); empty when the
assert.deepEqual(allowedMethods(plugins, "/x/missing"), []); assert.deepEqual(allowedMethods(plugins, "/x/missing"), []);
}); });
test("isAuthorized: open routes pass; gated routes require the role token; public is explicitly open", () => { test("isAuthorized: open routes pass; gated routes require the permission token; public is explicitly open", () => {
const open: Route = { handler: noop, method: "GET", path: "/" }; const open: Route = { handler: noop, method: "GET", path: "/" };
const gated: Route = { handler: noop, method: "GET", path: "/", role: "x:read" }; const gated: Route = { handler: noop, method: "GET", path: "/", permission: "x:read" };
const pub: Route = { handler: noop, method: "GET", path: "/", public: true }; // blessed public alias const pub: Route = { handler: noop, method: "GET", path: "/", public: true }; // blessed public alias
assert.equal(isAuthorized(open, []), true); assert.equal(isAuthorized(open, []), true);
assert.equal(isAuthorized(gated, []), false); assert.equal(isAuthorized(gated, []), false);
assert.equal(isAuthorized(gated, ["x:read"]), true); assert.equal(isAuthorized(gated, ["x:read"]), true);
assert.equal(isAuthorized(gated, ["other"]), false); assert.equal(isAuthorized(gated, ["other"]), false);
assert.equal(isAuthorized(pub, []), true); // open to anonymous, like omitting role — but stated outright assert.equal(isAuthorized(pub, []), true); // open to anonymous, like omitting permission — but stated outright
}); });
+5 -5
View File
@@ -74,9 +74,9 @@ export function allowedMethods(plugins: Plugin[], pathname: string): string[] {
return [...methods].sort(); return [...methods].sort();
} }
// Coarse role gate: a route marked `public` (or one with no `role`) is open; otherwise // Coarse permission gate: a route marked `public` (or one with no `permission`) is open; otherwise
// the user's roles (from the session JWT) must include the token. The same rule composeNav uses // the user's permissions (from the session JWT) must include the token. The same rule composeNav uses
// for the menu. `public` and `role` are mutually exclusive (discovery refuses both). // for the menu. `public` and `permission` are mutually exclusive (discovery refuses both).
export function isAuthorized(route: Route, roles: string[]): boolean { export function isAuthorized(route: Route, permissions: string[]): boolean {
return route.public === true || route.role == null || roles.includes(route.role); return route.public === true || route.permission == null || permissions.includes(route.permission);
} }
+1 -1
View File
@@ -31,7 +31,7 @@ const hydra = createHydraAdmin({ baseUrl: config.hydraAdminUrl, fetchImpl: oryFe
// or fetched http), then served from cache with TTL refresh + rotation-on-miss. // or fetched http), then served from cache with TTL refresh + rotation-on-miss.
const jwks = await createJwksProvider(config.jwksUrl, { fetchImpl: oryFetch }); // bound an http JWKS fetch too const jwks = await createJwksProvider(config.jwksUrl, { fetchImpl: oryFetch }); // bound an http JWKS fetch too
// Optional instant-revoke, off unless REVOCATION_DENYLIST=true: an in-memory denylist the // Optional instant-revoke, off unless REVOCATION_DENYLIST=true: an in-memory denylist the
// hot path consults and the admin screens populate on deactivate/delete/role-change. // hot path consults and the admin screens populate on deactivate/delete/permission-change.
const denylist = config.revocationDenylist ? createDenylist({ ttlSec: config.revocationTtlSec }) : undefined; const denylist = config.revocationDenylist ? createDenylist({ ttlSec: config.revocationTtlSec }) : undefined;
const plugins = await discoverPlugins(); // scans plugins/, validates — fails loud on a bad plugin const plugins = await discoverPlugins(); // scans plugins/, validates — fails loud on a bad plugin
+6 -6
View File
@@ -9,13 +9,13 @@ const scheduling: Plugin = {
apiVersion: "1.0.0", apiVersion: "1.0.0",
id: "scheduling", id: "scheduling",
nav: [{ nav: [{
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", role: "scheduling:read" }], children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }],
icon: "i-cal", id: "scheduling", label: "Scheduling", icon: "i-cal", id: "scheduling", label: "Scheduling",
}], }],
}; };
// A plugin with a public nav node (reachable by anyone, signed in or not). // A plugin with a public nav node (reachable by anyone, signed in or not).
const portal: Plugin = { apiVersion: "1.0.0", id: "portal", nav: [{ href: "/portal", id: "portal", label: "Portal", public: true }] }; const portal: Plugin = { apiVersion: "1.0.0", id: "portal", nav: [{ href: "/portal", id: "portal", label: "Portal", public: true }] };
// A gated section fragment like the admin plugin's nav: the header carries the role, so // A gated section fragment like the admin plugin's nav: the header carries the permission, so
// composeNav drops the whole subtree for a non-holder (the admin screens ship as a drop-in plugin). // composeNav drops the whole subtree for a non-holder (the admin screens ship as a drop-in plugin).
const adminLike: Plugin = { const adminLike: Plugin = {
apiVersion: "1.0.0", id: "admin", apiVersion: "1.0.0", id: "admin",
@@ -24,7 +24,7 @@ const adminLike: Plugin = {
{ href: "/admin/users", id: "users", label: "Users" }, { href: "/admin/users", id: "users", label: "Users" },
{ href: "/admin/groups", id: "groups", label: "Groups" }, { href: "/admin/groups", id: "groups", label: "Groups" },
], ],
icon: "i-shield", id: "admin", label: "Admin", role: "admin", icon: "i-shield", id: "admin", label: "Admin", permission: "admin",
}], }],
}; };
@@ -45,10 +45,10 @@ test("anonymous shell Sign-in link carries the current page as return_to", () =>
assert.equal(buildPluginChrome({ currentPath: "/portal", menu: DEFAULT_MENU }).signInHref, "/login?return_to=%2Fportal"); assert.equal(buildPluginChrome({ currentPath: "/portal", menu: DEFAULT_MENU }).signInHref, "/login?return_to=%2Fportal");
}); });
test("a role holder sees the Dashboard link + plugin nav; current path opens the active leaf", () => { test("a permission holder sees the Dashboard link + plugin nav; current path opens the active leaf", () => {
const chrome = buildPluginChrome({ const chrome = buildPluginChrome({
currentPath: "/scheduling/shifts", menu: DEFAULT_MENU, plugins: [scheduling], currentPath: "/scheduling/shifts", menu: DEFAULT_MENU, plugins: [scheduling],
identity: { email: "ada@x.io", id: "u1", roles: ["scheduling:read"] }, identity: { email: "ada@x.io", id: "u1", permissions: ["scheduling:read"] },
}); });
assert.deepEqual(labels(chrome.nav), ["Dashboard", "Scheduling"]); // Dashboard shown to a signed-in user assert.deepEqual(labels(chrome.nav), ["Dashboard", "Scheduling"]); // Dashboard shown to a signed-in user
const section = chrome.nav.find((n) => n.label === "Scheduling")!; const section = chrome.nav.find((n) => n.label === "Scheduling")!;
@@ -58,7 +58,7 @@ test("a role holder sees the Dashboard link + plugin nav; current path opens the
}); });
test("a gated section (like the admin plugin) shows to a holder; a sub-path marks its base leaf current", () => { test("a gated section (like the admin plugin) shows to a holder; a sub-path marks its base leaf current", () => {
const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, plugins: [adminLike], identity: { email: "a@b.c", id: "u1", roles: ["admin"] } }); const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, plugins: [adminLike], identity: { email: "a@b.c", id: "u1", permissions: ["admin"] } });
const admin = chrome.nav.find((n) => n.label === "Admin")!; const admin = chrome.nav.find((n) => n.label === "Admin")!;
assert.ok(admin); // gated section visible to an admin assert.ok(admin); // gated section visible to an admin
assert.equal(admin.open, true); // ancestor of the current leaf opened assert.equal(admin.open, true); // ancestor of the current leaf opened
+3 -3
View File
@@ -18,7 +18,7 @@ const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashbo
export interface PageChrome { export interface PageChrome {
brand: { logo?: string; name: string; sub?: string }; brand: { logo?: string; name: string; sub?: string };
csrfToken: string; // double-submit token for the shell's Sign-out form + a plugin's own forms csrfToken: string; // double-submit token for the shell's Sign-out form + a plugin's own forms
nav: NavNode[]; // global menu, composed + role-filtered + current-marked, ready for nav-tree.ejs nav: NavNode[]; // global menu, composed + permission-filtered + current-marked, ready for nav-tree.ejs
signInHref: string; // where the shell's anonymous "Sign in" link points — carries this page as return_to signInHref: string; // where the shell's anonymous "Sign in" link points — carries this page as return_to
theme?: string; theme?: string;
user: ShellUser; user: ShellUser;
@@ -39,8 +39,8 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
const fragments: NavNode[][] = opts.identity ? [[DASHBOARD_NAV]] : []; const fragments: NavNode[][] = opts.identity ? [[DASHBOARD_NAV]] : [];
for (const p of opts.plugins ?? []) if (p.nav?.length) fragments.push(p.nav); for (const p of opts.plugins ?? []) if (p.nav?.length) fragments.push(p.nav);
const roles = opts.identity?.roles ?? []; const permissions = opts.identity?.permissions ?? [];
const nav = composeNav(fragments, opts.menu.override, roles); const nav = composeNav(fragments, opts.menu.override, permissions);
if (opts.currentPath) { if (opts.currentPath) {
// Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like // Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like
// /admin/users/new marks the Users base leaf (/admin/users) and the dashboard marks Dashboard. // /admin/users/new marks the Users base leaf (/admin/users) and the dashboard marks Dashboard.
+1 -1
View File
@@ -8,7 +8,7 @@ import type { NavNode } from "./nav.ts";
const NAV: NavNode[] = [{ href: "/dashboard", label: "Dashboard" }, { children: [{ href: "/admin/users", label: "Users" }], label: "Admin" }]; const NAV: NavNode[] = [{ href: "/dashboard", label: "Dashboard" }, { children: [{ href: "/admin/users", label: "Users" }], label: "Admin" }];
test("dashboard model: titled shell, passes the unified nav + csrf + user through", () => { test("dashboard model: titled shell, passes the unified nav + csrf + user through", () => {
const m = buildDashboardModel({ csrfToken: "tok.sig", identity: { email: "ada@x.io", id: "u1", roles: ["admin"] }, nav: NAV }); const m = buildDashboardModel({ csrfToken: "tok.sig", identity: { email: "ada@x.io", id: "u1", permissions: ["admin"] }, nav: NAV });
assert.equal(m.shell.title, "Dashboard"); assert.equal(m.shell.title, "Dashboard");
assert.equal(m.shell.csrfToken, "tok.sig"); assert.equal(m.shell.csrfToken, "tok.sig");
assert.equal(m.shell.user.name, "ada"); // real signed-in identity, not a demo profile assert.equal(m.shell.user.name, "ada"); // real signed-in identity, not a demo profile
+1 -1
View File
@@ -1,7 +1,7 @@
// Central menu config: config/menu.ts lets an operator set branding (app name, logo, // Central menu config: config/menu.ts lets an operator set branding (app name, logo,
// default theme) and reorder/rename/group/hide nav nodes across all plugins. The reorder/rename/ // default theme) and reorder/rename/group/hide nav nodes across all plugins. The reorder/rename/
// group/hide part is the NavOverride composeNav already applies (the override always wins, before // group/hide part is the NavOverride composeNav already applies (the override always wins, before
// the per-user role filter). Authored as TypeScript (defineMenu types it); loaded once at // the per-user permission filter). Authored as TypeScript (defineMenu types it); loaded once at
// boot — fail-loud on a malformed file, defaults when absent (clean clone needs no config). // boot — fail-loud on a malformed file, defaults when absent (clean clone needs no config).
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
+3 -3
View File
@@ -18,8 +18,8 @@ test("menu renders trigger, positioning, the item matrix and check groups", asyn
{ label: "Docs", href: "/docs" }, // link { label: "Docs", href: "/docs" }, // link
{ sep: true }, { sep: true },
{ label: "Sign out", icon: "i-logout", danger: true }, { label: "Sign out", icon: "i-logout", danger: true },
{ group: { legend: "Role", name: "role", control: "radio", options: [ { group: { legend: "Permission", name: "permission", control: "radio", options: [
{ value: "", label: "Any role", checked: true }, { value: "", label: "Any permission", checked: true },
{ value: "admin", label: "Admin" }, { value: "admin", label: "Admin" },
] } }, ] } },
{ group: { name: "col", options: [{ value: "name", label: "Name", checked: true }] } }, // checkbox default, no legend { group: { name: "col", options: [{ value: "name", label: "Name", checked: true }] } }, // checkbox default, no legend
@@ -38,7 +38,7 @@ test("menu renders trigger, positioning, the item matrix and check groups", asyn
assert.match(html, /<button class="menu-item danger" type="button"><svg class="ico"><use href="#i-logout"\s*\/?><\/svg>Sign out<\/button>/); assert.match(html, /<button class="menu-item danger" type="button"><svg class="ico"><use href="#i-logout"\s*\/?><\/svg>Sign out<\/button>/);
// Check group: radios reflect `checked`; legend optional; control defaults to checkbox. // Check group: radios reflect `checked`; legend optional; control defaults to checkbox.
assert.match(html, /<fieldset class="menu-field"><legend class="menu-head">Role<\/legend><label class="menu-check"><input type="radio" name="role" value="" checked>Any role<\/label><label class="menu-check"><input type="radio" name="role" value="admin">Admin<\/label><\/fieldset>/); assert.match(html, /<fieldset class="menu-field"><legend class="menu-head">Permission<\/legend><label class="menu-check"><input type="radio" name="permission" value="" checked>Any permission<\/label><label class="menu-check"><input type="radio" name="permission" value="admin">Admin<\/label><\/fieldset>/);
assert.match(html, /<fieldset class="menu-field"><label class="menu-check"><input type="checkbox" name="col" value="name" checked>Name<\/label><\/fieldset>/); assert.match(html, /<fieldset class="menu-field"><label class="menu-check"><input type="checkbox" name="col" value="name" checked>Name<\/label><\/fieldset>/);
}); });
+3 -3
View File
@@ -25,7 +25,7 @@ const nodes = [
{ label: "Webhooks (soon)" }, // leaf · static { label: "Webhooks (soon)" }, // leaf · static
], ],
}, },
{ label: "Roles & Access", children: [{ label: "Roles", href: "/roles" }] }, // header · static · closed { label: "Permissions & Access", children: [{ label: "Permissions", href: "/permissions" }] }, // header · static · closed
], ],
}, },
]; ];
@@ -59,8 +59,8 @@ test("nav-tree renders the header/leaf × clickable/static matrix with counts, i
assert.match(html, /<span class="nav-self"><span class="nav-label">Webhooks \(soon\)<\/span><\/span>/); assert.match(html, /<span class="nav-self"><span class="nav-label">Webhooks \(soon\)<\/span><\/span>/);
// Header · static · closed (no [open]) + label escaping in both label and aria-label. // Header · static · closed (no [open]) + label escaping in both label and aria-label.
assert.match(html, /<details class="nav-disc"><summary class="nav-tog" aria-label="Toggle Roles &amp; Access">/); assert.match(html, /<details class="nav-disc"><summary class="nav-tog" aria-label="Toggle Permissions &amp; Access">/);
assert.match(html, /<span class="nav-label">Roles &amp; Access<\/span>/); assert.match(html, /<span class="nav-label">Permissions &amp; Access<\/span>/);
}); });
test("nav-tree renders an empty root list with no nodes and never throws", async () => { test("nav-tree renders an empty root list with no nodes and never throws", async () => {
+16 -16
View File
@@ -2,32 +2,32 @@ import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { composeNav, type NavNode } from "./nav.ts"; import { composeNav, type NavNode } from "./nav.ts";
// Two plugin fragments; ids let the override target nodes, `role` gates per role. // Two plugin fragments; ids let the override target nodes, `permission` gates per permission.
const fragments: NavNode[][] = [ const fragments: NavNode[][] = [
[{ [{
icon: "i-cal", id: "sched", label: "Scheduling", icon: "i-cal", id: "sched", label: "Scheduling",
children: [ children: [
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", role: "scheduling:read" }, { href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" },
{ href: "/scheduling/manage", id: "manage", label: "Manage", role: "scheduling:admin" }, { href: "/scheduling/manage", id: "manage", label: "Manage", permission: "scheduling:admin" },
], ],
}], }],
[{ href: "/reports", id: "reports", label: "Reports", role: "reports:read" }], [{ href: "/reports", id: "reports", label: "Reports", permission: "reports:read" }],
]; ];
test("composeNav merges fragments, filters by role, and emits clean render nodes", () => { test("composeNav merges fragments, filters by permission, and emits clean render nodes", () => {
const tree = composeNav(fragments, {}, ["scheduling:read"]); const tree = composeNav(fragments, {}, ["scheduling:read"]);
// Reports gone (no reports:read), Manage gone (no scheduling:admin), header kept with Shifts. // Reports gone (no reports:read), Manage gone (no scheduling:admin), header kept with Shifts.
// Output carries no `id`/`role` and omits absent fields — ready for nav-tree.ejs. // Output carries no `id`/`permission` and omits absent fields — ready for nav-tree.ejs.
assert.deepEqual(tree, [ assert.deepEqual(tree, [
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling/shifts", label: "Shifts" }] }, { icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling/shifts", label: "Shifts" }] },
]); ]);
}); });
test("composeNav drops gated subtrees, empty headers, and (with no roles) all gated nodes", () => { test("composeNav drops gated subtrees, empty headers, and (with no permissions) all gated nodes", () => {
// A header the user can't reach takes its whole subtree, even visible children. // A header the user can't reach takes its whole subtree, even visible children.
const gatedHeader: NavNode[][] = [[ const gatedHeader: NavNode[][] = [[
{ id: "admin", label: "Admin", role: "admin", children: [{ href: "/u", id: "u", label: "Users" }] }, { id: "admin", label: "Admin", permission: "admin", children: [{ href: "/u", id: "u", label: "Users" }] },
{ id: "free", label: "Free", children: [{ href: "/d", id: "d", label: "Docs" }] }, { id: "free", label: "Free", children: [{ href: "/d", id: "d", label: "Docs" }] },
]]; ]];
assert.deepEqual(composeNav(gatedHeader, {}, []), [ assert.deepEqual(composeNav(gatedHeader, {}, []), [
@@ -36,26 +36,26 @@ test("composeNav drops gated subtrees, empty headers, and (with no roles) all ga
// A pure header whose children are all filtered is dropped; a header with an href survives as a leaf. // A pure header whose children are all filtered is dropped; a header with an href survives as a leaf.
const emptyHeader: NavNode[][] = [[ const emptyHeader: NavNode[][] = [[
{ id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", role: "x" }] }, { id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", permission: "x" }] },
{ href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", role: "y" }] }, { href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", permission: "y" }] },
]]; ]];
assert.deepEqual(composeNav(emptyHeader, {}, []), [{ href: "/hub", label: "Hub" }]); assert.deepEqual(composeNav(emptyHeader, {}, []), [{ href: "/hub", label: "Hub" }]);
// No fragments / no roles → empty tree, never throws. // No fragments / no permissions → empty tree, never throws.
assert.deepEqual(composeNav(), []); assert.deepEqual(composeNav(), []);
}); });
test("composeNav keeps a node marked public for everyone — the blessed public alias", () => { test("composeNav keeps a node marked public for everyone — the blessed public alias", () => {
// A header with one public child + one gated child: with no roles, the public child keeps the // A header with one public child + one gated child: with no permissions, the public child keeps the
// header alive (the gated child is filtered out) — so a plugin can show a public menu option to all. // header alive (the gated child is filtered out) — so a plugin can show a public menu option to all.
const frag: NavNode[][] = [[{ const frag: NavNode[][] = [[{
icon: "i-cal", id: "sched", label: "Scheduling", icon: "i-cal", id: "sched", label: "Scheduling",
children: [ children: [
{ href: "/scheduling", id: "overview", label: "Overview", public: true }, { href: "/scheduling", id: "overview", label: "Overview", public: true },
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", role: "scheduling:read" }, { href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" },
], ],
}]]; }]];
// `public` is filter-only (like id/role) — never rendered into the output node. // `public` is filter-only (like id/permission) — never rendered into the output node.
assert.deepEqual(composeNav(frag, {}, []), [ assert.deepEqual(composeNav(frag, {}, []), [
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling", label: "Overview" }] }, { icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling", label: "Overview" }] },
]); ]);
@@ -66,7 +66,7 @@ test("composeNav applies the override: rename, group, order, hide (then filters)
{ href: "/a", id: "a", label: "Alpha" }, { href: "/a", id: "a", label: "Alpha" },
{ href: "/b", id: "b", label: "Beta" }, { href: "/b", id: "b", label: "Beta" },
{ href: "/c", id: "c", label: "Gamma" }, { href: "/c", id: "c", label: "Gamma" },
{ href: "/secret", id: "secret", label: "Secret", role: "root" }, { href: "/secret", id: "secret", label: "Secret", permission: "root" },
]]; ]];
const tree = composeNav(base, { const tree = composeNav(base, {
@@ -76,7 +76,7 @@ test("composeNav applies the override: rename, group, order, hide (then filters)
hide: ["c"], // remove c from inside the group hide: ["c"], // remove c from inside the group
}, ["root"]); }, ["root"]);
// grp emitted (b only, c hidden), reordered before a; Secret kept now that role "root" is present. // grp emitted (b only, c hidden), reordered before a; Secret kept now that permission "root" is present.
assert.deepEqual(tree, [ assert.deepEqual(tree, [
{ icon: "i-box", label: "Group", open: true, children: [{ href: "/b", label: "Beta" }] }, { icon: "i-box", label: "Group", open: true, children: [{ href: "/b", label: "Beta" }] },
{ href: "/a", label: "First" }, { href: "/a", label: "First" },
+13 -13
View File
@@ -1,10 +1,10 @@
// composeNav: merge each plugin's nav fragment into one tree, apply the central // composeNav: merge each plugin's nav fragment into one tree, apply the central
// override, then role-filter per user. Pure and I/O-free — menu gating reads the JWT // override, then permission-filter per user. Pure and I/O-free — menu gating reads the JWT
// `roles` claim (README "The menu system"), never Keto. A node is visible iff it is `public`, or // `permissions` claim (README "The menu system"), never Keto. A node is visible iff it is `public`, or
// declares no `role`, or `roles` includes that role name; a gated header hides its whole // declares no `permission`, or `permissions` includes that permission name; a gated header hides its whole
// subtree, and a pure header left with no children is dropped. The config/menu.ts supplies // subtree, and a pure header left with no children is dropped. The config/menu.ts supplies
// the override (+ branding); this helper only transforms data, so its result is per-deployment // the override (+ branding); this helper only transforms data, so its result is per-deployment
// up to the final role filter and emits clean nodes ready for nav-tree.ejs (no id/role). // up to the final permission filter and emits clean nodes ready for nav-tree.ejs (no id/permission).
export interface NavNode { export interface NavNode {
id?: string; // stable key for override targeting; stripped from the rendered tree id?: string; // stable key for override targeting; stripped from the rendered tree
@@ -15,12 +15,12 @@ export interface NavNode {
icon?: string; icon?: string;
label: string; label: string;
open?: boolean; open?: boolean;
role?: string; // required role token; consumed by the filter, never rendered permission?: string; // required permission token; consumed by the filter, never rendered
public?: boolean; // show to everyone, signed in or not — the blessed alias for "no role", stated outright; consumed by the filter, never rendered. Mutually exclusive with role (discovery refuses both). public?: boolean; // show to everyone, signed in or not — the blessed alias for "no permission", stated outright; consumed by the filter, never rendered. Mutually exclusive with permission (discovery refuses both).
} }
// Central override (config/menu.ts). Targets nodes by `id`; applied rename → group → // Central override (config/menu.ts). Targets nodes by `id`; applied rename → group →
// order → hide, then the per-user role filter runs last. // order → hide, then the per-user permission filter runs last.
export interface NavOverride { export interface NavOverride {
groups?: NavGroupSpec[]; // wrap top-level nodes (by id) under a new header groups?: NavGroupSpec[]; // wrap top-level nodes (by id) under a new header
hide?: string[]; // remove nodes by id, at any depth (incl. a group's id) hide?: string[]; // remove nodes by id, at any depth (incl. a group's id)
@@ -39,14 +39,14 @@ export interface NavGroupSpec {
export function composeNav( export function composeNav(
fragments: NavNode[][] = [], fragments: NavNode[][] = [],
override: NavOverride = {}, override: NavOverride = {},
roles: string[] = [], permissions: string[] = [],
): NavNode[] { ): NavNode[] {
let nodes: NavNode[] = fragments.flat(); let nodes: NavNode[] = fragments.flat();
if (override.rename) nodes = renameTree(nodes, override.rename); if (override.rename) nodes = renameTree(nodes, override.rename);
if (override.groups?.length) nodes = applyGroups(nodes, override.groups); if (override.groups?.length) nodes = applyGroups(nodes, override.groups);
if (override.order?.length) nodes = applyOrder(nodes, override.order); if (override.order?.length) nodes = applyOrder(nodes, override.order);
if (override.hide?.length) nodes = hideTree(nodes, new Set(override.hide)); if (override.hide?.length) nodes = hideTree(nodes, new Set(override.hide));
return filterByRoles(nodes, new Set(roles)).map(toRenderNode); return filterByRoles(nodes, new Set(permissions)).map(toRenderNode);
} }
function renameTree(nodes: NavNode[], rename: Record<string, string>): NavNode[] { function renameTree(nodes: NavNode[], rename: Record<string, string>): NavNode[] {
@@ -103,19 +103,19 @@ function hideTree(nodes: NavNode[], hide: Set<string>): NavNode[] {
return out; return out;
} }
function filterByRoles(nodes: NavNode[], roles: Set<string>): NavNode[] { function filterByRoles(nodes: NavNode[], permissions: Set<string>): NavNode[] {
const out: NavNode[] = []; const out: NavNode[] = [];
for (const n of nodes) { for (const n of nodes) {
if (n.public !== true && n.role != null && !roles.has(n.role)) continue; // gated → drop node + subtree (public always shows) if (n.public !== true && n.permission != null && !permissions.has(n.permission)) continue; // gated → drop node + subtree (public always shows)
if (!n.children) { out.push(n); continue; } if (!n.children) { out.push(n); continue; }
const children = filterByRoles(n.children, roles); const children = filterByRoles(n.children, permissions);
if (children.length === 0 && n.href == null) continue; // empty pure header → drop if (children.length === 0 && n.href == null) continue; // empty pure header → drop
out.push({ ...n, children }); out.push({ ...n, children });
} }
return out; return out;
} }
// Strip the helper-only fields (id/role) and drop absent ones, so the tree is exactly // Strip the helper-only fields (id/permission) and drop absent ones, so the tree is exactly
// what nav-tree.ejs reads. // what nav-tree.ejs reads.
function toRenderNode(n: NavNode): NavNode { function toRenderNode(n: NavNode): NavNode {
const out: NavNode = { label: n.label }; const out: NavNode = { label: n.label };
+2 -2
View File
@@ -5,7 +5,7 @@ import { buildShellContext, shellUser } from "./shell-context.ts";
test("shellUser derives the profile from the real user; anonymous → Guest", () => { test("shellUser derives the profile from the real user; anonymous → Guest", () => {
assert.deepEqual(shellUser(null), { email: "", initials: "G", name: "Guest" }); assert.deepEqual(shellUser(null), { email: "", initials: "G", name: "Guest" });
// Real user: name = email local part, email kept, initials = first two letters of the local part. // Real user: name = email local part, email kept, initials = first two letters of the local part.
assert.deepEqual(shellUser({ email: "ada@example.com", id: "u1", roles: [] }), { email: "ada@example.com", initials: "AD", name: "ada" }); assert.deepEqual(shellUser({ email: "ada@example.com", id: "u1", permissions: [] }), { email: "ada@example.com", initials: "AD", name: "ada" });
}); });
test("buildShellContext maps branding + breadcrumbs, omitting unset optional fields", () => { test("buildShellContext maps branding + breadcrumbs, omitting unset optional fields", () => {
@@ -22,7 +22,7 @@ test("buildShellContext maps branding + breadcrumbs, omitting unset optional fie
menu: { branding: { logo: "/l.svg", name: "Acme", sub: "Ops", theme: "dark" }, override: {} }, menu: { branding: { logo: "/l.svg", name: "Acme", sub: "Ops", theme: "dark" }, override: {} },
signInHref: "/login?return_to=%2Fx", signInHref: "/login?return_to=%2Fx",
title: "Users", title: "Users",
identity: { email: "a@b.c", id: "u1", roles: ["admin"] }, identity: { email: "a@b.c", id: "u1", permissions: ["admin"] },
}); });
assert.deepEqual(full.brand, { logo: "/l.svg", name: "Acme", sub: "Ops" }); assert.deepEqual(full.brand, { logo: "/l.svg", name: "Acme", sub: "Ops" });
assert.equal(full.theme, "dark"); assert.equal(full.theme, "dark");
+1 -1
View File
@@ -1,7 +1,7 @@
// Shell view-model builder: the brand/theme/user/title block every app-shell page // Shell view-model builder: the brand/theme/user/title block every app-shell page
// (the home dashboard, the built-in admin screens) hands to shell.ejs. Pure. Extracted so the // (the home dashboard, the built-in admin screens) hands to shell.ejs. Pure. Extracted so the
// shell user is the *real* signed-in identity — no hardcoded demo profile — and branding is // shell user is the *real* signed-in identity — no hardcoded demo profile — and branding is
// read from one place. The User carries no display name (the JWT holds only id/email/roles), so // read from one place. The User carries no display name (the JWT holds only id/email/permissions), so
// the profile shows the email's local part as the name with the full email beneath, initials from // the profile shows the email's local part as the name with the full email beneath, initials from
// the local part; anonymous ⇒ "Guest". // the local part; anonymous ⇒ "Guest".
+1 -1
View File
@@ -2,7 +2,7 @@
Themed Kratos self-service page inside the unified app shell: sign-in / register / Themed Kratos self-service page inside the unified app shell: sign-in / register /
reset / verify / settings. Renders a FlowView (src/auth/flow-view.ts) into the shell content, reusing the reset / verify / settings. Renders a FlowView (src/auth/flow-view.ts) into the shell content, reusing the
auth-card + field partials. The form posts straight to flow.ui.action — Kratos owns its CSRF. The auth-card + field partials. The form posts straight to flow.ui.action — Kratos owns its CSRF. The
shell's menu is role-filtered (anonymous ⇒ public items + Sign in); the topbar carries no heading, shell's menu is permission-filtered (anonymous ⇒ public items + Sign in); the topbar carries no heading,
so the card's own <h1> is the page's single heading. Data: chrome (PageChrome), flow (FlowView). so the card's own <h1> is the page's single heading. Data: chrome (PageChrome), flow (FlowView).
%><% %><%
const nav = include("partials/nav-tree", { nodes: chrome.nav }); const nav = include("partials/nav-tree", { nodes: chrome.nav });
+1 -1
View File
@@ -1,6 +1,6 @@
<%# <%#
Public landing page: the ungated "/", rendered inside the unified app shell so the menu Public landing page: the ungated "/", rendered inside the unified app shell so the menu
shows (role-filtered — anonymous ⇒ public items + Sign in). A brief intro + a prominent way in, or a shows (permission-filtered — anonymous ⇒ public items + Sign in). A brief intro + a prominent way in, or a
dashboard link when already signed in. A plugin may replace this via its `home` handler. dashboard link when already signed in. A plugin may replace this via its `home` handler.
Data: chrome (PageChrome), user (or null). Data: chrome (PageChrome), user (or null).
%><% %><%
+1 -1
View File
@@ -1,7 +1,7 @@
<%# <%#
App shell: sidebar (brand + nav slot + footer) · topbar · content slot. The one chrome every page App shell: sidebar (brand + nav slot + footer) · topbar · content slot. The one chrome every page
renders — dashboard, admin, plugin, login/registration/front — so the menu is identical renders — dashboard, admin, plugin, login/registration/front — so the menu is identical
everywhere, role-filtered to the visitor (anonymous ⇒ public items + Sign in). everywhere, permission-filtered to the visitor (anonymous ⇒ public items + Sign in).
Slots are pre-rendered HTML locals — `nav` (sidebar tree, see nav-tree partial), Slots are pre-rendered HTML locals — `nav` (sidebar tree, see nav-tree partial),
`actions` (topbar buttons), `body` (page content); `styles` is an optional array of `actions` (topbar buttons), `body` (page content); `styles` is an optional array of
extra stylesheet hrefs (e.g. a plugin's own /public/<id>/x.css). Text locals: `title` extra stylesheet hrefs (e.g. a plugin's own /public/<id>/x.css). Text locals: `title`