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
are co-located (`foo.test.ts` beside `foo.ts`). Add a new module to the folder that owns its
concern rather than to the root; don't reintroduce a flat tree. The core ships **no domain
screens** — even the admin GUI (users/groups/roles) is a drop-in plugin (`examples/plugins/admin/`),
screens** — even the admin GUI (users/groups/permissions) is a drop-in plugin (`examples/plugins/admin/`),
not `src/` code.
- **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the
base request context. It protects the I/O-free hot path on the public, bot-hit landing
@@ -120,14 +120,15 @@ docker compose -f compose.yml up --build -d # production
running **building plugins** comes first, then **configuring and securing** the system
(Configuration, Auth); the **inner workings** (Architecture) and ops/runbooks are
deliberately deferred — they're not top of mind when starting out. Concretely: Overview →
Users, groups & roles → Building plugins → menu/blocks/interactivity → Configuration → Auth →
Email → Architecture → Testing → Production → Observability → the JWT-rotation runbook → the
Project-layout file map → Extending. When adding a section, place it by this value (how
early an adopter needs it), not by where it sits in the stack.
Identities, groups & permissions → Building plugins → menu/blocks/interactivity →
Configuration → Auth → Email → Architecture → Testing → Production → Observability → the
JWT-rotation runbook → the Project-layout file map → Extending. When adding a section, place
it by this value (how early an adopter needs it), not by where it sits in the stack.
**Users, groups & roles precedes Building plugins** because a manifest's `role:` gate is
unreadable without the model, and operators need it as much as plugin authors. It is the one
home for that model — the plugin and auth sections link to it rather than restating it.
**Identities, groups & permissions precedes Building plugins** because a manifest's
`permission:` gate is unreadable without the model, and operators need it as much as plugin
authors. It is the one home for that model — the plugin and auth sections link to it rather
than restating it.
When editing: put content in the section it belongs to (don't prepend rationale above Quick
start); keep the ToC in sync when you add/rename/remove an `H2`/`H3`; and state each fact in
+1 -1
View File
@@ -190,4 +190,4 @@ export default definePlugin({
```
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`**.
**3. Enable user & group admin (optional).** The core ships **no admin GUI** — the Users / Groups
/ Roles / OAuth2-clients screens are a drop-in plugin. Copy it in to mount them at `/admin/*`:
/ Permissions / OAuth2-clients screens are a drop-in plugin. Copy it in to mount them at `/admin/*`:
```bash
cp -r examples/plugins/admin plugins/admin
docker compose restart web
```
The seeded admin already holds the `admin` role, so the **Admin** section now shows in the menu.
The seeded admin already holds the `admin` permission, so the **Admin** section now shows in the menu.
See [`examples/plugins/admin/`](examples/plugins/admin/).
**4. Add your first plugin.** The clone is bind-mounted into the container, so a new
@@ -69,9 +69,9 @@ From here, render real pages against the app shell and fetch upstream data — s
- [Overview](#overview)
- [how it compares](#how-it-compares)
- [Users, groups & roles](#users-groups--roles)
- [Identities, groups & permissions](#identities-groups--permissions)
- [a worked example](#a-worked-example)
- [granting a role](#granting-a-role)
- [granting a permission](#granting-a-permission)
- [fine-grained, per-row access](#fine-grained-per-row-access)
- [Building plugins](#building-plugins)
- [anatomy](#anatomy-of-a-plugin)
@@ -80,7 +80,7 @@ From here, render real pages against the app shell and fetch upstream data — s
- [landing pages](#the-landing-pages-home--dashboard)
- [RequestContext](#requestcontext)
- [system capabilities (ctx.system)](#system-capabilities-the-ctxsystem-surface)
- [nav & role gates](#nav--role-gates)
- [nav & permission gates](#nav--permission-gates)
- [versioning](#contract-versioning)
- [conflict rules](#conflict-rules)
- [hooks](#hooks)
@@ -120,7 +120,7 @@ or gated**, so the same foundation serves a purely public site, a fully locked-d
tool, or the common middle: a public front with an authenticated area behind it. Its **sweet
spot** is the **back-office and operational tooling** you'd otherwise hand-roll for the tenth
time, but nothing ties it to internal-only use. The core itself ships **no domain screens at
all** — even the screens for running the system (**users, groups, roles**) are a **drop-in
all** — even the screens for running the system (**users, groups, permissions**) are a **drop-in
plugin** you opt into ([`examples/plugins/admin/`](examples/plugins/admin/)). Everything is a plugin.
**Who it's for.** Experienced developers building server-rendered web products — back-office
@@ -135,7 +135,7 @@ obvious rather than surprising, you're the audience.
- **Included in the core:** themed sign-in / register / reset (Kratos-backed), the design
system + app shell, the config-driven menu, sessions, and access control. No domain screens.
- **Opt-in admin plugin:** the **users, groups, roles, and OAuth2-clients** screens (users via
- **Opt-in admin plugin:** the **users, groups, permissions, and OAuth2-clients** screens (users via
Kratos, the relationship graph via Keto, OAuth2 clients via Hydra) ship as
[`examples/plugins/admin/`](examples/plugins/admin/) — copy it into `plugins/` to get a GUI for
user & group admin. It's an ordinary plugin, using the privileged
@@ -146,7 +146,7 @@ obvious rather than surprising, you're the audience.
**Priorities (unchanged from day one):** **simplicity, few dependencies, strict
TypeScript, no build step, Docker-only, environment-agnostic** (no `NODE_ENV` — every
behaviour is an explicit config toggle). Heavy lifting that *isn't* simple to do well —
identity, sessions, SSO, OAuth2, role checks — is delegated to **Ory** sidecar
identity, sessions, SSO, OAuth2, permission checks — is delegated to **Ory** sidecar
services rather than reinvented. "Simple" is about the *whole architecture* staying simple
— not just at the start, but after you've dropped in 240 plugins and run it hard in
production. The shape doesn't change as it grows: every plugin is the same self-contained
@@ -198,22 +198,24 @@ server-rendered** design system, **[optional auth](#auth-sessions--access)** (an
public or gated), **no app database**, and a **framework-light TypeScript** core with no build
step. Each neighbour shares one trait and trades away the rest — Plainpages is the intersection.
## Users, groups & roles
## Identities, groups & permissions
Authorization here is **two hops, not three**: a user — directly, or through a group — is a
member of a **role**, and that role's *name* is exactly the string a plugin gates on. There is no
separate "permission" object to define, register, or wire up.
Authorization here is two hops: an **identity** — directly, or through a **group** — is granted a
**permission**, and that permission's *name* is exactly the string a plugin gates on.
- **Group** answers *who* — a reusable set of people. Optional: a role can be granted straight to a user.
- **Role** answers *what* — its **name is the string** you write in a manifest's `role:` gate.
- **A relation tuple** is the grant: `Role:<name>#members@identity:<id>`, or `@Group:<name>#members`.
- **Group** answers *who* — a reusable set of people. Optional: a permission can be granted
straight to an identity.
- **Permission** answers *what* its **name is the string** you write in a manifest's
`permission:` gate.
- **A relation tuple** is the grant: `Permission:<name>#granted@identity:<id>`, or
`@Group:<name>#members`.
- **Resource** answers *which row* — a live check, run only where a plugin explicitly asks for it.
| Entity | Lives in | Answers | Example |
| --- | --- | --- | --- |
| **Identity** | Kratos | who you are | `identity:0198f2c1-…` |
| **Group** | Keto | who — a reusable set | `Group:support` |
| **Role** | Keto | what you may do | `Role:scheduling:read` |
| **Permission** | Keto | what you may do | `Permission:scheduling:read` |
| **Resource** | Keto | which specific row | `Resource:shift-4471` |
Identities live in Kratos; every authorization edge is a Keto relation tuple. The app itself
@@ -223,42 +225,43 @@ stores none of it — it is [stateless](#stateless).
`namespace:object#relation@subject` — so the four namespaces above are *ours*, declared in
`ory/keto/namespaces.keto.ts`; Keto only supplies the machinery that resolves them (including
transitively, through nested groups). `Identity` is named to match Kratos, which owns that
record. `Group`, `Role` and `Resource` have no upstream counterpart to match, so they use the
ordinary words.
record. `Group`, `Permission` and `Resource` have no upstream counterpart to match, so they use
the ordinary words.
> **On the word "permission".** Ory uses it for the fine-grained `Resource` tier — the `permits`
> block (`view`/`edit`/`delete`). Plainpages therefore never uses it for the coarse tier: what a
> route or a menu item gates on is a **role**, always.
> **There is no `Role`.** In RBAC a permission is a single operation ("read shifts") and a role is
> a *bundle* of them ("IT Support staff"). A route gates on one operation, so it gates on a
> **permission**. When you want the bundle, make a group and grant it several — groups nest, so a
> group of groups works too.
### A worked example
Alice works support and leads scheduling; Bob works support; Carol administers the system.
```
people groups roles
────── ────── ─────
people groups permissions
────── ────── ───────────
alice ──┬─────────> Group:support ────┐
│ ├──> Group:staff ──> Role:scheduling:read
│ ├──> Group:staff ──> Permission:scheduling:read
bob ────┘ │
alice ────────────> Group:sched-leads ┴──> Role:scheduling:write
alice ────────────> Group:sched-leads ┴──> Permission:scheduling:write
carol ───────────────────────────────────────────────> Role:admin
carol ───────────────────────────────────────────────> Permission:admin
```
At login the host asks Keto which roles the user holds, walking those arrows transitively, and
bakes the answer into the session JWT (see [Login and the session
At login the host asks Keto which permissions the identity holds, walking those arrows
transitively, and bakes the answer into the session JWT (see [Login and the session
JWT](#login-and-the-session-jwt)):
```
alice → roles: ["scheduling:read", "scheduling:write"]
bob → roles: ["scheduling:read"]
carol → roles: ["admin"]
alice → permissions: ["scheduling:read", "scheduling:write"]
bob → permissions: ["scheduling:read"]
carol → permissions: ["admin"]
```
Note what Carol does *not* have. **There is no role hierarchy and no superuser**`admin` is
just another name, granting nothing except where a route gates on `admin` itself.
Note what Carol does *not* have. **Permissions do not nest, and there is no superuser**`admin`
is just another name, granting nothing except where a route gates on `admin` itself.
Against the reference plugins' actual routes:
@@ -274,41 +277,42 @@ Bob reaches the shifts list with no direct grant: he is in `support`, support's
`staff`, and staff holds `scheduling:read` — two hops, resolved by Keto at his login. He is
refused the new-shift form because `scheduling:write` hangs off `sched-leads`, which he is not in.
An anonymous visitor gets a **redirect**, not a 403, carrying `return_to` so signing in lands them
on the page they asked for; a signed-in user who merely lacks the role gets the 403 page, because
there is nothing to sign in *as* that would help. The menu is filtered by the same roles, so
nobody is shown a door they cannot open.
on the page they asked for; a signed-in user who merely lacks the permission gets the 403 page,
because there is nothing to sign in *as* that would help. The menu is filtered by the same
permissions, so nobody is shown a door they cannot open.
### Granting a role
### Granting a permission
Write the tuple. The admin plugin's **Groups** and **Roles** screens do exactly this, or use
Write the tuple. The admin plugin's **Groups** and **Permissions** screens do exactly this, or use
Keto's write API directly:
```bash
# everyone in sched-leads may write shifts
curl -X PUT http://keto:4467/admin/relation-tuples -H 'content-type: application/json' -d '{
"namespace": "Role", "object": "scheduling:write", "relation": "members",
"namespace": "Permission", "object": "scheduling:write", "relation": "granted",
"subject_set": { "namespace": "Group", "object": "sched-leads", "relation": "members" }
}'
```
Roles are authored **only in Keto** — nothing else writes them. Role names are a shared global
namespace on purpose, so an operator grants `scheduling:read` once and every plugin referencing it
is gated consistently; namespace yours as `<id>:<action>`.
Permissions are authored **only in Keto** — nothing else writes them. Their names are a shared
global namespace on purpose, so an operator grants `scheduling:read` once and every plugin
referencing it is gated consistently; namespace yours as `<id>:<action>`.
A change takes effect on the user's **next login or JWT re-mint** (~10 min) — see [Instant
revoke](#instant-revoke-the-optional-denylist) when you need it sooner.
### Fine-grained, per-row access
The `Resource` namespace covers what a role cannot express: *this* row, shared with *this* person.
Its permissions nest — `owner``editor` `viewer`.
The `Resource` namespace covers what a coarse permission cannot express: *this* row, shared with
*this* person. It is a separate mechanism — a `Resource` carries Keto `permits` (`view`, `edit`,
`delete`, which nest as `owner``editor``viewer`) and never appears in the JWT.
**A per-row grant never widens a coarse gate.** The route's `role` is checked *before* the handler
runs, so a user rejected there never reaches the check. Gate the route on something they hold,
then narrow inside the handler:
**A per-row grant never widens a coarse gate.** The route's `permission` is checked *before* the
handler runs, so a user rejected there never reaches the check. Gate the route on something they
hold, then narrow inside the handler:
```ts
{ method: "POST", path: "/shifts/:id", role: READ, handler: editShift }
{ method: "POST", path: "/shifts/:id", permission: READ, handler: editShift }
async function editShift(ctx) {
if (!(await check(keto, ctx, { namespace: "Resource", object: ctx.params.id, relation: "editors" })))
@@ -331,9 +335,9 @@ contract is **TypeScript** (`src/plugin-host/plugin.ts`), so the types there are
source of truth; the sections below explain them, the guarantees around them, and the rules
the host enforces. A complete, runnable example lives in
**[`examples/plugins/scheduling/`](examples/plugins/scheduling/)** — a public overview page, a
role-gated list page fetching upstream data (it points `SCHEDULING_UPSTREAM` at its backend;
permission-gated list page fetching upstream data (it points `SCHEDULING_UPSTREAM` at its backend;
the dev compose ships a tiny mock, `examples/shifts-upstream/`), a CSRF-guarded form forwarding
writes upstream, and a mix of public + role-gated nav. It is **not** pre-installed — `plugins/`
writes upstream, and a mix of public + permission-gated nav. It is **not** pre-installed — `plugins/`
ships empty so you mount your own. To run it in dev, copy it in
(`cp -r examples/plugins/scheduling plugins/scheduling`, then restart) — the dev compose already
points `SCHEDULING_UPSTREAM` at its mock backend. Copy it to `plugins/<id>/` and adapt.
@@ -364,7 +368,7 @@ single `plugin.ts`.
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
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
Kratos auth flows (`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`),
@@ -396,20 +400,20 @@ import { listThings, createThings } from "./handlers.ts";
export default definePlugin({
apiVersion: "1.0.0", // semver string of the host contract this plugin was built against (see Versioning)
// Nav fragment, merged into the global menu and role-filtered per user.
// Nav fragment, merged into the global menu and permission-filtered per user.
// `icon` is a Lucide icon by its sprite id (src/ui/icons.ts).
nav: [{ href: "/things", icon: "i-cal", id: "things:list", label: "Things", role: "things:read" }],
nav: [{ href: "/things", icon: "i-cal", id: "things:list", label: "Things", permission: "things:read" }],
// Roles this plugin gates on. Optional — see Nav & role gates.
roles: [
// Permissions this plugin gates on. Optional — see Nav & permission gates.
permissions: [
{ description: "View things", name: "things:read" },
{ description: "Create and edit things", name: "things:write" },
],
// Route handlers, mounted under the plugin's path (/things). `role` gates first.
// Route handlers, mounted under the plugin's path (/things). `permission` gates first.
routes: [
{ method: "GET", path: "/", role: "things:read", handler: listThings },
{ method: "POST", path: "/", role: "things:write", handler: createThings },
{ method: "GET", path: "/", permission: "things:read", handler: listThings },
{ method: "POST", path: "/", permission: "things:write", handler: createThings },
],
});
```
@@ -426,7 +430,7 @@ there is **no `id` or `basePath`** in the manifest — both come from the folder
| `home` | no | A `RouteHandler` that owns the **public** landing `/`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
| `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. |
| `roles` | no | Roles this plugin gates on. See [Nav & role gates](#nav--role-gates). |
| `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). |
| `routes` | no | See [Routes & handlers](#routes--handlers). |
| `hooks` | no | See [Hooks](#hooks). |
@@ -434,14 +438,14 @@ A plugin may be routes-only, nav-only, or hooks-only — every collection field
### Routes & handlers
A route is `{ method, path, role?, public?, handler }`. `path` is **relative to the plugin's
A route is `{ method, path, permission?, public?, handler }`. `path` is **relative to the plugin's
mount path `/<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`,
runs the `role` gate (a coarse JWT-claim check — see [Nav & role gates](#nav--role-gates)),
runs the `permission` gate (a coarse JWT-claim check — see [Nav & permission gates](#nav--permission-gates)),
and only then calls the handler with the [request context](#requestcontext). When the gate fails, an
**anonymous** visitor is redirected to `/login` to sign in; the
requested page is preserved as `return_to`, so after signing in they land **back on the page they
asked for**, not the dashboard. A **signed-in** user who simply lacks the role gets the **403** page.
asked for**, not the dashboard. A **signed-in** user who simply lacks the permission gets the **403** page.
A route marked **`public: true`** has no gate at all — anyone reaches it (see [Public pages & menu
items](#public-pages--menu-items)).
@@ -482,9 +486,9 @@ export async function listThings(ctx: RequestContext) {
partials/subfolders to render a full page — exactly as the admin plugin's screens do. To load the
plugin's own CSS, pass its `/public/<id>/x.css` href in the shell's `styles` slot (an array of
extra stylesheet hrefs) — see the reference's `views/shifts.ejs`.
- **Finer authorization than the route `role`** uses the guards from `#plugin-api`:
- **Finer authorization than the route `permission`** uses the guards from `#plugin-api`:
`requireSession(ctx)` (assert a session — throws a `GuardError` the host turns into a redirect
to sign in), `can(ctx, role)` (a coarse JWT-claim check, zero I/O), and `check(keto, ctx,
to sign in), `can(ctx, permission)` (a coarse JWT-claim check, zero I/O), and `check(keto, ctx,
{namespace, object, relation})` (a live Keto check for relationship rules — the subject is the
signed-in user, anonymous ⇒ denied). Throw `new GuardError(403, …)` after a failed `can`/`check`
to render the 403 page.
@@ -540,8 +544,8 @@ a signed-in visitor, or sign-in / register to an anonymous one). After login the
points there.
For the gated `dashboard`, the host enforces the session gate first, so `ctx.identity` is non-null;
branch on `ctx.roles` *inside* to tailor the page per role. Don't gate `dashboard` itself behind a
single role — there's no second dashboard to fall back to, so a user lacking it would land on a
branch on `ctx.permissions` *inside* to tailor the page per permission. Don't gate `dashboard` itself behind a
single permission — there's no second dashboard to fall back to, so a user lacking it would land on a
403. (Both slots answer `GET` and `HEAD`.)
Only **one** plugin may own each slot: two declaring `home` (or two declaring `dashboard`) is a
@@ -557,13 +561,13 @@ request:
```ts
interface RequestContext {
chrome: PageChrome; // brand/global-nav/user/theme/csrf for the native app shell
identity: SessionIdentity | null; // { id, email, roles } from the verified session JWT, or null
identity: SessionIdentity | null; // { id, email, permissions } from the verified session JWT, or null
log: Log; // request-scoped logger, in this request's trace
params: Record<string, string>; // path params from the route match, e.g. /things/:id → { id }
query: URLSearchParams; // alias of url.searchParams
req: IncomingMessage;
res: ServerResponse;
roles: string[]; // identity?.roles ?? [] — coarse gate without a null-check
permissions: string[]; // identity?.permissions ?? [] — coarse gate without a null-check
system?: SystemCapabilities; // privileged Ory clients + instant-revoke, for a system plugin (see below); undefined unless the host wired them
url: URL;
verifyCsrf(submitted): boolean; // gate a form POST against the request's signed CSRF cookie
@@ -574,7 +578,7 @@ interface RequestContext {
theme, user }`. Hand it to `partials/shell` so a `view` result renders the **native app shell** (the same
sidebar, branding, theme switch and signed-in profile every page uses); `chrome.nav` is the
global menu — your plugin's nav fragment plus every other installed plugin's (the admin section among
them, when that plugin is present) — already composed, role-filtered, and current-marked for this
them, when that plugin is present) — already composed, permission-filtered, and current-marked for this
request (the gated **Dashboard** link is omitted for an
anonymous visitor). `chrome.signInHref` is where the shell's anonymous **Sign in** link points — the
current page baked in as `return_to`. Map each `chrome.*` to the matching `partials/shell` local —
@@ -587,7 +591,7 @@ secret and sets the cookie; the plugin never touches it. It is **opt-in per hand
that never calls it has no CSRF guard at all. (See the reference: `examples/plugins/scheduling/`.)
The same shell renders **every** page (the dashboard, your plugin pages — the admin plugin's included, and the
login/registration/front pages), so the menu looks identical signed in or out — it just role-filters.
login/registration/front pages), so the menu looks identical signed in or out — it just permission-filters.
A page that wants a focused, chrome-free layout passes **`menu: false`** to `partials/shell` (drops the
sidebar, single column); everything else still renders.
@@ -604,7 +608,7 @@ OpenTelemetry Collector when `OTLP_ENDPOINT` is set).
**Stability guarantee.** The fields above are the stable contract — present and non-breaking
across a major `apiVersion`. New fields may be **added** within a major version (additive, never
breaking). `req`/`res` are the raw Node objects and the full escape hatch; reading them is fine,
but prefer the typed fields so a handler keeps working as the host evolves. `user`/`roles` come
but prefer the typed fields so a handler keeps working as the host evolves. `user`/`permissions` come
from the JWT middleware and are `null`/`[]` until a session exists.
### System capabilities (the `ctx.system` surface)
@@ -618,7 +622,7 @@ needs the host's Ory admin clients and the instant-revoke hook instead. The host
```ts
interface SystemCapabilities { // every field optional — present only when the host wired it
hydra?: HydraAdmin; // OAuth2 client admin (register/list/delete Hydra clients)
keto?: KetoClient; // relationship read/write (groups, roles)
keto?: KetoClient; // relationship read/write (groups, permissions)
kratosAdmin?: KratosAdmin; // identity admin (create/edit/deactivate/delete users)
revoke?: (sub: string) => void; // instant-revoke a subject's live tokens (needs the denylist)
}
@@ -628,8 +632,8 @@ interface SystemCapabilities { // every field optional — present only
Hydra configured, the [revocation denylist](#instant-revoke-the-optional-denylist) enabled). A system
plugin treats every field as optional and **degrades when absent** — the host never fails a request
over it. The built-in **admin plugin** ([`examples/plugins/admin/`](examples/plugins/admin/)) is the
reference consumer: its Users screen uses `ctx.system.kratosAdmin`, Groups/Roles use `ctx.system.keto`,
OAuth2 clients use `ctx.system.hydra`, and a deactivate/delete or user role-change calls
reference consumer: its Users screen uses `ctx.system.kratosAdmin`, Groups/Permissions use `ctx.system.keto`,
OAuth2 clients use `ctx.system.hydra`, and a deactivate/delete or user permission-change calls
`ctx.system.revoke` so the change lands now instead of after the JWT TTL; where a capability is missing
the screen renders a themed 503.
@@ -637,11 +641,11 @@ This is a **privileged** surface — it hands a plugin the keys to identity and
for first-party system plugins you author or vendor, the same trust level as any plugin (the host
doesn't sandbox — [crash-isolation is a non-goal](#overview)). An ordinary domain plugin ignores it.
### Nav & role gates
### Nav & permission gates
A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/ui/nav.ts`), which
applies the central override and then **filters per user** by the roles in the session JWT — a
node shows iff it is `public`, declares no `role`, or the user's roles include that name. Use
applies the central override and then **filters per user** by the permissions in the session JWT — a
node shows iff it is `public`, declares no `permission`, or the user's permissions include that name. Use
arbitrary depth, counts, and icons; see `composeNav` for the node shape. A node's `icon` is a
**Lucide icon**, referenced by its sprite id (e.g. `i-cal` → lucide `calendar`); the available ids
are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name there.
@@ -649,25 +653,25 @@ are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its luci
#### Public pages & menu items
A route or nav node may be marked **`public: true`** — reachable by **anyone, signed in or not**,
and the menu item shows for everyone. This is the same as omitting `role` (an ungated
and the menu item shows for everyone. This is the same as omitting `permission` (an ungated
route/node is already open) but stated outright, so "public" is a **deliberate choice, not the
accident of a forgotten gate**. `public` and `role` are **mutually exclusive** — declaring
accident of a forgotten gate**. `public` and `permission` are **mutually exclusive** — declaring
both is contradictory and discovery refuses the plugin at boot.
A public page still renders in the native shell via `ctx.chrome`; for an anonymous visitor
`ctx.identity` is `null`, the shell shows a **Sign in** link (`chrome.signInHref`, returning to this page)
in place of the profile/sign-out block, the gated **Dashboard** link is hidden, and `ctx.roles` is
empty (read a role with `can(ctx, …)` to branch). The reference plugin's `/scheduling`
in place of the profile/sign-out block, the gated **Dashboard** link is hidden, and `ctx.permissions` is
empty (read a permission with `can(ctx, …)` to branch). The reference plugin's `/scheduling`
**Overview** is a worked example: it's `public`, so the "Scheduling" menu header shows for everyone,
while the actual shifts list stays behind `scheduling:read`.
The gate passes iff the user's JWT `roles` include that name. How roles are granted, why their
The gate passes iff the user's JWT `permissions` include that name. How permissions are granted, why their
names are a shared global namespace, and the fine-grained per-row tier are all covered in
[Users, groups & roles](#users-groups--roles).
[Identities, groups & permissions](#identities-groups--permissions).
Declaring the ones you gate on in `roles` is **optional but recommended**: it documents them,
Declaring the ones you gate on in `permissions` is **optional but recommended**: it documents them,
feeds conflict detection, and lets the one-command bootstrap seed them — the demo admin is
granted every discovered plugin's declared roles, so a dropped-in plugin works out of the box
granted every discovered plugin's declared permissions, so a dropped-in plugin works out of the box
without editing host config.
### Contract versioning
@@ -703,15 +707,15 @@ with `findConflicts` and resolves them **loudly — never last-write-wins**. `er
| `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/<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. |
| `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
uniqueness follows from the id check. `role` is the one intentional overlap, so it warns
uniqueness follows from the id check. `permission` is the one intentional overlap, so it warns
rather than aborts; everything else is an error an author fixes before the host will start.
Beyond cross-plugin conflicts, discovery also rejects **per-manifest shape errors** at boot: a
non-array `nav`/`routes`/`roles`, a non-function `home`/`dashboard`, or a route/nav node that
sets both `public` and `role` (mutually exclusive — [Public pages](#public-pages--menu-items)).
non-array `nav`/`routes`/`permissions`, a non-function `home`/`dashboard`, or a route/nav node that
sets both `public` and `permission` (mutually exclusive — [Public pages](#public-pages--menu-items)).
### Hooks
@@ -778,7 +782,7 @@ can't escape its own package scope, so it can't point at the host's file directl
> Discovery — scanning `plugins/`, importing each `plugin.ts` default export, and
> validating it (id, `apiVersion`, conflicts) — runs at boot (`src/plugin-host/discovery.ts`); a bad
> plugin stops startup with a precise message. The router (`src/plugin-host/router.ts`) then mounts
> each route at `/<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
> `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>/`
@@ -809,7 +813,7 @@ worked example: thin handlers bound to an injectable upstream client, unit-teste
3. **E2E the user-facing flow.** Per AGENTS.md §6, ship a side-effect-free Playwright test in
`e2e-tests/` for each plugin page/form so the suite stays `fullyParallel`, run against the live `web`
service with the plugin mounted. The reference's role-gating is covered in `visual.spec.ts`;
service with the plugin mounted. The reference's permission-gating is covered in `visual.spec.ts`;
its authenticated list/form happy-path is the full-E2E item (needs cross-host login infra).
The validation an author hits is the same the host runs: bad `apiVersion` or a conflict
@@ -837,13 +841,13 @@ The menu is **driven entirely by config** and assembled from two sources:
export default defineMenu({ branding: { name: "Acme Ops" }, override: { hide: ["teams"] } });
```
Every nav item may carry a `role`; the rendered tree is **filtered per user** by
reading the roles in the session JWT (no per-request authz call — see
Every nav item may carry a `permission`; the rendered tree is **filtered per user** by
reading the permissions in the session JWT (no per-request authz call — see
[Auth, sessions & access](#auth-sessions--access)), so the menu only ever shows
what that person can reach. An item (or a whole page) may instead be marked **`public:
true`** to show it to **everyone, signed in or not** — the blessed, explicit way to expose
a public page and its menu entry (an ungated item is already public; `public` just
says so on purpose, and is mutually exclusive with `role`). The markup is the
says so on purpose, and is mutually exclusive with `permission`). The markup is the
recursive, zero-JS nav tree from the design foundation (header/leaf × clickable/static,
counts, arbitrary depth). Branding (name, logo, default theme) renders in the app shell —
the sidebar brand shows the configured logo (else a default mark), and the theme sets the
@@ -868,7 +872,7 @@ set of reusable EJS partials + TS helpers, fully styled and zero-JS:
- **Helpers:** `composeNav` (menu from config), `parseListQuery`
(`?q=…&status=…&sort=…&page=…` → filter/sort/pagination), `paginate` (page math), and the
auth guards a handler calls to authorize (`src/auth/guards.ts`): `requireSession` (assert a
session — a `GuardError` the host turns into a redirect to sign in), `can(role)` (a coarse
session — a `GuardError` the host turns into a redirect to sign in), `can(permission)` (a coarse
JWT-claim check, zero I/O), `check(relation, object)` (the one live Keto call, for
relationship rules).
@@ -916,7 +920,7 @@ The app is **environment-agnostic**: there is no `NODE_ENV`. Behaviour that used
| `JWT_ISSUER` / `JWT_AUDIENCE` | _unset_ | optional: when set, the session JWT's `iss` / `aud` must match (the dev tokenizer sets neither) |
| `JWT_CLOCK_SKEW_SEC` | `60` | exp/nbf leeway (s) for Kratos↔web clock drift (the auth E2E sets `0`) |
| `ORY_TIMEOUT_SEC` | `5` | per-call timeout for outbound Kratos/Keto/Hydra (and http JWKS) fetches, so a hung Ory can't park a request |
| `REVOCATION_DENYLIST` | `false` | when `true`, enable the optional [instant role/session revoke denylist](#instant-revoke-the-optional-denylist) |
| `REVOCATION_DENYLIST` | `false` | when `true`, enable the optional [instant permission/session revoke denylist](#instant-revoke-the-optional-denylist) |
| `REVOCATION_TTL_SEC` | `900` | how long a revoke entry lives; keep ≥ tokenizer TTL (10m) + clock skew |
| `CSRF_SECRET` | dev throwaway | signs our double-submit CSRF token; enforced by `REQUIRE_SECURE_SECRETS` |
@@ -989,7 +993,7 @@ blocks a clean clone:
[Social sign-in (SSO)](#social-sign-in-sso)).
Everything else is generated or seeded on first boot — Ory migrations, the dev signing key,
the demo admin identity and its Keto roles, the Keto OPL model — so there is nothing else to
the demo admin identity and its Keto permissions, the Keto OPL model — so there is nothing else to
hand-configure.
### Social sign-in (SSO)
@@ -1023,32 +1027,32 @@ the session for a signed JWT once** via the Kratos **session tokenizer** (`whoam
```
── AT LOGIN / REFRESH (the only time Ory is on the path) ──────────
Kratos verifies credentials
└─► app reads the user's roles from Keto (direct + transitive via groups)
└─► app reads the user's permissions from Keto (direct + transitive via groups)
└─► app writes them as a derived projection on the identity (admin API)
└─► whoami(tokenize_as: "plainpages") ─► signed JWT
claims: { sub, email, roles:[…from Keto], exp ≈ 10m }
claims: { sub, email, permissions:[…from Keto], exp ≈ 10m }
└─► stored as the session cookie
── EVERY REQUEST (hot path — pure CPU, no I/O) ───────────────────
Browser ─cookie(JWT)─► web : verify signature (cached JWKS)
read claims.roles
read claims.permissions
filter menu · gate routes
```
**Keto is the single source of truth for roles.** Coarse roles are Keto relations (e.g.
`Role:admin#members@identity:alice`); the admin screens write them *only* to Keto. But the
**Keto is the single source of truth for permissions.** Coarse permissions are Keto relations (e.g.
`Permission:admin#members@identity:alice`); the admin screens write them *only* to Keto. But the
tokenizer's claims mapper can read only the **identity**, not call Keto — so at login the
app reads the roles from Keto and refreshes a **derived projection**: a read-only copy
app reads the permissions from Keto and refreshes a **derived projection**: a read-only copy
written onto the identity's `metadata_public` for the tokenizer to see, which the template
maps into the JWT `roles` claim. (It must be `metadata_public`, not `metadata_admin`: the
maps into the JWT `permissions` claim. (It must be `metadata_public`, not `metadata_admin`: the
session Kratos hands the tokenizer carries only *public* metadata — and the user can already
read these coarse roles in their own JWT, so nothing is leaked.) That projection is a
read these coarse permissions in their own JWT, so nothing is leaked.) That projection is a
per-login cache, authoritative nowhere; nothing edits it by hand, and a stale one self-heals
on the next login.
A role can be granted to a user directly or to a **group** the user belongs to; login
resolves both (enumerate the defined roles, ask Keto to resolve each membership), so the JWT
`roles` match what the admin **Effective access** view shows.
A permission can be granted to a user directly or to a **group** the user belongs to; login
resolves both (enumerate the defined permissions, ask Keto to resolve each membership), so the JWT
`permissions` match what the admin **Effective access** view shows.
Cost: **a handful of Keto reads + one identity refresh per login** — never per request. JWKS
is cached, so even signature verification hits the network only on key rotation. The app
@@ -1060,8 +1064,8 @@ recomputed from Keto.
This design buys an I/O-free hot path that scales to **tens of thousands of concurrent
users** on modest hardware. In return:
- **Role changes lag by up to one TTL (~10m).** Gating reads the JWT, not Keto, so a granted
or revoked role only takes effect when the token is next minted (re-login or TTL refresh).
- **Permission changes lag by up to one TTL (~10m).** Gating reads the JWT, not Keto, so a granted
or revoked permission only takes effect when the token is next minted (re-login or TTL refresh).
For an admin tool this is intentional — the alternative is a Keto call per request, which
we traded away. For instant revoke, turn on the optional
[revocation denylist](#instant-revoke-the-optional-denylist) — it closes the gap for
@@ -1074,12 +1078,12 @@ users** on modest hardware. In return:
### Instant revoke: the optional denylist
Off by default; turn it on with `REVOCATION_DENYLIST=true` (`src/auth/denylist.ts`). For
security-critical revoke (offboarding, a compromised account) the ~10m role/session lag
security-critical revoke (offboarding, a compromised account) the ~10m permission/session lag
above is too long. When enabled, an admin **deactivating** or **deleting** a user, or
**granting/revoking** a role to a *user*, records that subject as revoked-now; the hot path
**granting/revoking** a permission to a *user*, records that subject as revoked-now; the hot path
then rejects every token for it minted **before** the revoke and forces a re-mint — which
re-reads roles from Keto, or clears a now-dead session. A fresh re-login (its JWT issued
*after* the revoke) passes, so a role downgrade lands immediately without locking the
re-reads permissions from Keto, or clears a now-dead session. A fresh re-login (its JWT issued
*after* the revoke) passes, so a permission downgrade lands immediately without locking the
account.
It's an in-memory, auto-evicting map — no database, like the JWKS cache, so it stays inside
@@ -1089,11 +1093,11 @@ CPU — **Keto stays off the hot path**. Two deliberate bounds: it's instant on
instance** that handled the revoke (across replicas/restarts the guarantee falls back to the
token TTL — back the denylist with a shared store for hard multi-instance instant-revoke),
and a **group** membership change is transitive across many users, so it's left to lag —
deactivate the user, or use a direct user-role change, for an instant effect.
deactivate the user, or use a direct user-permission change, for an instant effect.
### Three tiers of "may I?"
[Users, groups & roles](#users-groups--roles) covers *what* the entities are; this is where each
[Identities, groups & permissions](#identities-groups--permissions) covers *what* the entities are; this is where each
**kind** of rule belongs.
```
@@ -1110,8 +1114,8 @@ deactivate the user, or use a direct user-role change, for an instant effect.
is for. Reserve it for those; don't pay its tuple-sync cost for rules a service can already
answer from its own data.
The admin plugin's users / groups / roles screens write authorization **only to Keto** — coarse
roles and fine-grained relationships alike.
The admin plugin's users / groups / permissions screens write authorization **only to Keto** — coarse
permissions and fine-grained relationships alike.
### OAuth2 provider (Hydra)
@@ -1151,7 +1155,7 @@ ports (guarded by `src/compose.test.ts`); dev publishes only the two a browser m
expose one, and never front one with a proxy that lacks its own auth.
**The JWT is signed, not encrypted.** Claims are base64: a signed-in user can read their own
`sub`, `email` and `roles`. `HttpOnly` keeps page JavaScript out of the cookie, not the user.
`sub`, `email` and `permissions`. `HttpOnly` keeps page JavaScript out of the cookie, not the user.
Never put anything in a claim you wouldn't show them.
**The JWT's ~10m TTL is not the session lifetime.** The browser also holds Kratos'
@@ -1160,12 +1164,12 @@ JWT. So a stolen cookie jar is worth 30 days of re-mintable access, not ten minu
two cookies obey `SECURE_COOKIES`; the Kratos one takes its flags from Kratos' own config.
**Offboarding is not instant by default.** An expired JWT re-mints off that live Kratos session,
re-reading roles from Keto — so a revoked role, or a deactivated identity, lands within one
re-reading permissions from Keto — so a revoked permission, or a deactivated identity, lands within one
token TTL rather than immediately. With the
[denylist](#instant-revoke-the-optional-denylist) on (it is off by default), both take effect at
once, on the instance that handled the change.
**Not guaranteed** — accepted, and stated where each mechanism is: role changes
**Not guaranteed** — accepted, and stated where each mechanism is: permission changes
[lag up to one token TTL and sign-in needs Ory up](#two-trade-offs--both-deliberate), and the
denylist is [single-instance and skips group changes](#instant-revoke-the-optional-denylist).
Hardening a real deploy is `REQUIRE_SECURE_SECRETS=true`, `SECURE_COOKIES=true`, and replacing
@@ -1202,7 +1206,7 @@ docs for the full template-type list and the data each template receives.
Plainpages runs as a small set of containers, orchestrated by Docker Compose:
| Container | Role |
| Container | Permission |
| -------------- | ---- |
| `web` | The Node 24 + TypeScript app: server-rendered EJS, the plugin host, the building-block partials. Stays tiny. |
| `kratos` | **Ory Kratos** — identity: login, registration, password reset, SSO, sessions. |
@@ -1212,7 +1216,7 @@ Plainpages runs as a small set of containers, orchestrated by Docker Compose:
The `web` app is an Ory **relying party**: it never stores passwords. At login it turns
the Kratos session into a short-lived, **locally-validated JWT** (the Kratos session
tokenizer) carrying the user's coarse roles — so every later request gates the menu and
tokenizer) carrying the user's coarse permissions — so every later request gates the menu and
pages by **verifying the JWT in-process, with no per-request call to Ory**. Keto answers
the rarer fine-grained checks; Hydra is used only when the app acts as an OAuth2 **login &
consent provider** for other apps. It reaches the Ory services over their **REST APIs
@@ -1261,7 +1265,7 @@ service — no Node/browsers on the host. There are five suites:
**Visual + design system** (`visual.spec.ts`) — Ory-free, so it stays fast. It screenshots
the live pages and asserts the rendered design system — the app shell, theme switch, mobile
off-canvas layout, icon sprite, CSRF-guarded sign-out, the public landing, the 404 page, and
plugin role-gating — the last exercised by bind-mounting the reference example
plugin permission-gating — the last exercised by bind-mounting the reference example
(`examples/plugins/scheduling/`) onto `/app/plugins/scheduling`.
```bash
@@ -1273,7 +1277,7 @@ docker compose -f compose.yml -f e2e-tests/compose.visual.yml down -v
boots the real Ory stack (Postgres + Kratos + Keto + bootstrap), shortens the session→JWT TTL
to 8s (`ory/kratos/e2e.yml`) and sets `JWT_CLOCK_SKEW_SEC=0`, then logs in the seeded admin
and proves the "stay signed in" hot path: the lapsed JWT is silently **re-minted** from the
live Kratos session (roles re-read from Keto), and once that session is revoked the stale
live Kratos session (permissions re-read from Keto), and once that session is revoked the stale
cookie is **cleared**.
```bash
@@ -1295,9 +1299,9 @@ docker compose -f compose.yml -f e2e-tests/compose.oauth.yml down -v
**Full browser flow** (`full-flow.spec.ts`) — the real Playwright UI against the live stack:
the themed **password login** and a **mocked-SSO** login (an in-network mock OIDC provider,
`e2e-tests/mock-oidc.ts`), **menu filtering by role**, the **users/groups/roles** admin CRUD, the
`e2e-tests/mock-oidc.ts`), **menu filtering by permission**, the **users/groups/permissions** admin CRUD, the
**OAuth2-clients** admin screen (register → one-time secret → delete; Hydra is part of this stack
for it), a role-gated **plugin page**, and **logout**. Because the themed form posts straight to
for it), a permission-gated **plugin page**, and **logout**. Because the themed form posts straight to
Kratos and cookies are host-scoped, a tiny same-origin gateway (`e2e-tests/proxy.ts`) fronts web +
Kratos on one host (`ory/kratos/e2e-proxy.yml` points Kratos at it) — exactly as a production
reverse proxy would.
@@ -1341,7 +1345,7 @@ bash ci.sh
```
Each E2E suite **owns a clean stack** — never point two suites at one backend (auth-refresh
revokes the admin's sessions; full-flow writes users/groups/roles to Keto), which is why the
revokes the admin's sessions; full-flow writes users/groups/permissions to Keto), which is why the
gate runs them serially, one stack up/down per suite.
## CI/CD
@@ -1491,8 +1495,8 @@ The server drains in-flight requests on `SIGTERM`/`SIGINT` rather than cutting t
mid-response, so container restarts are clean.
The first-boot **bootstrap** is idempotent and runs on every `up` — it generates the JWT
signing key if absent, creates the demo admin in Kratos, and grants it the `admin` role plus
every discovered plugin's declared role names in Keto, so role checks (and any
signing key if absent, creates the demo admin in Kratos, and grants it the `admin` permission plus
every discovered plugin's declared permission names in Keto, so permission checks (and any
dropped-in plugin) resolve out of the box. The web app waits for Kratos + Keto to be healthy
*and* the bootstrap to finish before starting. **Change the demo admin before production.**
@@ -1513,7 +1517,7 @@ whole handler (an `AsyncLocalStorage`), so logs and traces correlate. Three expl
Every request emits one access line (`method`, `path` — the query is dropped, it can carry
tokens — `status`, `ms`, `requestId`); login/logout, admin writes (who-did-what), and
missing-role/CSRF rejections log at `info`/`warn`, and the catch-all 500 + the
missing-permission/CSRF rejections log at `info`/`warn`, and the catch-all 500 + the
Ory-unreachable re-mint at `error`/`warn`. An inbound W3C `traceparent` is **adopted**, so a
request continues a trace started by an upstream proxy/gateway.
@@ -1625,20 +1629,20 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *.
auth/ Identity, the session-JWT hot path, guards, and the Ory REST clients
jwt.ts JWS signature verify via node:crypto, no jose (decode + verify a compact JWS against one JWK)
jwt-middleware.ts resolveSession()/authenticate(): per-request session-JWT verify — key by kid → signature → exp/nbf/iss/aud (clock skew) → ctx.identity/roles; flags a lapsed token for re-mint
jwt-middleware.ts resolveSession()/authenticate(): per-request session-JWT verify — key by kid → signature → exp/nbf/iss/aud (clock skew) → ctx.identity/permissions; flags a lapsed token for re-mint
jwks.ts JwksProvider — resolve the verify key by kid; createJwksProvider() picks by scheme: staticJwks (base64) or cachingJwks (file/http: TTL cache + rotation-on-miss reload)
gen-jwks.ts generateJwks()/rotateJwks() + CLI (mint · --prepend · --prune): the ES256 session-tokenizer signing JWKS; see JWT signing key & rotation
login.ts completeLogin()/remintSession(): login completion + TTL re-mint — roles from Keto → metadata_public projection → tokenize → session JWT cookie
guards.ts requireSession()/can()/check(): in-handler authorization — the imperative counterpart to the route role gate; GuardError → 303 /login or 403; check() is the one live Keto "may I?" call
login.ts completeLogin()/remintSession(): login completion + TTL re-mint — permissions from Keto → metadata_public projection → tokenize → session JWT cookie
guards.ts requireSession()/can()/check(): in-handler authorization — the imperative counterpart to the route permission gate; GuardError → 303 /login or 403; check() is the one live Keto "may I?" call
csrf.ts CSRF for our own POST forms: signed double-submit token — issue/verify, cookie, request gate
denylist.ts Optional instant-revoke denylist: in-memory, auto-evicting; hot path rejects a revoked subject's pre-revoke tokens (REVOCATION_DENYLIST)
flow-view.ts buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs
oauth-login.ts resolveLoginChallenge(): authenticate a Hydra login challenge via the Kratos session → accept, or bounce to /login
oauth-consent.ts resolveConsentChallenge()/acceptConsent()/rejectConsent(): auto-accept first-party, else show the consent screen → grant scopes
routes.ts buildAuthRoutes(): the built-in auth/OAuth2 endpoints as named handlers on the internal route table — themed flow pages, /oauth2/* challenges, /auth/complete, POST /logout, /error; only what the wired clients support is registered
bootstrap.ts One-command bootstrap: idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin role in Keto
bootstrap.ts One-command bootstrap: idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin permission in Keto
kratos-public.ts createKratosPublic(): Kratos public-API fetch client — self-service flow init/get/submit, browser logout, whoami, session→JWT tokenize
kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public update (login role projection)
kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public update (login permission projection)
keto-client.ts createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API)
hydra-admin.ts createHydraAdmin(): Hydra admin-API fetch client — OAuth2 login + consent challenge get/accept/reject + OAuth2 client CRUD
fetch-timeout.ts withTimeout(): bound every outbound Ory call — wrap the injected fetch so each request aborts after a deadline unless the caller passed its own signal; server.ts wires it into the Kratos/Keto/Hydra clients
@@ -1648,7 +1652,7 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *.
plugin-api.ts Stable plugin author barrel — the one module a plugin imports, as `#plugin-api` (definePlugin, ctx/result types, guards, body/CSRF/list-query/paginate helpers, and the ctx.system Ory client types)
system.ts SystemCapabilities: the privileged ctx.system surface (Ory admin clients + instant-revoke) a system plugin uses; the host populates it from the wired clients, the admin plugin consumes it
discovery.ts discoverPlugins(): scan plugins/, import + validate each plugin.ts default export, fail loud at boot
router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, role gate
router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate
hooks.ts runBootHooks()/runRequestHooks()/runResponseHooks(): invoke a plugin's optional lifecycle hooks in discovery order; no sandbox (a throwing hook fails loud), skipped when no plugin declares one
view-resolver.ts renderPluginView(): render plugins/<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
shell-context.ts buildShellContext(): brand/theme/user view-model for the dashboard shell (real signed-in user, no demo profile)
dashboard.ts buildDashboardModel(): the gated "/dashboard" app home — a short instructional starter (replace it with a plugin `dashboard` handler); "/" is the public landing (a plugin `home` handler). Both render the one unified menu (ctx.chrome)
nav.ts composeNav(): merge plugin nav fragments + central override, role-filter → nav-tree model
nav.ts composeNav(): merge plugin nav fragments + central override, permission-filter → nav-tree model
menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding, imported as `#menu-config`), validated at boot
icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs)
list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize }
paginate.ts paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs
views/ Core EJS templates, all in the one app shell: home (public "/" landing), index (instructional /dashboard), auth (themed Kratos flows), oauth-consent (OAuth2 consent), error (flow-error sink → /error), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, landing/flow/consent bodies, menu/popover, theme switch, icon sprite). Domain screens live in plugins, not here — the admin plugin ships its own views/ (incl. its Users/Groups/Roles/Clients + confirm bodies)
views/ Core EJS templates, all in the one app shell: home (public "/" landing), index (instructional /dashboard), auth (themed Kratos flows), oauth-consent (OAuth2 consent), error (flow-error sink → /error), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, landing/flow/consent bodies, menu/popover, theme switch, icon sprite). Domain screens live in plugins, not here — the admin plugin ships its own views/ (incl. its Users/Groups/Permissions/Clients + confirm bodies)
public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
config/ Drop-in mount point for the central menu override + branding (config/menu.ts). Ships empty (.gitkeep, git-ignored otherwise) — mount your own or copy the template from examples/config/; defaults apply when absent
ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — role/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs → /oauth2/*) + storage init (postgres/init/init.sql: one DB per service)
ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — permission/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs → /oauth2/*) + storage init (postgres/init/init.sql: one DB per service)
plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in). Ships empty (.gitkeep, git-ignored otherwise) — mount your own; the E2E suites bind-mount the example plugins onto /app/plugins/scheduling and /app/plugins/admin
examples/ Copy-in reference material, mirroring the mount dirs: plugins/scheduling/ (the reference plugin — list/form over an upstream + role-gated nav), plugins/admin/ (the system-admin plugin — Users/Groups/Roles/OAuth2-clients over Ory via ctx.system), both copied into plugins/; and config/menu.ts (the menu/branding template copied into config/); shifts-upstream/ is the dev mock backend the scheduling plugin reads/writes (stand-in for your real service)
e2e-tests/ Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint) + oauth-login.spec (OAuth2 login + consent) + full-flow.spec (browser UI: password/SSO login, menu-by-role, admin CRUD, plugin page, logout) + devstack-login.spec (regression: login works from the banner's localhost URL and 127.0.0.1 is canonicalised, on the plain `docker compose up` topology); proxy.ts (same-origin gateway) + mock-oidc.ts (mock SSO provider) back full-flow. e2e-tests/Dockerfile + e2e-tests/compose.{visual,auth,oauth,full,devstack}.yml run them
examples/ Copy-in reference material, mirroring the mount dirs: plugins/scheduling/ (the reference plugin — list/form over an upstream + permission-gated nav), plugins/admin/ (the system-admin plugin — Users/Groups/Permissions/OAuth2-clients over Ory via ctx.system), both copied into plugins/; and config/menu.ts (the menu/branding template copied into config/); shifts-upstream/ is the dev mock backend the scheduling plugin reads/writes (stand-in for your real service)
e2e-tests/ Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint) + oauth-login.spec (OAuth2 login + consent) + full-flow.spec (browser UI: password/SSO login, menu-by-permission, admin CRUD, plugin page, logout) + devstack-login.spec (regression: login works from the banner's localhost URL and 127.0.0.1 is canonicalised, on the plain `docker compose up` topology); proxy.ts (same-origin gateway) + mock-oidc.ts (mock SSO provider) back full-flow. e2e-tests/Dockerfile + e2e-tests/compose.{visual,auth,oauth,full,devstack}.yml run them
ci.sh The full CI gate: typecheck → unit tests → every E2E suite, each on a fresh, always-torn-down stack (`bash ci.sh`)
.gitea/workflows/ Gitea Actions: ci.yml — the full gate (ci.sh) on every branch push except main;
mirror.yml — force-sync main + tags to the GitHub mirror; see CI/CD
+3 -3
View File
@@ -130,9 +130,9 @@ services:
environment:
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
# Base roles for the demo admin; bootstrap also grants every discovered plugin's declared
# role names (so the reference plugin — and any drop-in — works out of the box).
ADMIN_ROLES: ${ADMIN_ROLES:-admin}
# Base permissions for the demo admin; bootstrap also grants every discovered plugin's declared
# permission names (so the reference plugin — and any drop-in — works out of the box).
ADMIN_PERMISSIONS: ${ADMIN_PERMISSIONS:-admin}
APP_URL: ${APP_URL:-http://localhost:3000} # printed in the first-run login banner
JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json
KETO_WRITE_URL: http://keto:4467
+6 -6
View File
@@ -9,7 +9,7 @@ import { expect, test } from "@playwright/test";
const WEB = process.env.BASE_URL ?? "http://web:3000";
const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433";
const KRATOS_ADMIN = process.env.KRATOS_ADMIN_URL ?? "http://kratos:4434";
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap; admin role granted in Keto
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap; admin permission granted in Keto
const ADMIN_PASSWORD = "admin";
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
@@ -29,8 +29,8 @@ function relayCookies(res: Response): string {
.filter((kv) => kv.split("=")[1] !== "")
.join("; ");
}
// Read a JWT's claims without verifying (web already verified it; we only inspect exp/roles).
function jwtClaims(jwt: string): { email: string; exp: number; roles: string[]; sub: string } {
// Read a JWT's claims without verifying (web already verified it; we only inspect exp/permissions).
function jwtClaims(jwt: string): { email: string; exp: number; permissions: string[]; sub: string } {
return JSON.parse(Buffer.from(jwt.split(".")[1]!, "base64url").toString());
}
@@ -72,7 +72,7 @@ async function awaitJwtSetCookie(session: string, jwt: string): Promise<string>
test("an expired session JWT is silently re-minted while Kratos lives, then cleared once it dies", async () => {
test.setTimeout(90_000); // two short-TTL windows (8s each) + Ory round-trips
// 1. Log in for real, then complete login on web → our session JWT (roles read from Keto).
// 1. Log in for real, then complete login on web → our session JWT (permissions read from Keto).
const session = await kratosLogin();
const complete = await fetch(`${WEB}/auth/complete`, { headers: { cookie: `plainpages_session=${session}` }, redirect: "manual" });
expect(complete.status, "auth/complete redirects home").toBe(303);
@@ -83,7 +83,7 @@ test("an expired session JWT is silently re-minted while Kratos lives, then clea
const claims1 = jwtClaims(jwt1);
expect(claims1.email).toBe(ADMIN_EMAIL);
expect(claims1.sub, "sub is the Kratos identity id").toBeTruthy();
expect(claims1.roles, "roles are projected from Keto").toContain("admin");
expect(claims1.permissions, "permissions are projected from Keto").toContain("admin");
// 2. Token timeout → refresh: once the 8s TTL lapses, the next request re-mints a fresh JWT.
const jwt2Line = await awaitJwtSetCookie(session, jwt1);
@@ -91,7 +91,7 @@ test("an expired session JWT is silently re-minted while Kratos lives, then clea
expect(jwt2, "a different token was minted").not.toBe(jwt1);
const claims2 = jwtClaims(jwt2);
expect(claims2.exp, "the new token expires later").toBeGreaterThan(claims1.exp);
expect(claims2.roles, "re-mint re-reads roles from Keto").toContain("admin");
expect(claims2.permissions, "re-mint re-reads permissions from Keto").toContain("admin");
// 3. Kill the Kratos session: now the lapsed token cannot refresh — the cookie is cleared.
const revoke = await fetch(`${KRATOS_ADMIN}/admin/identities/${claims1.sub}/sessions`, { method: "DELETE" });
+2 -2
View File
@@ -1,5 +1,5 @@
# Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO
# login, menu filtering by role, users/groups/roles/OAuth2-clients CRUD, a plugin page, logout. A
# login, menu filtering by permission, users/groups/permissions/OAuth2-clients CRUD, a plugin page, logout. A
# tiny same-origin gateway (proxy, e2e-tests/proxy.ts) fronts web + Kratos on one host so the browser's cookies
# round-trip (ory/kratos/e2e-proxy.yml points Kratos at it); a mock OIDC provider backs the SSO test.
# docker compose -f compose.yml -f e2e-tests/compose.full.yml run --build --rm e2e
@@ -30,7 +30,7 @@ services:
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro
- ./examples/plugins/admin:/app/plugins/admin:ro
# bootstrap grants the demo admin every discovered plugin's role names, so it needs the
# bootstrap grants the demo admin every discovered plugin's permission names, so it needs the
# example plugins present too — else the admin lacks scheduling:read/write and the gated pages 403.
bootstrap:
volumes:
+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
// uses its own browser context, and only the SSO test writes the mock-OIDC identity — keep it so
// (no cross-group shared backend writes) or serialise the file if that ever changes.
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap, holds the admin role in Keto
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap, holds the admin permission in Keto
const ADMIN_PASSWORD = "admin";
const SSO_EMAIL = "sso-user@plainpages.local"; // minted by the mock OIDC provider on first SSO login
const suffix = randomUUID().slice(0, 8); // unique per run so re-runs don't collide on names
@@ -36,7 +36,7 @@ test.describe.serial("authenticated admin journey", () => {
});
test.afterAll(async () => { await page.context().close(); });
test("menu filters by role: an admin sees the gated Admin section + the plugin", async () => {
test("menu filters by permission: an admin sees the gated Admin section + the plugin", async () => {
// The signed-in admin holds admin + scheduling:read/write, so both gated sections are present
// in the menu (collapsed by default → assert they're in the DOM, not necessarily visible).
await page.goto("/dashboard");
@@ -65,7 +65,7 @@ test.describe.serial("authenticated admin journey", () => {
await expect(page.locator("tr", { hasText: email })).toHaveCount(0);
});
test("groups + roles CRUD: create one of each (writes go to Keto) and see them listed", async () => {
test("groups + permissions CRUD: create one of each (writes go to Keto) and see them listed", async () => {
// A Keto set exists only while it has ≥1 member, so create needs a first member (the form
// enforces it); pick the first option (a user) from the required picker.
const group = `e2e-grp-${suffix}`;
@@ -76,13 +76,13 @@ test.describe.serial("authenticated admin journey", () => {
await expect(page).toHaveURL(/\/admin\/groups(\?|\/|$)/);
await expect(page.locator("main")).toContainText(group);
const role = `e2e-role-${suffix}`;
await page.goto("/admin/roles/new");
await page.fill('input[name="name"]', role);
const permission = `e2e-permission-${suffix}`;
await page.goto("/admin/permissions/new");
await page.fill('input[name="name"]', permission);
await page.locator('select[name="member"]').selectOption({ index: 1 });
await page.locator('.form-card button[type="submit"]').click();
await expect(page).toHaveURL(/\/admin\/roles(\?|\/|$)/);
await expect(page.locator("main")).toContainText(role);
await expect(page).toHaveURL(/\/admin\/permissions(\?|\/|$)/);
await expect(page.locator("main")).toContainText(permission);
});
test("OAuth2 clients CRUD: register a client (writes go to Hydra), see the one-time secret once, then delete it via the confirm step", async () => {
@@ -153,6 +153,6 @@ test("mocked SSO login: the provider button signs a user in via OIDC", async ({
await page.locator(".sso-btn").click();
// Mock OIDC auto-approves → Kratos creates the identity → /auth/complete → dashboard, signed in.
await expect(page.locator(".profile-mail")).toHaveText(SSO_EMAIL);
// A fresh SSO identity holds no roles, so the gated Admin section stays hidden.
// A fresh SSO identity holds no permissions, so the gated Admin section stays hidden.
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0);
});
+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
// gated dashboard renders for a "signed-in" user without standing up Ory — web verifies it
// with the same key by `kid`, exactly as it verifies a real Kratos-tokenizer JWT.
function devSession(roles: string[] = []): string {
function devSession(permissions: string[] = []): string {
const jwk = JSON.parse(readFileSync("/repo/jwks.json", "utf8")).keys[0];
const key = createPrivateKey({ format: "jwk", key: jwk });
const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url");
const now = Math.floor(Date.now() / 1000);
const input = `${b64({ alg: "ES256", kid: jwk.kid, typ: "JWT" })}.${b64({ email: "demo@plainpages.local", exp: now + 3600, iat: now, roles, sub: "visual-demo" })}`;
const input = `${b64({ alg: "ES256", kid: jwk.kid, typ: "JWT" })}.${b64({ email: "demo@plainpages.local", exp: now + 3600, iat: now, permissions, sub: "visual-demo" })}`;
return `${input}.${sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key }).toString("base64url")}`;
}
test.beforeAll(async () => { await mkdir(SHOTS, { recursive: true }); });
// The dashboard is gated: a page navigation needs a session. Plant one per test — a plain
// member (no roles) so the gated scheduling nav stays filtered out.
// member (no permissions) so the gated scheduling nav stays filtered out.
test.beforeEach(async ({ context }) => {
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]);
});
@@ -99,7 +99,7 @@ test("the public landing at / is ungated and links to sign in + register", async
await context.clearCookies(); // visit "/" as a logged-out visitor (drop the beforeEach session)
await page.goto("/");
await expect(page.locator(".landing")).toBeVisible();
// the same app shell every page renders — the menu shows even signed out (role-filtered).
// the same app shell every page renders — the menu shows even signed out (permission-filtered).
await expect(page.locator(".sidebar")).toBeVisible();
await expect(page.getByRole("link", { name: "Log in" })).toHaveAttribute("href", "/login");
await expect(page.getByRole("link", { name: "Create account" })).toHaveAttribute("href", "/registration");
@@ -114,7 +114,7 @@ test("unknown routes serve the 404 page (a real user-facing flow, covered end-to
});
// The reference plugin (plugins/scheduling) ships discovered in the image. Its public Overview is
// reachable by anyone and its menu header shows for everyone; the shifts list stays role-gated,
// reachable by anyone and its menu header shows for everyone; the shifts list stays permission-gated,
// so an anonymous visitor is bounced to sign in. The authenticated list/form flow is the full
// E2E (full-flow.spec). Side-effect-free.
test("the reference plugin: public Overview is open to all, the gated Shifts redirects to /login", async ({ page, request }) => {
@@ -136,7 +136,7 @@ test("the reference plugin: public Overview is open to all, the gated Shifts red
expect(res.status()).toBe(303);
expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts");
// The signed-in member (no scheduling role) sees the public Scheduling → Overview leaf in the nav,
// The signed-in member (no scheduling permission) sees the public Scheduling → Overview leaf in the nav,
// but the gated Shifts leaf is filtered out.
await page.goto("/dashboard");
await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders
+2 -2
View File
@@ -5,7 +5,7 @@ across (or bind-mount your own) and restart.
| Path | Copy into | Example of |
| --- | --- | --- |
| [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and role-gated nav — built from the core building blocks, holding no state. Imports the host surface as `#plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). |
| [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Roles / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). |
| [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and permission-gated nav — built from the core building blocks, holding no state. Imports the host surface as `#plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). |
| [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). |
| [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). |
| [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `SCHEDULING_UPSTREAM` at the real thing instead. |
+2 -2
View File
@@ -3,7 +3,7 @@
// config/ ships empty; mount your own or copy this in. Absent config = built-in defaults.
//
// Brand the app and reorder/rename/group/hide nav nodes (by their `id`) across all plugins —
// the override always wins, applied before the per-user role filter. Every field is
// the override always wins, applied before the per-user permission filter. Every field is
// optional; delete one to fall back to the default.
// See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README.md (The menu system).
@@ -20,7 +20,7 @@ export default defineMenu({
// Operator override (rename → group → order → hide), keyed by node id.
override: {
// rename: { people: "Staff" }, // node id → new label
// groups: [{ id: "admin", label: "Admin", children: ["users", "roles"] }],
// groups: [{ id: "admin", label: "Admin", children: ["users", "permissions"] }],
// order: ["people", "reports"], // top-level order by id
// hide: ["teams"], // remove nodes (any depth)
},
+8 -8
View File
@@ -1,6 +1,6 @@
# Admin — the system-administration plugin
The Users / Groups / Roles / OAuth2-clients screens for running Plainpages itself. These used to be
The Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. These used to be
built into the core; they now ship as a **drop-in example plugin** so a fresh clone has no admin GUI
until you opt in. Copy this folder into `plugins/` (it keeps the id and mount path `admin`, so the
screens live at `/admin/*`) and restart:
@@ -10,7 +10,7 @@ cp -r examples/plugins/admin plugins/admin
docker compose restart web
```
The seeded `admin@plainpages.local` already holds the `admin` role, so the section appears in the
The seeded `admin@plainpages.local` already holds the `admin` permission, so the section appears in the
menu and the screens work immediately.
## What it demonstrates — a *system* plugin
@@ -20,21 +20,21 @@ reference](../scheduling/README.md)). The admin screens instead administer **Pla
stack**, so they use the privileged **`ctx.system`** surface the host exposes to a system plugin:
- **`ctx.system.kratosAdmin`** — create/edit/deactivate/delete Kratos identities (Users).
- **`ctx.system.keto`** — read/write the Keto relationship graph (Groups, Roles).
- **`ctx.system.keto`** — read/write the Keto relationship graph (Groups, Permissions).
- **`ctx.system.hydra`** — register/list/delete Ory Hydra OAuth2 clients.
- **`ctx.system.revoke(sub)`** — the optional instant-revoke hook: a deactivate/delete or a
user's role change kills that subject's live tokens at once instead of waiting out the JWT TTL.
user's permission change kills that subject's live tokens at once instead of waiting out the JWT TTL.
`ctx.system` is populated only when the host wired those services (the dev stack wires Kratos + Keto,
and Hydra when configured). Where a capability is absent the screen degrades to a themed 503 rather
than crashing — see `admin-shared.ts`. Everything else is an ordinary plugin: folder-discovered,
gated per route by `role: "admin"`, rendering the core building blocks in `views/`.
gated per route by `permission: "admin"`, rendering the core building blocks in `views/`.
## Layout
- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` role, and the
route table — one thin handler per method+path, all gated by `role: "admin"`.
- `admin-users.ts` · `admin-groups.ts` · `admin-roles.ts` · `admin-clients.ts` — each a set of pure
- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` permission, and the
route table — one thin handler per method+path, all gated by `permission: "admin"`.
- `admin-users.ts` · `admin-groups.ts` · `admin-permissions.ts` · `admin-clients.ts` — each a set of pure
view-model builders (unit-tested in the matching `*.test.ts`) plus thin per-route handlers keyed on
`ctx.params` (the host extracts `:id`/`:name`), sharing a small `withX` wrapper that resolves the
admin gate + the needed `ctx.system` clients once.
@@ -1,34 +1,34 @@
// Built-in Roles admin screen: the pure view-model + Keto builders. A role is a
// Keto subject set (Role:<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
// distinct set of users who hold the role directly or transitively via a group. The HTTP
// Built-in Roles admin screen: the pure view-model + Keto builders. A permission is a
// Keto subject set (Permission:<name>#members); members are users (subject_id) or groups (subject_set) —
// "assign permissions to users/groups". The "effective access" view flattens a Keto `expand` tree into the
// distinct set of users who hold the permission directly or transitively via a group. The HTTP
// routing/gate/CSRF + live Keto/Kratos calls are exercised over HTTP in app.test.ts.
import assert from "node:assert/strict";
import { test } from "node:test";
import { memberView } from "./admin-groups.ts";
import {
buildRoleDetailModel,
buildRoleFormModel,
buildRolesListModel,
buildPermissionDetailModel,
buildPermissionFormModel,
buildPermissionsListModel,
expandToEffectiveUsers,
isValidRoleName,
roleMemberTuple,
} from "./admin-roles.ts";
permissionGrantTuple,
} from "./admin-permissions.ts";
import type { ExpandTree, RelationTuple } from "#plugin-api";
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
const userTuple = (role: string, n: number): RelationTuple =>
({ namespace: "Role", object: role, relation: "members", subject_id: `identity:${uid(n)}` });
const groupTuple = (role: string, group: string): RelationTuple =>
({ namespace: "Role", object: role, relation: "members", subject_set: { namespace: "Group", object: group, relation: "members" } });
const userTuple = (permission: string, n: number): RelationTuple =>
({ namespace: "Permission", object: permission, relation: "granted", subject_id: `identity:${uid(n)}` });
const groupTuple = (permission: string, group: string): RelationTuple =>
({ namespace: "Permission", object: permission, relation: "granted", subject_set: { namespace: "Group", object: group, relation: "members" } });
test("isValidRoleName + roleMemberTuple map the form value to a Role tuple over a user/group (else null)", () => {
test("isValidRoleName + permissionGrantTuple map the form value to a Permission tuple over a user/group (else null)", () => {
for (const ok of ["admin", "editor", "team-a", "a1_b9"]) assert.equal(isValidRoleName(ok), true, ok);
for (const bad of ["", "Admin", "a b", "-bad", "a".repeat(65)]) assert.equal(isValidRoleName(bad), false, bad);
assert.deepEqual(roleMemberTuple("editor", `identity:${uid(2)}`), { namespace: "Role", object: "editor", relation: "members", subject_id: `identity:${uid(2)}` });
assert.deepEqual(roleMemberTuple("editor", "group:eng"), { namespace: "Role", object: "editor", relation: "members", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
for (const bad of ["", "identity:not-a-uuid", "group:Bad Name", "nope:x"]) assert.equal(roleMemberTuple("editor", bad), null, bad);
assert.deepEqual(permissionGrantTuple("editor", `identity:${uid(2)}`), { namespace: "Permission", object: "editor", relation: "granted", subject_id: `identity:${uid(2)}` });
assert.deepEqual(permissionGrantTuple("editor", "group:eng"), { namespace: "Permission", object: "editor", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
for (const bad of ["", "identity:not-a-uuid", "group:Bad Name", "nope:x"]) assert.equal(permissionGrantTuple("editor", bad), null, bad);
});
test("expandToEffectiveUsers flattens an expand tree → sorted distinct user ids, transitive through groups", () => {
@@ -43,50 +43,50 @@ test("expandToEffectiveUsers flattens an expand tree → sorted distinct user id
type: "union",
},
],
tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Role", object: "admin", relation: "members" } },
tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Permission", object: "admin", relation: "granted" } },
type: "union",
};
assert.deepEqual(expandToEffectiveUsers(tree), [uid(1), uid(2)]);
assert.deepEqual(expandToEffectiveUsers(null), []);
assert.deepEqual(expandToEffectiveUsers({ type: "leaf" }), []); // an empty role
assert.deepEqual(expandToEffectiveUsers({ type: "leaf" }), []); // an empty permission
});
test("buildRolesListModel filters by search, sorts, paginates; the name links to the detail page", () => {
const roles = Array.from({ length: 30 }, (_, i) => ({ memberCount: i + 1, name: `role-${String(i).padStart(2, "0")}` }));
test("buildPermissionsListModel filters by search, sorts, paginates; the name links to the detail page", () => {
const permissions = Array.from({ length: 30 }, (_, i) => ({ memberCount: i + 1, name: `permission-${String(i).padStart(2, "0")}` }));
const all = buildRolesListModel({ roles, url: "http://x/admin/roles" });
const all = buildPermissionsListModel({ permissions, url: "http://x/admin/permissions" });
assert.equal(all.pagination.summary.total, 30);
assert.equal(all.table.rows.length, 25); // default page size
assert.equal(all.title, "Roles");
assert.equal(all.title, "Permissions");
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
assert.equal(first.rowHeader.text, "role-00");
assert.equal(first.rowHeader.href, "/admin/roles/role-00");
assert.equal(first.rowHeader.text, "permission-00");
assert.equal(first.rowHeader.href, "/admin/permissions/permission-00");
const one = buildRolesListModel({ roles, url: "http://x/admin/roles?q=role-07" });
const one = buildPermissionsListModel({ permissions, url: "http://x/admin/permissions?q=permission-07" });
assert.equal(one.pagination.summary.total, 1);
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
const desc = buildRolesListModel({ roles, url: "http://x/admin/roles?sort=-members" });
assert.equal((desc.table.rows[0]!.cells[0] as { rowHeader: { text: string } }).rowHeader.text, "role-29");
const desc = buildPermissionsListModel({ permissions, url: "http://x/admin/permissions?sort=-members" });
assert.equal((desc.table.rows[0]!.cells[0] as { rowHeader: { text: string } }).rowHeader.text, "permission-29");
});
test("buildRoleFormModel: a create form with a required name field + member options (user or group)", () => {
test("buildPermissionFormModel: a create form with a required name field + member options (user or group)", () => {
const options = [{ label: "ada@example.com", value: `identity:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
const m = buildRoleFormModel({ csrfToken: "tok.sig", memberOptions: options });
assert.equal(m.title, "New role");
assert.equal(m.form.action, "/admin/roles");
assert.equal(m.form.submitLabel, "Create role");
const m = buildPermissionFormModel({ csrfToken: "tok.sig", memberOptions: options });
assert.equal(m.title, "New permission");
assert.equal(m.form.action, "/admin/permissions");
assert.equal(m.form.submitLabel, "Create permission");
assert.equal(m.form.csrfToken, "tok.sig");
assert.equal(m.form.nameField.required, true);
assert.deepEqual(m.form.memberOptions, options);
const err = buildRoleFormModel({ error: "That name is taken.", memberOptions: options, values: { member: "group:eng", name: "Admin" } });
const err = buildPermissionFormModel({ error: "That name is taken.", memberOptions: options, values: { member: "group:eng", name: "Admin" } });
assert.equal(err.error, "That name is taken.");
assert.equal(err.form.nameField.value, "Admin");
assert.equal(err.form.selectedMember, "group:eng");
});
test("buildRoleDetailModel: members → rows, add-options exclude current members, effective access listed, actions wired", () => {
test("buildPermissionDetailModel: members → rows, add-options exclude current members, effective access listed, actions wired", () => {
const members = [memberView(userTuple("admin", 1), new Map([[uid(1), "ada@example.com"]])), memberView(groupTuple("admin", "eng"), new Map())];
const candidates = [
{ label: "ada@example.com", value: `identity:${uid(1)}` }, // already a member → excluded
@@ -95,12 +95,12 @@ test("buildRoleDetailModel: members → rows, add-options exclude current member
{ label: "ops (group)", value: "group:ops" },
];
const effective = [{ label: "ada@example.com" }, { label: "grace@example.com" }]; // ada direct, grace via eng
const m = buildRoleDetailModel({ candidates, effective, members, role: { name: "admin" } });
const m = buildPermissionDetailModel({ candidates, effective, members, permission: { name: "admin" } });
assert.equal(m.title, "admin");
assert.equal(m.members.rows.length, 2);
assert.equal(m.members.action, "/admin/roles/admin/members/delete");
assert.equal(m.add.action, "/admin/roles/admin/members");
assert.equal(m.members.action, "/admin/permissions/admin/members/delete");
assert.equal(m.add.action, "/admin/permissions/admin/members");
assert.deepEqual(m.add.options.map((o) => o.value), [`identity:${uid(2)}`, "group:ops"]);
assert.deepEqual(m.effective.map((e) => e.label), ["ada@example.com", "grace@example.com"]);
assert.equal(m.delete.action, "/admin/roles/admin/delete");
assert.equal(m.delete.action, "/admin/permissions/admin/delete");
});
@@ -1,15 +1,15 @@
// Roles admin screen: list / create / delete Keto roles and assign
// them to users and groups. A role is a Keto subject set `Role:<name>#members` (OPL: members are users
// or groups, resolved transitively) — the source of truth for the JWT `roles` claim. It shares the
// Permissions admin screen: list / create / delete Keto permissions and assign
// them to users and groups. A permission is a Keto subject set `Permission:<name>#members` (OPL: members are users
// or groups, resolved transitively) — the source of truth for the JWT `permissions` claim. It shares the
// Groups screen's membership model, so the pure helpers (parseSubject, member pickers, tuple paging)
// are reused from admin-groups. The role-specific piece is the **effective access** view:
// `keto.expand(Role:<name>#members)` flattened to the distinct users who hold the role directly or via
// a group — matching what login projects into the JWT (login.ts readRoles). Writes go only to Keto;
// are reused from admin-groups. The permission-specific piece is the **effective access** view:
// `keto.expand(Permission:<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 readPermissions). Writes go only to Keto;
// Kratos is read only to label members. Below the builders are thin per-route handlers (keyed on
// ctx.params) over a shared `withRoles` gate — admin-only, CSRF-guarded.
import { type ExpandTree, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SessionIdentity } from "#plugin-api";
import { ADMIN_ROLE, ADMIN_ROLES_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
import { ADMIN_PERMISSION, ADMIN_PERMISSIONS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
import {
type GroupView,
groupsFromTuples,
@@ -23,29 +23,29 @@ import {
} from "./admin-groups.ts";
import type { FieldConfig } from "./admin-users.ts";
const ROLE_NS = "Role";
const MEMBERS = "members";
const PERMISSION_NS = "Permission";
const GRANTED = "granted";
const DEFAULT_PAGE_SIZE = 25;
const PAGE_SIZES = [25, 50, 100];
// Expand far past any sane group-nesting depth so the effective-access view never silently
// under-reports the deepest members (Keto's own default is shallow).
const EXPAND_MAX_DEPTH = 50;
// A role and a group share the URL-safe name rule and the user|group membership model.
export type RoleView = GroupView;
// A permission and a group share the URL-safe name rule and the user|group membership model.
export type PermissionView = GroupView;
export const isValidRoleName = isValidGroupName;
export const rolesFromTuples = groupsFromTuples;
export const permissionsFromTuples = groupsFromTuples;
export interface EffectiveUser {
label: string; // email (or the raw id when unresolved)
}
// The full membership tuple for assigning/revoking `value` to/from `role` (null if value is invalid).
export function roleMemberTuple(role: string, value: string): RelationTuple | null {
// The full membership tuple for assigning/revoking `value` to/from `permission` (null if value is invalid).
export function permissionGrantTuple(permission: string, value: string): RelationTuple | null {
const subject = parseSubject(value);
return subject ? { namespace: ROLE_NS, object: role, relation: MEMBERS, ...subject } : null;
return subject ? { namespace: PERMISSION_NS, object: permission, relation: GRANTED, ...subject } : null;
}
// Flatten a Keto `expand` tree → the sorted, distinct user ids that effectively hold the role
// Flatten a Keto `expand` tree → the sorted, distinct user ids that effectively hold the permission
// (direct leaves + users reached through member groups, any depth). The subject rides on each
// node's `tuple`; subject-set nodes (the groups) contribute nothing directly — their members
// surface as leaves under them.
@@ -70,17 +70,17 @@ interface ListState {
sort: string | null;
}
const SORT: Record<string, (r: RoleView) => number | string> = {
const SORT: Record<string, (r: PermissionView) => number | string> = {
members: (r) => r.memberCount,
name: (r) => r.name,
};
const COLUMNS = [
{ key: "name", label: "Role" },
{ key: "name", label: "Permission" },
{ key: "members", label: "Members" },
];
function detailHref(name: string): string {
return `${ADMIN_ROLES_BASE}/${encodeURIComponent(name)}`;
return `${ADMIN_PERMISSIONS_BASE}/${encodeURIComponent(name)}`;
}
function listHref(state: ListState, overrides: Partial<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.pageSize !== DEFAULT_PAGE_SIZE) p.set("pageSize", String(s.pageSize));
const qs = p.toString();
return qs ? `${ADMIN_ROLES_BASE}?${qs}` : ADMIN_ROLES_BASE;
return qs ? `${ADMIN_PERMISSIONS_BASE}?${qs}` : ADMIN_PERMISSIONS_BASE;
}
export function buildRolesListModel(opts: {
export function buildPermissionsListModel(opts: {
csrfToken?: string;
roles: RoleView[];
permissions: PermissionView[];
url: URL | URLSearchParams | string;
}) {
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
@@ -104,7 +104,7 @@ export function buildRolesListModel(opts: {
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
const needle = query.q.toLowerCase();
let list = opts.roles.filter((r) => !needle || r.name.toLowerCase().includes(needle));
let list = opts.permissions.filter((r) => !needle || r.name.toLowerCase().includes(needle));
if (sort) {
const get = SORT[sort.field]!;
const dir = sort.dir === "desc" ? -1 : 1;
@@ -121,17 +121,17 @@ export function buildRolesListModel(opts: {
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken };
return {
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Admin" }, { label: "Roles" }],
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Admin" }, { label: "Permissions" }],
filterBar: listFilterBar(state),
pagination: listPagination(state, page),
table: listTable(rows, state, sort),
title: "Roles",
title: "Permissions",
};
}
function listTable(rows: RoleView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null) {
function listTable(rows: PermissionView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null) {
return {
caption: "Roles",
caption: "Permissions",
columns: COLUMNS.map((c) => {
const dir = sort && sort.field === c.key ? sort.dir : undefined;
const next = dir === "asc" ? `-${c.key}` : c.key;
@@ -149,11 +149,11 @@ function listFilterBar(state: ListState) {
if (state.q) pills.push({ label: "Search", remove: listHref(state, { page: 1, q: "" }), value: state.q });
return {
applyLabel: "Apply",
clearHref: ADMIN_ROLES_BASE,
label: "Filter roles",
clearHref: ADMIN_PERMISSIONS_BASE,
label: "Filter permissions",
pills,
rows: [[
{ label: "Search roles", name: "q", placeholder: "Search role name…", type: "search", value: state.q },
{ label: "Search permissions", name: "q", placeholder: "Search permission name…", type: "search", value: state.q },
{ type: "spacer" },
]],
};
@@ -178,7 +178,7 @@ function listPagination(state: ListState, page: ReturnType<typeof paginate>) {
// ---- create form + detail view models ----
export function buildRoleFormModel(opts: {
export function buildPermissionFormModel(opts: {
csrfToken?: string;
error?: string;
memberOptions: MemberOption[];
@@ -186,69 +186,69 @@ export function buildRoleFormModel(opts: {
}) {
const nameField: FieldConfig = {
autocomplete: "off", hint: "Lowercase letters, digits, dashes and underscores.", icon: "i-shield",
id: "name", label: "Role name", name: "name", required: true, value: opts.values?.name ?? "",
id: "name", label: "Permission name", name: "name", required: true, value: opts.values?.name ?? "",
};
return {
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: "New" }],
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Permissions" }, { label: "New" }],
error: opts.error,
form: {
action: ADMIN_ROLES_BASE,
cancelHref: ADMIN_ROLES_BASE,
action: ADMIN_PERMISSIONS_BASE,
cancelHref: ADMIN_PERMISSIONS_BASE,
csrfToken: opts.csrfToken ?? "",
memberOptions: opts.memberOptions,
nameField,
selectedMember: opts.values?.member ?? "",
submitLabel: "Create role",
submitLabel: "Create permission",
},
title: "New role",
title: "New permission",
};
}
export function buildRoleDetailModel(opts: {
export function buildPermissionDetailModel(opts: {
candidates: MemberOption[];
csrfToken?: string;
effective: EffectiveUser[];
error?: string;
members: MemberView[];
role: { name: string };
permission: { name: string };
}) {
const name = opts.role.name;
const name = opts.permission.name;
const base = detailHref(name);
const taken = new Set(opts.members.map((m) => m.subject));
const options = opts.candidates.filter((c) => !taken.has(c.value)); // members are users/groups, never the role itself
const options = opts.candidates.filter((c) => !taken.has(c.value)); // members are users/groups, never the permission itself
return {
add: { action: `${base}/members`, options },
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: name }],
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Permissions" }, { label: name }],
csrfToken: opts.csrfToken ?? "",
delete: { action: `${base}/delete` },
effective: opts.effective,
error: opts.error,
members: { action: `${base}/members/delete`, rows: opts.members },
role: { name },
permission: { name },
title: name,
};
}
// ---- request handler (imperative shell) ----
// instant-revoke: a role change for a `identity:<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
// 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 permissions from Keto). A `group:<name>` change is
// transitive across many users — left to lag (documented), so only direct user members revoke.
function revokeUserMember(revoke: ((sub: string) => void) | undefined, member: string): void {
if (revoke && member.startsWith("identity:")) revoke(member.slice("identity:".length));
}
// A role exists exactly while it has ≥1 member (Keto has no create-object).
// A permission exists exactly while it has ≥1 member (Keto has no create-object).
async function roleExists(keto: KetoClient, name: string): Promise<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;
}
// The distinct users who effectively hold the role (expand → flatten → label by email). Skipped for
// an empty role (no member tuples) so we don't expand a non-existent Keto object.
// The distinct users who effectively hold the permission (expand → flatten → label by email). Skipped for
// an empty permission (no member tuples) so we don't expand a non-existent Keto object.
async function effectiveUsers(keto: KetoClient, name: string, hasMembers: boolean, emailById: Map<string, string>): Promise<EffectiveUser[]> {
if (!hasMembers) return [];
const tree = await keto.expand({ namespace: ROLE_NS, object: name, relation: MEMBERS }, { maxDepth: EXPAND_MAX_DEPTH });
const tree = await keto.expand({ namespace: PERMISSION_NS, object: name, relation: GRANTED }, { maxDepth: EXPAND_MAX_DEPTH });
return expandToEffectiveUsers(tree)
.map((id) => ({ label: emailById.get(id) ?? `identity:${id}` }))
.sort((a, b) => a.label.localeCompare(b.label));
@@ -268,7 +268,7 @@ function withRoles(inner: (deps: RolesDeps) => Promise<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 {
return withRoles((deps) => {
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 { 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 { emailById, options } = await memberCandidates(deps.keto, deps.kratosAdmin);
const tuples = await pagedTuples(deps.keto, { namespace: ROLE_NS, object: name, relation: MEMBERS });
const tuples = await pagedTuples(deps.keto, { namespace: PERMISSION_NS, object: name, relation: GRANTED });
const members = tuples.map((t) => memberView(t, emailById));
const effective = await effectiveUsers(deps.keto, name, tuples.length > 0, emailById);
const result: RouteResult = { data: { chrome: deps.ctx.chrome, model: buildRoleDetailModel({ candidates: options, csrfToken: deps.ctx.chrome.csrfToken, effective, members, role: { name }, ...(error ? { error } : {}) }) }, view: "role-detail" };
const result: RouteResult = { data: { chrome: deps.ctx.chrome, model: buildPermissionDetailModel({ candidates: options, csrfToken: deps.ctx.chrome.csrfToken, effective, members, permission: { name }, ...(error ? { error } : {}) }) }, view: "permission-detail" };
return error ? { ...result, status: 400 } : result;
};
// GET /admin/roles — the list.
// GET /admin/permissions — the list.
export const rolesList = withRoles(async ({ ctx, keto }) => {
const roles = rolesFromTuples(await pagedTuples(keto, { namespace: ROLE_NS, relation: MEMBERS }));
return { data: { chrome: ctx.chrome, model: buildRolesListModel({ csrfToken: ctx.chrome.csrfToken, roles, url: ctx.url }) }, view: "roles" };
const permissions = permissionsFromTuples(await pagedTuples(keto, { namespace: PERMISSION_NS, relation: GRANTED }));
return { data: { chrome: ctx.chrome, model: buildPermissionsListModel({ csrfToken: ctx.chrome.csrfToken, permissions, url: ctx.url }) }, view: "permissions" };
});
// POST /admin/roles — create + assign the first member (a *user* grant revokes their live tokens).
// POST /admin/permissions — create + assign the first member (a *user* grant revokes their live tokens).
export const rolesCreate = withRoles(async (deps) => {
const { ctx, keto, revoke, user } = deps;
const form = (await guardedForm(ctx))!;
const name = (form.get("name") ?? "").trim();
const member = (form.get("member") ?? "").trim();
const tuple = roleMemberTuple(name, member);
const tuple = permissionGrantTuple(name, member);
const reject = async (error: string): Promise<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 (!tuple) return reject("Pick a user or group to assign the role to.");
if (await roleExists(keto, name)) return reject("A role with that name already exists.");
if (!isValidRoleName(name)) return reject("Permission names use lowercase letters, digits, dashes and underscores.");
if (!tuple) return reject("Pick a user or group to assign the permission to.");
if (await roleExists(keto, name)) return reject("A permission with that name already exists.");
await keto.writeTuple(tuple);
revokeUserMember(revoke, member);
ctx.log.info("admin: role created + first member assigned", { actor: user.id, member, role: name });
ctx.log.info("admin: permission created + first member assigned", { actor: user.id, member, permission: name });
return { redirect: detailHref(name) };
});
// GET /admin/roles/new — the create form.
// GET /admin/permissions/new — the create form.
export const rolesNewForm = withRoles((deps) => roleFormResult(deps, {}));
// GET /admin/roles/:name — the detail (members + effective access via Keto expand).
// GET /admin/permissions/:name — the detail (members + effective access via Keto expand).
export const rolesDetail = withRoleName((deps, name) => roleDetailResult(deps, name));
// POST /admin/roles/:name/members — assign a user/group; a *user* grant revokes their live tokens.
// POST /admin/permissions/:name/members — assign a user/group; a *user* grant revokes their live tokens.
export const rolesAddMember = withRoleName(async (deps, name) => {
const { ctx, keto, revoke, user } = deps;
const form = (await guardedForm(ctx))!;
const member = (form.get("member") ?? "").trim();
const tuple = roleMemberTuple(name, member); // the picker only offers real users/groups
if (tuple) { await keto.writeTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: role assigned", { actor: user.id, member, role: name }); }
const tuple = permissionGrantTuple(name, member); // the picker only offers real users/groups
if (tuple) { await keto.writeTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: permission assigned", { actor: user.id, member, permission: name }); }
return { redirect: detailHref(name) };
});
// GET /admin/roles/:name/delete — confirm, except the admin role can't be deleted.
// GET /admin/permissions/:name/delete — confirm, except the admin permission can't be deleted.
export const rolesDeleteConfirm = withRoleName((deps, name) => {
if (name === ADMIN_ROLE) return roleDetailResult(deps, name, "The admin role can't be deleted — it would remove all admin access.");
if (name === ADMIN_PERMISSION) return roleDetailResult(deps, name, "The admin permission can't be deleted — it would remove all admin access.");
const base = detailHref(name);
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { href: base, label: name }, { label: "Delete" }],
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete role",
message: `Delete role ${name}? This revokes it from everyone it's assigned to.`, title: "Delete role",
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Permissions" }, { href: base, label: name }, { label: "Delete" }],
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete permission",
message: `Delete permission ${name}? This revokes it from everyone it's assigned to.`, title: "Delete permission",
}) }, view: "confirm" });
});
// POST /admin/roles/:name/delete — remove every member tuple (a whole-role delete lags per the
// documented instant-revoke tradeoff; the admin role is protected).
// POST /admin/permissions/:name/delete — remove every member tuple (a whole-permission delete lags per the
// documented instant-revoke tradeoff; the admin permission is protected).
export const rolesDelete = withRoleName(async (deps, name) => {
const { ctx, keto, user } = deps;
await guardedForm(ctx); // CSRF-verify the POST
if (name === ADMIN_ROLE) return roleDetailResult(deps, name, "The admin role can't be deleted — it would remove all admin access.");
await keto.deleteTuple({ namespace: ROLE_NS, object: name, relation: MEMBERS });
ctx.log.info("admin: role deleted", { actor: user.id, role: name });
return { redirect: ADMIN_ROLES_BASE };
if (name === ADMIN_PERMISSION) return roleDetailResult(deps, name, "The admin permission can't be deleted — it would remove all admin access.");
await keto.deleteTuple({ namespace: PERMISSION_NS, object: name, relation: GRANTED });
ctx.log.info("admin: permission deleted", { actor: user.id, permission: name });
return { redirect: ADMIN_PERMISSIONS_BASE };
});
// POST /admin/roles/:name/members/delete — unassign; a *user* unassign revokes their live tokens.
// POST /admin/permissions/:name/members/delete — unassign; a *user* unassign revokes their live tokens.
// Self-protection: an admin can't revoke their own *direct* admin grant (a group-held admin isn't
// covered — the robust "last effective admin" check is deferred).
export const rolesRemoveMember = withRoleName(async (deps, name) => {
const { ctx, keto, revoke, user } = deps;
const form = (await guardedForm(ctx))!;
const member = (form.get("member") ?? "").trim();
if (name === ADMIN_ROLE && member === `identity:${user.id}`) return roleDetailResult(deps, name, "You can't revoke your own admin access.");
const tuple = roleMemberTuple(name, member);
if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: role unassigned", { actor: user.id, member, role: name }); }
if (name === ADMIN_PERMISSION && member === `identity:${user.id}`) return roleDetailResult(deps, name, "You can't revoke your own admin access.");
const tuple = permissionGrantTuple(name, member);
if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: permission unassigned", { actor: user.id, member, permission: name }); }
return { redirect: detailHref(name) };
});
+8 -8
View File
@@ -7,10 +7,10 @@ import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream";
import { test } from "node:test";
import { GuardError, type Log, type PageChrome, type RequestContext, type SessionIdentity } from "#plugin-api";
import { ADMIN_NAV, ADMIN_ROLE, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts";
import { ADMIN_NAV, ADMIN_PERMISSION, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts";
const admin: SessionIdentity = { email: "ada@x.io", id: "u1", roles: ["admin"] };
const member: SessionIdentity = { email: "bo@x.io", id: "u2", roles: ["scheduling:read"] };
const admin: SessionIdentity = { email: "ada@x.io", id: "u1", permissions: ["admin"] };
const member: SessionIdentity = { email: "bo@x.io", id: "u2", permissions: ["scheduling:read"] };
const CHROME = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } } as PageChrome;
function fakeCtx(opts: { body?: string; method?: string; user?: SessionIdentity | null; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
@@ -19,7 +19,7 @@ function fakeCtx(opts: { body?: string; method?: string; user?: SessionIdentity
req.method = opts.method ?? "GET";
return {
chrome: CHROME, identity: opts.user ?? null, log: {} as Log, params: {}, query: url.searchParams, req, res: {} as ServerResponse,
roles: opts.user?.roles ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true),
permissions: opts.user?.permissions ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true),
};
}
@@ -27,11 +27,11 @@ function fakeCtx(opts: { body?: string; method?: string; user?: SessionIdentity
test("ADMIN_NAV: a gated Admin header over the four screens; no per-request current/open state", () => {
assert.equal(ADMIN_NAV.id, "admin");
assert.equal(ADMIN_NAV.role, ADMIN_ROLE); // gate on the header ⇒ composeNav drops the whole subtree for a non-admin
assert.equal(ADMIN_NAV.permission, ADMIN_PERMISSION); // gate on the header ⇒ composeNav drops the whole subtree for a non-admin
assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/roles", "/admin/clients"]);
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["Users", "Groups", "Roles", "OAuth2 clients"]);
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined && c.role === undefined)); // the header's gate covers the subtree
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/permissions", "/admin/clients"]);
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["Users", "Groups", "Permissions", "OAuth2 clients"]);
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined && c.permission === undefined)); // the header's gate covers the subtree
});
// ---- auth gates ----
+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";
export const ADMIN_ROLE = "admin"; // the role gating the whole admin section
export const ADMIN_PERMISSION = "admin"; // the permission gating the whole admin section
export const ADMIN_USERS_BASE = "/admin/users";
export const ADMIN_GROUPS_BASE = "/admin/groups";
export const ADMIN_ROLES_BASE = "/admin/roles";
export const ADMIN_PERMISSIONS_BASE = "/admin/permissions";
export const ADMIN_CLIENTS_BASE = "/admin/clients";
export type AdminScreen = "clients" | "groups" | "roles" | "users";
export type AdminScreen = "clients" | "groups" | "permissions" | "users";
// The plugin's nav fragment: the gated "Admin" header + its four screens. The host composes it into
// the one global menu, filters per user (the header's `role` drops the whole subtree for a
// the one global menu, filters per user (the header's `permission` drops the whole subtree for a
// non-admin), and current-marks the active item — so there is no `current`/`open` state here.
export const ADMIN_NAV: NavNode = {
children: [
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "Users" },
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "Groups" },
{ href: ADMIN_ROLES_BASE, icon: "i-shield", id: "roles", label: "Roles" },
{ href: ADMIN_PERMISSIONS_BASE, icon: "i-shield", id: "permissions", label: "Permissions" },
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "OAuth2 clients" },
],
icon: "i-shield",
id: "admin",
label: "Admin",
role: ADMIN_ROLE,
permission: ADMIN_PERMISSION,
};
// The admin gate: a signed-in admin only. Each route already declares `role: "admin"`, so the
// The admin gate: a signed-in admin only. Each route already declares `permission: "admin"`, so the
// host enforces this before the handler runs; this is defence-in-depth and what a direct unit test
// relies on. Returns the (non-null) user for the handler to thread on. GuardError → /login or 403.
export function requireAdmin(ctx: RequestContext): SessionIdentity {
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
if (!can(ctx, ADMIN_ROLE)) throw new GuardError(403, "admin role required");
if (!can(ctx, ADMIN_PERMISSION)) throw new GuardError(403, "admin permission required");
return user;
}
+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.
interface UsersDeps { ctx: RequestContext; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: SessionIdentity; }
// Resolve the shared deps, then run `inner`. The route's `role: "admin"` already gated at the
// Resolve the shared deps, then run `inner`. The route's `permission: "admin"` already gated at the
// host; `requireAdmin` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it.
function withUser(inner: (deps: UsersDeps) => Promise<RouteResult>): RouteHandler {
return async (ctx) => {
+13 -13
View File
@@ -9,21 +9,21 @@
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "#plugin-api";
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsRemoveMember } from "./admin-groups.ts";
import { rolesAddMember, rolesCreate, rolesDelete, rolesDeleteConfirm, rolesDetail, rolesList, rolesNewForm, rolesRemoveMember } from "./admin-roles.ts";
import { rolesAddMember, rolesCreate, rolesDelete, rolesDeleteConfirm, rolesDetail, rolesList, rolesNewForm, rolesRemoveMember } from "./admin-permissions.ts";
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
import { ADMIN_NAV, ADMIN_ROLE } from "./admin-shared.ts";
import { ADMIN_NAV, ADMIN_PERMISSION } from "./admin-shared.ts";
// Every admin route is gated by the one `admin` role — the host redirects an anonymous visitor
// Every admin route is gated by the one `admin` permission — the host redirects an anonymous visitor
// to /login, gives a signed-in non-admin the 403 page, and filters the nav the same way. Handlers are
// thin and keyed on ctx.params (the host extracts :id / :name), the idiomatic per-route style.
const r = (method: HttpMethod, path: string, handler: RouteHandler): Route => ({ handler, method, path, role: ADMIN_ROLE });
const r = (method: HttpMethod, path: string, handler: RouteHandler): Route => ({ handler, method, path, permission: ADMIN_PERMISSION });
export default definePlugin({
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
nav: [ADMIN_NAV],
roles: [{ description: "Administer users, groups, roles, and OAuth2 clients", name: ADMIN_ROLE }],
permissions: [{ description: "Administer users, groups, permissions, and OAuth2 clients", name: ADMIN_PERMISSION }],
routes: [
// Users
@@ -46,14 +46,14 @@ export default definePlugin({
r("POST", "/groups/:name/delete", groupsDelete),
r("POST", "/groups/:name/members/delete", groupsRemoveMember),
// Roles
r("GET", "/roles", rolesList),
r("POST", "/roles", rolesCreate),
r("GET", "/roles/new", rolesNewForm),
r("GET", "/roles/:name", rolesDetail),
r("POST", "/roles/:name/members", rolesAddMember),
r("GET", "/roles/:name/delete", rolesDeleteConfirm),
r("POST", "/roles/:name/delete", rolesDelete),
r("POST", "/roles/:name/members/delete", rolesRemoveMember),
r("GET", "/permissions", rolesList),
r("POST", "/permissions", rolesCreate),
r("GET", "/permissions/new", rolesNewForm),
r("GET", "/permissions/:name", rolesDetail),
r("POST", "/permissions/:name/members", rolesAddMember),
r("GET", "/permissions/:name/delete", rolesDeleteConfirm),
r("POST", "/permissions/:name/delete", rolesDelete),
r("POST", "/permissions/:name/members/delete", rolesRemoveMember),
// OAuth2 clients
r("GET", "/clients", clientsList),
r("POST", "/clients", clientsCreate),
+1 -1
View File
@@ -1,6 +1,6 @@
<%#
OAuth2 clients admin list: apps that log in *through* us (Hydra). Same building blocks as
the Roles screen, around the shell, backed by live Hydra OAuth2 clients (admin-clients.ts).
the Permissions screen, around the shell, backed by live Hydra OAuth2 clients (admin-clients.ts).
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const filters = include("partials/filter-bar", model.filterBar);
@@ -1,13 +1,13 @@
<%#
Admin role detail body, captured into the shell content slot. Config:
role { name }
Admin permission detail body, captured into the shell content slot. Config:
permission { name }
members { action, rows: { kind:"group"|"identity", label, subject }[] } action = revoke endpoint
effective { label }[] users who hold the role (expand)
effective { label }[] users who hold the permission (expand)
add { action, options: {label,value}[] } action = assign endpoint
del { action } delete the whole role
del { action } delete the whole permission
csrfToken, error?
%><%
const role = locals.role;
const permission = locals.permission;
const members = locals.members;
const effective = locals.effective;
const add = locals.add;
@@ -21,7 +21,7 @@
<section class="form-card" aria-labelledby="members-h">
<h2 class="card-title" id="members-h">Assigned to</h2>
<% 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) => { -%>
<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 class="form-card" aria-labelledby="effective-h">
<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) { -%>
<ul class="plain-list">
<% effective.forEach((u) => { -%>
@@ -40,18 +40,18 @@
<% }) -%>
</ul>
<% } 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 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) { -%>
<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 { -%>
<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 class="form-card admin-actions" aria-label="Role 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>
<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 permission</a>
</section>
</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,
memberOptions: {label,value}[], selectedMember }
error? string shown when a write was rejected
@@ -16,7 +16,7 @@
<div class="field">
<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="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 class="form-actions">
<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 body = include("partials/role-form-body", { error: model.error, form: model.form });
const body = include("partials/permission-form-body", { error: model.error, form: model.form });
-%>
<%- include("partials/shell", {
body,
@@ -1,12 +1,12 @@
<%#
Roles admin list: the same building blocks as the Groups screen, around the shell, backed
by live Keto Role subject sets (admin-roles.ts). Filter/sort/page round-trip the URL.
Permissions admin list: the same building blocks as the Groups screen, around the shell, backed
by live Keto Permission subject sets (admin-permissions.ts). Filter/sort/page round-trip the URL.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const filters = include("partials/filter-bar", model.filterBar);
const table = include("partials/data-table", model.table);
const pager = include("partials/pagination", model.pagination);
const actions = '<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", {
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,
then POST-redirect-GET. The form body lives in the plugin's own `views/partials/shift-form.ejs`,
reusing the core `field` partial.
- **Role-gated nav** — the "Shifts" nav leaf and routes are gated on `scheduling:read` /
- **Permission-gated nav** — the "Shifts" nav leaf and routes are gated on `scheduling:read` /
`scheduling:write`; the whole "Scheduling" section is invisible to anyone without the grant.
The plugin holds **no state** — data lives upstream (README → *Stateless*). Handlers are thin and
@@ -46,6 +46,6 @@ cosmetically) — normalise to your backend's format there if it matters.
## Granting access
A user sees Scheduling once they hold the `scheduling:read` role in Keto (and `scheduling:write`
A user sees Scheduling once they hold the `scheduling:read` permission in Keto (and `scheduling:write`
to create). The one-command bootstrap grants both to the demo admin, so the seeded
`admin@plainpages.local` can use it immediately.
+8 -8
View File
@@ -1,5 +1,5 @@
// Reference plugin: a worked example of the contract — a list page that fetches upstream
// data, a CSRF-guarded form that forwards a write upstream, and role-gated nav. Copy this
// data, a CSRF-guarded form that forwards a write upstream, and permission-gated nav. Copy this
// folder, rename it, point it at your own backend. Full contract: README.md → Building plugins.
import { definePlugin } from "#plugin-api";
@@ -23,7 +23,7 @@ export default definePlugin({
nav: [{
children: [
{ href: SCHEDULING_PATH, id: "scheduling:overview", label: "Overview", public: true },
{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "Shifts", role: READ },
{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "Shifts", permission: READ },
],
icon: "i-cal",
id: "scheduling",
@@ -31,17 +31,17 @@ export default definePlugin({
}],
// Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
roles: [
permissions: [
{ description: "View shifts", name: READ },
{ description: "Create and edit shifts", name: WRITE },
],
// Mounted under /scheduling; `role` gates before the handler runs. The overview is `public`
// (anyone may reach /scheduling, signed in or not); the rest need a role.
// Mounted under /scheduling; `permission` gates before the handler runs. The overview is `public`
// (anyone may reach /scheduling, signed in or not); the rest need a permission.
routes: [
{ handler: overview(), method: "GET", path: "/", public: true },
{ handler: listShifts(upstream), method: "GET", path: "/shifts", role: READ },
{ handler: newShiftForm(), method: "GET", path: "/shifts/new", role: WRITE },
{ handler: createShift(upstream), method: "POST", path: "/shifts", role: WRITE },
{ handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ },
{ handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE },
{ handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE },
],
});
+7 -7
View File
@@ -12,12 +12,12 @@ import {
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } };
function fakeCtx(opts: { body?: string; roles?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
return {
chrome: CHROME, identity: null, log: new Log("none"), params: {}, query: url.searchParams, req, res: {} as ServerResponse,
roles: opts.roles ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true),
permissions: opts.permissions ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true),
};
}
@@ -93,8 +93,8 @@ test("readInput trims; validate requires title + assignee", () => {
// ---- list handler ----
test("listShifts renders the upstream rows; q filters; canWrite reflects the role", async () => {
const r = asView(await listShifts(fakeUpstream())(fakeCtx({ roles: ["scheduling:write"] })));
test("listShifts renders the upstream rows; q filters; canWrite reflects the permission", async () => {
const r = asView(await listShifts(fakeUpstream())(fakeCtx({ permissions: ["scheduling:write"] })));
assert.equal(r.view, "shifts");
const table = r.data["table"] as { rows: { name: string }[] };
assert.deepEqual(table.rows.map((x) => x.name), ["Morning desk", "Afternoon support"]);
@@ -112,15 +112,15 @@ test("listShifts degrades to a recoverable error page when the upstream is down
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []);
});
// ---- public overview handler (a page anyone can reach, gated data stays behind the role) ----
// ---- public overview handler (a page anyone can reach, gated data stays behind the permission) ----
test("overview renders a public page for anyone; it links straight to Shifts only for a reader", async () => {
const anon = asView(await overview()(fakeCtx())); // user null, no roles
const anon = asView(await overview()(fakeCtx())); // user null, no permissions
assert.equal(anon.view, "overview");
assert.equal(anon.data["chrome"], CHROME);
assert.equal(anon.data["canRead"], false); // anonymous → prompt to sign in, no shifts link
const reader = asView(await overview()(fakeCtx({ roles: ["scheduling:read"] })));
const reader = asView(await overview()(fakeCtx({ permissions: ["scheduling:read"] })));
assert.equal(reader.data["canRead"], true); // a reader gets a link straight to the shifts list
});
+3 -3
View File
@@ -10,8 +10,8 @@ import { can, CSRF_FIELD, GuardError, type PageChrome, parseListQuery, readFormB
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page
export const SHIFTS_PATH = "/scheduling/shifts";
export const READ = "scheduling:read"; // the role gating the list + nav
export const WRITE = "scheduling:write"; // the role gating create
export const READ = "scheduling:read"; // the permission gating the list + nav
export const WRITE = "scheduling:write"; // the permission gating create
export interface Shift {
id: string;
@@ -188,7 +188,7 @@ export function newShiftForm(): RouteHandler {
// Public overview: a page anyone may reach — its route + nav node are marked `public`, so the
// gate lets an anonymous visitor through and the menu option shows for everyone. The real data
// (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone
// else a prompt to sign in. ctx.identity may be null here, so read the role via can() (zero I/O).
// else a prompt to sign in. ctx.identity may be null here, so read the permission via can() (zero I/O).
export function overview(): RouteHandler {
return (ctx) => ({
data: { breadcrumbs: [{ label: "Overview" }], canRead: can(ctx, READ), chrome: ctx.chrome, shiftsHref: SHIFTS_PATH, title: "Scheduling" },
@@ -12,7 +12,7 @@
-%>
<%- include("partials/shell", {
actions: "",
body: '<div class="scheduling-page"><p>Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> role.</p>' + cta + '</div>',
body: '<div class="scheduling-page"><p>Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> permission.</p>' + cta + '</div>',
brand: chrome.brand,
breadcrumbs,
csrfToken: chrome.csrfToken,
+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
# 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
+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>`.
class Identity implements Namespace {}
// A subject set: a named collection of users (and nested groups), resolved transitively.
// The admin "Groups" screen manages membership; checks expand it automatically.
// A named set of identities (and nested groups), resolved transitively. The admin "Groups"
// screen manages membership; checks expand it automatically.
class Group implements Namespace {
related: {
members: (Identity | SubjectSet<Group, "members">)[]
}
}
// A coarse role — the source of truth for the JWT `roles` claim. At login the app reads
// `Role:<name>#members@identity:<id>` from Keto and projects the result into the token
// (README: Login → session JWT). A group can hold a role, so members can be users or groups.
class Role implements Namespace {
// A coarse permission — an operation a route or menu item gates on, and the source of truth
// for the JWT `permissions` claim. At login the app reads `Permission:<name>#granted@identity:<id>`
// from Keto and projects the result into the token (README: Login → session JWT). A group can
// hold a permission, so grants go to an identity or to a whole group.
class Permission implements Namespace {
related: {
members: (Identity | SubjectSet<Group, "members">)[]
granted: (Identity | SubjectSet<Group, "members">)[]
}
}
// A fine-grained, relationship-checked resource — README's third "may I?" tier, the rare
// live Keto check (e.g. sharing/delegation). Permissions nest: owner ⊇ editor ⊇ viewer.
// Grants accept a user directly or any member of a group.
// live Keto check (e.g. sharing/delegation). Permits nest: owner ⊇ editor ⊇ viewer.
// Grants accept an identity directly or any member of a group.
class Resource implements Namespace {
related: {
owners: (Identity | SubjectSet<Group, "members">)[]
+2 -2
View File
@@ -41,7 +41,7 @@ selfservice:
ui_url: http://localhost:3000/login
after:
# After authenticating, land on our completion route — it mints the session JWT
# (roles from Keto → metadata_public projection → tokenize) and sets our cookie.
# (permissions from Keto → metadata_public projection → tokenize) and sets our cookie.
default_browser_return_url: http://localhost:3000/auth/complete
registration:
ui_url: http://localhost:3000/registration
@@ -94,7 +94,7 @@ session:
same_site: Lax
# Session→JWT tokenizer: whoami(tokenize_as: plainpages) mints a short-lived,
# locally-verifiable JWT so the hot path never calls Ory. Claims come from the
# committed Jsonnet mapper (sub = identity id, email from traits, roles from the
# committed Jsonnet mapper (sub = identity id, email from traits, permissions from the
# metadata_public projection); signed with tokenizer/jwks.json.
whoami:
tokenizer:
+3 -3
View File
@@ -1,7 +1,7 @@
// Session→JWT claims mapper for the `plainpages` tokenizer. Kratos exposes the
// session as `session`; `sub` is set from the identity id (subject_source: id) and
// can't be overridden here. roles come from metadata_public — the per-login projection
// of Keto roles the app refreshes at login (metadata_admin is NOT carried in the session
// can't be overridden here. permissions come from metadata_public — the per-login projection
// of Keto permissions the app refreshes at login (metadata_admin is NOT carried in the session
// the tokenizer sees; metadata_public is). Absent on a fresh identity ⇒ empty list.
local session = std.extVar('session');
local meta =
@@ -12,6 +12,6 @@ local meta =
{
claims: {
email: session.identity.traits.email,
roles: if std.objectHas(meta, 'roles') then meta.roles else [],
permissions: if std.objectHas(meta, 'permissions') then meta.permissions else [],
},
}
+24 -24
View File
@@ -1,11 +1,11 @@
// One-command bootstrap: idempotent first-boot seeding. Guards the pure payload
// builders (Kratos create-identity body + Keto role tuple), the idempotent seedAdmin
// builders (Kratos create-identity body + Keto permission tuple), the idempotent seedAdmin
// orchestration (fresh 201 vs existing 409 → reuse id), and the JWKS generate-if-absent
// safety net. Live boot is verified by running the stack; these catch contract drift.
import { test } from "node:test";
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { ensureJwks, firstRunBanner, identityPayload, roleTuple, seedAdmin, seedRoles } from "./bootstrap.ts";
import { ensureJwks, firstRunBanner, identityPayload, permissionTuple, seedAdmin, seedPermissions } from "./bootstrap.ts";
const json = (status: number, body?: unknown) =>
new Response(body === undefined ? null : JSON.stringify(body), {
@@ -20,27 +20,27 @@ test("identityPayload is a valid Kratos create-identity body with a password cre
assert.equal(body.credentials.password.config.password, "admin");
});
test("roleTuple grants a role to identity:<id> in the Role namespace", () => {
test("permissionTuple grants a permission to identity:<id> in the Permission namespace", () => {
const id = randomUUID();
assert.deepEqual(roleTuple(id, "admin"), {
namespace: "Role",
assert.deepEqual(permissionTuple(id, "admin"), {
namespace: "Permission",
object: "admin",
relation: "members",
relation: "granted",
subject_id: `identity:${id}`,
});
});
test("seedRoles unions ADMIN_ROLES (default 'admin') with the discovered plugins' declared roles", () => {
// Clean clone: no ADMIN_ROLES, the scheduling plugin declares its two tokens → the demo admin
test("seedPermissions unions ADMIN_PERMISSIONS (default 'admin') with the discovered plugins' declared permissions", () => {
// Clean clone: no ADMIN_PERMISSIONS, the scheduling plugin declares its two tokens → the demo admin
// gets exactly today's behaviour, but derived from discovery, not hardcoded in the host.
assert.deepEqual(seedRoles(undefined, ["scheduling:read", "scheduling:write"]), ["admin", "scheduling:read", "scheduling:write"]);
assert.deepEqual(seedRoles(undefined, []), ["admin"]); // no plugins → just the base admin role
assert.deepEqual(seedRoles("admin, ops ", ["inventory:read"]), ["admin", "ops", "inventory:read"]); // env trimmed + extended
assert.deepEqual(seedRoles("admin,scheduling:read", ["scheduling:read"]), ["admin", "scheduling:read"]); // dedup, no double grant
assert.deepEqual(seedRoles("admin,, ", [" scheduling:read ", ""]), ["admin", "scheduling:read"]); // blanks dropped, tokens trimmed (both sides)
assert.deepEqual(seedPermissions(undefined, ["scheduling:read", "scheduling:write"]), ["admin", "scheduling:read", "scheduling:write"]);
assert.deepEqual(seedPermissions(undefined, []), ["admin"]); // no plugins → just the base admin permission
assert.deepEqual(seedPermissions("admin, ops ", ["inventory:read"]), ["admin", "ops", "inventory:read"]); // env trimmed + extended
assert.deepEqual(seedPermissions("admin,scheduling:read", ["scheduling:read"]), ["admin", "scheduling:read"]); // dedup, no double grant
assert.deepEqual(seedPermissions("admin,, ", [" scheduling:read ", ""]), ["admin", "scheduling:read"]); // blanks dropped, tokens trimmed (both sides)
});
test("seedAdmin on a fresh stack creates the identity and grants every role (one tuple each)", async () => {
test("seedAdmin on a fresh stack creates the identity and grants every permission (one tuple each)", async () => {
const id = randomUUID();
const calls: { method: string; url: string; body?: unknown }[] = [];
const fetchImpl = (async (url, init) => {
@@ -57,20 +57,20 @@ test("seedAdmin on a fresh stack creates the identity and grants every role (one
ketoWriteUrl: "http://keto:4467",
kratosAdminUrl: "http://kratos:4434",
password: "admin",
roles: ["admin", "scheduling:read"],
permissions: ["admin", "scheduling:read"],
});
assert.deepEqual(result, { created: true, id, roles: ["admin", "scheduling:read"] });
assert.deepEqual(result, { created: true, id, permissions: ["admin", "scheduling:read"] });
const puts = calls.filter((c) => c.url.includes("relation-tuples"));
assert.equal(puts.length, 2); // one grant per role
assert.equal(puts.length, 2); // one grant per permission
assert.ok(puts.every((p) => p.method === "PUT"));
assert.deepEqual(puts.map((p) => p.body), [
{ namespace: "Role", object: "admin", relation: "members", subject_id: `identity:${id}` },
{ namespace: "Role", object: "scheduling:read", relation: "members", subject_id: `identity:${id}` },
{ namespace: "Permission", object: "admin", relation: "granted", subject_id: `identity:${id}` },
{ namespace: "Permission", object: "scheduling:read", relation: "granted", subject_id: `identity:${id}` },
]);
});
test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants the role", async () => {
test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants the permission", async () => {
const id = randomUUID();
let granted: unknown;
const fetchImpl = (async (url, init) => {
@@ -90,11 +90,11 @@ test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants
ketoWriteUrl: "http://keto:4467",
kratosAdminUrl: "http://kratos:4434",
password: "admin",
roles: ["admin"],
permissions: ["admin"],
});
assert.deepEqual(result, { created: false, id, roles: ["admin"] });
assert.deepEqual(granted, { namespace: "Role", object: "admin", relation: "members", subject_id: `identity:${id}` });
assert.deepEqual(result, { created: false, id, permissions: ["admin"] });
assert.deepEqual(granted, { namespace: "Permission", object: "admin", relation: "granted", subject_id: `identity:${id}` });
});
test("seedAdmin fails loud on an unexpected Kratos error", async () => {
@@ -106,7 +106,7 @@ test("seedAdmin fails loud on an unexpected Kratos error", async () => {
ketoWriteUrl: "http://keto:4467",
kratosAdminUrl: "http://kratos:4434",
password: "admin",
roles: ["admin"],
permissions: ["admin"],
}),
/Kratos/,
);
+23 -23
View File
@@ -2,8 +2,8 @@
// kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`:
// 1. generate the JWKS signing key if absent (committed dev key makes this a safety net);
// 2. seed a demo admin (admin@plainpages.local / admin) in Kratos;
// 3. grant it its roles in Keto so menu/role checks resolve out of the box — `admin` plus
// every discovered plugin's declared role names, so a dropped-in plugin is usable by
// 3. grant it its permissions in Keto so menu/permission checks resolve out of the box — `admin` plus
// every discovered plugin's declared permission names, so a dropped-in plugin is usable by
// the demo admin with no host config edit (the host stays plugin-agnostic).
// Then prints a first-run banner; fails loud on any unexpected upstream error.
import { existsSync, writeFileSync } from "node:fs";
@@ -22,19 +22,19 @@ export function identityPayload(email: string, password: string) {
};
}
// Coarse-role grant: `Role:<role>#members@identity:<id>`. Subject ids are `identity:<kratos-id>`
// (namespaces.keto.ts) — the source of truth the login flow projects into the JWT roles.
export function roleTuple(identityId: string, role: string) {
return { namespace: "Role", object: role, relation: "members", subject_id: `identity:${identityId}` };
// Coarse-permission grant: `Permission:<permission>#members@identity:<id>`. Subject ids are `identity:<kratos-id>`
// (namespaces.keto.ts) — the source of truth the login flow projects into the JWT permissions.
export function permissionTuple(identityId: string, permission: string) {
return { namespace: "Permission", object: permission, relation: "granted", subject_id: `identity:${identityId}` };
}
// The roles to grant the demo admin = the configured base (ADMIN_ROLES, default just `admin`)
// unioned with every discovered plugin's declared role names (a route/nav `role` is a
// coarse role — granted as a Keto `Role:<token>#members` tuple). So the host names no plugin, yet a
// The permissions to grant the demo admin = the configured base (ADMIN_PERMISSIONS, default just `admin`)
// unioned with every discovered plugin's declared permission names (a route/nav `permission` is a
// coarse permission — granted as a Keto `Permission:<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.
export function seedRoles(adminRolesEnv: string | undefined, declaredRoles: string[]): string[] {
export function seedPermissions(adminRolesEnv: string | undefined, declaredPermissions: string[]): string[] {
const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean);
return [...new Set([...clean((adminRolesEnv ?? "admin").split(",")), ...clean(declaredRoles)])];
return [...new Set([...clean((adminRolesEnv ?? "admin").split(",")), ...clean(declaredPermissions)])];
}
// --- JWKS safety net -----------------------------------------------------------------
@@ -63,13 +63,13 @@ export interface SeedOptions {
ketoWriteUrl: string;
kratosAdminUrl: string;
password: string;
roles: string[];
permissions: string[];
}
export interface SeedResult {
created: boolean;
id: string;
roles: string[];
permissions: string[];
}
export async function seedAdmin(opts: SeedOptions): Promise<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()}`);
}
// Grant each role in Keto. PUT is idempotent — re-running just re-asserts the tuple.
for (const role of opts.roles) {
// Grant each permission in Keto. PUT is idempotent — re-running just re-asserts the tuple.
for (const permission of opts.permissions) {
const grant = await http(`${opts.ketoWriteUrl}/admin/relation-tuples`, {
body: JSON.stringify(roleTuple(id, role)),
body: JSON.stringify(permissionTuple(id, permission)),
headers: { "content-type": "application/json" },
method: "PUT",
});
if (!grant.ok) throw new Error(`bootstrap: Keto grant role "${role}" failed (${grant.status}): ${await grant.text()}`);
if (!grant.ok) throw new Error(`bootstrap: Keto grant permission "${permission}" failed (${grant.status}): ${await grant.text()}`);
}
return { created, id, roles: opts.roles };
return { created, id, permissions: opts.permissions };
}
async function findIdentityId(http: typeof fetch, adminUrl: string, email: string): Promise<string> {
@@ -143,10 +143,10 @@ async function main() {
await runWithLog(log, async () => {
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key");
// Seed `admin` (or ADMIN_ROLES) + every discovered plugin's declared role names, so the
// Seed `admin` (or ADMIN_PERMISSIONS) + every discovered plugin's declared permission names, so the
// shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
const declared = (await discoverPlugins()).flatMap((p) => (p.roles ?? []).map((d) => d.name));
const roles = seedRoles(env["ADMIN_ROLES"], declared);
const declared = (await discoverPlugins()).flatMap((p) => (p.permissions ?? []).map((d) => d.name));
const permissions = seedPermissions(env["ADMIN_PERMISSIONS"], declared);
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
const password = env["ADMIN_PASSWORD"] ?? "admin";
const result = await seedAdmin({
@@ -155,9 +155,9 @@ async function main() {
ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467",
kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434",
password,
roles,
permissions,
});
log.info("admin seeded", { created: result.created, id: result.id, roles: result.roles.join(", ") });
log.info("admin seeded", { created: result.created, id: result.id, permissions: result.permissions.join(", ") });
// The banner is human-facing UX (the first-run "you're ready" block), not a log event — print raw.
console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password }));
});
+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.
//
// The hot path verifies a short-lived (~10m) session JWT in-process, so a revoked role or a
// The hot path verifies a short-lived (~10m) session JWT in-process, so a revoked permission or a
// killed session only takes effect when the token is next minted (re-login / TTL refresh) —
// up to one token TTL of lag. For security-critical revoke (offboarding, a compromised
// account) that lag is too long. An admin action records the subject as revoked-now and the
// hot path then rejects that subject's pre-revoke tokens at once, forcing a re-mint (which
// re-reads roles from Keto, or clears a now-dead session).
// re-reads permissions from Keto, or clears a now-dead session).
//
// Cost & scope: an in-memory, auto-evicting Map — no database, like the JWKS cache, so it
// stays inside the stateless model. A token carries `iat`, so a *fresh* re-login (iat after
+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)", () => {
const key = generateJwks().keys[0]!;
const head = b64url(JSON.stringify({ alg: "ES256", kid: key.kid }));
const body = b64url(JSON.stringify({ email: "a@b.c", roles: [], sub: key.kid }));
const body = b64url(JSON.stringify({ email: "a@b.c", permissions: [], sub: key.kid }));
const sig = sign("SHA256", Buffer.from(`${head}.${body}`), { dsaEncoding: "ieee-p1363", key: createPrivateKey({ key: key as unknown as JsonWebKey, format: "jwk" }) });
const token = `${head}.${body}.${sig.toString("base64url")}`;
+2 -2
View File
@@ -12,7 +12,7 @@ function ctxFor(user: SessionIdentity | null, url = "/"): RequestContext {
return buildContext(req, new ServerResponse(req), { identity: user });
}
const alice: SessionIdentity = { email: "a@b.c", id: "u1", roles: ["admin", "scheduling:read"] };
const alice: SessionIdentity = { email: "a@b.c", id: "u1", permissions: ["admin", "scheduling:read"] };
test("requireSession returns the user, or throws GuardError(401)→/login (preserving return_to) when anonymous", () => {
assert.equal(requireSession(ctxFor(alice)), alice);
@@ -30,7 +30,7 @@ test("requireSession returns the user, or throws GuardError(401)→/login (prese
err instanceof GuardError && err.location === "/login?return_to=%2Fscheduling%2Fshifts%3Fq%3D1");
});
test("can reads a coarse role from the JWT claims; anonymous has none", () => {
test("can reads a coarse permission from the JWT claims; anonymous has none", () => {
assert.equal(can(ctxFor(alice), "admin"), true);
assert.equal(can(ctxFor(alice), "billing:write"), false);
assert.equal(can(ctxFor(null), "admin"), false);
+4 -4
View File
@@ -1,5 +1,5 @@
// Auth guards: in-handler authorization, the imperative counterpart to the
// declarative route `role` gate. The middleware already verified the session JWT and put
// declarative route `permission` gate. The middleware already verified the session JWT and put
// the User on ctx; these read it. `requireSession` asserts (throws GuardError, which app.ts maps
// to a response); `can`/`check` are predicates a handler branches on. `check` is the one live
// Keto call — the fine-grained "may I?" tier (README), reserved for relationship rules.
@@ -37,9 +37,9 @@ export function requireSession(ctx: RequestContext): SessionIdentity {
return ctx.identity;
}
// Coarse role check straight from the JWT claims — in-process, zero I/O. Anonymous ⇒ false.
export function can(ctx: RequestContext, role: string): boolean {
return ctx.roles.includes(role);
// Coarse permission check straight from the JWT claims — in-process, zero I/O. Anonymous ⇒ false.
export function can(ctx: RequestContext, permission: string): boolean {
return ctx.permissions.includes(permission);
}
// Live Keto relationship check at the point of action. The subject is the current user;
+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 NOW = 1_700_000_000; // fixed clock for deterministic exp/nbf checks
const valid = { email: "a@b.c", exp: NOW + 600, roles: ["admin"], sub: "u1" };
const valid = { email: "a@b.c", exp: NOW + 600, permissions: ["admin"], sub: "u1" };
test("verifyToken: a valid token → User, selecting the verify key by kid across a rotated set", async () => {
const user = await verifyToken(mint(k2.privateKey, "k2", valid), jwks, { now: NOW });
assert.deepEqual(user, { email: "a@b.c", id: "u1", roles: ["admin"] });
assert.deepEqual(user, { email: "a@b.c", id: "u1", permissions: ["admin"] });
});
test("verifyToken requires exp, rejects expiry and future nbf, with clock-skew leeway", async () => {
@@ -59,18 +59,18 @@ test("verifyToken rejects a bad signature and an unknown kid", async () => {
await assert.rejects(verifyToken(mint(k1.privateKey, "nope", valid), jwks, { now: NOW }), /no JWKS key/);
});
test("claimsToIdentity requires sub + email, defaults roles to [], keeps only string roles", () => {
test("claimsToIdentity requires sub + email, defaults permissions to [], keeps only string permissions", () => {
assert.throws(() => claimsToIdentity({ email: "a@b.c", exp: NOW }), /sub/);
assert.throws(() => claimsToIdentity({ email: "a@b.c", exp: NOW, sub: "" }), /sub/); // empty sub rejected too
assert.throws(() => claimsToIdentity({ exp: NOW, sub: "u" }), /email/);
assert.throws(() => claimsToIdentity({ email: "", exp: NOW, sub: "u" }), /email/); // empty email rejected (the shell keys signed-in vs anonymous off it)
assert.deepEqual(claimsToIdentity({ email: "a@b.c", sub: "u" }).roles, []); // roles absent
assert.deepEqual(claimsToIdentity({ email: "a@b.c", roles: ["a", 1, "b"], sub: "u" }).roles, ["a", "b"]);
assert.deepEqual(claimsToIdentity({ email: "a@b.c", sub: "u" }).permissions, []); // permissions absent
assert.deepEqual(claimsToIdentity({ email: "a@b.c", permissions: ["a", 1, "b"], sub: "u" }).permissions, ["a", "b"]);
});
test("resolveSession classifies the cookie; authenticate is its fail-closed identity projection", async () => {
const cookie = (extra: Record<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.
assert.deepEqual(await resolveSession(cookie(), jwks, { now: NOW }), { expired: false, identity });
@@ -96,6 +96,6 @@ test("verifyToken honours an optional denylist: a revoked subject's token reject
await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 }), jwks, { denylist, now: NOW }), /revoked/);
assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=${mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 })}`, jwks, { denylist, now: NOW }), { expired: true, identity: null });
// A token minted after the revoke (fresh login) is accepted; a different subject is untouched.
assert.deepEqual(await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW + 5 }), jwks, { denylist, now: NOW }), { email: "a@b.c", id: "u1", roles: ["admin"] });
assert.deepEqual(await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW + 5 }), jwks, { denylist, now: NOW }), { email: "a@b.c", id: "u1", permissions: ["admin"] });
await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5, sub: "u2" }), jwks, { denylist, now: NOW });
});
+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,
// check the signature (src/auth/jwt.ts), validate the time/issuer/audience claims, project the
// User onto the request context. `authenticate` fails closed: any bad/expired token ⇒ null
// (anonymous), so the route renders signed-out and the role gate denies.
// (anonymous), so the route renders signed-out and the permission gate denies.
import type { SessionIdentity } from "../http/context.ts";
import { parseCookies } from "../http/cookie.ts";
import type { Denylist } from "./denylist.ts";
@@ -59,15 +59,15 @@ export function validateClaims(payload: Record<string, unknown>, options: Verify
}
// Map verified claims → the request User. sub/email are required and non-empty (the tokenizer
// always sets them; an empty email would read as anonymous in the shell); roles defaults to [] and
// always sets them; an empty email would read as anonymous in the shell); permissions defaults to [] and
// keeps only string entries (defensive).
export function claimsToIdentity(payload: Record<string, unknown>): SessionIdentity {
const sub = payload["sub"];
if (typeof sub !== "string" || sub === "") throw new TokenError("token missing sub");
const email = payload["email"];
if (typeof email !== "string" || email === "") throw new TokenError("token missing email");
const roles = payload["roles"];
return { email, id: sub, roles: Array.isArray(roles) ? roles.filter((r): r is string => typeof r === "string") : [] };
const permissions = payload["permissions"];
return { email, id: sub, permissions: Array.isArray(permissions) ? permissions.filter((r): r is string => typeof r === "string") : [] };
}
// Verify a session JWT end-to-end: select the key by `kid`, check the signature, validate
@@ -80,7 +80,7 @@ export async function verifyToken(token: string, jwks: JwksProvider, options: Ve
validateClaims(verified.payload, options);
const user = claimsToIdentity(verified.payload);
// Instant revoke: a denylisted subject's pre-revoke token is rejected as *expired* so
// resolveSession routes it through the re-mint (fresh roles from Keto, or a cleared session).
// resolveSession routes it through the re-mint (fresh permissions from Keto, or a cleared session).
if (options.denylist?.isRevoked(user.id, num(verified.payload, "iat"))) throw new TokenError("token revoked", true);
return user;
}
+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;
test("verifies an RS256 token, returning the decoded header + payload", () => {
const token = makeJws("RS256", rsa.privateKey, { roles: ["admin"], sub: "u" });
const token = makeJws("RS256", rsa.privateKey, { permissions: ["admin"], sub: "u" });
const verified = verifyJws(token, rsaJwk);
assert.equal(verified.header.alg, "RS256");
assert.deepEqual(verified.payload, { roles: ["admin"], sub: "u" });
assert.deepEqual(verified.payload, { permissions: ["admin"], sub: "u" });
});
test("verifies an ES256 token (raw r‖s signature)", () => {
@@ -35,10 +35,10 @@ test("verifies an ES256 token (raw r‖s signature)", () => {
// All three reach and fail the signature check itself, not an earlier structural guard.
test("rejects a signature that fails verification (tampered payload, wrong key, empty)", () => {
const token = makeJws("RS256", rsa.privateKey, { roles: ["user"], sub: "u" });
const token = makeJws("RS256", rsa.privateKey, { permissions: ["user"], sub: "u" });
const [header, payload, signature] = token.split(".");
const forged = `${header}.${b64url(JSON.stringify({ roles: ["admin"], sub: "u" }))}.${signature}`;
const forged = `${header}.${b64url(JSON.stringify({ permissions: ["admin"], sub: "u" }))}.${signature}`;
assert.throws(() => verifyJws(forged, rsaJwk), /invalid signature/);
const otherJwk = generateKeyPairSync("rsa", { modulusLength: 2048 }).publicKey.export({ format: "jwk" }) as JsonWebKey;
+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 () => {
const allow = recorder(() => res(200, { allowed: true }));
assert.equal(await keto(allow.fetchImpl).check({ namespace: "Role", object: "admin", relation: "members", subject_id: USER }), true);
assert.equal(await keto(allow.fetchImpl).check({ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER }), true);
assert.match(allow.calls[0]!.url, /^http:\/\/keto:4466\/relation-tuples\/check\?/);
assert.match(allow.calls[0]!.url, /namespace=Role&object=admin&relation=members/);
assert.match(allow.calls[0]!.url, /namespace=Permission&object=admin&relation=granted/);
assert.match(allow.calls[0]!.url, new RegExp(`subject_id=${encodeURIComponent(USER).replace(/[.]/g, "\\.")}`));
// A denied check is 403 {allowed:false} (not a 200) — both statuses carry the verdict.
const deny = recorder(() => res(403, { allowed: false }));
assert.equal(await keto(deny.fetchImpl).check({ namespace: "Role", object: "admin", relation: "members", subject_id: "identity:nobody" }), false);
assert.equal(await keto(deny.fetchImpl).check({ namespace: "Permission", object: "admin", relation: "granted", subject_id: "identity:nobody" }), false);
});
test("check on a subject_set builds subject_set.* params and forwards max-depth", async () => {
@@ -51,20 +51,20 @@ test("check on a subject_set builds subject_set.* params and forwards max-depth"
test("check throws a KetoError carrying the status on an unexpected response", async () => {
await assert.rejects(
keto((async () => res(400, { error: "bad" })) as typeof fetch).check({ namespace: "Role", object: "admin", relation: "members", subject_id: USER }),
keto((async () => res(400, { error: "bad" })) as typeof fetch).check({ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER }),
(e: unknown) => e instanceof KetoError && e.status === 400,
);
});
test("listRelations builds the filter query + pagination and parses next_page_token", async () => {
const tuples = [{ namespace: "Role", object: "admin", relation: "members", subject_id: USER }];
const tuples = [{ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER }];
const { calls, fetchImpl } = recorder(() => res(200, { next_page_token: "NEXT", relation_tuples: tuples }));
const out = await keto(fetchImpl).listRelations({ namespace: "Role", object: "admin", pageSize: 10, pageToken: "CUR", relation: "members" });
const out = await keto(fetchImpl).listRelations({ namespace: "Permission", object: "admin", pageSize: 10, pageToken: "CUR", relation: "granted" });
assert.deepEqual(out.tuples, tuples);
assert.equal(out.nextPageToken, "NEXT");
const url = calls[0]!.url;
assert.match(url, /^http:\/\/keto:4466\/relation-tuples\?/);
assert.match(url, /namespace=Role&object=admin&relation=members/);
assert.match(url, /namespace=Permission&object=admin&relation=granted/);
assert.match(url, /page_size=10&page_token=CUR/);
// No Link header / token in the body ⇒ null, empty list ⇒ [].
const empty = await keto((async () => res(200, {})) as typeof fetch).listRelations();
@@ -72,16 +72,16 @@ test("listRelations builds the filter query + pagination and parses next_page_to
});
test("expand GETs the read API for a subject set and returns the tree (with max-depth)", async () => {
const tree = { children: [{ tuple: { namespace: "", object: "", relation: "", subject_id: USER }, type: "leaf" }], tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Role", object: "admin", relation: "members" } }, type: "union" };
const tree = { children: [{ tuple: { namespace: "", object: "", relation: "", subject_id: USER }, type: "leaf" }], tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Permission", object: "admin", relation: "granted" } }, type: "union" };
const { calls, fetchImpl } = recorder(() => res(200, tree));
const out = await keto(fetchImpl).expand({ namespace: "Role", object: "admin", relation: "members" }, { maxDepth: 3 });
const out = await keto(fetchImpl).expand({ namespace: "Permission", object: "admin", relation: "granted" }, { maxDepth: 3 });
assert.deepEqual(out, tree);
assert.match(calls[0]!.url, /^http:\/\/keto:4466\/relation-tuples\/expand\?/);
assert.match(calls[0]!.url, /namespace=Role&object=admin&relation=members&max-depth=3/);
assert.match(calls[0]!.url, /namespace=Permission&object=admin&relation=granted&max-depth=3/);
});
test("writeTuple PUTs the tuple as JSON to the write API (idempotent; non-2xx throws)", async () => {
const tuple = { namespace: "Role", object: "admin", relation: "members", subject_id: USER };
const tuple = { namespace: "Permission", object: "admin", relation: "granted", subject_id: USER };
const { calls, fetchImpl } = recorder(() => res(201, tuple));
await keto(fetchImpl).writeTuple(tuple);
assert.equal(calls[0]!.method, "PUT");
@@ -95,12 +95,12 @@ test("writeTuple PUTs the tuple as JSON to the write API (idempotent; non-2xx th
test("deleteTuple DELETEs the write API by query params (204 resolves; non-204 throws)", async () => {
const { calls, fetchImpl } = recorder(() => res(204));
await keto(fetchImpl).deleteTuple({ namespace: "Role", object: "admin", relation: "members", subject_id: USER });
await keto(fetchImpl).deleteTuple({ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER });
assert.equal(calls[0]!.method, "DELETE");
assert.match(calls[0]!.url, /^http:\/\/keto:4467\/admin\/relation-tuples\?/);
assert.match(calls[0]!.url, /namespace=Role&object=admin&relation=members/);
assert.match(calls[0]!.url, /namespace=Permission&object=admin&relation=granted/);
await assert.rejects(
keto((async () => res(404)) as typeof fetch).deleteTuple({ namespace: "Role", object: "x", relation: "members", subject_id: USER }),
keto((async () => res(404)) as typeof fetch).deleteTuple({ namespace: "Permission", object: "x", relation: "granted", subject_id: USER }),
(e: unknown) => e instanceof KetoError && e.status === 404,
);
});
+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
// is `subject_id` xor `subject_set` (never both). Mirrors bootstrap.ts's roleTuple.
// is `subject_id` xor `subject_set` (never both). Mirrors bootstrap.ts's permissionTuple.
export interface RelationTuple {
namespace: string;
object: string;
+4 -4
View File
@@ -1,5 +1,5 @@
// Kratos admin-API client: typed fetch wrappers over Ory Kratos' admin endpoints —
// identity CRUD + the surgical metadata_public update the login flow projects roles into.
// identity CRUD + the surgical metadata_public update the login flow projects permissions into.
// Guards the request contracts (URLs, method, JSON-Patch body, query/pagination) and the
// result mapping (201/200/404/4xx). Live wiring is verified by login completion.
import { test } from "node:test";
@@ -90,13 +90,13 @@ test("updateIdentity PUTs the full body to /admin/identities/<id> and returns th
});
test("updateMetadataPublic PATCHes a JSON-Patch `add /metadata_public` so it never clobbers traits", async () => {
const identity = { id: ID, metadata_public: { roles: ["admin"] } };
const identity = { id: ID, metadata_public: { permissions: ["admin"] } };
const { calls, fetchImpl } = recorder(() => res(200, identity));
const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).updateMetadataPublic(ID, { roles: ["admin"] });
const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).updateMetadataPublic(ID, { permissions: ["admin"] });
assert.deepEqual(out, identity);
assert.equal(calls[0]!.method, "PATCH");
assert.match(calls[0]!.url, new RegExp(`/admin/identities/${ID}$`));
assert.deepEqual(JSON.parse(calls[0]!.body!), [{ op: "add", path: "/metadata_public", value: { roles: ["admin"] } }]);
assert.deepEqual(JSON.parse(calls[0]!.body!), [{ op: "add", path: "/metadata_public", value: { permissions: ["admin"] } }]);
});
test("createRecoveryCode POSTs the identity id to /admin/recovery/code → { code, link }", async () => {
+2 -2
View File
@@ -1,6 +1,6 @@
// Kratos admin-API client: typed `fetch` wrappers over Ory Kratos' admin endpoints
// (internal-only admin port) — identity CRUD + the surgical `metadata_public` update login
// completion projects Keto roles into (README). Built-in `fetch` only, no SDK dep (AGENTS.md);
// completion projects Keto permissions into (README). Built-in `fetch` only, no SDK dep (AGENTS.md);
// `fetchImpl`-injectable, reuses kratos-public.ts's `KratosError` (branch on `.status`).
import { KratosError } from "./kratos-public.ts";
@@ -106,7 +106,7 @@ export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof
},
// JSON Patch `add` sets metadata_public whether it's currently absent, null, or set, and
// touches nothing else — so the login role projection never clobbers traits/state.
// touches nothing else — so the login permission projection never clobbers traits/state.
// (metadata_public, not _admin: the session the tokenizer sees carries only public metadata.)
async updateMetadataPublic(id, metadata) {
const patch = [{ op: "add", path: "/metadata_public", value: metadata }];
+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;
// the live, full-stack login is verified by the Playwright E2E.
import { test } from "node:test";
@@ -6,10 +6,10 @@ import assert from "node:assert/strict";
import type { KetoClient, RelationTuple } from "./keto-client.ts";
import type { Identity, KratosAdmin } from "./kratos-admin.ts";
import type { KratosPublic, Session } from "./kratos-public.ts";
import { completeLogin, readRoles, remintSession, SESSION_COOKIE, sessionCookie } from "./login.ts";
import { completeLogin, readPermissions, remintSession, SESSION_COOKIE, sessionCookie } from "./login.ts";
const ID = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
const roleTuple = (object: string): RelationTuple => ({ namespace: "Role", object, relation: "members", subject_id: `identity:${ID}` });
const permissionTuple = (object: string): RelationTuple => ({ namespace: "Permission", object, relation: "granted", subject_id: `identity:${ID}` });
const ketoStub = (over: Partial<KetoClient> = {}): KetoClient => ({
check: async () => false,
@@ -40,32 +40,32 @@ const publicStub = (over: Partial<KratosPublic> = {}): KratosPublic => ({
...over,
});
test("readRoles returns roles held directly OR transitively (enumerate defined roles → Keto-check each)", async () => {
test("readPermissions returns permissions held directly OR transitively (enumerate defined permissions → Keto-check each)", async () => {
const listQ: unknown[] = [];
const checked: string[] = [];
const role = (object: string, subject: Partial<RelationTuple>): RelationTuple => ({ namespace: "Role", object, relation: "members", ...subject });
const permission = (object: string, subject: Partial<RelationTuple>): RelationTuple => ({ namespace: "Permission", object, relation: "granted", ...subject });
const keto = ketoStub({
// Enumerate every Role tuple (paged, no subject filter) to find the distinct role names —
// Enumerate every Permission tuple (paged, no subject filter) to find the distinct permission names —
// subjects vary (a direct user, a group) and a name repeats across pages → de-duped.
listRelations: async (q) => {
listQ.push(q);
if (q?.pageToken === "p2") return { nextPageToken: null, tuples: [role("editor", { subject_id: "identity:other" })] };
if (q?.pageToken === "p2") return { nextPageToken: null, tuples: [permission("editor", { subject_id: "identity:other" })] };
return { nextPageToken: "p2", tuples: [
role("editor", { subject_set: { namespace: "Group", object: "eng", relation: "members" } }),
role("admin", { subject_id: `identity:${ID}` }),
role("viewer", { subject_id: "identity:stranger" }),
permission("editor", { subject_set: { namespace: "Group", object: "eng", relation: "members" } }),
permission("admin", { subject_id: `identity:${ID}` }),
permission("viewer", { subject_id: "identity:stranger" }),
] };
},
// Keto resolves transitively: the user holds editor (via a group) + admin (direct), not viewer.
check: async (t) => { checked.push(t.object); return t.object === "admin" || t.object === "editor"; },
});
assert.deepEqual(await readRoles(keto, ID), ["admin", "editor"]);
assert.deepEqual(listQ[0], { namespace: "Role", relation: "members" }); // enumerate, not subject-filtered
assert.deepEqual(await readPermissions(keto, ID), ["admin", "editor"]);
assert.deepEqual(listQ[0], { namespace: "Permission", relation: "granted" }); // enumerate, not subject-filtered
assert.equal((listQ[1] as { pageToken?: string }).pageToken, "p2"); // second page follows the cursor
assert.deepEqual(checked.sort(), ["admin", "editor", "viewer"]); // every distinct role checked for the user
assert.deepEqual(checked.sort(), ["admin", "editor", "viewer"]); // every distinct permission checked for the user
});
test("completeLogin: read roles → project onto metadata_public → tokenize → JWT (in that order)", async () => {
test("completeLogin: read permissions → project onto metadata_public → tokenize → JWT (in that order)", async () => {
const events: string[] = [];
let projected: unknown;
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
@@ -76,11 +76,11 @@ test("completeLogin: read roles → project onto metadata_public → tokenize
},
});
const kratosAdmin = adminStub({ updateMetadataPublic: async (_id, meta) => { events.push("project"); projected = meta; return identity; } });
const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [roleTuple("admin")] }) });
const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [permissionTuple("admin")] }) });
const out = await completeLogin({ keto, kratosAdmin, kratosPublic }, "plainpages_session=s");
assert.deepEqual(out, { email: "admin@plainpages.local", identityId: ID, jwt: "h.p.s", roles: ["admin"] });
assert.deepEqual(projected, { roles: ["admin"] }); // Keto roles, projected for the tokenizer
assert.deepEqual(out, { email: "admin@plainpages.local", identityId: ID, jwt: "h.p.s", permissions: ["admin"] });
assert.deepEqual(projected, { permissions: ["admin"] }); // Keto permissions, projected for the tokenizer
assert.deepEqual(events, ["whoami", "project", "tokenize"]); // projection MUST precede tokenize
});
@@ -101,11 +101,11 @@ test("completeLogin maps a missing email trait to null and throws if the tokeniz
test("remintSession: a live Kratos session → fresh cookie + refreshed user; a dead session → a clearing cookie + null", async () => {
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
const kratosPublic = publicStub({ whoami: async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session });
const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [roleTuple("admin")] }) });
const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [permissionTuple("admin")] }) });
// TTL lapsed but the Kratos session lives → re-read roles from Keto, re-tokenize, fresh cookie.
// TTL lapsed but the Kratos session lives → re-read permissions from Keto, re-tokenize, fresh cookie.
const live = await remintSession({ keto, kratosAdmin: adminStub(), kratosPublic }, "plainpages_session=s");
assert.deepEqual(live.identity, { email: "admin@plainpages.local", id: ID, roles: ["admin"] });
assert.deepEqual(live.identity, { email: "admin@plainpages.local", id: ID, permissions: ["admin"] });
assert.match(live.setCookie, /^plainpages_jwt=h\.p\.s;.*Max-Age=2592000.*HttpOnly/);
// Kratos session also gone → clear the stale JWT so the next request falls through to anonymous.
+18 -18
View File
@@ -1,9 +1,9 @@
// Login completion: turn a fresh Kratos session into our locally-verifiable
// session JWT — the one moment Ory is on the path (README: Login → session JWT):
// 1. whoami(cookie) → the identity (id, email); no active session ⇒ null
// 2. read roles from Keto → the source of truth for the `roles` claim
// 2. read permissions from Keto → the source of truth for the `permissions` claim
// 3. project onto metadata_public (admin API) so the tokenizer's mapper can read them
// 4. whoami(tokenize_as) → the signed JWT { sub, email, roles }, stored as our cookie
// 4. whoami(tokenize_as) → the signed JWT { sub, email, permissions }, stored as our cookie
// Order matters: the projection is written before tokenizing, because the claims mapper
// reads only the identity, never Keto.
import type { SessionIdentity } from "../http/context.ts";
@@ -34,26 +34,26 @@ export interface CompletedLogin {
email: string | null;
identityId: string;
jwt: string;
roles: string[];
permissions: string[];
}
// The coarse roles a user holds — directly (`Role:<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
// namespace) and asks Keto to resolve each membership, so a role granted to a group reaches the JWT —
// The coarse permissions a user holds — directly (`Permission:<name>#members@identity:<id>`) or transitively via a
// group that is a member of the permission. Enumerates the defined permissions (the distinct objects in the Permission
// namespace) and asks Keto to resolve each membership, so a permission granted to a group reaches the JWT —
// matching the OPL model and the admin "Effective access" view. At login/refresh only, never per
// request; role count is small, so the per-role checks are cheap and run in parallel.
export async function readRoles(keto: KetoClient, identityId: string): Promise<string[]> {
// request; permission count is small, so the per-permission checks are cheap and run in parallel.
export async function readPermissions(keto: KetoClient, identityId: string): Promise<string[]> {
const subject_id = `identity:${identityId}`;
const names = new Set<string>();
let pageToken: string | undefined;
do {
const page = await keto.listRelations({ namespace: "Role", relation: "members", ...(pageToken ? { pageToken } : {}) });
const page = await keto.listRelations({ namespace: "Permission", relation: "granted", ...(pageToken ? { pageToken } : {}) });
for (const t of page.tuples) names.add(t.object);
pageToken = page.nextPageToken ?? undefined;
} while (pageToken);
const roles = [...names];
const held = await Promise.all(roles.map((object) => keto.check({ namespace: "Role", object, relation: "members", subject_id })));
return roles.filter((_, i) => held[i]).sort();
const permissions = [...names];
const held = await Promise.all(permissions.map((object) => keto.check({ namespace: "Permission", object, relation: "granted", subject_id })));
return permissions.filter((_, i) => held[i]).sort();
}
export async function completeLogin(deps: LoginDeps, cookie: string | undefined): Promise<CompletedLogin | null> {
@@ -63,15 +63,15 @@ export async function completeLogin(deps: LoginDeps, cookie: string | undefined)
const emailTrait = session.identity.traits?.["email"];
const email = typeof emailTrait === "string" ? emailTrait : null;
const roles = await readRoles(deps.keto, identityId);
await deps.kratosAdmin.updateMetadataPublic(identityId, { roles });
const permissions = await readPermissions(deps.keto, identityId);
await deps.kratosAdmin.updateMetadataPublic(identityId, { permissions });
const tokenized = await deps.kratosPublic.whoami({ ...(cookie ? { cookie } : {}), tokenizeAs: TOKENIZE_AS });
const jwt = tokenized?.tokenized;
if (!jwt) throw new Error("login completion: Kratos tokenizer returned no JWT");
currentLog()?.info("session minted", { roles: roles.join(","), sub: identityId }); // login or TTL re-mint
return { email, identityId, jwt, roles };
currentLog()?.info("session minted", { permissions: permissions.join(","), sub: identityId }); // login or TTL re-mint
return { email, identityId, jwt, permissions };
}
export interface Reminted {
@@ -80,14 +80,14 @@ export interface Reminted {
}
// Re-mint the session JWT on TTL expiry — "stay signed in" (README): the ~10m token lapsed but
// the long-lived Kratos session may still be live. A live session ⇒ re-read roles from Keto,
// the long-lived Kratos session may still be live. A live session ⇒ re-read permissions from Keto,
// re-tokenize, fresh cookie + the refreshed user (the one moment authz recomputes). A dead
// session ⇒ a cookie that *clears* the stale JWT, so later requests fall straight through to
// anonymous instead of re-hitting Ory on every one.
export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise<Reminted> {
const completed = await completeLogin(deps, cookie);
if (!completed) return { setCookie: clearSessionCookie(options), identity: null };
return { setCookie: sessionCookie(completed.jwt, options), identity: { email: completed.email ?? "", id: completed.identityId, roles: completed.roles } };
return { setCookie: sessionCookie(completed.jwt, options), identity: { email: completed.email ?? "", id: completed.identityId, permissions: completed.permissions } };
}
// Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is
+1 -1
View File
@@ -2,7 +2,7 @@
// /oauth2/consent?consent_challenge=… (hydra.yml urls.consent). A first-party client (or one
// Hydra already skipped) is auto-granted the requested scopes; a third-party client shows the
// themed consent screen, then accept (allow) / reject (deny). id_token claims (email/name) come
// from the Kratos identity. OAuth2-provider role only — no first-party page needs this (README).
// from the Kratos identity. OAuth2-provider permission only — no first-party page needs this (README).
import type { AcceptConsent, ConsentRequest, HydraAdmin, OAuth2Client } from "./hydra-admin.ts";
import type { KratosPublic } from "./kratos-public.ts";
+1 -1
View File
@@ -2,7 +2,7 @@
// Hydra hands the browser to /oauth2/login?login_challenge=… (hydra.yml urls.login). We
// authenticate the user with their existing Kratos session and accept the request; Hydra then
// proceeds to consent and mints the tokens. No first-party page needs this — it's the OAuth2
// provider role only (README).
// provider permission only (README).
import type { HydraAdmin } from "./hydra-admin.ts";
import type { KratosPublic } from "./kratos-public.ts";
+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
// session JWT — read roles from Keto, project onto the identity, tokenize — and store it as the
// session JWT — read permissions from Keto, project onto the identity, tokenize — and store it as the
// cookie; no active session bounces back to sign in.
function completeAuth(deps: { keto: KetoClient; kratosAdmin: KratosAdmin; kratosPublic: KratosPublic }, secureCookies: boolean): BuiltinRoute["handler"] {
return async (ctx: RequestContext): Promise<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)
otlpProtocol: "http/json" | "http/protobuf"; // OTLP wire format (protobuf for json-averse collectors)
port: number;
revocationDenylist: boolean; // enable the optional instant role/session revoke denylist
revocationDenylist: boolean; // enable the optional instant permission/session revoke denylist
revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew
secureCookies: boolean;
serviceName: string; // OTLP service.name — an implementer brands their own logs/traces
@@ -157,7 +157,7 @@ export function loadConfig(env: Env = process.env): Config {
otlpEndpoint: readOptionalUrl(env, "OTLP_ENDPOINT"),
otlpProtocol: readEnum(env, "OTLP_PROTOCOL", ["http/json", "http/protobuf"] as const, "http/json"),
port: readPort(env),
// Optional instant-revoke, off by default. When on, an admin deactivate/delete or role
// Optional instant-revoke, off by default. When on, an admin deactivate/delete or permission
// change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m
// tokenizer TTL + skew, so it outlasts any pre-revoke token).
revocationDenylist: readBool(env, "REVOCATION_DENYLIST", false),
+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))}`;
return `${input}.${b64url(sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key: ec.privateKey }))}`;
}
// A session cookie carrying `roles`, valid for 10 min — the auth most tests need to reach a gated page.
const session = (roles: string[] = []): string =>
`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, roles, sub: "u1" })}`;
// A session cookie carrying `permissions`, valid for 10 min — the auth most tests need to reach a gated page.
const session = (permissions: string[] = []): string =>
`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, permissions, sub: "u1" })}`;
const server = createApp({ jwks: staticJwks([ecJwk]) });
let base = "";
@@ -83,7 +83,7 @@ test("/ is the public landing: anonymous → 200 with intro + sign-in/register l
const html = await res.text();
assert.match(html, /href="\/login"/); // a prominent path to sign in
assert.match(html, /href="\/registration"/); // and to register
// the same app shell every page renders — the menu shows even when signed out (role-filtered).
// the same app shell every page renders — the menu shows even when signed out (permission-filtered).
assert.match(html, /<aside class="sidebar"/);
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 = {
apiVersion: "1.0.0",
id: "demo",
@@ -393,7 +393,7 @@ const demoPlugin: Plugin = {
{ handler: (ctx) => ({ html: `<p>Hi ${ctx.params.name}</p>` }), method: "GET", path: "/hello/:name" },
{ handler: () => ({ json: { ok: true } }), method: "GET", path: "/data" },
{ 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: () => ({ 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}`;
}
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-"));
mkdirSync(join(dir, "demo", "views"), { 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);
});
// 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).
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] });
await new Promise<void>((r) => app.listen(0, r));
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 secret = (cookie?: string) => fetch(url + "/demo/secret", { redirect: "manual", ...(cookie ? { headers: { cookie } } : {}) });
// Token carrying the gating role → the handler runs (200).
const ok = await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["demo:read"], sub: "u1" })}`);
// Token carrying the gating permission → the handler runs (200).
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(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();
assert.equal(noCookie.status, 303);
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
// 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.
const dash = await fetch(url + "/dashboard", { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["admin"], sub: "u1" })}` } });
// 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, permissions: ["admin"], sub: "u1" })}` } });
assert.equal(dash.status, 200);
const anonDash = await fetch(url + "/dashboard", { redirect: "manual" });
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());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
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
@@ -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) => {
const identity: Identity = { id: "u1", traits: { email: "a@b.c" } };
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 keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "demo:read", relation: "members", 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 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, 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.
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) => { 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: () => ({ 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] });
@@ -618,7 +618,7 @@ test("guards map to responses: requireSession → /login, a failed can/check →
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
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.
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.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(["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/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" });
assert.equal(gAnon.status, 303);
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");
// 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"]) {
const res = await fetch(url + path, signedIn);
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 });
// 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";
async function adminHarness(t: TestContext, opts: AppOptions = {}) {
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 token = issueCsrfToken(ADMIN_CSRF);
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 get = (path: string, roles: string[] = ["admin"]) => fetch(url + path, { headers: { cookie: cookie(roles) }, redirect: "manual" });
const cookie = (permissions: string[]) => `${SESSION_COOKIE}=${mintJwt({ email: "admin@x", exp: nowSec + 600, permissions, sub: "admin1" })}; ${CSRF_COOKIE}=${token}`;
const get = (path: string, permissions: string[] = ["admin"]) => fetch(url + path, { headers: { cookie: cookie(permissions) }, redirect: "manual" });
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" });
return { get, post, token, url };
}
// 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" });
assert.equal(anon.status, 303);
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;
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 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) => {
await new Promise<void>((r) => app.listen(0, r));
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" });
};
// 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");
assert.equal(ok.status, 303);
assert.equal(ok.headers.get("location"), "/dashboard");
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
// 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(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/%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: 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[] = [
{ 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.
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 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 });
await assertAdminGate(url, get, "/admin/roles");
await assertAdminGate(url, get, "/admin/permissions");
// List: the existing role shows + the "add" link.
const listHtml = await (await get("/admin/roles")).text();
assert.match(listHtml, /href="\/admin\/roles\/editor"/);
assert.match(listHtml, /href="\/admin\/roles\/new"/);
// List: the existing permission shows + the "add" link.
const listHtml = await (await get("/admin/permissions")).text();
assert.match(listHtml, /href="\/admin\/permissions\/editor"/);
assert.match(listHtml, /href="\/admin\/permissions\/new"/);
// 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/);
const created = await post("/admin/roles", `_csrf=${token}&name=viewer&member=identity:${ada}`);
assert.match(await (await get("/admin/permissions/new")).text(), /Create permission/);
const created = await post("/admin/permissions", `_csrf=${token}&name=viewer&member=identity:${ada}`);
assert.equal(created.status, 303);
assert.equal(created.headers.get("location"), "/admin/roles/viewer");
assert.ok(tuples.some((tp) => tp.namespace === "Role" && 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(created.headers.get("location"), "/admin/permissions/viewer");
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 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.
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/roles", `_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", `_csrf=${token}&name=Bad Name&member=identity:${ada}`)).status, 400);
assert.equal((await post("/admin/permissions", `_csrf=${token}&name=editor&member=identity:${ada}`)).status, 400); // already exists
assert.equal((await post("/admin/permissions", `name=x&member=identity:${ada}`)).status, 403);
assert.equal(tuples.length, before);
// 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>.
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.doesNotMatch(detail, effectiveLi("grace@example.com"));
// Assign the `eng` group to the role → grace now holds it transitively (effective access via expand).
await post("/admin/roles/editor/members", `_csrf=${token}&member=group:eng`);
assert.ok(tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor" && tp.subject_set?.object === "eng"));
const withGroup = await (await get("/admin/roles/editor")).text();
// Assign the `eng` group to the permission → grace now holds it transitively (effective access via expand).
await post("/admin/permissions/editor/members", `_csrf=${token}&member=group:eng`);
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "editor" && tp.subject_set?.object === "eng"));
const withGroup = await (await get("/admin/permissions/editor")).text();
assert.match(withGroup, effectiveLi("grace@example.com"));
// Revoke the group membership.
await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=group:eng`);
assert.ok(!tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor" && tp.subject_set?.object === "eng"));
await post("/admin/permissions/editor/members/delete", `_csrf=${token}&member=group: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.
await post("/admin/roles/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", `_csrf=${token}&member=identity:${grace}`);
await post("/admin/permissions/editor/members/delete", `_csrf=${token}&member=identity:${grace}`);
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.
assert.match(await (await get("/admin/roles/editor/delete")).text(), /Cancel/);
const del = await post("/admin/roles/editor/delete", `_csrf=${token}`);
// Delete the permission: a confirm step (GET) then the POST removes every member tuple, back to the list.
assert.match(await (await get("/admin/permissions/editor/delete")).text(), /Cancel/);
const del = await post("/admin/permissions/editor/delete", `_csrf=${token}`);
assert.equal(del.status, 303);
assert.equal(del.headers.get("location"), "/admin/roles");
assert.ok(!tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor"));
assert.equal(del.headers.get("location"), "/admin/permissions");
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).
tuples.push({ namespace: "Role", object: "admin", relation: "members", subject_id: "identity:admin1" });
assert.equal((await post("/admin/roles/admin/delete", `_csrf=${token}`)).status, 400);
// Self-protection: the admin permission can't be deleted, nor can you revoke your own admin (sub admin1).
tuples.push({ namespace: "Permission", object: "admin", relation: "granted", subject_id: "identity:admin1" });
assert.equal((await post("/admin/permissions/admin/delete", `_csrf=${token}`)).status, 400);
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"));
// An invalid role 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/roles/%ZZ")).status, 404);
// An invalid permission name in the path → 404; malformed %-encoding doesn't 500.
assert.equal((await get("/admin/permissions/Bad%20Name")).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
+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
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
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
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes
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
// 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
// (a dead session clears the stale cookie). This is the only place the hot path touches Ory.
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
// CSRF cookie is set so those forms have a valid double-submit token.
const match = matchRoute(plugins, method, pathname);
if (match) {
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
// 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; }
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" }));
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("page"), "2");
assert.equal(ctx.identity, null);
assert.deepEqual(ctx.roles, []);
assert.deepEqual(ctx.permissions, []);
assert.deepEqual(ctx.params, {});
});
@@ -33,12 +33,12 @@ test("buildContext threads path params supplied by the router", () => {
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 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 });
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 /", () => {
+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.
// 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 {
email: string;
id: string;
roles: string[];
permissions: string[];
}
export interface RequestContext {
@@ -29,7 +29,7 @@ export interface RequestContext {
query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q")
req: IncomingMessage;
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
// unless the host wired them; every field optional. Ordinary domain plugins ignore it.
system?: SystemCapabilities;
@@ -74,7 +74,7 @@ export function buildContext(
query: url.searchParams,
req,
res,
roles: identity?.roles ?? [],
permissions: identity?.permissions ?? [],
...(options.system ? { system: options.system } : {}),
url,
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 →
// 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.
import { test } from "node:test";
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");
});
test("the OPL declares role, group and a resource namespace over identity subjects", () => {
for (const ns of ["Identity", "Group", "Role", "Resource"])
test("the OPL declares permission, group and a resource namespace over identity subjects", () => {
for (const ns of ["Identity", "Group", "Permission", "Resource"])
assert.match(opl, new RegExp(`class ${ns} implements Namespace`), `defines ${ns}`);
// role + group are subject sets read at login → JWT roles claim (README).
assert.match(opl, /class Role implements Namespace\s*{\s*related:\s*{\s*members:/,
"Role has a members relation");
// permission + group are subject sets read at login → JWT permissions claim (README).
assert.match(opl, /class Permission implements Namespace\s*{\s*related:\s*{\s*granted:/,
"Permission has a granted relation");
assert.match(opl, /class Group implements Namespace\s*{\s*related:\s*{\s*members:/,
"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", () => {
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", () => {
@@ -79,12 +79,12 @@ test("session tokenizer template 'plainpages' mints a short-lived signed JWT", (
"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 (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");
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", () => {
+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: "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: "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 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 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 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 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");
});
test("a shared role name only warns — both plugins still load", async (t) => {
const shared = `export default { apiVersion: "1.0.0", roles: [{ name: "shared:read" }] };`;
test("a shared permission name only warns — both plugins still load", async (t) => {
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 warnings: string[] = [];
const plugins = await discoverPlugins({ dir, logger: { warn: (m) => warnings.push(String(m)) } });
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
// (isValidPluginId, checkApiVersion, findConflicts). Fails loud: every per-plugin problem and
// 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 { 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.
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`;
}
// `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) {
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
// "needs this role". Refuse rather than silently pick one, so the author's intent is unambiguous.
// `public` and `permission` are contradictory on the same route/nav node — "open to all" vs
// "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 : []) {
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);
if (navContradiction) return navContradiction;
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 {
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);
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.
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 { PageChrome } from "../ui/chrome.ts";
export type { NavNode } from "../ui/nav.ts";
+10 -10
View File
@@ -21,13 +21,13 @@ const scheduling: PluginManifest = definePlugin({
apiVersion: "1.0.0",
hooks: { onBoot: () => {} },
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",
}],
roles: [{ description: "View shifts", name: "scheduling:read" }],
permissions: [{ description: "View shifts", name: "scheduling:read" }],
routes: [
{ handler: () => ({ data: { rows: [] }, view: "shifts" }), method: "GET", path: "/shifts", role: "scheduling:read" },
{ handler: () => ({ redirect: "/scheduling/shifts" }), method: "POST", path: "/shifts", role: "scheduling:write" },
{ handler: () => ({ data: { rows: [] }, view: "shifts" }), method: "GET", path: "/shifts", permission: "scheduling:read" },
{ 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
],
});
@@ -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")));
});
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([
p({ id: "a", nav: [{ id: "dup", label: "A" }] }),
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")));
// Sharing a role across plugins is legitimate → warn, not error.
const roleDup = findConflicts([
p({ id: "a", roles: [{ name: "shared:read" }] }),
p({ id: "b", roles: [{ name: "shared:read" }] }),
// Sharing a permission across plugins is legitimate → warn, not error.
const permissionDup = findConflicts([
p({ id: "a", permissions: [{ 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", () => {
+10 -10
View File
@@ -29,16 +29,16 @@ export interface Route {
handler: RouteHandler;
method: HttpMethod;
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
// Mark the page reachable by anyone, signed in or not. The same as omitting `role`
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 `permission`
// — 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;
}
// 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>`.
export interface RoleDecl {
export interface PermissionDecl {
description?: string;
name: string;
}
@@ -63,7 +63,7 @@ export interface PluginManifest {
home?: RouteHandler;
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
roles?: RoleDecl[];
permissions?: PermissionDecl[];
routes?: Route[];
}
@@ -147,7 +147,7 @@ export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HO
}
export interface PluginConflict {
kind: "dashboard" | "home" | "id" | "nav-id" | "role" | "route";
kind: "dashboard" | "home" | "id" | "nav-id" | "permission" | "route";
level: "error" | "warn";
message: string;
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
// 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.
export function findConflicts(plugins: Plugin[]): PluginConflict[] {
const out: PluginConflict[] = [];
@@ -184,9 +184,9 @@ export function findConflicts(plugins: Plugin[]): PluginConflict[] {
});
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) => {
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;
+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"), []);
});
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 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
assert.equal(isAuthorized(open, []), true);
assert.equal(isAuthorized(gated, []), false);
assert.equal(isAuthorized(gated, ["x:read"]), true);
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();
}
// Coarse role gate: a route marked `public` (or one with no `role`) is open; otherwise
// the user's roles (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).
export function isAuthorized(route: Route, roles: string[]): boolean {
return route.public === true || route.role == null || roles.includes(route.role);
// Coarse permission gate: a route marked `public` (or one with no `permission`) is open; otherwise
// the user's permissions (from the session JWT) must include the token. The same rule composeNav uses
// for the menu. `public` and `permission` are mutually exclusive (discovery refuses both).
export function isAuthorized(route: Route, permissions: string[]): boolean {
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.
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
// 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 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",
id: "scheduling",
nav: [{
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", role: "scheduling:read" }],
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }],
icon: "i-cal", id: "scheduling", label: "Scheduling",
}],
};
// A plugin with a public nav node (reachable by anyone, signed in or not).
const portal: Plugin = { apiVersion: "1.0.0", id: "portal", nav: [{ href: "/portal", id: "portal", label: "Portal", public: true }] };
// A gated section fragment like the admin plugin's nav: the header carries the role, so
// A gated section fragment like the admin plugin's nav: the header carries the permission, so
// composeNav drops the whole subtree for a non-holder (the admin screens ship as a drop-in plugin).
const adminLike: Plugin = {
apiVersion: "1.0.0", id: "admin",
@@ -24,7 +24,7 @@ const adminLike: Plugin = {
{ href: "/admin/users", id: "users", label: "Users" },
{ href: "/admin/groups", id: "groups", label: "Groups" },
],
icon: "i-shield", id: "admin", label: "Admin", role: "admin",
icon: "i-shield", id: "admin", label: "Admin", permission: "admin",
}],
};
@@ -45,10 +45,10 @@ test("anonymous shell Sign-in link carries the current page as return_to", () =>
assert.equal(buildPluginChrome({ currentPath: "/portal", menu: DEFAULT_MENU }).signInHref, "/login?return_to=%2Fportal");
});
test("a role holder sees the Dashboard link + plugin nav; current path opens the active leaf", () => {
test("a permission holder sees the Dashboard link + plugin nav; current path opens the active leaf", () => {
const chrome = buildPluginChrome({
currentPath: "/scheduling/shifts", menu: DEFAULT_MENU, plugins: [scheduling],
identity: { email: "ada@x.io", id: "u1", roles: ["scheduling:read"] },
identity: { email: "ada@x.io", id: "u1", permissions: ["scheduling:read"] },
});
assert.deepEqual(labels(chrome.nav), ["Dashboard", "Scheduling"]); // Dashboard shown to a signed-in user
const section = chrome.nav.find((n) => n.label === "Scheduling")!;
@@ -58,7 +58,7 @@ test("a role holder sees the Dashboard link + plugin nav; current path opens the
});
test("a gated section (like the admin plugin) shows to a holder; a sub-path marks its base leaf current", () => {
const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, plugins: [adminLike], identity: { email: "a@b.c", id: "u1", roles: ["admin"] } });
const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, plugins: [adminLike], identity: { email: "a@b.c", id: "u1", permissions: ["admin"] } });
const admin = chrome.nav.find((n) => n.label === "Admin")!;
assert.ok(admin); // gated section visible to an admin
assert.equal(admin.open, true); // ancestor of the current leaf opened
+3 -3
View File
@@ -18,7 +18,7 @@ const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashbo
export interface PageChrome {
brand: { logo?: string; name: string; sub?: string };
csrfToken: string; // double-submit token for the shell's Sign-out form + a plugin's own forms
nav: NavNode[]; // global menu, composed + role-filtered + current-marked, ready for nav-tree.ejs
nav: NavNode[]; // global menu, composed + permission-filtered + current-marked, ready for nav-tree.ejs
signInHref: string; // where the shell's anonymous "Sign in" link points — carries this page as return_to
theme?: string;
user: ShellUser;
@@ -39,8 +39,8 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
const fragments: NavNode[][] = opts.identity ? [[DASHBOARD_NAV]] : [];
for (const p of opts.plugins ?? []) if (p.nav?.length) fragments.push(p.nav);
const roles = opts.identity?.roles ?? [];
const nav = composeNav(fragments, opts.menu.override, roles);
const permissions = opts.identity?.permissions ?? [];
const nav = composeNav(fragments, opts.menu.override, permissions);
if (opts.currentPath) {
// Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like
// /admin/users/new marks the Users base leaf (/admin/users) and the dashboard marks Dashboard.
+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" }];
test("dashboard model: titled shell, passes the unified nav + csrf + user through", () => {
const m = buildDashboardModel({ csrfToken: "tok.sig", identity: { email: "ada@x.io", id: "u1", roles: ["admin"] }, nav: NAV });
const m = buildDashboardModel({ csrfToken: "tok.sig", identity: { email: "ada@x.io", id: "u1", permissions: ["admin"] }, nav: NAV });
assert.equal(m.shell.title, "Dashboard");
assert.equal(m.shell.csrfToken, "tok.sig");
assert.equal(m.shell.user.name, "ada"); // real signed-in identity, not a demo profile
+1 -1
View File
@@ -1,7 +1,7 @@
// Central menu config: config/menu.ts lets an operator set branding (app name, logo,
// default theme) and reorder/rename/group/hide nav nodes across all plugins. The reorder/rename/
// group/hide part is the NavOverride composeNav already applies (the override always wins, before
// the per-user role filter). Authored as TypeScript (defineMenu types it); loaded once at
// the per-user permission filter). Authored as TypeScript (defineMenu types it); loaded once at
// boot — fail-loud on a malformed file, defaults when absent (clean clone needs no config).
import { existsSync } from "node:fs";
+3 -3
View File
@@ -18,8 +18,8 @@ test("menu renders trigger, positioning, the item matrix and check groups", asyn
{ label: "Docs", href: "/docs" }, // link
{ sep: true },
{ label: "Sign out", icon: "i-logout", danger: true },
{ group: { legend: "Role", name: "role", control: "radio", options: [
{ value: "", label: "Any role", checked: true },
{ group: { legend: "Permission", name: "permission", control: "radio", options: [
{ value: "", label: "Any permission", checked: true },
{ value: "admin", label: "Admin" },
] } },
{ group: { name: "col", options: [{ value: "name", label: "Name", checked: true }] } }, // checkbox default, no legend
@@ -38,7 +38,7 @@ test("menu renders trigger, positioning, the item matrix and check groups", asyn
assert.match(html, /<button class="menu-item danger" type="button"><svg class="ico"><use href="#i-logout"\s*\/?><\/svg>Sign out<\/button>/);
// Check group: radios reflect `checked`; legend optional; control defaults to checkbox.
assert.match(html, /<fieldset class="menu-field"><legend class="menu-head">Role<\/legend><label class="menu-check"><input type="radio" name="role" value="" checked>Any role<\/label><label class="menu-check"><input type="radio" name="role" value="admin">Admin<\/label><\/fieldset>/);
assert.match(html, /<fieldset class="menu-field"><legend class="menu-head">Permission<\/legend><label class="menu-check"><input type="radio" name="permission" value="" checked>Any permission<\/label><label class="menu-check"><input type="radio" name="permission" value="admin">Admin<\/label><\/fieldset>/);
assert.match(html, /<fieldset class="menu-field"><label class="menu-check"><input type="checkbox" name="col" value="name" checked>Name<\/label><\/fieldset>/);
});
+3 -3
View File
@@ -25,7 +25,7 @@ const nodes = [
{ label: "Webhooks (soon)" }, // leaf · static
],
},
{ label: "Roles & Access", children: [{ label: "Roles", href: "/roles" }] }, // header · static · closed
{ label: "Permissions & Access", children: [{ label: "Permissions", href: "/permissions" }] }, // header · static · closed
],
},
];
@@ -59,8 +59,8 @@ test("nav-tree renders the header/leaf × clickable/static matrix with counts, i
assert.match(html, /<span class="nav-self"><span class="nav-label">Webhooks \(soon\)<\/span><\/span>/);
// Header · static · closed (no [open]) + label escaping in both label and aria-label.
assert.match(html, /<details class="nav-disc"><summary class="nav-tog" aria-label="Toggle Roles &amp; Access">/);
assert.match(html, /<span class="nav-label">Roles &amp; Access<\/span>/);
assert.match(html, /<details class="nav-disc"><summary class="nav-tog" aria-label="Toggle Permissions &amp; Access">/);
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 () => {
+16 -16
View File
@@ -2,32 +2,32 @@ import assert from "node:assert/strict";
import { test } from "node:test";
import { composeNav, type NavNode } from "./nav.ts";
// Two plugin fragments; ids let the override target nodes, `role` gates per role.
// Two plugin fragments; ids let the override target nodes, `permission` gates per permission.
const fragments: NavNode[][] = [
[{
icon: "i-cal", id: "sched", label: "Scheduling",
children: [
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", role: "scheduling:read" },
{ href: "/scheduling/manage", id: "manage", label: "Manage", role: "scheduling:admin" },
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" },
{ href: "/scheduling/manage", id: "manage", label: "Manage", permission: "scheduling:admin" },
],
}],
[{ href: "/reports", id: "reports", label: "Reports", role: "reports:read" }],
[{ href: "/reports", id: "reports", label: "Reports", permission: "reports:read" }],
];
test("composeNav merges fragments, filters by role, and emits clean render nodes", () => {
test("composeNav merges fragments, filters by permission, and emits clean render nodes", () => {
const tree = composeNav(fragments, {}, ["scheduling:read"]);
// Reports gone (no reports:read), Manage gone (no scheduling:admin), header kept with Shifts.
// Output carries no `id`/`role` and omits absent fields — ready for nav-tree.ejs.
// Output carries no `id`/`permission` and omits absent fields — ready for nav-tree.ejs.
assert.deepEqual(tree, [
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling/shifts", label: "Shifts" }] },
]);
});
test("composeNav drops gated subtrees, empty headers, and (with no roles) all gated nodes", () => {
test("composeNav drops gated subtrees, empty headers, and (with no permissions) all gated nodes", () => {
// A header the user can't reach takes its whole subtree, even visible children.
const gatedHeader: NavNode[][] = [[
{ id: "admin", label: "Admin", role: "admin", children: [{ href: "/u", id: "u", label: "Users" }] },
{ id: "admin", label: "Admin", permission: "admin", children: [{ href: "/u", id: "u", label: "Users" }] },
{ id: "free", label: "Free", children: [{ href: "/d", id: "d", label: "Docs" }] },
]];
assert.deepEqual(composeNav(gatedHeader, {}, []), [
@@ -36,26 +36,26 @@ test("composeNav drops gated subtrees, empty headers, and (with no roles) all ga
// A pure header whose children are all filtered is dropped; a header with an href survives as a leaf.
const emptyHeader: NavNode[][] = [[
{ id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", role: "x" }] },
{ href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", role: "y" }] },
{ id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", permission: "x" }] },
{ href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", permission: "y" }] },
]];
assert.deepEqual(composeNav(emptyHeader, {}, []), [{ href: "/hub", label: "Hub" }]);
// No fragments / no roles → empty tree, never throws.
// No fragments / no permissions → empty tree, never throws.
assert.deepEqual(composeNav(), []);
});
test("composeNav keeps a node marked public for everyone — the blessed public alias", () => {
// A header with one public child + one gated child: with no roles, the public child keeps the
// A header with one public child + one gated child: with no permissions, the public child keeps the
// header alive (the gated child is filtered out) — so a plugin can show a public menu option to all.
const frag: NavNode[][] = [[{
icon: "i-cal", id: "sched", label: "Scheduling",
children: [
{ href: "/scheduling", id: "overview", label: "Overview", public: true },
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", role: "scheduling:read" },
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" },
],
}]];
// `public` is filter-only (like id/role) — never rendered into the output node.
// `public` is filter-only (like id/permission) — never rendered into the output node.
assert.deepEqual(composeNav(frag, {}, []), [
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling", label: "Overview" }] },
]);
@@ -66,7 +66,7 @@ test("composeNav applies the override: rename, group, order, hide (then filters)
{ href: "/a", id: "a", label: "Alpha" },
{ href: "/b", id: "b", label: "Beta" },
{ href: "/c", id: "c", label: "Gamma" },
{ href: "/secret", id: "secret", label: "Secret", role: "root" },
{ href: "/secret", id: "secret", label: "Secret", permission: "root" },
]];
const tree = composeNav(base, {
@@ -76,7 +76,7 @@ test("composeNav applies the override: rename, group, order, hide (then filters)
hide: ["c"], // remove c from inside the group
}, ["root"]);
// grp emitted (b only, c hidden), reordered before a; Secret kept now that role "root" is present.
// grp emitted (b only, c hidden), reordered before a; Secret kept now that permission "root" is present.
assert.deepEqual(tree, [
{ icon: "i-box", label: "Group", open: true, children: [{ href: "/b", label: "Beta" }] },
{ href: "/a", label: "First" },
+13 -13
View File
@@ -1,10 +1,10 @@
// composeNav: merge each plugin's nav fragment into one tree, apply the central
// override, then role-filter per user. Pure and I/O-free — menu gating reads the JWT
// `roles` claim (README "The menu system"), never Keto. A node is visible iff it is `public`, or
// declares no `role`, or `roles` includes that role name; a gated header hides its whole
// override, then permission-filter per user. Pure and I/O-free — menu gating reads the JWT
// `permissions` claim (README "The menu system"), never Keto. A node is visible iff it is `public`, or
// declares no `permission`, or `permissions` includes that permission name; a gated header hides its whole
// subtree, and a pure header left with no children is dropped. The config/menu.ts supplies
// the override (+ branding); this helper only transforms data, so its result is per-deployment
// up to the final role filter and emits clean nodes ready for nav-tree.ejs (no id/role).
// up to the final permission filter and emits clean nodes ready for nav-tree.ejs (no id/permission).
export interface NavNode {
id?: string; // stable key for override targeting; stripped from the rendered tree
@@ -15,12 +15,12 @@ export interface NavNode {
icon?: string;
label: string;
open?: boolean;
role?: string; // required role token; consumed by the filter, never rendered
public?: boolean; // show to everyone, signed in or not — the blessed alias for "no role", stated outright; consumed by the filter, never rendered. Mutually exclusive with role (discovery refuses both).
permission?: string; // required permission token; consumed by the filter, never rendered
public?: boolean; // show to everyone, signed in or not — the blessed alias for "no permission", stated outright; consumed by the filter, never rendered. Mutually exclusive with permission (discovery refuses both).
}
// Central override (config/menu.ts). Targets nodes by `id`; applied rename → group →
// order → hide, then the per-user role filter runs last.
// order → hide, then the per-user permission filter runs last.
export interface NavOverride {
groups?: NavGroupSpec[]; // wrap top-level nodes (by id) under a new header
hide?: string[]; // remove nodes by id, at any depth (incl. a group's id)
@@ -39,14 +39,14 @@ export interface NavGroupSpec {
export function composeNav(
fragments: NavNode[][] = [],
override: NavOverride = {},
roles: string[] = [],
permissions: string[] = [],
): NavNode[] {
let nodes: NavNode[] = fragments.flat();
if (override.rename) nodes = renameTree(nodes, override.rename);
if (override.groups?.length) nodes = applyGroups(nodes, override.groups);
if (override.order?.length) nodes = applyOrder(nodes, override.order);
if (override.hide?.length) nodes = hideTree(nodes, new Set(override.hide));
return filterByRoles(nodes, new Set(roles)).map(toRenderNode);
return filterByRoles(nodes, new Set(permissions)).map(toRenderNode);
}
function renameTree(nodes: NavNode[], rename: Record<string, string>): NavNode[] {
@@ -103,19 +103,19 @@ function hideTree(nodes: NavNode[], hide: Set<string>): NavNode[] {
return out;
}
function filterByRoles(nodes: NavNode[], roles: Set<string>): NavNode[] {
function filterByRoles(nodes: NavNode[], permissions: Set<string>): NavNode[] {
const out: NavNode[] = [];
for (const n of nodes) {
if (n.public !== true && n.role != null && !roles.has(n.role)) continue; // gated → drop node + subtree (public always shows)
if (n.public !== true && n.permission != null && !permissions.has(n.permission)) continue; // gated → drop node + subtree (public always shows)
if (!n.children) { out.push(n); continue; }
const children = filterByRoles(n.children, roles);
const children = filterByRoles(n.children, permissions);
if (children.length === 0 && n.href == null) continue; // empty pure header → drop
out.push({ ...n, children });
}
return out;
}
// Strip the helper-only fields (id/role) and drop absent ones, so the tree is exactly
// Strip the helper-only fields (id/permission) and drop absent ones, so the tree is exactly
// what nav-tree.ejs reads.
function toRenderNode(n: NavNode): NavNode {
const out: NavNode = { label: n.label };
+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", () => {
assert.deepEqual(shellUser(null), { email: "", initials: "G", name: "Guest" });
// Real user: name = email local part, email kept, initials = first two letters of the local part.
assert.deepEqual(shellUser({ email: "ada@example.com", id: "u1", roles: [] }), { email: "ada@example.com", initials: "AD", name: "ada" });
assert.deepEqual(shellUser({ email: "ada@example.com", id: "u1", permissions: [] }), { email: "ada@example.com", initials: "AD", name: "ada" });
});
test("buildShellContext maps branding + breadcrumbs, omitting unset optional fields", () => {
@@ -22,7 +22,7 @@ test("buildShellContext maps branding + breadcrumbs, omitting unset optional fie
menu: { branding: { logo: "/l.svg", name: "Acme", sub: "Ops", theme: "dark" }, override: {} },
signInHref: "/login?return_to=%2Fx",
title: "Users",
identity: { email: "a@b.c", id: "u1", roles: ["admin"] },
identity: { email: "a@b.c", id: "u1", permissions: ["admin"] },
});
assert.deepEqual(full.brand, { logo: "/l.svg", name: "Acme", sub: "Ops" });
assert.equal(full.theme, "dark");
+1 -1
View File
@@ -1,7 +1,7 @@
// Shell view-model builder: the brand/theme/user/title block every app-shell page
// (the home dashboard, the built-in admin screens) hands to shell.ejs. Pure. Extracted so the
// shell user is the *real* signed-in identity — no hardcoded demo profile — and branding is
// read from one place. The User carries no display name (the JWT holds only id/email/roles), so
// read from one place. The User carries no display name (the JWT holds only id/email/permissions), so
// the profile shows the email's local part as the name with the full email beneath, initials from
// the local part; anonymous ⇒ "Guest".
+1 -1
View File
@@ -2,7 +2,7 @@
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
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).
%><%
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
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.
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
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),
`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`