Rename the coarse gate from role to permission, matching RBAC
This commit is contained in:
@@ -58,7 +58,7 @@ them. Revisit only if the stated reason stops holding.
|
|||||||
`server.ts`/`config.ts`/`logger.ts` and the topology-guard `*.test.ts` stay at the root. Tests
|
`server.ts`/`config.ts`/`logger.ts` and the topology-guard `*.test.ts` stay at the root. Tests
|
||||||
are co-located (`foo.test.ts` beside `foo.ts`). Add a new module to the folder that owns its
|
are co-located (`foo.test.ts` beside `foo.ts`). Add a new module to the folder that owns its
|
||||||
concern rather than to the root; don't reintroduce a flat tree. The core ships **no domain
|
concern rather than to the root; don't reintroduce a flat tree. The core ships **no domain
|
||||||
screens** — even the admin GUI (users/groups/roles) is a drop-in plugin (`examples/plugins/admin/`),
|
screens** — even the admin GUI (users/groups/permissions) is a drop-in plugin (`examples/plugins/admin/`),
|
||||||
not `src/` code.
|
not `src/` code.
|
||||||
- **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the
|
- **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the
|
||||||
base request context. It protects the I/O-free hot path on the public, bot-hit landing
|
base request context. It protects the I/O-free hot path on the public, bot-hit landing
|
||||||
@@ -120,14 +120,15 @@ docker compose -f compose.yml up --build -d # production
|
|||||||
running **building plugins** comes first, then **configuring and securing** the system
|
running **building plugins** comes first, then **configuring and securing** the system
|
||||||
(Configuration, Auth); the **inner workings** (Architecture) and ops/runbooks are
|
(Configuration, Auth); the **inner workings** (Architecture) and ops/runbooks are
|
||||||
deliberately deferred — they're not top of mind when starting out. Concretely: Overview →
|
deliberately deferred — they're not top of mind when starting out. Concretely: Overview →
|
||||||
Users, groups & roles → Building plugins → menu/blocks/interactivity → Configuration → Auth →
|
Identities, groups & permissions → Building plugins → menu/blocks/interactivity →
|
||||||
Email → Architecture → Testing → Production → Observability → the JWT-rotation runbook → the
|
Configuration → Auth → Email → Architecture → Testing → Production → Observability → the
|
||||||
Project-layout file map → Extending. When adding a section, place it by this value (how
|
JWT-rotation runbook → the Project-layout file map → Extending. When adding a section, place
|
||||||
early an adopter needs it), not by where it sits in the stack.
|
it by this value (how early an adopter needs it), not by where it sits in the stack.
|
||||||
|
|
||||||
**Users, groups & roles precedes Building plugins** because a manifest's `role:` gate is
|
**Identities, groups & permissions precedes Building plugins** because a manifest's
|
||||||
unreadable without the model, and operators need it as much as plugin authors. It is the one
|
`permission:` gate is unreadable without the model, and operators need it as much as plugin
|
||||||
home for that model — the plugin and auth sections link to it rather than restating it.
|
authors. It is the one home for that model — the plugin and auth sections link to it rather
|
||||||
|
than restating it.
|
||||||
|
|
||||||
When editing: put content in the section it belongs to (don't prepend rationale above Quick
|
When editing: put content in the section it belongs to (don't prepend rationale above Quick
|
||||||
start); keep the ToC in sync when you add/rename/remove an `H2`/`H3`; and state each fact in
|
start); keep the ToC in sync when you add/rename/remove an `H2`/`H3`; and state each fact in
|
||||||
|
|||||||
+1
-1
@@ -190,4 +190,4 @@ export default definePlugin({
|
|||||||
```
|
```
|
||||||
|
|
||||||
Restart (`docker compose restart web`) and visit <http://localhost:3000/hello>. Views,
|
Restart (`docker compose restart web`) and visit <http://localhost:3000/hello>. Views,
|
||||||
forms, roles, and the runnable reference plugin: repo README → Building plugins.
|
forms, permissions, and the runnable reference plugin: repo README → Building plugins.
|
||||||
|
|||||||
@@ -28,14 +28,14 @@ docker compose up -d # http://localhost:3000, live-reloads on source chan
|
|||||||
**`admin@plainpages.local` / `admin`**.
|
**`admin@plainpages.local` / `admin`**.
|
||||||
|
|
||||||
**3. Enable user & group admin (optional).** The core ships **no admin GUI** — the Users / Groups
|
**3. Enable user & group admin (optional).** The core ships **no admin GUI** — the Users / Groups
|
||||||
/ Roles / OAuth2-clients screens are a drop-in plugin. Copy it in to mount them at `/admin/*`:
|
/ Permissions / OAuth2-clients screens are a drop-in plugin. Copy it in to mount them at `/admin/*`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp -r examples/plugins/admin plugins/admin
|
cp -r examples/plugins/admin plugins/admin
|
||||||
docker compose restart web
|
docker compose restart web
|
||||||
```
|
```
|
||||||
|
|
||||||
The seeded admin already holds the `admin` role, so the **Admin** section now shows in the menu.
|
The seeded admin already holds the `admin` permission, so the **Admin** section now shows in the menu.
|
||||||
See [`examples/plugins/admin/`](examples/plugins/admin/).
|
See [`examples/plugins/admin/`](examples/plugins/admin/).
|
||||||
|
|
||||||
**4. Add your first plugin.** The clone is bind-mounted into the container, so a new
|
**4. Add your first plugin.** The clone is bind-mounted into the container, so a new
|
||||||
@@ -69,9 +69,9 @@ From here, render real pages against the app shell and fetch upstream data — s
|
|||||||
|
|
||||||
- [Overview](#overview)
|
- [Overview](#overview)
|
||||||
- [how it compares](#how-it-compares)
|
- [how it compares](#how-it-compares)
|
||||||
- [Users, groups & roles](#users-groups--roles)
|
- [Identities, groups & permissions](#identities-groups--permissions)
|
||||||
- [a worked example](#a-worked-example)
|
- [a worked example](#a-worked-example)
|
||||||
- [granting a role](#granting-a-role)
|
- [granting a permission](#granting-a-permission)
|
||||||
- [fine-grained, per-row access](#fine-grained-per-row-access)
|
- [fine-grained, per-row access](#fine-grained-per-row-access)
|
||||||
- [Building plugins](#building-plugins)
|
- [Building plugins](#building-plugins)
|
||||||
- [anatomy](#anatomy-of-a-plugin)
|
- [anatomy](#anatomy-of-a-plugin)
|
||||||
@@ -80,7 +80,7 @@ From here, render real pages against the app shell and fetch upstream data — s
|
|||||||
- [landing pages](#the-landing-pages-home--dashboard)
|
- [landing pages](#the-landing-pages-home--dashboard)
|
||||||
- [RequestContext](#requestcontext)
|
- [RequestContext](#requestcontext)
|
||||||
- [system capabilities (ctx.system)](#system-capabilities-the-ctxsystem-surface)
|
- [system capabilities (ctx.system)](#system-capabilities-the-ctxsystem-surface)
|
||||||
- [nav & role gates](#nav--role-gates)
|
- [nav & permission gates](#nav--permission-gates)
|
||||||
- [versioning](#contract-versioning)
|
- [versioning](#contract-versioning)
|
||||||
- [conflict rules](#conflict-rules)
|
- [conflict rules](#conflict-rules)
|
||||||
- [hooks](#hooks)
|
- [hooks](#hooks)
|
||||||
@@ -120,7 +120,7 @@ or gated**, so the same foundation serves a purely public site, a fully locked-d
|
|||||||
tool, or the common middle: a public front with an authenticated area behind it. Its **sweet
|
tool, or the common middle: a public front with an authenticated area behind it. Its **sweet
|
||||||
spot** is the **back-office and operational tooling** you'd otherwise hand-roll for the tenth
|
spot** is the **back-office and operational tooling** you'd otherwise hand-roll for the tenth
|
||||||
time, but nothing ties it to internal-only use. The core itself ships **no domain screens at
|
time, but nothing ties it to internal-only use. The core itself ships **no domain screens at
|
||||||
all** — even the screens for running the system (**users, groups, roles**) are a **drop-in
|
all** — even the screens for running the system (**users, groups, permissions**) are a **drop-in
|
||||||
plugin** you opt into ([`examples/plugins/admin/`](examples/plugins/admin/)). Everything is a plugin.
|
plugin** you opt into ([`examples/plugins/admin/`](examples/plugins/admin/)). Everything is a plugin.
|
||||||
|
|
||||||
**Who it's for.** Experienced developers building server-rendered web products — back-office
|
**Who it's for.** Experienced developers building server-rendered web products — back-office
|
||||||
@@ -135,7 +135,7 @@ obvious rather than surprising, you're the audience.
|
|||||||
|
|
||||||
- **Included in the core:** themed sign-in / register / reset (Kratos-backed), the design
|
- **Included in the core:** themed sign-in / register / reset (Kratos-backed), the design
|
||||||
system + app shell, the config-driven menu, sessions, and access control. No domain screens.
|
system + app shell, the config-driven menu, sessions, and access control. No domain screens.
|
||||||
- **Opt-in admin plugin:** the **users, groups, roles, and OAuth2-clients** screens (users via
|
- **Opt-in admin plugin:** the **users, groups, permissions, and OAuth2-clients** screens (users via
|
||||||
Kratos, the relationship graph via Keto, OAuth2 clients via Hydra) ship as
|
Kratos, the relationship graph via Keto, OAuth2 clients via Hydra) ship as
|
||||||
[`examples/plugins/admin/`](examples/plugins/admin/) — copy it into `plugins/` to get a GUI for
|
[`examples/plugins/admin/`](examples/plugins/admin/) — copy it into `plugins/` to get a GUI for
|
||||||
user & group admin. It's an ordinary plugin, using the privileged
|
user & group admin. It's an ordinary plugin, using the privileged
|
||||||
@@ -146,7 +146,7 @@ obvious rather than surprising, you're the audience.
|
|||||||
**Priorities (unchanged from day one):** **simplicity, few dependencies, strict
|
**Priorities (unchanged from day one):** **simplicity, few dependencies, strict
|
||||||
TypeScript, no build step, Docker-only, environment-agnostic** (no `NODE_ENV` — every
|
TypeScript, no build step, Docker-only, environment-agnostic** (no `NODE_ENV` — every
|
||||||
behaviour is an explicit config toggle). Heavy lifting that *isn't* simple to do well —
|
behaviour is an explicit config toggle). Heavy lifting that *isn't* simple to do well —
|
||||||
identity, sessions, SSO, OAuth2, role checks — is delegated to **Ory** sidecar
|
identity, sessions, SSO, OAuth2, permission checks — is delegated to **Ory** sidecar
|
||||||
services rather than reinvented. "Simple" is about the *whole architecture* staying simple
|
services rather than reinvented. "Simple" is about the *whole architecture* staying simple
|
||||||
— not just at the start, but after you've dropped in 240 plugins and run it hard in
|
— not just at the start, but after you've dropped in 240 plugins and run it hard in
|
||||||
production. The shape doesn't change as it grows: every plugin is the same self-contained
|
production. The shape doesn't change as it grows: every plugin is the same self-contained
|
||||||
@@ -198,22 +198,24 @@ server-rendered** design system, **[optional auth](#auth-sessions--access)** (an
|
|||||||
public or gated), **no app database**, and a **framework-light TypeScript** core with no build
|
public or gated), **no app database**, and a **framework-light TypeScript** core with no build
|
||||||
step. Each neighbour shares one trait and trades away the rest — Plainpages is the intersection.
|
step. Each neighbour shares one trait and trades away the rest — Plainpages is the intersection.
|
||||||
|
|
||||||
## Users, groups & roles
|
## Identities, groups & permissions
|
||||||
|
|
||||||
Authorization here is **two hops, not three**: a user — directly, or through a group — is a
|
Authorization here is two hops: an **identity** — directly, or through a **group** — is granted a
|
||||||
member of a **role**, and that role's *name* is exactly the string a plugin gates on. There is no
|
**permission**, and that permission's *name* is exactly the string a plugin gates on.
|
||||||
separate "permission" object to define, register, or wire up.
|
|
||||||
|
|
||||||
- **Group** answers *who* — a reusable set of people. Optional: a role can be granted straight to a user.
|
- **Group** answers *who* — a reusable set of people. Optional: a permission can be granted
|
||||||
- **Role** answers *what* — its **name is the string** you write in a manifest's `role:` gate.
|
straight to an identity.
|
||||||
- **A relation tuple** is the grant: `Role:<name>#members@identity:<id>`, or `@Group:<name>#members`.
|
- **Permission** answers *what* — its **name is the string** you write in a manifest's
|
||||||
|
`permission:` gate.
|
||||||
|
- **A relation tuple** is the grant: `Permission:<name>#granted@identity:<id>`, or
|
||||||
|
`@Group:<name>#members`.
|
||||||
- **Resource** answers *which row* — a live check, run only where a plugin explicitly asks for it.
|
- **Resource** answers *which row* — a live check, run only where a plugin explicitly asks for it.
|
||||||
|
|
||||||
| Entity | Lives in | Answers | Example |
|
| Entity | Lives in | Answers | Example |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| **Identity** | Kratos | who you are | `identity:0198f2c1-…` |
|
| **Identity** | Kratos | who you are | `identity:0198f2c1-…` |
|
||||||
| **Group** | Keto | who — a reusable set | `Group:support` |
|
| **Group** | Keto | who — a reusable set | `Group:support` |
|
||||||
| **Role** | Keto | what you may do | `Role:scheduling:read` |
|
| **Permission** | Keto | what you may do | `Permission:scheduling:read` |
|
||||||
| **Resource** | Keto | which specific row | `Resource:shift-4471` |
|
| **Resource** | Keto | which specific row | `Resource:shift-4471` |
|
||||||
|
|
||||||
Identities live in Kratos; every authorization edge is a Keto relation tuple. The app itself
|
Identities live in Kratos; every authorization edge is a Keto relation tuple. The app itself
|
||||||
@@ -223,42 +225,43 @@ stores none of it — it is [stateless](#stateless).
|
|||||||
`namespace:object#relation@subject` — so the four namespaces above are *ours*, declared in
|
`namespace:object#relation@subject` — so the four namespaces above are *ours*, declared in
|
||||||
`ory/keto/namespaces.keto.ts`; Keto only supplies the machinery that resolves them (including
|
`ory/keto/namespaces.keto.ts`; Keto only supplies the machinery that resolves them (including
|
||||||
transitively, through nested groups). `Identity` is named to match Kratos, which owns that
|
transitively, through nested groups). `Identity` is named to match Kratos, which owns that
|
||||||
record. `Group`, `Role` and `Resource` have no upstream counterpart to match, so they use the
|
record. `Group`, `Permission` and `Resource` have no upstream counterpart to match, so they use
|
||||||
ordinary words.
|
the ordinary words.
|
||||||
|
|
||||||
> **On the word "permission".** Ory uses it for the fine-grained `Resource` tier — the `permits`
|
> **There is no `Role`.** In RBAC a permission is a single operation ("read shifts") and a role is
|
||||||
> block (`view`/`edit`/`delete`). Plainpages therefore never uses it for the coarse tier: what a
|
> a *bundle* of them ("IT Support staff"). A route gates on one operation, so it gates on a
|
||||||
> route or a menu item gates on is a **role**, always.
|
> **permission**. When you want the bundle, make a group and grant it several — groups nest, so a
|
||||||
|
> group of groups works too.
|
||||||
|
|
||||||
### A worked example
|
### A worked example
|
||||||
|
|
||||||
Alice works support and leads scheduling; Bob works support; Carol administers the system.
|
Alice works support and leads scheduling; Bob works support; Carol administers the system.
|
||||||
|
|
||||||
```
|
```
|
||||||
people groups roles
|
people groups permissions
|
||||||
────── ────── ─────
|
────── ────── ───────────
|
||||||
|
|
||||||
alice ──┬─────────> Group:support ────┐
|
alice ──┬─────────> Group:support ────┐
|
||||||
│ ├──> Group:staff ──> Role:scheduling:read
|
│ ├──> Group:staff ──> Permission:scheduling:read
|
||||||
bob ────┘ │
|
bob ────┘ │
|
||||||
│
|
│
|
||||||
alice ────────────> Group:sched-leads ┴──> Role:scheduling:write
|
alice ────────────> Group:sched-leads ┴──> Permission:scheduling:write
|
||||||
|
|
||||||
carol ───────────────────────────────────────────────> Role:admin
|
carol ───────────────────────────────────────────────> Permission:admin
|
||||||
```
|
```
|
||||||
|
|
||||||
At login the host asks Keto which roles the user holds, walking those arrows transitively, and
|
At login the host asks Keto which permissions the identity holds, walking those arrows
|
||||||
bakes the answer into the session JWT (see [Login and the session
|
transitively, and bakes the answer into the session JWT (see [Login and the session
|
||||||
JWT](#login-and-the-session-jwt)):
|
JWT](#login-and-the-session-jwt)):
|
||||||
|
|
||||||
```
|
```
|
||||||
alice → roles: ["scheduling:read", "scheduling:write"]
|
alice → permissions: ["scheduling:read", "scheduling:write"]
|
||||||
bob → roles: ["scheduling:read"]
|
bob → permissions: ["scheduling:read"]
|
||||||
carol → roles: ["admin"]
|
carol → permissions: ["admin"]
|
||||||
```
|
```
|
||||||
|
|
||||||
Note what Carol does *not* have. **There is no role hierarchy and no superuser** — `admin` is
|
Note what Carol does *not* have. **Permissions do not nest, and there is no superuser** — `admin`
|
||||||
just another name, granting nothing except where a route gates on `admin` itself.
|
is just another name, granting nothing except where a route gates on `admin` itself.
|
||||||
|
|
||||||
Against the reference plugins' actual routes:
|
Against the reference plugins' actual routes:
|
||||||
|
|
||||||
@@ -274,41 +277,42 @@ Bob reaches the shifts list with no direct grant: he is in `support`, support's
|
|||||||
`staff`, and staff holds `scheduling:read` — two hops, resolved by Keto at his login. He is
|
`staff`, and staff holds `scheduling:read` — two hops, resolved by Keto at his login. He is
|
||||||
refused the new-shift form because `scheduling:write` hangs off `sched-leads`, which he is not in.
|
refused the new-shift form because `scheduling:write` hangs off `sched-leads`, which he is not in.
|
||||||
An anonymous visitor gets a **redirect**, not a 403, carrying `return_to` so signing in lands them
|
An anonymous visitor gets a **redirect**, not a 403, carrying `return_to` so signing in lands them
|
||||||
on the page they asked for; a signed-in user who merely lacks the role gets the 403 page, because
|
on the page they asked for; a signed-in user who merely lacks the permission gets the 403 page,
|
||||||
there is nothing to sign in *as* that would help. The menu is filtered by the same roles, so
|
because there is nothing to sign in *as* that would help. The menu is filtered by the same
|
||||||
nobody is shown a door they cannot open.
|
permissions, so nobody is shown a door they cannot open.
|
||||||
|
|
||||||
### Granting a role
|
### Granting a permission
|
||||||
|
|
||||||
Write the tuple. The admin plugin's **Groups** and **Roles** screens do exactly this, or use
|
Write the tuple. The admin plugin's **Groups** and **Permissions** screens do exactly this, or use
|
||||||
Keto's write API directly:
|
Keto's write API directly:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# everyone in sched-leads may write shifts
|
# everyone in sched-leads may write shifts
|
||||||
curl -X PUT http://keto:4467/admin/relation-tuples -H 'content-type: application/json' -d '{
|
curl -X PUT http://keto:4467/admin/relation-tuples -H 'content-type: application/json' -d '{
|
||||||
"namespace": "Role", "object": "scheduling:write", "relation": "members",
|
"namespace": "Permission", "object": "scheduling:write", "relation": "granted",
|
||||||
"subject_set": { "namespace": "Group", "object": "sched-leads", "relation": "members" }
|
"subject_set": { "namespace": "Group", "object": "sched-leads", "relation": "members" }
|
||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
Roles are authored **only in Keto** — nothing else writes them. Role names are a shared global
|
Permissions are authored **only in Keto** — nothing else writes them. Their names are a shared
|
||||||
namespace on purpose, so an operator grants `scheduling:read` once and every plugin referencing it
|
global namespace on purpose, so an operator grants `scheduling:read` once and every plugin
|
||||||
is gated consistently; namespace yours as `<id>:<action>`.
|
referencing it is gated consistently; namespace yours as `<id>:<action>`.
|
||||||
|
|
||||||
A change takes effect on the user's **next login or JWT re-mint** (~10 min) — see [Instant
|
A change takes effect on the user's **next login or JWT re-mint** (~10 min) — see [Instant
|
||||||
revoke](#instant-revoke-the-optional-denylist) when you need it sooner.
|
revoke](#instant-revoke-the-optional-denylist) when you need it sooner.
|
||||||
|
|
||||||
### Fine-grained, per-row access
|
### Fine-grained, per-row access
|
||||||
|
|
||||||
The `Resource` namespace covers what a role cannot express: *this* row, shared with *this* person.
|
The `Resource` namespace covers what a coarse permission cannot express: *this* row, shared with
|
||||||
Its permissions nest — `owner` ⊇ `editor` ⊇ `viewer`.
|
*this* person. It is a separate mechanism — a `Resource` carries Keto `permits` (`view`, `edit`,
|
||||||
|
`delete`, which nest as `owner` ⊇ `editor` ⊇ `viewer`) and never appears in the JWT.
|
||||||
|
|
||||||
**A per-row grant never widens a coarse gate.** The route's `role` is checked *before* the handler
|
**A per-row grant never widens a coarse gate.** The route's `permission` is checked *before* the
|
||||||
runs, so a user rejected there never reaches the check. Gate the route on something they hold,
|
handler runs, so a user rejected there never reaches the check. Gate the route on something they
|
||||||
then narrow inside the handler:
|
hold, then narrow inside the handler:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
{ method: "POST", path: "/shifts/:id", role: READ, handler: editShift }
|
{ method: "POST", path: "/shifts/:id", permission: READ, handler: editShift }
|
||||||
|
|
||||||
async function editShift(ctx) {
|
async function editShift(ctx) {
|
||||||
if (!(await check(keto, ctx, { namespace: "Resource", object: ctx.params.id, relation: "editors" })))
|
if (!(await check(keto, ctx, { namespace: "Resource", object: ctx.params.id, relation: "editors" })))
|
||||||
@@ -331,9 +335,9 @@ contract is **TypeScript** (`src/plugin-host/plugin.ts`), so the types there are
|
|||||||
source of truth; the sections below explain them, the guarantees around them, and the rules
|
source of truth; the sections below explain them, the guarantees around them, and the rules
|
||||||
the host enforces. A complete, runnable example lives in
|
the host enforces. A complete, runnable example lives in
|
||||||
**[`examples/plugins/scheduling/`](examples/plugins/scheduling/)** — a public overview page, a
|
**[`examples/plugins/scheduling/`](examples/plugins/scheduling/)** — a public overview page, a
|
||||||
role-gated list page fetching upstream data (it points `SCHEDULING_UPSTREAM` at its backend;
|
permission-gated list page fetching upstream data (it points `SCHEDULING_UPSTREAM` at its backend;
|
||||||
the dev compose ships a tiny mock, `examples/shifts-upstream/`), a CSRF-guarded form forwarding
|
the dev compose ships a tiny mock, `examples/shifts-upstream/`), a CSRF-guarded form forwarding
|
||||||
writes upstream, and a mix of public + role-gated nav. It is **not** pre-installed — `plugins/`
|
writes upstream, and a mix of public + permission-gated nav. It is **not** pre-installed — `plugins/`
|
||||||
ships empty so you mount your own. To run it in dev, copy it in
|
ships empty so you mount your own. To run it in dev, copy it in
|
||||||
(`cp -r examples/plugins/scheduling plugins/scheduling`, then restart) — the dev compose already
|
(`cp -r examples/plugins/scheduling plugins/scheduling`, then restart) — the dev compose already
|
||||||
points `SCHEDULING_UPSTREAM` at its mock backend. Copy it to `plugins/<id>/` and adapt.
|
points `SCHEDULING_UPSTREAM` at its mock backend. Copy it to `plugins/<id>/` and adapt.
|
||||||
@@ -364,7 +368,7 @@ single `plugin.ts`.
|
|||||||
must be **URL/path-safe** (`isValidPluginId`: lowercase `a–z`, digits, and dashes — dashes
|
must be **URL/path-safe** (`isValidPluginId`: lowercase `a–z`, digits, and dashes — dashes
|
||||||
anywhere; no uppercase, underscores, dots, or slashes); the host rejects a malformed folder name
|
anywhere; no uppercase, underscores, dots, or slashes); the host rejects a malformed folder name
|
||||||
at discovery. The id also namespaces the plugin's `views/`, its `/public/<id>/` assets, and (by
|
at discovery. The id also namespaces the plugin's `views/`, its `/public/<id>/` assets, and (by
|
||||||
convention) its nav/role names.
|
convention) its nav/permission names.
|
||||||
|
|
||||||
A handful of ids are **reserved** for the host's own first-party mounts — the gated `dashboard`, the
|
A handful of ids are **reserved** for the host's own first-party mounts — the gated `dashboard`, the
|
||||||
Kratos auth flows (`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`),
|
Kratos auth flows (`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`),
|
||||||
@@ -396,20 +400,20 @@ import { listThings, createThings } from "./handlers.ts";
|
|||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "1.0.0", // semver string of the host contract this plugin was built against (see Versioning)
|
apiVersion: "1.0.0", // semver string of the host contract this plugin was built against (see Versioning)
|
||||||
|
|
||||||
// Nav fragment, merged into the global menu and role-filtered per user.
|
// Nav fragment, merged into the global menu and permission-filtered per user.
|
||||||
// `icon` is a Lucide icon by its sprite id (src/ui/icons.ts).
|
// `icon` is a Lucide icon by its sprite id (src/ui/icons.ts).
|
||||||
nav: [{ href: "/things", icon: "i-cal", id: "things:list", label: "Things", role: "things:read" }],
|
nav: [{ href: "/things", icon: "i-cal", id: "things:list", label: "Things", permission: "things:read" }],
|
||||||
|
|
||||||
// Roles this plugin gates on. Optional — see Nav & role gates.
|
// Permissions this plugin gates on. Optional — see Nav & permission gates.
|
||||||
roles: [
|
permissions: [
|
||||||
{ description: "View things", name: "things:read" },
|
{ description: "View things", name: "things:read" },
|
||||||
{ description: "Create and edit things", name: "things:write" },
|
{ description: "Create and edit things", name: "things:write" },
|
||||||
],
|
],
|
||||||
|
|
||||||
// Route handlers, mounted under the plugin's path (/things). `role` gates first.
|
// Route handlers, mounted under the plugin's path (/things). `permission` gates first.
|
||||||
routes: [
|
routes: [
|
||||||
{ method: "GET", path: "/", role: "things:read", handler: listThings },
|
{ method: "GET", path: "/", permission: "things:read", handler: listThings },
|
||||||
{ method: "POST", path: "/", role: "things:write", handler: createThings },
|
{ method: "POST", path: "/", permission: "things:write", handler: createThings },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
@@ -426,7 +430,7 @@ there is **no `id` or `basePath`** in the manifest — both come from the folder
|
|||||||
| `home` | no | A `RouteHandler` that owns the **public** landing `/`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
|
| `home` | no | A `RouteHandler` that owns the **public** landing `/`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
|
||||||
| `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
|
| `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
|
||||||
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. |
|
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. |
|
||||||
| `roles` | no | Roles this plugin gates on. See [Nav & role gates](#nav--role-gates). |
|
| `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). |
|
||||||
| `routes` | no | See [Routes & handlers](#routes--handlers). |
|
| `routes` | no | See [Routes & handlers](#routes--handlers). |
|
||||||
| `hooks` | no | See [Hooks](#hooks). |
|
| `hooks` | no | See [Hooks](#hooks). |
|
||||||
|
|
||||||
@@ -434,14 +438,14 @@ A plugin may be routes-only, nav-only, or hooks-only — every collection field
|
|||||||
|
|
||||||
### Routes & handlers
|
### Routes & handlers
|
||||||
|
|
||||||
A route is `{ method, path, role?, public?, handler }`. `path` is **relative to the plugin's
|
A route is `{ method, path, permission?, public?, handler }`. `path` is **relative to the plugin's
|
||||||
mount path `/<id>`** (so `path: "/:id"` in the `things` plugin serves `/things/:id`); the host
|
mount path `/<id>`** (so `path: "/:id"` in the `things` plugin serves `/things/:id`); the host
|
||||||
matches `method` + the resolved full path, extracts `:name` segments into `ctx.params.name`,
|
matches `method` + the resolved full path, extracts `:name` segments into `ctx.params.name`,
|
||||||
runs the `role` gate (a coarse JWT-claim check — see [Nav & role gates](#nav--role-gates)),
|
runs the `permission` gate (a coarse JWT-claim check — see [Nav & permission gates](#nav--permission-gates)),
|
||||||
and only then calls the handler with the [request context](#requestcontext). When the gate fails, an
|
and only then calls the handler with the [request context](#requestcontext). When the gate fails, an
|
||||||
**anonymous** visitor is redirected to `/login` to sign in; the
|
**anonymous** visitor is redirected to `/login` to sign in; the
|
||||||
requested page is preserved as `return_to`, so after signing in they land **back on the page they
|
requested page is preserved as `return_to`, so after signing in they land **back on the page they
|
||||||
asked for**, not the dashboard. A **signed-in** user who simply lacks the role gets the **403** page.
|
asked for**, not the dashboard. A **signed-in** user who simply lacks the permission gets the **403** page.
|
||||||
A route marked **`public: true`** has no gate at all — anyone reaches it (see [Public pages & menu
|
A route marked **`public: true`** has no gate at all — anyone reaches it (see [Public pages & menu
|
||||||
items](#public-pages--menu-items)).
|
items](#public-pages--menu-items)).
|
||||||
|
|
||||||
@@ -482,9 +486,9 @@ export async function listThings(ctx: RequestContext) {
|
|||||||
partials/subfolders to render a full page — exactly as the admin plugin's screens do. To load the
|
partials/subfolders to render a full page — exactly as the admin plugin's screens do. To load the
|
||||||
plugin's own CSS, pass its `/public/<id>/x.css` href in the shell's `styles` slot (an array of
|
plugin's own CSS, pass its `/public/<id>/x.css` href in the shell's `styles` slot (an array of
|
||||||
extra stylesheet hrefs) — see the reference's `views/shifts.ejs`.
|
extra stylesheet hrefs) — see the reference's `views/shifts.ejs`.
|
||||||
- **Finer authorization than the route `role`** uses the guards from `#plugin-api`:
|
- **Finer authorization than the route `permission`** uses the guards from `#plugin-api`:
|
||||||
`requireSession(ctx)` (assert a session — throws a `GuardError` the host turns into a redirect
|
`requireSession(ctx)` (assert a session — throws a `GuardError` the host turns into a redirect
|
||||||
to sign in), `can(ctx, role)` (a coarse JWT-claim check, zero I/O), and `check(keto, ctx,
|
to sign in), `can(ctx, permission)` (a coarse JWT-claim check, zero I/O), and `check(keto, ctx,
|
||||||
{namespace, object, relation})` (a live Keto check for relationship rules — the subject is the
|
{namespace, object, relation})` (a live Keto check for relationship rules — the subject is the
|
||||||
signed-in user, anonymous ⇒ denied). Throw `new GuardError(403, …)` after a failed `can`/`check`
|
signed-in user, anonymous ⇒ denied). Throw `new GuardError(403, …)` after a failed `can`/`check`
|
||||||
to render the 403 page.
|
to render the 403 page.
|
||||||
@@ -540,8 +544,8 @@ a signed-in visitor, or sign-in / register to an anonymous one). After login the
|
|||||||
points there.
|
points there.
|
||||||
|
|
||||||
For the gated `dashboard`, the host enforces the session gate first, so `ctx.identity` is non-null;
|
For the gated `dashboard`, the host enforces the session gate first, so `ctx.identity` is non-null;
|
||||||
branch on `ctx.roles` *inside* to tailor the page per role. Don't gate `dashboard` itself behind a
|
branch on `ctx.permissions` *inside* to tailor the page per permission. Don't gate `dashboard` itself behind a
|
||||||
single role — there's no second dashboard to fall back to, so a user lacking it would land on a
|
single permission — there's no second dashboard to fall back to, so a user lacking it would land on a
|
||||||
403. (Both slots answer `GET` and `HEAD`.)
|
403. (Both slots answer `GET` and `HEAD`.)
|
||||||
|
|
||||||
Only **one** plugin may own each slot: two declaring `home` (or two declaring `dashboard`) is a
|
Only **one** plugin may own each slot: two declaring `home` (or two declaring `dashboard`) is a
|
||||||
@@ -557,13 +561,13 @@ request:
|
|||||||
```ts
|
```ts
|
||||||
interface RequestContext {
|
interface RequestContext {
|
||||||
chrome: PageChrome; // brand/global-nav/user/theme/csrf for the native app shell
|
chrome: PageChrome; // brand/global-nav/user/theme/csrf for the native app shell
|
||||||
identity: SessionIdentity | null; // { id, email, roles } from the verified session JWT, or null
|
identity: SessionIdentity | null; // { id, email, permissions } from the verified session JWT, or null
|
||||||
log: Log; // request-scoped logger, in this request's trace
|
log: Log; // request-scoped logger, in this request's trace
|
||||||
params: Record<string, string>; // path params from the route match, e.g. /things/:id → { id }
|
params: Record<string, string>; // path params from the route match, e.g. /things/:id → { id }
|
||||||
query: URLSearchParams; // alias of url.searchParams
|
query: URLSearchParams; // alias of url.searchParams
|
||||||
req: IncomingMessage;
|
req: IncomingMessage;
|
||||||
res: ServerResponse;
|
res: ServerResponse;
|
||||||
roles: string[]; // identity?.roles ?? [] — coarse gate without a null-check
|
permissions: string[]; // identity?.permissions ?? [] — coarse gate without a null-check
|
||||||
system?: SystemCapabilities; // privileged Ory clients + instant-revoke, for a system plugin (see below); undefined unless the host wired them
|
system?: SystemCapabilities; // privileged Ory clients + instant-revoke, for a system plugin (see below); undefined unless the host wired them
|
||||||
url: URL;
|
url: URL;
|
||||||
verifyCsrf(submitted): boolean; // gate a form POST against the request's signed CSRF cookie
|
verifyCsrf(submitted): boolean; // gate a form POST against the request's signed CSRF cookie
|
||||||
@@ -574,7 +578,7 @@ interface RequestContext {
|
|||||||
theme, user }`. Hand it to `partials/shell` so a `view` result renders the **native app shell** (the same
|
theme, user }`. Hand it to `partials/shell` so a `view` result renders the **native app shell** (the same
|
||||||
sidebar, branding, theme switch and signed-in profile every page uses); `chrome.nav` is the
|
sidebar, branding, theme switch and signed-in profile every page uses); `chrome.nav` is the
|
||||||
global menu — your plugin's nav fragment plus every other installed plugin's (the admin section among
|
global menu — your plugin's nav fragment plus every other installed plugin's (the admin section among
|
||||||
them, when that plugin is present) — already composed, role-filtered, and current-marked for this
|
them, when that plugin is present) — already composed, permission-filtered, and current-marked for this
|
||||||
request (the gated **Dashboard** link is omitted for an
|
request (the gated **Dashboard** link is omitted for an
|
||||||
anonymous visitor). `chrome.signInHref` is where the shell's anonymous **Sign in** link points — the
|
anonymous visitor). `chrome.signInHref` is where the shell's anonymous **Sign in** link points — the
|
||||||
current page baked in as `return_to`. Map each `chrome.*` to the matching `partials/shell` local —
|
current page baked in as `return_to`. Map each `chrome.*` to the matching `partials/shell` local —
|
||||||
@@ -587,7 +591,7 @@ secret and sets the cookie; the plugin never touches it. It is **opt-in per hand
|
|||||||
that never calls it has no CSRF guard at all. (See the reference: `examples/plugins/scheduling/`.)
|
that never calls it has no CSRF guard at all. (See the reference: `examples/plugins/scheduling/`.)
|
||||||
|
|
||||||
The same shell renders **every** page (the dashboard, your plugin pages — the admin plugin's included, and the
|
The same shell renders **every** page (the dashboard, your plugin pages — the admin plugin's included, and the
|
||||||
login/registration/front pages), so the menu looks identical signed in or out — it just role-filters.
|
login/registration/front pages), so the menu looks identical signed in or out — it just permission-filters.
|
||||||
A page that wants a focused, chrome-free layout passes **`menu: false`** to `partials/shell` (drops the
|
A page that wants a focused, chrome-free layout passes **`menu: false`** to `partials/shell` (drops the
|
||||||
sidebar, single column); everything else still renders.
|
sidebar, single column); everything else still renders.
|
||||||
|
|
||||||
@@ -604,7 +608,7 @@ OpenTelemetry Collector when `OTLP_ENDPOINT` is set).
|
|||||||
**Stability guarantee.** The fields above are the stable contract — present and non-breaking
|
**Stability guarantee.** The fields above are the stable contract — present and non-breaking
|
||||||
across a major `apiVersion`. New fields may be **added** within a major version (additive, never
|
across a major `apiVersion`. New fields may be **added** within a major version (additive, never
|
||||||
breaking). `req`/`res` are the raw Node objects and the full escape hatch; reading them is fine,
|
breaking). `req`/`res` are the raw Node objects and the full escape hatch; reading them is fine,
|
||||||
but prefer the typed fields so a handler keeps working as the host evolves. `user`/`roles` come
|
but prefer the typed fields so a handler keeps working as the host evolves. `user`/`permissions` come
|
||||||
from the JWT middleware and are `null`/`[]` until a session exists.
|
from the JWT middleware and are `null`/`[]` until a session exists.
|
||||||
|
|
||||||
### System capabilities (the `ctx.system` surface)
|
### System capabilities (the `ctx.system` surface)
|
||||||
@@ -618,7 +622,7 @@ needs the host's Ory admin clients and the instant-revoke hook instead. The host
|
|||||||
```ts
|
```ts
|
||||||
interface SystemCapabilities { // every field optional — present only when the host wired it
|
interface SystemCapabilities { // every field optional — present only when the host wired it
|
||||||
hydra?: HydraAdmin; // OAuth2 client admin (register/list/delete Hydra clients)
|
hydra?: HydraAdmin; // OAuth2 client admin (register/list/delete Hydra clients)
|
||||||
keto?: KetoClient; // relationship read/write (groups, roles)
|
keto?: KetoClient; // relationship read/write (groups, permissions)
|
||||||
kratosAdmin?: KratosAdmin; // identity admin (create/edit/deactivate/delete users)
|
kratosAdmin?: KratosAdmin; // identity admin (create/edit/deactivate/delete users)
|
||||||
revoke?: (sub: string) => void; // instant-revoke a subject's live tokens (needs the denylist)
|
revoke?: (sub: string) => void; // instant-revoke a subject's live tokens (needs the denylist)
|
||||||
}
|
}
|
||||||
@@ -628,8 +632,8 @@ interface SystemCapabilities { // every field optional — present only
|
|||||||
Hydra configured, the [revocation denylist](#instant-revoke-the-optional-denylist) enabled). A system
|
Hydra configured, the [revocation denylist](#instant-revoke-the-optional-denylist) enabled). A system
|
||||||
plugin treats every field as optional and **degrades when absent** — the host never fails a request
|
plugin treats every field as optional and **degrades when absent** — the host never fails a request
|
||||||
over it. The built-in **admin plugin** ([`examples/plugins/admin/`](examples/plugins/admin/)) is the
|
over it. The built-in **admin plugin** ([`examples/plugins/admin/`](examples/plugins/admin/)) is the
|
||||||
reference consumer: its Users screen uses `ctx.system.kratosAdmin`, Groups/Roles use `ctx.system.keto`,
|
reference consumer: its Users screen uses `ctx.system.kratosAdmin`, Groups/Permissions use `ctx.system.keto`,
|
||||||
OAuth2 clients use `ctx.system.hydra`, and a deactivate/delete or user role-change calls
|
OAuth2 clients use `ctx.system.hydra`, and a deactivate/delete or user permission-change calls
|
||||||
`ctx.system.revoke` so the change lands now instead of after the JWT TTL; where a capability is missing
|
`ctx.system.revoke` so the change lands now instead of after the JWT TTL; where a capability is missing
|
||||||
the screen renders a themed 503.
|
the screen renders a themed 503.
|
||||||
|
|
||||||
@@ -637,11 +641,11 @@ This is a **privileged** surface — it hands a plugin the keys to identity and
|
|||||||
for first-party system plugins you author or vendor, the same trust level as any plugin (the host
|
for first-party system plugins you author or vendor, the same trust level as any plugin (the host
|
||||||
doesn't sandbox — [crash-isolation is a non-goal](#overview)). An ordinary domain plugin ignores it.
|
doesn't sandbox — [crash-isolation is a non-goal](#overview)). An ordinary domain plugin ignores it.
|
||||||
|
|
||||||
### Nav & role gates
|
### Nav & permission gates
|
||||||
|
|
||||||
A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/ui/nav.ts`), which
|
A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/ui/nav.ts`), which
|
||||||
applies the central override and then **filters per user** by the roles in the session JWT — a
|
applies the central override and then **filters per user** by the permissions in the session JWT — a
|
||||||
node shows iff it is `public`, declares no `role`, or the user's roles include that name. Use
|
node shows iff it is `public`, declares no `permission`, or the user's permissions include that name. Use
|
||||||
arbitrary depth, counts, and icons; see `composeNav` for the node shape. A node's `icon` is a
|
arbitrary depth, counts, and icons; see `composeNav` for the node shape. A node's `icon` is a
|
||||||
**Lucide icon**, referenced by its sprite id (e.g. `i-cal` → lucide `calendar`); the available ids
|
**Lucide icon**, referenced by its sprite id (e.g. `i-cal` → lucide `calendar`); the available ids
|
||||||
are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name there.
|
are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name there.
|
||||||
@@ -649,25 +653,25 @@ are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its luci
|
|||||||
#### Public pages & menu items
|
#### Public pages & menu items
|
||||||
|
|
||||||
A route or nav node may be marked **`public: true`** — reachable by **anyone, signed in or not**,
|
A route or nav node may be marked **`public: true`** — reachable by **anyone, signed in or not**,
|
||||||
and the menu item shows for everyone. This is the same as omitting `role` (an ungated
|
and the menu item shows for everyone. This is the same as omitting `permission` (an ungated
|
||||||
route/node is already open) but stated outright, so "public" is a **deliberate choice, not the
|
route/node is already open) but stated outright, so "public" is a **deliberate choice, not the
|
||||||
accident of a forgotten gate**. `public` and `role` are **mutually exclusive** — declaring
|
accident of a forgotten gate**. `public` and `permission` are **mutually exclusive** — declaring
|
||||||
both is contradictory and discovery refuses the plugin at boot.
|
both is contradictory and discovery refuses the plugin at boot.
|
||||||
|
|
||||||
A public page still renders in the native shell via `ctx.chrome`; for an anonymous visitor
|
A public page still renders in the native shell via `ctx.chrome`; for an anonymous visitor
|
||||||
`ctx.identity` is `null`, the shell shows a **Sign in** link (`chrome.signInHref`, returning to this page)
|
`ctx.identity` is `null`, the shell shows a **Sign in** link (`chrome.signInHref`, returning to this page)
|
||||||
in place of the profile/sign-out block, the gated **Dashboard** link is hidden, and `ctx.roles` is
|
in place of the profile/sign-out block, the gated **Dashboard** link is hidden, and `ctx.permissions` is
|
||||||
empty (read a role with `can(ctx, …)` to branch). The reference plugin's `/scheduling`
|
empty (read a permission with `can(ctx, …)` to branch). The reference plugin's `/scheduling`
|
||||||
**Overview** is a worked example: it's `public`, so the "Scheduling" menu header shows for everyone,
|
**Overview** is a worked example: it's `public`, so the "Scheduling" menu header shows for everyone,
|
||||||
while the actual shifts list stays behind `scheduling:read`.
|
while the actual shifts list stays behind `scheduling:read`.
|
||||||
|
|
||||||
The gate passes iff the user's JWT `roles` include that name. How roles are granted, why their
|
The gate passes iff the user's JWT `permissions` include that name. How permissions are granted, why their
|
||||||
names are a shared global namespace, and the fine-grained per-row tier are all covered in
|
names are a shared global namespace, and the fine-grained per-row tier are all covered in
|
||||||
[Users, groups & roles](#users-groups--roles).
|
[Identities, groups & permissions](#identities-groups--permissions).
|
||||||
|
|
||||||
Declaring the ones you gate on in `roles` is **optional but recommended**: it documents them,
|
Declaring the ones you gate on in `permissions` is **optional but recommended**: it documents them,
|
||||||
feeds conflict detection, and lets the one-command bootstrap seed them — the demo admin is
|
feeds conflict detection, and lets the one-command bootstrap seed them — the demo admin is
|
||||||
granted every discovered plugin's declared roles, so a dropped-in plugin works out of the box
|
granted every discovered plugin's declared permissions, so a dropped-in plugin works out of the box
|
||||||
without editing host config.
|
without editing host config.
|
||||||
|
|
||||||
### Contract versioning
|
### Contract versioning
|
||||||
@@ -703,15 +707,15 @@ with `findConflicts` and resolves them **loudly — never last-write-wins**. `er
|
|||||||
| `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/<id>` prefix is unique), so this catches a plugin duplicating one of its own. |
|
| `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/<id>` prefix is unique), so this catches a plugin duplicating one of its own. |
|
||||||
| `nav-id` | error | A nav node `id` is used more than once — the central override targets ids, so they must be unique. |
|
| `nav-id` | error | A nav node `id` is used more than once — the central override targets ids, so they must be unique. |
|
||||||
| `home` / `dashboard` | error | More than one plugin declares `home` (or `dashboard`). Each landing page is a single slot, so only one may own it ([The landing pages](#the-landing-pages-home--dashboard)). |
|
| `home` / `dashboard` | error | More than one plugin declares `home` (or `dashboard`). Each landing page is a single slot, so only one may own it ([The landing pages](#the-landing-pages-home--dashboard)). |
|
||||||
| `role` | warn | A role name is declared by more than one plugin. Sharing is legitimate; namespace as `<id>:<action>` if unintended. |
|
| `permission` | warn | A permission name is declared by more than one plugin. Sharing is legitimate; namespace as `<id>:<action>` if unintended. |
|
||||||
|
|
||||||
There is **no separate `basePath` rule**: the mount path is the derived `/<id>`, so its
|
There is **no separate `basePath` rule**: the mount path is the derived `/<id>`, so its
|
||||||
uniqueness follows from the id check. `role` is the one intentional overlap, so it warns
|
uniqueness follows from the id check. `permission` is the one intentional overlap, so it warns
|
||||||
rather than aborts; everything else is an error an author fixes before the host will start.
|
rather than aborts; everything else is an error an author fixes before the host will start.
|
||||||
|
|
||||||
Beyond cross-plugin conflicts, discovery also rejects **per-manifest shape errors** at boot: a
|
Beyond cross-plugin conflicts, discovery also rejects **per-manifest shape errors** at boot: a
|
||||||
non-array `nav`/`routes`/`roles`, a non-function `home`/`dashboard`, or a route/nav node that
|
non-array `nav`/`routes`/`permissions`, a non-function `home`/`dashboard`, or a route/nav node that
|
||||||
sets both `public` and `role` (mutually exclusive — [Public pages](#public-pages--menu-items)).
|
sets both `public` and `permission` (mutually exclusive — [Public pages](#public-pages--menu-items)).
|
||||||
|
|
||||||
### Hooks
|
### Hooks
|
||||||
|
|
||||||
@@ -778,7 +782,7 @@ can't escape its own package scope, so it can't point at the host's file directl
|
|||||||
> Discovery — scanning `plugins/`, importing each `plugin.ts` default export, and
|
> Discovery — scanning `plugins/`, importing each `plugin.ts` default export, and
|
||||||
> validating it (id, `apiVersion`, conflicts) — runs at boot (`src/plugin-host/discovery.ts`); a bad
|
> validating it (id, `apiVersion`, conflicts) — runs at boot (`src/plugin-host/discovery.ts`); a bad
|
||||||
> plugin stops startup with a precise message. The router (`src/plugin-host/router.ts`) then mounts
|
> plugin stops startup with a precise message. The router (`src/plugin-host/router.ts`) then mounts
|
||||||
> each route at `/<id>`, resolves `:name` params, runs the role gate, and turns the
|
> each route at `/<id>`, resolves `:name` params, runs the permission gate, and turns the
|
||||||
> handler's `RouteResult` into the response; a `view` result renders
|
> handler's `RouteResult` into the response; a `view` result renders
|
||||||
> `plugins/<id>/views/<view>.ejs` (`src/plugin-host/view-resolver.ts`), which may `include()` the core
|
> `plugins/<id>/views/<view>.ejs` (`src/plugin-host/view-resolver.ts`), which may `include()` the core
|
||||||
> building-block partials. A plugin's `public/` assets are served at `/public/<id>/`
|
> building-block partials. A plugin's `public/` assets are served at `/public/<id>/`
|
||||||
@@ -809,7 +813,7 @@ worked example: thin handlers bound to an injectable upstream client, unit-teste
|
|||||||
|
|
||||||
3. **E2E the user-facing flow.** Per AGENTS.md §6, ship a side-effect-free Playwright test in
|
3. **E2E the user-facing flow.** Per AGENTS.md §6, ship a side-effect-free Playwright test in
|
||||||
`e2e-tests/` for each plugin page/form so the suite stays `fullyParallel`, run against the live `web`
|
`e2e-tests/` for each plugin page/form so the suite stays `fullyParallel`, run against the live `web`
|
||||||
service with the plugin mounted. The reference's role-gating is covered in `visual.spec.ts`;
|
service with the plugin mounted. The reference's permission-gating is covered in `visual.spec.ts`;
|
||||||
its authenticated list/form happy-path is the full-E2E item (needs cross-host login infra).
|
its authenticated list/form happy-path is the full-E2E item (needs cross-host login infra).
|
||||||
|
|
||||||
The validation an author hits is the same the host runs: bad `apiVersion` or a conflict
|
The validation an author hits is the same the host runs: bad `apiVersion` or a conflict
|
||||||
@@ -837,13 +841,13 @@ The menu is **driven entirely by config** and assembled from two sources:
|
|||||||
export default defineMenu({ branding: { name: "Acme Ops" }, override: { hide: ["teams"] } });
|
export default defineMenu({ branding: { name: "Acme Ops" }, override: { hide: ["teams"] } });
|
||||||
```
|
```
|
||||||
|
|
||||||
Every nav item may carry a `role`; the rendered tree is **filtered per user** by
|
Every nav item may carry a `permission`; the rendered tree is **filtered per user** by
|
||||||
reading the roles in the session JWT (no per-request authz call — see
|
reading the permissions in the session JWT (no per-request authz call — see
|
||||||
[Auth, sessions & access](#auth-sessions--access)), so the menu only ever shows
|
[Auth, sessions & access](#auth-sessions--access)), so the menu only ever shows
|
||||||
what that person can reach. An item (or a whole page) may instead be marked **`public:
|
what that person can reach. An item (or a whole page) may instead be marked **`public:
|
||||||
true`** to show it to **everyone, signed in or not** — the blessed, explicit way to expose
|
true`** to show it to **everyone, signed in or not** — the blessed, explicit way to expose
|
||||||
a public page and its menu entry (an ungated item is already public; `public` just
|
a public page and its menu entry (an ungated item is already public; `public` just
|
||||||
says so on purpose, and is mutually exclusive with `role`). The markup is the
|
says so on purpose, and is mutually exclusive with `permission`). The markup is the
|
||||||
recursive, zero-JS nav tree from the design foundation (header/leaf × clickable/static,
|
recursive, zero-JS nav tree from the design foundation (header/leaf × clickable/static,
|
||||||
counts, arbitrary depth). Branding (name, logo, default theme) renders in the app shell —
|
counts, arbitrary depth). Branding (name, logo, default theme) renders in the app shell —
|
||||||
the sidebar brand shows the configured logo (else a default mark), and the theme sets the
|
the sidebar brand shows the configured logo (else a default mark), and the theme sets the
|
||||||
@@ -868,7 +872,7 @@ set of reusable EJS partials + TS helpers, fully styled and zero-JS:
|
|||||||
- **Helpers:** `composeNav` (menu from config), `parseListQuery`
|
- **Helpers:** `composeNav` (menu from config), `parseListQuery`
|
||||||
(`?q=…&status=…&sort=…&page=…` → filter/sort/pagination), `paginate` (page math), and the
|
(`?q=…&status=…&sort=…&page=…` → filter/sort/pagination), `paginate` (page math), and the
|
||||||
auth guards a handler calls to authorize (`src/auth/guards.ts`): `requireSession` (assert a
|
auth guards a handler calls to authorize (`src/auth/guards.ts`): `requireSession` (assert a
|
||||||
session — a `GuardError` the host turns into a redirect to sign in), `can(role)` (a coarse
|
session — a `GuardError` the host turns into a redirect to sign in), `can(permission)` (a coarse
|
||||||
JWT-claim check, zero I/O), `check(relation, object)` (the one live Keto call, for
|
JWT-claim check, zero I/O), `check(relation, object)` (the one live Keto call, for
|
||||||
relationship rules).
|
relationship rules).
|
||||||
|
|
||||||
@@ -916,7 +920,7 @@ The app is **environment-agnostic**: there is no `NODE_ENV`. Behaviour that used
|
|||||||
| `JWT_ISSUER` / `JWT_AUDIENCE` | _unset_ | optional: when set, the session JWT's `iss` / `aud` must match (the dev tokenizer sets neither) |
|
| `JWT_ISSUER` / `JWT_AUDIENCE` | _unset_ | optional: when set, the session JWT's `iss` / `aud` must match (the dev tokenizer sets neither) |
|
||||||
| `JWT_CLOCK_SKEW_SEC` | `60` | exp/nbf leeway (s) for Kratos↔web clock drift (the auth E2E sets `0`) |
|
| `JWT_CLOCK_SKEW_SEC` | `60` | exp/nbf leeway (s) for Kratos↔web clock drift (the auth E2E sets `0`) |
|
||||||
| `ORY_TIMEOUT_SEC` | `5` | per-call timeout for outbound Kratos/Keto/Hydra (and http JWKS) fetches, so a hung Ory can't park a request |
|
| `ORY_TIMEOUT_SEC` | `5` | per-call timeout for outbound Kratos/Keto/Hydra (and http JWKS) fetches, so a hung Ory can't park a request |
|
||||||
| `REVOCATION_DENYLIST` | `false` | when `true`, enable the optional [instant role/session revoke denylist](#instant-revoke-the-optional-denylist) |
|
| `REVOCATION_DENYLIST` | `false` | when `true`, enable the optional [instant permission/session revoke denylist](#instant-revoke-the-optional-denylist) |
|
||||||
| `REVOCATION_TTL_SEC` | `900` | how long a revoke entry lives; keep ≥ tokenizer TTL (10m) + clock skew |
|
| `REVOCATION_TTL_SEC` | `900` | how long a revoke entry lives; keep ≥ tokenizer TTL (10m) + clock skew |
|
||||||
| `CSRF_SECRET` | dev throwaway | signs our double-submit CSRF token; enforced by `REQUIRE_SECURE_SECRETS` |
|
| `CSRF_SECRET` | dev throwaway | signs our double-submit CSRF token; enforced by `REQUIRE_SECURE_SECRETS` |
|
||||||
|
|
||||||
@@ -989,7 +993,7 @@ blocks a clean clone:
|
|||||||
[Social sign-in (SSO)](#social-sign-in-sso)).
|
[Social sign-in (SSO)](#social-sign-in-sso)).
|
||||||
|
|
||||||
Everything else is generated or seeded on first boot — Ory migrations, the dev signing key,
|
Everything else is generated or seeded on first boot — Ory migrations, the dev signing key,
|
||||||
the demo admin identity and its Keto roles, the Keto OPL model — so there is nothing else to
|
the demo admin identity and its Keto permissions, the Keto OPL model — so there is nothing else to
|
||||||
hand-configure.
|
hand-configure.
|
||||||
|
|
||||||
### Social sign-in (SSO)
|
### Social sign-in (SSO)
|
||||||
@@ -1023,32 +1027,32 @@ the session for a signed JWT once** via the Kratos **session tokenizer** (`whoam
|
|||||||
```
|
```
|
||||||
── AT LOGIN / REFRESH (the only time Ory is on the path) ──────────
|
── AT LOGIN / REFRESH (the only time Ory is on the path) ──────────
|
||||||
Kratos verifies credentials
|
Kratos verifies credentials
|
||||||
└─► app reads the user's roles from Keto (direct + transitive via groups)
|
└─► app reads the user's permissions from Keto (direct + transitive via groups)
|
||||||
└─► app writes them as a derived projection on the identity (admin API)
|
└─► app writes them as a derived projection on the identity (admin API)
|
||||||
└─► whoami(tokenize_as: "plainpages") ─► signed JWT
|
└─► whoami(tokenize_as: "plainpages") ─► signed JWT
|
||||||
claims: { sub, email, roles:[…from Keto], exp ≈ 10m }
|
claims: { sub, email, permissions:[…from Keto], exp ≈ 10m }
|
||||||
└─► stored as the session cookie
|
└─► stored as the session cookie
|
||||||
|
|
||||||
── EVERY REQUEST (hot path — pure CPU, no I/O) ───────────────────
|
── EVERY REQUEST (hot path — pure CPU, no I/O) ───────────────────
|
||||||
Browser ─cookie(JWT)─► web : verify signature (cached JWKS)
|
Browser ─cookie(JWT)─► web : verify signature (cached JWKS)
|
||||||
read claims.roles
|
read claims.permissions
|
||||||
filter menu · gate routes
|
filter menu · gate routes
|
||||||
```
|
```
|
||||||
|
|
||||||
**Keto is the single source of truth for roles.** Coarse roles are Keto relations (e.g.
|
**Keto is the single source of truth for permissions.** Coarse permissions are Keto relations (e.g.
|
||||||
`Role:admin#members@identity:alice`); the admin screens write them *only* to Keto. But the
|
`Permission:admin#members@identity:alice`); the admin screens write them *only* to Keto. But the
|
||||||
tokenizer's claims mapper can read only the **identity**, not call Keto — so at login the
|
tokenizer's claims mapper can read only the **identity**, not call Keto — so at login the
|
||||||
app reads the roles from Keto and refreshes a **derived projection**: a read-only copy
|
app reads the permissions from Keto and refreshes a **derived projection**: a read-only copy
|
||||||
written onto the identity's `metadata_public` for the tokenizer to see, which the template
|
written onto the identity's `metadata_public` for the tokenizer to see, which the template
|
||||||
maps into the JWT `roles` claim. (It must be `metadata_public`, not `metadata_admin`: the
|
maps into the JWT `permissions` claim. (It must be `metadata_public`, not `metadata_admin`: the
|
||||||
session Kratos hands the tokenizer carries only *public* metadata — and the user can already
|
session Kratos hands the tokenizer carries only *public* metadata — and the user can already
|
||||||
read these coarse roles in their own JWT, so nothing is leaked.) That projection is a
|
read these coarse permissions in their own JWT, so nothing is leaked.) That projection is a
|
||||||
per-login cache, authoritative nowhere; nothing edits it by hand, and a stale one self-heals
|
per-login cache, authoritative nowhere; nothing edits it by hand, and a stale one self-heals
|
||||||
on the next login.
|
on the next login.
|
||||||
|
|
||||||
A role can be granted to a user directly or to a **group** the user belongs to; login
|
A permission can be granted to a user directly or to a **group** the user belongs to; login
|
||||||
resolves both (enumerate the defined roles, ask Keto to resolve each membership), so the JWT
|
resolves both (enumerate the defined permissions, ask Keto to resolve each membership), so the JWT
|
||||||
`roles` match what the admin **Effective access** view shows.
|
`permissions` match what the admin **Effective access** view shows.
|
||||||
|
|
||||||
Cost: **a handful of Keto reads + one identity refresh per login** — never per request. JWKS
|
Cost: **a handful of Keto reads + one identity refresh per login** — never per request. JWKS
|
||||||
is cached, so even signature verification hits the network only on key rotation. The app
|
is cached, so even signature verification hits the network only on key rotation. The app
|
||||||
@@ -1060,8 +1064,8 @@ recomputed from Keto.
|
|||||||
This design buys an I/O-free hot path that scales to **tens of thousands of concurrent
|
This design buys an I/O-free hot path that scales to **tens of thousands of concurrent
|
||||||
users** on modest hardware. In return:
|
users** on modest hardware. In return:
|
||||||
|
|
||||||
- **Role changes lag by up to one TTL (~10m).** Gating reads the JWT, not Keto, so a granted
|
- **Permission changes lag by up to one TTL (~10m).** Gating reads the JWT, not Keto, so a granted
|
||||||
or revoked role only takes effect when the token is next minted (re-login or TTL refresh).
|
or revoked permission only takes effect when the token is next minted (re-login or TTL refresh).
|
||||||
For an admin tool this is intentional — the alternative is a Keto call per request, which
|
For an admin tool this is intentional — the alternative is a Keto call per request, which
|
||||||
we traded away. For instant revoke, turn on the optional
|
we traded away. For instant revoke, turn on the optional
|
||||||
[revocation denylist](#instant-revoke-the-optional-denylist) — it closes the gap for
|
[revocation denylist](#instant-revoke-the-optional-denylist) — it closes the gap for
|
||||||
@@ -1074,12 +1078,12 @@ users** on modest hardware. In return:
|
|||||||
### Instant revoke: the optional denylist
|
### Instant revoke: the optional denylist
|
||||||
|
|
||||||
Off by default; turn it on with `REVOCATION_DENYLIST=true` (`src/auth/denylist.ts`). For
|
Off by default; turn it on with `REVOCATION_DENYLIST=true` (`src/auth/denylist.ts`). For
|
||||||
security-critical revoke (offboarding, a compromised account) the ~10m role/session lag
|
security-critical revoke (offboarding, a compromised account) the ~10m permission/session lag
|
||||||
above is too long. When enabled, an admin **deactivating** or **deleting** a user, or
|
above is too long. When enabled, an admin **deactivating** or **deleting** a user, or
|
||||||
**granting/revoking** a role to a *user*, records that subject as revoked-now; the hot path
|
**granting/revoking** a permission to a *user*, records that subject as revoked-now; the hot path
|
||||||
then rejects every token for it minted **before** the revoke and forces a re-mint — which
|
then rejects every token for it minted **before** the revoke and forces a re-mint — which
|
||||||
re-reads roles from Keto, or clears a now-dead session. A fresh re-login (its JWT issued
|
re-reads permissions from Keto, or clears a now-dead session. A fresh re-login (its JWT issued
|
||||||
*after* the revoke) passes, so a role downgrade lands immediately without locking the
|
*after* the revoke) passes, so a permission downgrade lands immediately without locking the
|
||||||
account.
|
account.
|
||||||
|
|
||||||
It's an in-memory, auto-evicting map — no database, like the JWKS cache, so it stays inside
|
It's an in-memory, auto-evicting map — no database, like the JWKS cache, so it stays inside
|
||||||
@@ -1089,11 +1093,11 @@ CPU — **Keto stays off the hot path**. Two deliberate bounds: it's instant on
|
|||||||
instance** that handled the revoke (across replicas/restarts the guarantee falls back to the
|
instance** that handled the revoke (across replicas/restarts the guarantee falls back to the
|
||||||
token TTL — back the denylist with a shared store for hard multi-instance instant-revoke),
|
token TTL — back the denylist with a shared store for hard multi-instance instant-revoke),
|
||||||
and a **group** membership change is transitive across many users, so it's left to lag —
|
and a **group** membership change is transitive across many users, so it's left to lag —
|
||||||
deactivate the user, or use a direct user-role change, for an instant effect.
|
deactivate the user, or use a direct user-permission change, for an instant effect.
|
||||||
|
|
||||||
### Three tiers of "may I?"
|
### Three tiers of "may I?"
|
||||||
|
|
||||||
[Users, groups & roles](#users-groups--roles) covers *what* the entities are; this is where each
|
[Identities, groups & permissions](#identities-groups--permissions) covers *what* the entities are; this is where each
|
||||||
**kind** of rule belongs.
|
**kind** of rule belongs.
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -1110,8 +1114,8 @@ deactivate the user, or use a direct user-role change, for an instant effect.
|
|||||||
is for. Reserve it for those; don't pay its tuple-sync cost for rules a service can already
|
is for. Reserve it for those; don't pay its tuple-sync cost for rules a service can already
|
||||||
answer from its own data.
|
answer from its own data.
|
||||||
|
|
||||||
The admin plugin's users / groups / roles screens write authorization **only to Keto** — coarse
|
The admin plugin's users / groups / permissions screens write authorization **only to Keto** — coarse
|
||||||
roles and fine-grained relationships alike.
|
permissions and fine-grained relationships alike.
|
||||||
|
|
||||||
### OAuth2 provider (Hydra)
|
### OAuth2 provider (Hydra)
|
||||||
|
|
||||||
@@ -1151,7 +1155,7 @@ ports (guarded by `src/compose.test.ts`); dev publishes only the two a browser m
|
|||||||
expose one, and never front one with a proxy that lacks its own auth.
|
expose one, and never front one with a proxy that lacks its own auth.
|
||||||
|
|
||||||
**The JWT is signed, not encrypted.** Claims are base64: a signed-in user can read their own
|
**The JWT is signed, not encrypted.** Claims are base64: a signed-in user can read their own
|
||||||
`sub`, `email` and `roles`. `HttpOnly` keeps page JavaScript out of the cookie, not the user.
|
`sub`, `email` and `permissions`. `HttpOnly` keeps page JavaScript out of the cookie, not the user.
|
||||||
Never put anything in a claim you wouldn't show them.
|
Never put anything in a claim you wouldn't show them.
|
||||||
|
|
||||||
**The JWT's ~10m TTL is not the session lifetime.** The browser also holds Kratos'
|
**The JWT's ~10m TTL is not the session lifetime.** The browser also holds Kratos'
|
||||||
@@ -1160,12 +1164,12 @@ JWT. So a stolen cookie jar is worth 30 days of re-mintable access, not ten minu
|
|||||||
two cookies obey `SECURE_COOKIES`; the Kratos one takes its flags from Kratos' own config.
|
two cookies obey `SECURE_COOKIES`; the Kratos one takes its flags from Kratos' own config.
|
||||||
|
|
||||||
**Offboarding is not instant by default.** An expired JWT re-mints off that live Kratos session,
|
**Offboarding is not instant by default.** An expired JWT re-mints off that live Kratos session,
|
||||||
re-reading roles from Keto — so a revoked role, or a deactivated identity, lands within one
|
re-reading permissions from Keto — so a revoked permission, or a deactivated identity, lands within one
|
||||||
token TTL rather than immediately. With the
|
token TTL rather than immediately. With the
|
||||||
[denylist](#instant-revoke-the-optional-denylist) on (it is off by default), both take effect at
|
[denylist](#instant-revoke-the-optional-denylist) on (it is off by default), both take effect at
|
||||||
once, on the instance that handled the change.
|
once, on the instance that handled the change.
|
||||||
|
|
||||||
**Not guaranteed** — accepted, and stated where each mechanism is: role changes
|
**Not guaranteed** — accepted, and stated where each mechanism is: permission changes
|
||||||
[lag up to one token TTL and sign-in needs Ory up](#two-trade-offs--both-deliberate), and the
|
[lag up to one token TTL and sign-in needs Ory up](#two-trade-offs--both-deliberate), and the
|
||||||
denylist is [single-instance and skips group changes](#instant-revoke-the-optional-denylist).
|
denylist is [single-instance and skips group changes](#instant-revoke-the-optional-denylist).
|
||||||
Hardening a real deploy is `REQUIRE_SECURE_SECRETS=true`, `SECURE_COOKIES=true`, and replacing
|
Hardening a real deploy is `REQUIRE_SECURE_SECRETS=true`, `SECURE_COOKIES=true`, and replacing
|
||||||
@@ -1202,7 +1206,7 @@ docs for the full template-type list and the data each template receives.
|
|||||||
|
|
||||||
Plainpages runs as a small set of containers, orchestrated by Docker Compose:
|
Plainpages runs as a small set of containers, orchestrated by Docker Compose:
|
||||||
|
|
||||||
| Container | Role |
|
| Container | Permission |
|
||||||
| -------------- | ---- |
|
| -------------- | ---- |
|
||||||
| `web` | The Node 24 + TypeScript app: server-rendered EJS, the plugin host, the building-block partials. Stays tiny. |
|
| `web` | The Node 24 + TypeScript app: server-rendered EJS, the plugin host, the building-block partials. Stays tiny. |
|
||||||
| `kratos` | **Ory Kratos** — identity: login, registration, password reset, SSO, sessions. |
|
| `kratos` | **Ory Kratos** — identity: login, registration, password reset, SSO, sessions. |
|
||||||
@@ -1212,7 +1216,7 @@ Plainpages runs as a small set of containers, orchestrated by Docker Compose:
|
|||||||
|
|
||||||
The `web` app is an Ory **relying party**: it never stores passwords. At login it turns
|
The `web` app is an Ory **relying party**: it never stores passwords. At login it turns
|
||||||
the Kratos session into a short-lived, **locally-validated JWT** (the Kratos session
|
the Kratos session into a short-lived, **locally-validated JWT** (the Kratos session
|
||||||
tokenizer) carrying the user's coarse roles — so every later request gates the menu and
|
tokenizer) carrying the user's coarse permissions — so every later request gates the menu and
|
||||||
pages by **verifying the JWT in-process, with no per-request call to Ory**. Keto answers
|
pages by **verifying the JWT in-process, with no per-request call to Ory**. Keto answers
|
||||||
the rarer fine-grained checks; Hydra is used only when the app acts as an OAuth2 **login &
|
the rarer fine-grained checks; Hydra is used only when the app acts as an OAuth2 **login &
|
||||||
consent provider** for other apps. It reaches the Ory services over their **REST APIs
|
consent provider** for other apps. It reaches the Ory services over their **REST APIs
|
||||||
@@ -1261,7 +1265,7 @@ service — no Node/browsers on the host. There are five suites:
|
|||||||
**Visual + design system** (`visual.spec.ts`) — Ory-free, so it stays fast. It screenshots
|
**Visual + design system** (`visual.spec.ts`) — Ory-free, so it stays fast. It screenshots
|
||||||
the live pages and asserts the rendered design system — the app shell, theme switch, mobile
|
the live pages and asserts the rendered design system — the app shell, theme switch, mobile
|
||||||
off-canvas layout, icon sprite, CSRF-guarded sign-out, the public landing, the 404 page, and
|
off-canvas layout, icon sprite, CSRF-guarded sign-out, the public landing, the 404 page, and
|
||||||
plugin role-gating — the last exercised by bind-mounting the reference example
|
plugin permission-gating — the last exercised by bind-mounting the reference example
|
||||||
(`examples/plugins/scheduling/`) onto `/app/plugins/scheduling`.
|
(`examples/plugins/scheduling/`) onto `/app/plugins/scheduling`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -1273,7 +1277,7 @@ docker compose -f compose.yml -f e2e-tests/compose.visual.yml down -v
|
|||||||
boots the real Ory stack (Postgres + Kratos + Keto + bootstrap), shortens the session→JWT TTL
|
boots the real Ory stack (Postgres + Kratos + Keto + bootstrap), shortens the session→JWT TTL
|
||||||
to 8s (`ory/kratos/e2e.yml`) and sets `JWT_CLOCK_SKEW_SEC=0`, then logs in the seeded admin
|
to 8s (`ory/kratos/e2e.yml`) and sets `JWT_CLOCK_SKEW_SEC=0`, then logs in the seeded admin
|
||||||
and proves the "stay signed in" hot path: the lapsed JWT is silently **re-minted** from the
|
and proves the "stay signed in" hot path: the lapsed JWT is silently **re-minted** from the
|
||||||
live Kratos session (roles re-read from Keto), and once that session is revoked the stale
|
live Kratos session (permissions re-read from Keto), and once that session is revoked the stale
|
||||||
cookie is **cleared**.
|
cookie is **cleared**.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -1295,9 +1299,9 @@ docker compose -f compose.yml -f e2e-tests/compose.oauth.yml down -v
|
|||||||
|
|
||||||
**Full browser flow** (`full-flow.spec.ts`) — the real Playwright UI against the live stack:
|
**Full browser flow** (`full-flow.spec.ts`) — the real Playwright UI against the live stack:
|
||||||
the themed **password login** and a **mocked-SSO** login (an in-network mock OIDC provider,
|
the themed **password login** and a **mocked-SSO** login (an in-network mock OIDC provider,
|
||||||
`e2e-tests/mock-oidc.ts`), **menu filtering by role**, the **users/groups/roles** admin CRUD, the
|
`e2e-tests/mock-oidc.ts`), **menu filtering by permission**, the **users/groups/permissions** admin CRUD, the
|
||||||
**OAuth2-clients** admin screen (register → one-time secret → delete; Hydra is part of this stack
|
**OAuth2-clients** admin screen (register → one-time secret → delete; Hydra is part of this stack
|
||||||
for it), a role-gated **plugin page**, and **logout**. Because the themed form posts straight to
|
for it), a permission-gated **plugin page**, and **logout**. Because the themed form posts straight to
|
||||||
Kratos and cookies are host-scoped, a tiny same-origin gateway (`e2e-tests/proxy.ts`) fronts web +
|
Kratos and cookies are host-scoped, a tiny same-origin gateway (`e2e-tests/proxy.ts`) fronts web +
|
||||||
Kratos on one host (`ory/kratos/e2e-proxy.yml` points Kratos at it) — exactly as a production
|
Kratos on one host (`ory/kratos/e2e-proxy.yml` points Kratos at it) — exactly as a production
|
||||||
reverse proxy would.
|
reverse proxy would.
|
||||||
@@ -1341,7 +1345,7 @@ bash ci.sh
|
|||||||
```
|
```
|
||||||
|
|
||||||
Each E2E suite **owns a clean stack** — never point two suites at one backend (auth-refresh
|
Each E2E suite **owns a clean stack** — never point two suites at one backend (auth-refresh
|
||||||
revokes the admin's sessions; full-flow writes users/groups/roles to Keto), which is why the
|
revokes the admin's sessions; full-flow writes users/groups/permissions to Keto), which is why the
|
||||||
gate runs them serially, one stack up/down per suite.
|
gate runs them serially, one stack up/down per suite.
|
||||||
|
|
||||||
## CI/CD
|
## CI/CD
|
||||||
@@ -1491,8 +1495,8 @@ The server drains in-flight requests on `SIGTERM`/`SIGINT` rather than cutting t
|
|||||||
mid-response, so container restarts are clean.
|
mid-response, so container restarts are clean.
|
||||||
|
|
||||||
The first-boot **bootstrap** is idempotent and runs on every `up` — it generates the JWT
|
The first-boot **bootstrap** is idempotent and runs on every `up` — it generates the JWT
|
||||||
signing key if absent, creates the demo admin in Kratos, and grants it the `admin` role plus
|
signing key if absent, creates the demo admin in Kratos, and grants it the `admin` permission plus
|
||||||
every discovered plugin's declared role names in Keto, so role checks (and any
|
every discovered plugin's declared permission names in Keto, so permission checks (and any
|
||||||
dropped-in plugin) resolve out of the box. The web app waits for Kratos + Keto to be healthy
|
dropped-in plugin) resolve out of the box. The web app waits for Kratos + Keto to be healthy
|
||||||
*and* the bootstrap to finish before starting. **Change the demo admin before production.**
|
*and* the bootstrap to finish before starting. **Change the demo admin before production.**
|
||||||
|
|
||||||
@@ -1513,7 +1517,7 @@ whole handler (an `AsyncLocalStorage`), so logs and traces correlate. Three expl
|
|||||||
|
|
||||||
Every request emits one access line (`method`, `path` — the query is dropped, it can carry
|
Every request emits one access line (`method`, `path` — the query is dropped, it can carry
|
||||||
tokens — `status`, `ms`, `requestId`); login/logout, admin writes (who-did-what), and
|
tokens — `status`, `ms`, `requestId`); login/logout, admin writes (who-did-what), and
|
||||||
missing-role/CSRF rejections log at `info`/`warn`, and the catch-all 500 + the
|
missing-permission/CSRF rejections log at `info`/`warn`, and the catch-all 500 + the
|
||||||
Ory-unreachable re-mint at `error`/`warn`. An inbound W3C `traceparent` is **adopted**, so a
|
Ory-unreachable re-mint at `error`/`warn`. An inbound W3C `traceparent` is **adopted**, so a
|
||||||
request continues a trace started by an upstream proxy/gateway.
|
request continues a trace started by an upstream proxy/gateway.
|
||||||
|
|
||||||
@@ -1625,20 +1629,20 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *.
|
|||||||
|
|
||||||
auth/ Identity, the session-JWT hot path, guards, and the Ory REST clients
|
auth/ Identity, the session-JWT hot path, guards, and the Ory REST clients
|
||||||
jwt.ts JWS signature verify via node:crypto, no jose (decode + verify a compact JWS against one JWK)
|
jwt.ts JWS signature verify via node:crypto, no jose (decode + verify a compact JWS against one JWK)
|
||||||
jwt-middleware.ts resolveSession()/authenticate(): per-request session-JWT verify — key by kid → signature → exp/nbf/iss/aud (clock skew) → ctx.identity/roles; flags a lapsed token for re-mint
|
jwt-middleware.ts resolveSession()/authenticate(): per-request session-JWT verify — key by kid → signature → exp/nbf/iss/aud (clock skew) → ctx.identity/permissions; flags a lapsed token for re-mint
|
||||||
jwks.ts JwksProvider — resolve the verify key by kid; createJwksProvider() picks by scheme: staticJwks (base64) or cachingJwks (file/http: TTL cache + rotation-on-miss reload)
|
jwks.ts JwksProvider — resolve the verify key by kid; createJwksProvider() picks by scheme: staticJwks (base64) or cachingJwks (file/http: TTL cache + rotation-on-miss reload)
|
||||||
gen-jwks.ts generateJwks()/rotateJwks() + CLI (mint · --prepend · --prune): the ES256 session-tokenizer signing JWKS; see JWT signing key & rotation
|
gen-jwks.ts generateJwks()/rotateJwks() + CLI (mint · --prepend · --prune): the ES256 session-tokenizer signing JWKS; see JWT signing key & rotation
|
||||||
login.ts completeLogin()/remintSession(): login completion + TTL re-mint — roles from Keto → metadata_public projection → tokenize → session JWT cookie
|
login.ts completeLogin()/remintSession(): login completion + TTL re-mint — permissions from Keto → metadata_public projection → tokenize → session JWT cookie
|
||||||
guards.ts requireSession()/can()/check(): in-handler authorization — the imperative counterpart to the route role gate; GuardError → 303 /login or 403; check() is the one live Keto "may I?" call
|
guards.ts requireSession()/can()/check(): in-handler authorization — the imperative counterpart to the route permission gate; GuardError → 303 /login or 403; check() is the one live Keto "may I?" call
|
||||||
csrf.ts CSRF for our own POST forms: signed double-submit token — issue/verify, cookie, request gate
|
csrf.ts CSRF for our own POST forms: signed double-submit token — issue/verify, cookie, request gate
|
||||||
denylist.ts Optional instant-revoke denylist: in-memory, auto-evicting; hot path rejects a revoked subject's pre-revoke tokens (REVOCATION_DENYLIST)
|
denylist.ts Optional instant-revoke denylist: in-memory, auto-evicting; hot path rejects a revoked subject's pre-revoke tokens (REVOCATION_DENYLIST)
|
||||||
flow-view.ts buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs
|
flow-view.ts buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs
|
||||||
oauth-login.ts resolveLoginChallenge(): authenticate a Hydra login challenge via the Kratos session → accept, or bounce to /login
|
oauth-login.ts resolveLoginChallenge(): authenticate a Hydra login challenge via the Kratos session → accept, or bounce to /login
|
||||||
oauth-consent.ts resolveConsentChallenge()/acceptConsent()/rejectConsent(): auto-accept first-party, else show the consent screen → grant scopes
|
oauth-consent.ts resolveConsentChallenge()/acceptConsent()/rejectConsent(): auto-accept first-party, else show the consent screen → grant scopes
|
||||||
routes.ts buildAuthRoutes(): the built-in auth/OAuth2 endpoints as named handlers on the internal route table — themed flow pages, /oauth2/* challenges, /auth/complete, POST /logout, /error; only what the wired clients support is registered
|
routes.ts buildAuthRoutes(): the built-in auth/OAuth2 endpoints as named handlers on the internal route table — themed flow pages, /oauth2/* challenges, /auth/complete, POST /logout, /error; only what the wired clients support is registered
|
||||||
bootstrap.ts One-command bootstrap: idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin role in Keto
|
bootstrap.ts One-command bootstrap: idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin permission in Keto
|
||||||
kratos-public.ts createKratosPublic(): Kratos public-API fetch client — self-service flow init/get/submit, browser logout, whoami, session→JWT tokenize
|
kratos-public.ts createKratosPublic(): Kratos public-API fetch client — self-service flow init/get/submit, browser logout, whoami, session→JWT tokenize
|
||||||
kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public update (login role projection)
|
kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public update (login permission projection)
|
||||||
keto-client.ts createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API)
|
keto-client.ts createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API)
|
||||||
hydra-admin.ts createHydraAdmin(): Hydra admin-API fetch client — OAuth2 login + consent challenge get/accept/reject + OAuth2 client CRUD
|
hydra-admin.ts createHydraAdmin(): Hydra admin-API fetch client — OAuth2 login + consent challenge get/accept/reject + OAuth2 client CRUD
|
||||||
fetch-timeout.ts withTimeout(): bound every outbound Ory call — wrap the injected fetch so each request aborts after a deadline unless the caller passed its own signal; server.ts wires it into the Kratos/Keto/Hydra clients
|
fetch-timeout.ts withTimeout(): bound every outbound Ory call — wrap the injected fetch so each request aborts after a deadline unless the caller passed its own signal; server.ts wires it into the Kratos/Keto/Hydra clients
|
||||||
@@ -1648,7 +1652,7 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *.
|
|||||||
plugin-api.ts Stable plugin author barrel — the one module a plugin imports, as `#plugin-api` (definePlugin, ctx/result types, guards, body/CSRF/list-query/paginate helpers, and the ctx.system Ory client types)
|
plugin-api.ts Stable plugin author barrel — the one module a plugin imports, as `#plugin-api` (definePlugin, ctx/result types, guards, body/CSRF/list-query/paginate helpers, and the ctx.system Ory client types)
|
||||||
system.ts SystemCapabilities: the privileged ctx.system surface (Ory admin clients + instant-revoke) a system plugin uses; the host populates it from the wired clients, the admin plugin consumes it
|
system.ts SystemCapabilities: the privileged ctx.system surface (Ory admin clients + instant-revoke) a system plugin uses; the host populates it from the wired clients, the admin plugin consumes it
|
||||||
discovery.ts discoverPlugins(): scan plugins/, import + validate each plugin.ts default export, fail loud at boot
|
discovery.ts discoverPlugins(): scan plugins/, import + validate each plugin.ts default export, fail loud at boot
|
||||||
router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, role gate
|
router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate
|
||||||
hooks.ts runBootHooks()/runRequestHooks()/runResponseHooks(): invoke a plugin's optional lifecycle hooks in discovery order; no sandbox (a throwing hook fails loud), skipped when no plugin declares one
|
hooks.ts runBootHooks()/runRequestHooks()/runResponseHooks(): invoke a plugin's optional lifecycle hooks in discovery order; no sandbox (a throwing hook fails loud), skipped when no plugin declares one
|
||||||
view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.ejs; plugin views can include() core partials
|
view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.ejs; plugin views can include() core partials
|
||||||
|
|
||||||
@@ -1656,19 +1660,19 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *.
|
|||||||
chrome.ts buildPluginChrome(): the one global menu + brand/user/theme/csrf every page renders the shell from (unified across all pages) — exposed on ctx.chrome
|
chrome.ts buildPluginChrome(): the one global menu + brand/user/theme/csrf every page renders the shell from (unified across all pages) — exposed on ctx.chrome
|
||||||
shell-context.ts buildShellContext(): brand/theme/user view-model for the dashboard shell (real signed-in user, no demo profile)
|
shell-context.ts buildShellContext(): brand/theme/user view-model for the dashboard shell (real signed-in user, no demo profile)
|
||||||
dashboard.ts buildDashboardModel(): the gated "/dashboard" app home — a short instructional starter (replace it with a plugin `dashboard` handler); "/" is the public landing (a plugin `home` handler). Both render the one unified menu (ctx.chrome)
|
dashboard.ts buildDashboardModel(): the gated "/dashboard" app home — a short instructional starter (replace it with a plugin `dashboard` handler); "/" is the public landing (a plugin `home` handler). Both render the one unified menu (ctx.chrome)
|
||||||
nav.ts composeNav(): merge plugin nav fragments + central override, role-filter → nav-tree model
|
nav.ts composeNav(): merge plugin nav fragments + central override, permission-filter → nav-tree model
|
||||||
menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding, imported as `#menu-config`), validated at boot
|
menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding, imported as `#menu-config`), validated at boot
|
||||||
icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs)
|
icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs)
|
||||||
list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize }
|
list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize }
|
||||||
paginate.ts paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs
|
paginate.ts paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs
|
||||||
|
|
||||||
views/ Core EJS templates, all in the one app shell: home (public "/" landing), index (instructional /dashboard), auth (themed Kratos flows), oauth-consent (OAuth2 consent), error (flow-error sink → /error), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, landing/flow/consent bodies, menu/popover, theme switch, icon sprite). Domain screens live in plugins, not here — the admin plugin ships its own views/ (incl. its Users/Groups/Roles/Clients + confirm bodies)
|
views/ Core EJS templates, all in the one app shell: home (public "/" landing), index (instructional /dashboard), auth (themed Kratos flows), oauth-consent (OAuth2 consent), error (flow-error sink → /error), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, landing/flow/consent bodies, menu/popover, theme switch, icon sprite). Domain screens live in plugins, not here — the admin plugin ships its own views/ (incl. its Users/Groups/Permissions/Clients + confirm bodies)
|
||||||
public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
|
public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
|
||||||
config/ Drop-in mount point for the central menu override + branding (config/menu.ts). Ships empty (.gitkeep, git-ignored otherwise) — mount your own or copy the template from examples/config/; defaults apply when absent
|
config/ Drop-in mount point for the central menu override + branding (config/menu.ts). Ships empty (.gitkeep, git-ignored otherwise) — mount your own or copy the template from examples/config/; defaults apply when absent
|
||||||
ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — role/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs → /oauth2/*) + storage init (postgres/init/init.sql: one DB per service)
|
ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — permission/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs → /oauth2/*) + storage init (postgres/init/init.sql: one DB per service)
|
||||||
plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in). Ships empty (.gitkeep, git-ignored otherwise) — mount your own; the E2E suites bind-mount the example plugins onto /app/plugins/scheduling and /app/plugins/admin
|
plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in). Ships empty (.gitkeep, git-ignored otherwise) — mount your own; the E2E suites bind-mount the example plugins onto /app/plugins/scheduling and /app/plugins/admin
|
||||||
examples/ Copy-in reference material, mirroring the mount dirs: plugins/scheduling/ (the reference plugin — list/form over an upstream + role-gated nav), plugins/admin/ (the system-admin plugin — Users/Groups/Roles/OAuth2-clients over Ory via ctx.system), both copied into plugins/; and config/menu.ts (the menu/branding template copied into config/); shifts-upstream/ is the dev mock backend the scheduling plugin reads/writes (stand-in for your real service)
|
examples/ Copy-in reference material, mirroring the mount dirs: plugins/scheduling/ (the reference plugin — list/form over an upstream + permission-gated nav), plugins/admin/ (the system-admin plugin — Users/Groups/Permissions/OAuth2-clients over Ory via ctx.system), both copied into plugins/; and config/menu.ts (the menu/branding template copied into config/); shifts-upstream/ is the dev mock backend the scheduling plugin reads/writes (stand-in for your real service)
|
||||||
e2e-tests/ Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint) + oauth-login.spec (OAuth2 login + consent) + full-flow.spec (browser UI: password/SSO login, menu-by-role, admin CRUD, plugin page, logout) + devstack-login.spec (regression: login works from the banner's localhost URL and 127.0.0.1 is canonicalised, on the plain `docker compose up` topology); proxy.ts (same-origin gateway) + mock-oidc.ts (mock SSO provider) back full-flow. e2e-tests/Dockerfile + e2e-tests/compose.{visual,auth,oauth,full,devstack}.yml run them
|
e2e-tests/ Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint) + oauth-login.spec (OAuth2 login + consent) + full-flow.spec (browser UI: password/SSO login, menu-by-permission, admin CRUD, plugin page, logout) + devstack-login.spec (regression: login works from the banner's localhost URL and 127.0.0.1 is canonicalised, on the plain `docker compose up` topology); proxy.ts (same-origin gateway) + mock-oidc.ts (mock SSO provider) back full-flow. e2e-tests/Dockerfile + e2e-tests/compose.{visual,auth,oauth,full,devstack}.yml run them
|
||||||
ci.sh The full CI gate: typecheck → unit tests → every E2E suite, each on a fresh, always-torn-down stack (`bash ci.sh`)
|
ci.sh The full CI gate: typecheck → unit tests → every E2E suite, each on a fresh, always-torn-down stack (`bash ci.sh`)
|
||||||
.gitea/workflows/ Gitea Actions: ci.yml — the full gate (ci.sh) on every branch push except main;
|
.gitea/workflows/ Gitea Actions: ci.yml — the full gate (ci.sh) on every branch push except main;
|
||||||
mirror.yml — force-sync main + tags to the GitHub mirror; see CI/CD
|
mirror.yml — force-sync main + tags to the GitHub mirror; see CI/CD
|
||||||
|
|||||||
+3
-3
@@ -130,9 +130,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local}
|
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
|
||||||
# Base roles for the demo admin; bootstrap also grants every discovered plugin's declared
|
# Base permissions for the demo admin; bootstrap also grants every discovered plugin's declared
|
||||||
# role names (so the reference plugin — and any drop-in — works out of the box).
|
# permission names (so the reference plugin — and any drop-in — works out of the box).
|
||||||
ADMIN_ROLES: ${ADMIN_ROLES:-admin}
|
ADMIN_PERMISSIONS: ${ADMIN_PERMISSIONS:-admin}
|
||||||
APP_URL: ${APP_URL:-http://localhost:3000} # printed in the first-run login banner
|
APP_URL: ${APP_URL:-http://localhost:3000} # printed in the first-run login banner
|
||||||
JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json
|
JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json
|
||||||
KETO_WRITE_URL: http://keto:4467
|
KETO_WRITE_URL: http://keto:4467
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { expect, test } from "@playwright/test";
|
|||||||
const WEB = process.env.BASE_URL ?? "http://web:3000";
|
const WEB = process.env.BASE_URL ?? "http://web:3000";
|
||||||
const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433";
|
const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433";
|
||||||
const KRATOS_ADMIN = process.env.KRATOS_ADMIN_URL ?? "http://kratos:4434";
|
const KRATOS_ADMIN = process.env.KRATOS_ADMIN_URL ?? "http://kratos:4434";
|
||||||
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap; admin role granted in Keto
|
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap; admin permission granted in Keto
|
||||||
const ADMIN_PASSWORD = "admin";
|
const ADMIN_PASSWORD = "admin";
|
||||||
|
|
||||||
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||||
@@ -29,8 +29,8 @@ function relayCookies(res: Response): string {
|
|||||||
.filter((kv) => kv.split("=")[1] !== "")
|
.filter((kv) => kv.split("=")[1] !== "")
|
||||||
.join("; ");
|
.join("; ");
|
||||||
}
|
}
|
||||||
// Read a JWT's claims without verifying (web already verified it; we only inspect exp/roles).
|
// Read a JWT's claims without verifying (web already verified it; we only inspect exp/permissions).
|
||||||
function jwtClaims(jwt: string): { email: string; exp: number; roles: string[]; sub: string } {
|
function jwtClaims(jwt: string): { email: string; exp: number; permissions: string[]; sub: string } {
|
||||||
return JSON.parse(Buffer.from(jwt.split(".")[1]!, "base64url").toString());
|
return JSON.parse(Buffer.from(jwt.split(".")[1]!, "base64url").toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ async function awaitJwtSetCookie(session: string, jwt: string): Promise<string>
|
|||||||
test("an expired session JWT is silently re-minted while Kratos lives, then cleared once it dies", async () => {
|
test("an expired session JWT is silently re-minted while Kratos lives, then cleared once it dies", async () => {
|
||||||
test.setTimeout(90_000); // two short-TTL windows (8s each) + Ory round-trips
|
test.setTimeout(90_000); // two short-TTL windows (8s each) + Ory round-trips
|
||||||
|
|
||||||
// 1. Log in for real, then complete login on web → our session JWT (roles read from Keto).
|
// 1. Log in for real, then complete login on web → our session JWT (permissions read from Keto).
|
||||||
const session = await kratosLogin();
|
const session = await kratosLogin();
|
||||||
const complete = await fetch(`${WEB}/auth/complete`, { headers: { cookie: `plainpages_session=${session}` }, redirect: "manual" });
|
const complete = await fetch(`${WEB}/auth/complete`, { headers: { cookie: `plainpages_session=${session}` }, redirect: "manual" });
|
||||||
expect(complete.status, "auth/complete redirects home").toBe(303);
|
expect(complete.status, "auth/complete redirects home").toBe(303);
|
||||||
@@ -83,7 +83,7 @@ test("an expired session JWT is silently re-minted while Kratos lives, then clea
|
|||||||
const claims1 = jwtClaims(jwt1);
|
const claims1 = jwtClaims(jwt1);
|
||||||
expect(claims1.email).toBe(ADMIN_EMAIL);
|
expect(claims1.email).toBe(ADMIN_EMAIL);
|
||||||
expect(claims1.sub, "sub is the Kratos identity id").toBeTruthy();
|
expect(claims1.sub, "sub is the Kratos identity id").toBeTruthy();
|
||||||
expect(claims1.roles, "roles are projected from Keto").toContain("admin");
|
expect(claims1.permissions, "permissions are projected from Keto").toContain("admin");
|
||||||
|
|
||||||
// 2. Token timeout → refresh: once the 8s TTL lapses, the next request re-mints a fresh JWT.
|
// 2. Token timeout → refresh: once the 8s TTL lapses, the next request re-mints a fresh JWT.
|
||||||
const jwt2Line = await awaitJwtSetCookie(session, jwt1);
|
const jwt2Line = await awaitJwtSetCookie(session, jwt1);
|
||||||
@@ -91,7 +91,7 @@ test("an expired session JWT is silently re-minted while Kratos lives, then clea
|
|||||||
expect(jwt2, "a different token was minted").not.toBe(jwt1);
|
expect(jwt2, "a different token was minted").not.toBe(jwt1);
|
||||||
const claims2 = jwtClaims(jwt2);
|
const claims2 = jwtClaims(jwt2);
|
||||||
expect(claims2.exp, "the new token expires later").toBeGreaterThan(claims1.exp);
|
expect(claims2.exp, "the new token expires later").toBeGreaterThan(claims1.exp);
|
||||||
expect(claims2.roles, "re-mint re-reads roles from Keto").toContain("admin");
|
expect(claims2.permissions, "re-mint re-reads permissions from Keto").toContain("admin");
|
||||||
|
|
||||||
// 3. Kill the Kratos session: now the lapsed token cannot refresh — the cookie is cleared.
|
// 3. Kill the Kratos session: now the lapsed token cannot refresh — the cookie is cleared.
|
||||||
const revoke = await fetch(`${KRATOS_ADMIN}/admin/identities/${claims1.sub}/sessions`, { method: "DELETE" });
|
const revoke = await fetch(`${KRATOS_ADMIN}/admin/identities/${claims1.sub}/sessions`, { method: "DELETE" });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO
|
# Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO
|
||||||
# login, menu filtering by role, users/groups/roles/OAuth2-clients CRUD, a plugin page, logout. A
|
# login, menu filtering by permission, users/groups/permissions/OAuth2-clients CRUD, a plugin page, logout. A
|
||||||
# tiny same-origin gateway (proxy, e2e-tests/proxy.ts) fronts web + Kratos on one host so the browser's cookies
|
# tiny same-origin gateway (proxy, e2e-tests/proxy.ts) fronts web + Kratos on one host so the browser's cookies
|
||||||
# round-trip (ory/kratos/e2e-proxy.yml points Kratos at it); a mock OIDC provider backs the SSO test.
|
# round-trip (ory/kratos/e2e-proxy.yml points Kratos at it); a mock OIDC provider backs the SSO test.
|
||||||
# docker compose -f compose.yml -f e2e-tests/compose.full.yml run --build --rm e2e
|
# docker compose -f compose.yml -f e2e-tests/compose.full.yml run --build --rm e2e
|
||||||
@@ -30,7 +30,7 @@ services:
|
|||||||
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro
|
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro
|
||||||
- ./examples/plugins/admin:/app/plugins/admin:ro
|
- ./examples/plugins/admin:/app/plugins/admin:ro
|
||||||
|
|
||||||
# bootstrap grants the demo admin every discovered plugin's role names, so it needs the
|
# bootstrap grants the demo admin every discovered plugin's permission names, so it needs the
|
||||||
# example plugins present too — else the admin lacks scheduling:read/write and the gated pages 403.
|
# example plugins present too — else the admin lacks scheduling:read/write and the gated pages 403.
|
||||||
bootstrap:
|
bootstrap:
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { randomUUID } from "node:crypto";
|
|||||||
// journey and the standalone SSO test run in parallel (fullyParallel) but stay independent: each
|
// journey and the standalone SSO test run in parallel (fullyParallel) but stay independent: each
|
||||||
// uses its own browser context, and only the SSO test writes the mock-OIDC identity — keep it so
|
// uses its own browser context, and only the SSO test writes the mock-OIDC identity — keep it so
|
||||||
// (no cross-group shared backend writes) or serialise the file if that ever changes.
|
// (no cross-group shared backend writes) or serialise the file if that ever changes.
|
||||||
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap, holds the admin role in Keto
|
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap, holds the admin permission in Keto
|
||||||
const ADMIN_PASSWORD = "admin";
|
const ADMIN_PASSWORD = "admin";
|
||||||
const SSO_EMAIL = "sso-user@plainpages.local"; // minted by the mock OIDC provider on first SSO login
|
const SSO_EMAIL = "sso-user@plainpages.local"; // minted by the mock OIDC provider on first SSO login
|
||||||
const suffix = randomUUID().slice(0, 8); // unique per run so re-runs don't collide on names
|
const suffix = randomUUID().slice(0, 8); // unique per run so re-runs don't collide on names
|
||||||
@@ -36,7 +36,7 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
});
|
});
|
||||||
test.afterAll(async () => { await page.context().close(); });
|
test.afterAll(async () => { await page.context().close(); });
|
||||||
|
|
||||||
test("menu filters by role: an admin sees the gated Admin section + the plugin", async () => {
|
test("menu filters by permission: an admin sees the gated Admin section + the plugin", async () => {
|
||||||
// The signed-in admin holds admin + scheduling:read/write, so both gated sections are present
|
// The signed-in admin holds admin + scheduling:read/write, so both gated sections are present
|
||||||
// in the menu (collapsed by default → assert they're in the DOM, not necessarily visible).
|
// in the menu (collapsed by default → assert they're in the DOM, not necessarily visible).
|
||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
@@ -65,7 +65,7 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
await expect(page.locator("tr", { hasText: email })).toHaveCount(0);
|
await expect(page.locator("tr", { hasText: email })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("groups + roles CRUD: create one of each (writes go to Keto) and see them listed", async () => {
|
test("groups + permissions CRUD: create one of each (writes go to Keto) and see them listed", async () => {
|
||||||
// A Keto set exists only while it has ≥1 member, so create needs a first member (the form
|
// A Keto set exists only while it has ≥1 member, so create needs a first member (the form
|
||||||
// enforces it); pick the first option (a user) from the required picker.
|
// enforces it); pick the first option (a user) from the required picker.
|
||||||
const group = `e2e-grp-${suffix}`;
|
const group = `e2e-grp-${suffix}`;
|
||||||
@@ -76,13 +76,13 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
await expect(page).toHaveURL(/\/admin\/groups(\?|\/|$)/);
|
await expect(page).toHaveURL(/\/admin\/groups(\?|\/|$)/);
|
||||||
await expect(page.locator("main")).toContainText(group);
|
await expect(page.locator("main")).toContainText(group);
|
||||||
|
|
||||||
const role = `e2e-role-${suffix}`;
|
const permission = `e2e-permission-${suffix}`;
|
||||||
await page.goto("/admin/roles/new");
|
await page.goto("/admin/permissions/new");
|
||||||
await page.fill('input[name="name"]', role);
|
await page.fill('input[name="name"]', permission);
|
||||||
await page.locator('select[name="member"]').selectOption({ index: 1 });
|
await page.locator('select[name="member"]').selectOption({ index: 1 });
|
||||||
await page.locator('.form-card button[type="submit"]').click();
|
await page.locator('.form-card button[type="submit"]').click();
|
||||||
await expect(page).toHaveURL(/\/admin\/roles(\?|\/|$)/);
|
await expect(page).toHaveURL(/\/admin\/permissions(\?|\/|$)/);
|
||||||
await expect(page.locator("main")).toContainText(role);
|
await expect(page.locator("main")).toContainText(permission);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("OAuth2 clients CRUD: register a client (writes go to Hydra), see the one-time secret once, then delete it via the confirm step", async () => {
|
test("OAuth2 clients CRUD: register a client (writes go to Hydra), see the one-time secret once, then delete it via the confirm step", async () => {
|
||||||
@@ -153,6 +153,6 @@ test("mocked SSO login: the provider button signs a user in via OIDC", async ({
|
|||||||
await page.locator(".sso-btn").click();
|
await page.locator(".sso-btn").click();
|
||||||
// Mock OIDC auto-approves → Kratos creates the identity → /auth/complete → dashboard, signed in.
|
// Mock OIDC auto-approves → Kratos creates the identity → /auth/complete → dashboard, signed in.
|
||||||
await expect(page.locator(".profile-mail")).toHaveText(SSO_EMAIL);
|
await expect(page.locator(".profile-mail")).toHaveText(SSO_EMAIL);
|
||||||
// A fresh SSO identity holds no roles, so the gated Admin section stays hidden.
|
// A fresh SSO identity holds no permissions, so the gated Admin section stays hidden.
|
||||||
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0);
|
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,19 +13,19 @@ const shot = (page: Page, name: string): Promise<Buffer> =>
|
|||||||
// Sign a session JWT with the committed dev tokenizer key (bind-mounted at /repo/jwks.json), so the
|
// Sign a session JWT with the committed dev tokenizer key (bind-mounted at /repo/jwks.json), so the
|
||||||
// gated dashboard renders for a "signed-in" user without standing up Ory — web verifies it
|
// gated dashboard renders for a "signed-in" user without standing up Ory — web verifies it
|
||||||
// with the same key by `kid`, exactly as it verifies a real Kratos-tokenizer JWT.
|
// with the same key by `kid`, exactly as it verifies a real Kratos-tokenizer JWT.
|
||||||
function devSession(roles: string[] = []): string {
|
function devSession(permissions: string[] = []): string {
|
||||||
const jwk = JSON.parse(readFileSync("/repo/jwks.json", "utf8")).keys[0];
|
const jwk = JSON.parse(readFileSync("/repo/jwks.json", "utf8")).keys[0];
|
||||||
const key = createPrivateKey({ format: "jwk", key: jwk });
|
const key = createPrivateKey({ format: "jwk", key: jwk });
|
||||||
const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url");
|
const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url");
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const input = `${b64({ alg: "ES256", kid: jwk.kid, typ: "JWT" })}.${b64({ email: "demo@plainpages.local", exp: now + 3600, iat: now, roles, sub: "visual-demo" })}`;
|
const input = `${b64({ alg: "ES256", kid: jwk.kid, typ: "JWT" })}.${b64({ email: "demo@plainpages.local", exp: now + 3600, iat: now, permissions, sub: "visual-demo" })}`;
|
||||||
return `${input}.${sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key }).toString("base64url")}`;
|
return `${input}.${sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key }).toString("base64url")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
test.beforeAll(async () => { await mkdir(SHOTS, { recursive: true }); });
|
test.beforeAll(async () => { await mkdir(SHOTS, { recursive: true }); });
|
||||||
|
|
||||||
// The dashboard is gated: a page navigation needs a session. Plant one per test — a plain
|
// The dashboard is gated: a page navigation needs a session. Plant one per test — a plain
|
||||||
// member (no roles) so the gated scheduling nav stays filtered out.
|
// member (no permissions) so the gated scheduling nav stays filtered out.
|
||||||
test.beforeEach(async ({ context }) => {
|
test.beforeEach(async ({ context }) => {
|
||||||
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]);
|
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]);
|
||||||
});
|
});
|
||||||
@@ -99,7 +99,7 @@ test("the public landing at / is ungated and links to sign in + register", async
|
|||||||
await context.clearCookies(); // visit "/" as a logged-out visitor (drop the beforeEach session)
|
await context.clearCookies(); // visit "/" as a logged-out visitor (drop the beforeEach session)
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await expect(page.locator(".landing")).toBeVisible();
|
await expect(page.locator(".landing")).toBeVisible();
|
||||||
// the same app shell every page renders — the menu shows even signed out (role-filtered).
|
// the same app shell every page renders — the menu shows even signed out (permission-filtered).
|
||||||
await expect(page.locator(".sidebar")).toBeVisible();
|
await expect(page.locator(".sidebar")).toBeVisible();
|
||||||
await expect(page.getByRole("link", { name: "Log in" })).toHaveAttribute("href", "/login");
|
await expect(page.getByRole("link", { name: "Log in" })).toHaveAttribute("href", "/login");
|
||||||
await expect(page.getByRole("link", { name: "Create account" })).toHaveAttribute("href", "/registration");
|
await expect(page.getByRole("link", { name: "Create account" })).toHaveAttribute("href", "/registration");
|
||||||
@@ -114,7 +114,7 @@ test("unknown routes serve the 404 page (a real user-facing flow, covered end-to
|
|||||||
});
|
});
|
||||||
|
|
||||||
// The reference plugin (plugins/scheduling) ships discovered in the image. Its public Overview is
|
// The reference plugin (plugins/scheduling) ships discovered in the image. Its public Overview is
|
||||||
// reachable by anyone and its menu header shows for everyone; the shifts list stays role-gated,
|
// reachable by anyone and its menu header shows for everyone; the shifts list stays permission-gated,
|
||||||
// so an anonymous visitor is bounced to sign in. The authenticated list/form flow is the full
|
// so an anonymous visitor is bounced to sign in. The authenticated list/form flow is the full
|
||||||
// E2E (full-flow.spec). Side-effect-free.
|
// E2E (full-flow.spec). Side-effect-free.
|
||||||
test("the reference plugin: public Overview is open to all, the gated Shifts redirects to /login", async ({ page, request }) => {
|
test("the reference plugin: public Overview is open to all, the gated Shifts redirects to /login", async ({ page, request }) => {
|
||||||
@@ -136,7 +136,7 @@ test("the reference plugin: public Overview is open to all, the gated Shifts red
|
|||||||
expect(res.status()).toBe(303);
|
expect(res.status()).toBe(303);
|
||||||
expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts");
|
expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts");
|
||||||
|
|
||||||
// The signed-in member (no scheduling role) sees the public Scheduling → Overview leaf in the nav,
|
// The signed-in member (no scheduling permission) sees the public Scheduling → Overview leaf in the nav,
|
||||||
// but the gated Shifts leaf is filtered out.
|
// but the gated Shifts leaf is filtered out.
|
||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders
|
await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@ across (or bind-mount your own) and restart.
|
|||||||
|
|
||||||
| Path | Copy into | Example of |
|
| Path | Copy into | Example of |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and role-gated nav — built from the core building blocks, holding no state. Imports the host surface as `#plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). |
|
| [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and permission-gated nav — built from the core building blocks, holding no state. Imports the host surface as `#plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). |
|
||||||
| [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Roles / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). |
|
| [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). |
|
||||||
| [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). |
|
| [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). |
|
||||||
| [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `SCHEDULING_UPSTREAM` at the real thing instead. |
|
| [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `SCHEDULING_UPSTREAM` at the real thing instead. |
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// config/ ships empty; mount your own or copy this in. Absent config = built-in defaults.
|
// config/ ships empty; mount your own or copy this in. Absent config = built-in defaults.
|
||||||
//
|
//
|
||||||
// Brand the app and reorder/rename/group/hide nav nodes (by their `id`) across all plugins —
|
// Brand the app and reorder/rename/group/hide nav nodes (by their `id`) across all plugins —
|
||||||
// the override always wins, applied before the per-user role filter. Every field is
|
// the override always wins, applied before the per-user permission filter. Every field is
|
||||||
// optional; delete one to fall back to the default.
|
// optional; delete one to fall back to the default.
|
||||||
// See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README.md (The menu system).
|
// See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README.md (The menu system).
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ export default defineMenu({
|
|||||||
// Operator override (rename → group → order → hide), keyed by node id.
|
// Operator override (rename → group → order → hide), keyed by node id.
|
||||||
override: {
|
override: {
|
||||||
// rename: { people: "Staff" }, // node id → new label
|
// rename: { people: "Staff" }, // node id → new label
|
||||||
// groups: [{ id: "admin", label: "Admin", children: ["users", "roles"] }],
|
// groups: [{ id: "admin", label: "Admin", children: ["users", "permissions"] }],
|
||||||
// order: ["people", "reports"], // top-level order by id
|
// order: ["people", "reports"], // top-level order by id
|
||||||
// hide: ["teams"], // remove nodes (any depth)
|
// hide: ["teams"], // remove nodes (any depth)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Admin — the system-administration plugin
|
# Admin — the system-administration plugin
|
||||||
|
|
||||||
The Users / Groups / Roles / OAuth2-clients screens for running Plainpages itself. These used to be
|
The Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. These used to be
|
||||||
built into the core; they now ship as a **drop-in example plugin** so a fresh clone has no admin GUI
|
built into the core; they now ship as a **drop-in example plugin** so a fresh clone has no admin GUI
|
||||||
until you opt in. Copy this folder into `plugins/` (it keeps the id and mount path `admin`, so the
|
until you opt in. Copy this folder into `plugins/` (it keeps the id and mount path `admin`, so the
|
||||||
screens live at `/admin/*`) and restart:
|
screens live at `/admin/*`) and restart:
|
||||||
@@ -10,7 +10,7 @@ cp -r examples/plugins/admin plugins/admin
|
|||||||
docker compose restart web
|
docker compose restart web
|
||||||
```
|
```
|
||||||
|
|
||||||
The seeded `admin@plainpages.local` already holds the `admin` role, so the section appears in the
|
The seeded `admin@plainpages.local` already holds the `admin` permission, so the section appears in the
|
||||||
menu and the screens work immediately.
|
menu and the screens work immediately.
|
||||||
|
|
||||||
## What it demonstrates — a *system* plugin
|
## What it demonstrates — a *system* plugin
|
||||||
@@ -20,21 +20,21 @@ reference](../scheduling/README.md)). The admin screens instead administer **Pla
|
|||||||
stack**, so they use the privileged **`ctx.system`** surface the host exposes to a system plugin:
|
stack**, so they use the privileged **`ctx.system`** surface the host exposes to a system plugin:
|
||||||
|
|
||||||
- **`ctx.system.kratosAdmin`** — create/edit/deactivate/delete Kratos identities (Users).
|
- **`ctx.system.kratosAdmin`** — create/edit/deactivate/delete Kratos identities (Users).
|
||||||
- **`ctx.system.keto`** — read/write the Keto relationship graph (Groups, Roles).
|
- **`ctx.system.keto`** — read/write the Keto relationship graph (Groups, Permissions).
|
||||||
- **`ctx.system.hydra`** — register/list/delete Ory Hydra OAuth2 clients.
|
- **`ctx.system.hydra`** — register/list/delete Ory Hydra OAuth2 clients.
|
||||||
- **`ctx.system.revoke(sub)`** — the optional instant-revoke hook: a deactivate/delete or a
|
- **`ctx.system.revoke(sub)`** — the optional instant-revoke hook: a deactivate/delete or a
|
||||||
user's role change kills that subject's live tokens at once instead of waiting out the JWT TTL.
|
user's permission change kills that subject's live tokens at once instead of waiting out the JWT TTL.
|
||||||
|
|
||||||
`ctx.system` is populated only when the host wired those services (the dev stack wires Kratos + Keto,
|
`ctx.system` is populated only when the host wired those services (the dev stack wires Kratos + Keto,
|
||||||
and Hydra when configured). Where a capability is absent the screen degrades to a themed 503 rather
|
and Hydra when configured). Where a capability is absent the screen degrades to a themed 503 rather
|
||||||
than crashing — see `admin-shared.ts`. Everything else is an ordinary plugin: folder-discovered,
|
than crashing — see `admin-shared.ts`. Everything else is an ordinary plugin: folder-discovered,
|
||||||
gated per route by `role: "admin"`, rendering the core building blocks in `views/`.
|
gated per route by `permission: "admin"`, rendering the core building blocks in `views/`.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` role, and the
|
- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` permission, and the
|
||||||
route table — one thin handler per method+path, all gated by `role: "admin"`.
|
route table — one thin handler per method+path, all gated by `permission: "admin"`.
|
||||||
- `admin-users.ts` · `admin-groups.ts` · `admin-roles.ts` · `admin-clients.ts` — each a set of pure
|
- `admin-users.ts` · `admin-groups.ts` · `admin-permissions.ts` · `admin-clients.ts` — each a set of pure
|
||||||
view-model builders (unit-tested in the matching `*.test.ts`) plus thin per-route handlers keyed on
|
view-model builders (unit-tested in the matching `*.test.ts`) plus thin per-route handlers keyed on
|
||||||
`ctx.params` (the host extracts `:id`/`:name`), sharing a small `withX` wrapper that resolves the
|
`ctx.params` (the host extracts `:id`/`:name`), sharing a small `withX` wrapper that resolves the
|
||||||
admin gate + the needed `ctx.system` clients once.
|
admin gate + the needed `ctx.system` clients once.
|
||||||
|
|||||||
+39
-39
@@ -1,34 +1,34 @@
|
|||||||
// Built-in Roles admin screen: the pure view-model + Keto builders. A role is a
|
// Built-in Roles admin screen: the pure view-model + Keto builders. A permission is a
|
||||||
// Keto subject set (Role:<name>#members); members are users (subject_id) or groups (subject_set) —
|
// Keto subject set (Permission:<name>#members); members are users (subject_id) or groups (subject_set) —
|
||||||
// "assign roles to users/groups". The "effective access" view flattens a Keto `expand` tree into the
|
// "assign permissions to users/groups". The "effective access" view flattens a Keto `expand` tree into the
|
||||||
// distinct set of users who hold the role directly or transitively via a group. The HTTP
|
// distinct set of users who hold the permission directly or transitively via a group. The HTTP
|
||||||
// routing/gate/CSRF + live Keto/Kratos calls are exercised over HTTP in app.test.ts.
|
// routing/gate/CSRF + live Keto/Kratos calls are exercised over HTTP in app.test.ts.
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { memberView } from "./admin-groups.ts";
|
import { memberView } from "./admin-groups.ts";
|
||||||
import {
|
import {
|
||||||
buildRoleDetailModel,
|
buildPermissionDetailModel,
|
||||||
buildRoleFormModel,
|
buildPermissionFormModel,
|
||||||
buildRolesListModel,
|
buildPermissionsListModel,
|
||||||
expandToEffectiveUsers,
|
expandToEffectiveUsers,
|
||||||
isValidRoleName,
|
isValidRoleName,
|
||||||
roleMemberTuple,
|
permissionGrantTuple,
|
||||||
} from "./admin-roles.ts";
|
} from "./admin-permissions.ts";
|
||||||
import type { ExpandTree, RelationTuple } from "#plugin-api";
|
import type { ExpandTree, RelationTuple } from "#plugin-api";
|
||||||
|
|
||||||
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
|
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
|
||||||
const userTuple = (role: string, n: number): RelationTuple =>
|
const userTuple = (permission: string, n: number): RelationTuple =>
|
||||||
({ namespace: "Role", object: role, relation: "members", subject_id: `identity:${uid(n)}` });
|
({ namespace: "Permission", object: permission, relation: "granted", subject_id: `identity:${uid(n)}` });
|
||||||
const groupTuple = (role: string, group: string): RelationTuple =>
|
const groupTuple = (permission: string, group: string): RelationTuple =>
|
||||||
({ namespace: "Role", object: role, relation: "members", subject_set: { namespace: "Group", object: group, relation: "members" } });
|
({ namespace: "Permission", object: permission, relation: "granted", subject_set: { namespace: "Group", object: group, relation: "members" } });
|
||||||
|
|
||||||
test("isValidRoleName + roleMemberTuple map the form value to a Role tuple over a user/group (else null)", () => {
|
test("isValidRoleName + permissionGrantTuple map the form value to a Permission tuple over a user/group (else null)", () => {
|
||||||
for (const ok of ["admin", "editor", "team-a", "a1_b9"]) assert.equal(isValidRoleName(ok), true, ok);
|
for (const ok of ["admin", "editor", "team-a", "a1_b9"]) assert.equal(isValidRoleName(ok), true, ok);
|
||||||
for (const bad of ["", "Admin", "a b", "-bad", "a".repeat(65)]) assert.equal(isValidRoleName(bad), false, bad);
|
for (const bad of ["", "Admin", "a b", "-bad", "a".repeat(65)]) assert.equal(isValidRoleName(bad), false, bad);
|
||||||
|
|
||||||
assert.deepEqual(roleMemberTuple("editor", `identity:${uid(2)}`), { namespace: "Role", object: "editor", relation: "members", subject_id: `identity:${uid(2)}` });
|
assert.deepEqual(permissionGrantTuple("editor", `identity:${uid(2)}`), { namespace: "Permission", object: "editor", relation: "granted", subject_id: `identity:${uid(2)}` });
|
||||||
assert.deepEqual(roleMemberTuple("editor", "group:eng"), { namespace: "Role", object: "editor", relation: "members", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
|
assert.deepEqual(permissionGrantTuple("editor", "group:eng"), { namespace: "Permission", object: "editor", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
|
||||||
for (const bad of ["", "identity:not-a-uuid", "group:Bad Name", "nope:x"]) assert.equal(roleMemberTuple("editor", bad), null, bad);
|
for (const bad of ["", "identity:not-a-uuid", "group:Bad Name", "nope:x"]) assert.equal(permissionGrantTuple("editor", bad), null, bad);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("expandToEffectiveUsers flattens an expand tree → sorted distinct user ids, transitive through groups", () => {
|
test("expandToEffectiveUsers flattens an expand tree → sorted distinct user ids, transitive through groups", () => {
|
||||||
@@ -43,50 +43,50 @@ test("expandToEffectiveUsers flattens an expand tree → sorted distinct user id
|
|||||||
type: "union",
|
type: "union",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Role", object: "admin", relation: "members" } },
|
tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Permission", object: "admin", relation: "granted" } },
|
||||||
type: "union",
|
type: "union",
|
||||||
};
|
};
|
||||||
assert.deepEqual(expandToEffectiveUsers(tree), [uid(1), uid(2)]);
|
assert.deepEqual(expandToEffectiveUsers(tree), [uid(1), uid(2)]);
|
||||||
assert.deepEqual(expandToEffectiveUsers(null), []);
|
assert.deepEqual(expandToEffectiveUsers(null), []);
|
||||||
assert.deepEqual(expandToEffectiveUsers({ type: "leaf" }), []); // an empty role
|
assert.deepEqual(expandToEffectiveUsers({ type: "leaf" }), []); // an empty permission
|
||||||
});
|
});
|
||||||
|
|
||||||
test("buildRolesListModel filters by search, sorts, paginates; the name links to the detail page", () => {
|
test("buildPermissionsListModel filters by search, sorts, paginates; the name links to the detail page", () => {
|
||||||
const roles = Array.from({ length: 30 }, (_, i) => ({ memberCount: i + 1, name: `role-${String(i).padStart(2, "0")}` }));
|
const permissions = Array.from({ length: 30 }, (_, i) => ({ memberCount: i + 1, name: `permission-${String(i).padStart(2, "0")}` }));
|
||||||
|
|
||||||
const all = buildRolesListModel({ roles, url: "http://x/admin/roles" });
|
const all = buildPermissionsListModel({ permissions, url: "http://x/admin/permissions" });
|
||||||
assert.equal(all.pagination.summary.total, 30);
|
assert.equal(all.pagination.summary.total, 30);
|
||||||
assert.equal(all.table.rows.length, 25); // default page size
|
assert.equal(all.table.rows.length, 25); // default page size
|
||||||
assert.equal(all.title, "Roles");
|
assert.equal(all.title, "Permissions");
|
||||||
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
|
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
|
||||||
assert.equal(first.rowHeader.text, "role-00");
|
assert.equal(first.rowHeader.text, "permission-00");
|
||||||
assert.equal(first.rowHeader.href, "/admin/roles/role-00");
|
assert.equal(first.rowHeader.href, "/admin/permissions/permission-00");
|
||||||
|
|
||||||
const one = buildRolesListModel({ roles, url: "http://x/admin/roles?q=role-07" });
|
const one = buildPermissionsListModel({ permissions, url: "http://x/admin/permissions?q=permission-07" });
|
||||||
assert.equal(one.pagination.summary.total, 1);
|
assert.equal(one.pagination.summary.total, 1);
|
||||||
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
|
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
|
||||||
|
|
||||||
const desc = buildRolesListModel({ roles, url: "http://x/admin/roles?sort=-members" });
|
const desc = buildPermissionsListModel({ permissions, url: "http://x/admin/permissions?sort=-members" });
|
||||||
assert.equal((desc.table.rows[0]!.cells[0] as { rowHeader: { text: string } }).rowHeader.text, "role-29");
|
assert.equal((desc.table.rows[0]!.cells[0] as { rowHeader: { text: string } }).rowHeader.text, "permission-29");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("buildRoleFormModel: a create form with a required name field + member options (user or group)", () => {
|
test("buildPermissionFormModel: a create form with a required name field + member options (user or group)", () => {
|
||||||
const options = [{ label: "ada@example.com", value: `identity:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
|
const options = [{ label: "ada@example.com", value: `identity:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
|
||||||
const m = buildRoleFormModel({ csrfToken: "tok.sig", memberOptions: options });
|
const m = buildPermissionFormModel({ csrfToken: "tok.sig", memberOptions: options });
|
||||||
assert.equal(m.title, "New role");
|
assert.equal(m.title, "New permission");
|
||||||
assert.equal(m.form.action, "/admin/roles");
|
assert.equal(m.form.action, "/admin/permissions");
|
||||||
assert.equal(m.form.submitLabel, "Create role");
|
assert.equal(m.form.submitLabel, "Create permission");
|
||||||
assert.equal(m.form.csrfToken, "tok.sig");
|
assert.equal(m.form.csrfToken, "tok.sig");
|
||||||
assert.equal(m.form.nameField.required, true);
|
assert.equal(m.form.nameField.required, true);
|
||||||
assert.deepEqual(m.form.memberOptions, options);
|
assert.deepEqual(m.form.memberOptions, options);
|
||||||
|
|
||||||
const err = buildRoleFormModel({ error: "That name is taken.", memberOptions: options, values: { member: "group:eng", name: "Admin" } });
|
const err = buildPermissionFormModel({ error: "That name is taken.", memberOptions: options, values: { member: "group:eng", name: "Admin" } });
|
||||||
assert.equal(err.error, "That name is taken.");
|
assert.equal(err.error, "That name is taken.");
|
||||||
assert.equal(err.form.nameField.value, "Admin");
|
assert.equal(err.form.nameField.value, "Admin");
|
||||||
assert.equal(err.form.selectedMember, "group:eng");
|
assert.equal(err.form.selectedMember, "group:eng");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("buildRoleDetailModel: members → rows, add-options exclude current members, effective access listed, actions wired", () => {
|
test("buildPermissionDetailModel: members → rows, add-options exclude current members, effective access listed, actions wired", () => {
|
||||||
const members = [memberView(userTuple("admin", 1), new Map([[uid(1), "ada@example.com"]])), memberView(groupTuple("admin", "eng"), new Map())];
|
const members = [memberView(userTuple("admin", 1), new Map([[uid(1), "ada@example.com"]])), memberView(groupTuple("admin", "eng"), new Map())];
|
||||||
const candidates = [
|
const candidates = [
|
||||||
{ label: "ada@example.com", value: `identity:${uid(1)}` }, // already a member → excluded
|
{ label: "ada@example.com", value: `identity:${uid(1)}` }, // already a member → excluded
|
||||||
@@ -95,12 +95,12 @@ test("buildRoleDetailModel: members → rows, add-options exclude current member
|
|||||||
{ label: "ops (group)", value: "group:ops" },
|
{ label: "ops (group)", value: "group:ops" },
|
||||||
];
|
];
|
||||||
const effective = [{ label: "ada@example.com" }, { label: "grace@example.com" }]; // ada direct, grace via eng
|
const effective = [{ label: "ada@example.com" }, { label: "grace@example.com" }]; // ada direct, grace via eng
|
||||||
const m = buildRoleDetailModel({ candidates, effective, members, role: { name: "admin" } });
|
const m = buildPermissionDetailModel({ candidates, effective, members, permission: { name: "admin" } });
|
||||||
assert.equal(m.title, "admin");
|
assert.equal(m.title, "admin");
|
||||||
assert.equal(m.members.rows.length, 2);
|
assert.equal(m.members.rows.length, 2);
|
||||||
assert.equal(m.members.action, "/admin/roles/admin/members/delete");
|
assert.equal(m.members.action, "/admin/permissions/admin/members/delete");
|
||||||
assert.equal(m.add.action, "/admin/roles/admin/members");
|
assert.equal(m.add.action, "/admin/permissions/admin/members");
|
||||||
assert.deepEqual(m.add.options.map((o) => o.value), [`identity:${uid(2)}`, "group:ops"]);
|
assert.deepEqual(m.add.options.map((o) => o.value), [`identity:${uid(2)}`, "group:ops"]);
|
||||||
assert.deepEqual(m.effective.map((e) => e.label), ["ada@example.com", "grace@example.com"]);
|
assert.deepEqual(m.effective.map((e) => e.label), ["ada@example.com", "grace@example.com"]);
|
||||||
assert.equal(m.delete.action, "/admin/roles/admin/delete");
|
assert.equal(m.delete.action, "/admin/permissions/admin/delete");
|
||||||
});
|
});
|
||||||
+84
-84
@@ -1,15 +1,15 @@
|
|||||||
// Roles admin screen: list / create / delete Keto roles and assign
|
// Permissions admin screen: list / create / delete Keto permissions and assign
|
||||||
// them to users and groups. A role is a Keto subject set `Role:<name>#members` (OPL: members are users
|
// them to users and groups. A permission is a Keto subject set `Permission:<name>#members` (OPL: members are users
|
||||||
// or groups, resolved transitively) — the source of truth for the JWT `roles` claim. It shares the
|
// or groups, resolved transitively) — the source of truth for the JWT `permissions` claim. It shares the
|
||||||
// Groups screen's membership model, so the pure helpers (parseSubject, member pickers, tuple paging)
|
// Groups screen's membership model, so the pure helpers (parseSubject, member pickers, tuple paging)
|
||||||
// are reused from admin-groups. The role-specific piece is the **effective access** view:
|
// are reused from admin-groups. The permission-specific piece is the **effective access** view:
|
||||||
// `keto.expand(Role:<name>#members)` flattened to the distinct users who hold the role directly or via
|
// `keto.expand(Permission:<name>#members)` flattened to the distinct users who hold the permission directly or via
|
||||||
// a group — matching what login projects into the JWT (login.ts readRoles). Writes go only to Keto;
|
// a group — matching what login projects into the JWT (login.ts readPermissions). Writes go only to Keto;
|
||||||
// Kratos is read only to label members. Below the builders are thin per-route handlers (keyed on
|
// Kratos is read only to label members. Below the builders are thin per-route handlers (keyed on
|
||||||
// ctx.params) over a shared `withRoles` gate — admin-only, CSRF-guarded.
|
// ctx.params) over a shared `withRoles` gate — admin-only, CSRF-guarded.
|
||||||
|
|
||||||
import { type ExpandTree, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SessionIdentity } from "#plugin-api";
|
import { type ExpandTree, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SessionIdentity } from "#plugin-api";
|
||||||
import { ADMIN_ROLE, ADMIN_ROLES_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
import { ADMIN_PERMISSION, ADMIN_PERMISSIONS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
||||||
import {
|
import {
|
||||||
type GroupView,
|
type GroupView,
|
||||||
groupsFromTuples,
|
groupsFromTuples,
|
||||||
@@ -23,29 +23,29 @@ import {
|
|||||||
} from "./admin-groups.ts";
|
} from "./admin-groups.ts";
|
||||||
import type { FieldConfig } from "./admin-users.ts";
|
import type { FieldConfig } from "./admin-users.ts";
|
||||||
|
|
||||||
const ROLE_NS = "Role";
|
const PERMISSION_NS = "Permission";
|
||||||
const MEMBERS = "members";
|
const GRANTED = "granted";
|
||||||
const DEFAULT_PAGE_SIZE = 25;
|
const DEFAULT_PAGE_SIZE = 25;
|
||||||
const PAGE_SIZES = [25, 50, 100];
|
const PAGE_SIZES = [25, 50, 100];
|
||||||
// Expand far past any sane group-nesting depth so the effective-access view never silently
|
// Expand far past any sane group-nesting depth so the effective-access view never silently
|
||||||
// under-reports the deepest members (Keto's own default is shallow).
|
// under-reports the deepest members (Keto's own default is shallow).
|
||||||
const EXPAND_MAX_DEPTH = 50;
|
const EXPAND_MAX_DEPTH = 50;
|
||||||
|
|
||||||
// A role and a group share the URL-safe name rule and the user|group membership model.
|
// A permission and a group share the URL-safe name rule and the user|group membership model.
|
||||||
export type RoleView = GroupView;
|
export type PermissionView = GroupView;
|
||||||
export const isValidRoleName = isValidGroupName;
|
export const isValidRoleName = isValidGroupName;
|
||||||
export const rolesFromTuples = groupsFromTuples;
|
export const permissionsFromTuples = groupsFromTuples;
|
||||||
export interface EffectiveUser {
|
export interface EffectiveUser {
|
||||||
label: string; // email (or the raw id when unresolved)
|
label: string; // email (or the raw id when unresolved)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The full membership tuple for assigning/revoking `value` to/from `role` (null if value is invalid).
|
// The full membership tuple for assigning/revoking `value` to/from `permission` (null if value is invalid).
|
||||||
export function roleMemberTuple(role: string, value: string): RelationTuple | null {
|
export function permissionGrantTuple(permission: string, value: string): RelationTuple | null {
|
||||||
const subject = parseSubject(value);
|
const subject = parseSubject(value);
|
||||||
return subject ? { namespace: ROLE_NS, object: role, relation: MEMBERS, ...subject } : null;
|
return subject ? { namespace: PERMISSION_NS, object: permission, relation: GRANTED, ...subject } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flatten a Keto `expand` tree → the sorted, distinct user ids that effectively hold the role
|
// Flatten a Keto `expand` tree → the sorted, distinct user ids that effectively hold the permission
|
||||||
// (direct leaves + users reached through member groups, any depth). The subject rides on each
|
// (direct leaves + users reached through member groups, any depth). The subject rides on each
|
||||||
// node's `tuple`; subject-set nodes (the groups) contribute nothing directly — their members
|
// node's `tuple`; subject-set nodes (the groups) contribute nothing directly — their members
|
||||||
// surface as leaves under them.
|
// surface as leaves under them.
|
||||||
@@ -70,17 +70,17 @@ interface ListState {
|
|||||||
sort: string | null;
|
sort: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SORT: Record<string, (r: RoleView) => number | string> = {
|
const SORT: Record<string, (r: PermissionView) => number | string> = {
|
||||||
members: (r) => r.memberCount,
|
members: (r) => r.memberCount,
|
||||||
name: (r) => r.name,
|
name: (r) => r.name,
|
||||||
};
|
};
|
||||||
const COLUMNS = [
|
const COLUMNS = [
|
||||||
{ key: "name", label: "Role" },
|
{ key: "name", label: "Permission" },
|
||||||
{ key: "members", label: "Members" },
|
{ key: "members", label: "Members" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function detailHref(name: string): string {
|
function detailHref(name: string): string {
|
||||||
return `${ADMIN_ROLES_BASE}/${encodeURIComponent(name)}`;
|
return `${ADMIN_PERMISSIONS_BASE}/${encodeURIComponent(name)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function listHref(state: ListState, overrides: Partial<ListState> = {}): string {
|
function listHref(state: ListState, overrides: Partial<ListState> = {}): string {
|
||||||
@@ -91,12 +91,12 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
|
|||||||
if (s.page > 1) p.set("page", String(s.page));
|
if (s.page > 1) p.set("page", String(s.page));
|
||||||
if (s.pageSize !== DEFAULT_PAGE_SIZE) p.set("pageSize", String(s.pageSize));
|
if (s.pageSize !== DEFAULT_PAGE_SIZE) p.set("pageSize", String(s.pageSize));
|
||||||
const qs = p.toString();
|
const qs = p.toString();
|
||||||
return qs ? `${ADMIN_ROLES_BASE}?${qs}` : ADMIN_ROLES_BASE;
|
return qs ? `${ADMIN_PERMISSIONS_BASE}?${qs}` : ADMIN_PERMISSIONS_BASE;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildRolesListModel(opts: {
|
export function buildPermissionsListModel(opts: {
|
||||||
csrfToken?: string;
|
csrfToken?: string;
|
||||||
roles: RoleView[];
|
permissions: PermissionView[];
|
||||||
url: URL | URLSearchParams | string;
|
url: URL | URLSearchParams | string;
|
||||||
}) {
|
}) {
|
||||||
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
||||||
@@ -104,7 +104,7 @@ export function buildRolesListModel(opts: {
|
|||||||
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
|
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
|
||||||
const needle = query.q.toLowerCase();
|
const needle = query.q.toLowerCase();
|
||||||
|
|
||||||
let list = opts.roles.filter((r) => !needle || r.name.toLowerCase().includes(needle));
|
let list = opts.permissions.filter((r) => !needle || r.name.toLowerCase().includes(needle));
|
||||||
if (sort) {
|
if (sort) {
|
||||||
const get = SORT[sort.field]!;
|
const get = SORT[sort.field]!;
|
||||||
const dir = sort.dir === "desc" ? -1 : 1;
|
const dir = sort.dir === "desc" ? -1 : 1;
|
||||||
@@ -121,17 +121,17 @@ export function buildRolesListModel(opts: {
|
|||||||
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken };
|
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Admin" }, { label: "Roles" }],
|
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Admin" }, { label: "Permissions" }],
|
||||||
filterBar: listFilterBar(state),
|
filterBar: listFilterBar(state),
|
||||||
pagination: listPagination(state, page),
|
pagination: listPagination(state, page),
|
||||||
table: listTable(rows, state, sort),
|
table: listTable(rows, state, sort),
|
||||||
title: "Roles",
|
title: "Permissions",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function listTable(rows: RoleView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null) {
|
function listTable(rows: PermissionView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null) {
|
||||||
return {
|
return {
|
||||||
caption: "Roles",
|
caption: "Permissions",
|
||||||
columns: COLUMNS.map((c) => {
|
columns: COLUMNS.map((c) => {
|
||||||
const dir = sort && sort.field === c.key ? sort.dir : undefined;
|
const dir = sort && sort.field === c.key ? sort.dir : undefined;
|
||||||
const next = dir === "asc" ? `-${c.key}` : c.key;
|
const next = dir === "asc" ? `-${c.key}` : c.key;
|
||||||
@@ -149,11 +149,11 @@ function listFilterBar(state: ListState) {
|
|||||||
if (state.q) pills.push({ label: "Search", remove: listHref(state, { page: 1, q: "" }), value: state.q });
|
if (state.q) pills.push({ label: "Search", remove: listHref(state, { page: 1, q: "" }), value: state.q });
|
||||||
return {
|
return {
|
||||||
applyLabel: "Apply",
|
applyLabel: "Apply",
|
||||||
clearHref: ADMIN_ROLES_BASE,
|
clearHref: ADMIN_PERMISSIONS_BASE,
|
||||||
label: "Filter roles",
|
label: "Filter permissions",
|
||||||
pills,
|
pills,
|
||||||
rows: [[
|
rows: [[
|
||||||
{ label: "Search roles", name: "q", placeholder: "Search role name…", type: "search", value: state.q },
|
{ label: "Search permissions", name: "q", placeholder: "Search permission name…", type: "search", value: state.q },
|
||||||
{ type: "spacer" },
|
{ type: "spacer" },
|
||||||
]],
|
]],
|
||||||
};
|
};
|
||||||
@@ -178,7 +178,7 @@ function listPagination(state: ListState, page: ReturnType<typeof paginate>) {
|
|||||||
|
|
||||||
// ---- create form + detail view models ----
|
// ---- create form + detail view models ----
|
||||||
|
|
||||||
export function buildRoleFormModel(opts: {
|
export function buildPermissionFormModel(opts: {
|
||||||
csrfToken?: string;
|
csrfToken?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
memberOptions: MemberOption[];
|
memberOptions: MemberOption[];
|
||||||
@@ -186,69 +186,69 @@ export function buildRoleFormModel(opts: {
|
|||||||
}) {
|
}) {
|
||||||
const nameField: FieldConfig = {
|
const nameField: FieldConfig = {
|
||||||
autocomplete: "off", hint: "Lowercase letters, digits, dashes and underscores.", icon: "i-shield",
|
autocomplete: "off", hint: "Lowercase letters, digits, dashes and underscores.", icon: "i-shield",
|
||||||
id: "name", label: "Role name", name: "name", required: true, value: opts.values?.name ?? "",
|
id: "name", label: "Permission name", name: "name", required: true, value: opts.values?.name ?? "",
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: "New" }],
|
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Permissions" }, { label: "New" }],
|
||||||
error: opts.error,
|
error: opts.error,
|
||||||
form: {
|
form: {
|
||||||
action: ADMIN_ROLES_BASE,
|
action: ADMIN_PERMISSIONS_BASE,
|
||||||
cancelHref: ADMIN_ROLES_BASE,
|
cancelHref: ADMIN_PERMISSIONS_BASE,
|
||||||
csrfToken: opts.csrfToken ?? "",
|
csrfToken: opts.csrfToken ?? "",
|
||||||
memberOptions: opts.memberOptions,
|
memberOptions: opts.memberOptions,
|
||||||
nameField,
|
nameField,
|
||||||
selectedMember: opts.values?.member ?? "",
|
selectedMember: opts.values?.member ?? "",
|
||||||
submitLabel: "Create role",
|
submitLabel: "Create permission",
|
||||||
},
|
},
|
||||||
title: "New role",
|
title: "New permission",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildRoleDetailModel(opts: {
|
export function buildPermissionDetailModel(opts: {
|
||||||
candidates: MemberOption[];
|
candidates: MemberOption[];
|
||||||
csrfToken?: string;
|
csrfToken?: string;
|
||||||
effective: EffectiveUser[];
|
effective: EffectiveUser[];
|
||||||
error?: string;
|
error?: string;
|
||||||
members: MemberView[];
|
members: MemberView[];
|
||||||
role: { name: string };
|
permission: { name: string };
|
||||||
}) {
|
}) {
|
||||||
const name = opts.role.name;
|
const name = opts.permission.name;
|
||||||
const base = detailHref(name);
|
const base = detailHref(name);
|
||||||
const taken = new Set(opts.members.map((m) => m.subject));
|
const taken = new Set(opts.members.map((m) => m.subject));
|
||||||
const options = opts.candidates.filter((c) => !taken.has(c.value)); // members are users/groups, never the role itself
|
const options = opts.candidates.filter((c) => !taken.has(c.value)); // members are users/groups, never the permission itself
|
||||||
return {
|
return {
|
||||||
add: { action: `${base}/members`, options },
|
add: { action: `${base}/members`, options },
|
||||||
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: name }],
|
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Permissions" }, { label: name }],
|
||||||
csrfToken: opts.csrfToken ?? "",
|
csrfToken: opts.csrfToken ?? "",
|
||||||
delete: { action: `${base}/delete` },
|
delete: { action: `${base}/delete` },
|
||||||
effective: opts.effective,
|
effective: opts.effective,
|
||||||
error: opts.error,
|
error: opts.error,
|
||||||
members: { action: `${base}/members/delete`, rows: opts.members },
|
members: { action: `${base}/members/delete`, rows: opts.members },
|
||||||
role: { name },
|
permission: { name },
|
||||||
title: name,
|
title: name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- request handler (imperative shell) ----
|
// ---- request handler (imperative shell) ----
|
||||||
|
|
||||||
// instant-revoke: a role change for a `identity:<id>` member must take effect now, so revoke that
|
// instant-revoke: a permission change for a `identity:<id>` member must take effect now, so revoke that
|
||||||
// user's live tokens (a re-mint then re-reads roles from Keto). A `group:<name>` change is
|
// user's live tokens (a re-mint then re-reads permissions from Keto). A `group:<name>` change is
|
||||||
// transitive across many users — left to lag (documented), so only direct user members revoke.
|
// transitive across many users — left to lag (documented), so only direct user members revoke.
|
||||||
function revokeUserMember(revoke: ((sub: string) => void) | undefined, member: string): void {
|
function revokeUserMember(revoke: ((sub: string) => void) | undefined, member: string): void {
|
||||||
if (revoke && member.startsWith("identity:")) revoke(member.slice("identity:".length));
|
if (revoke && member.startsWith("identity:")) revoke(member.slice("identity:".length));
|
||||||
}
|
}
|
||||||
|
|
||||||
// A role exists exactly while it has ≥1 member (Keto has no create-object).
|
// A permission exists exactly while it has ≥1 member (Keto has no create-object).
|
||||||
async function roleExists(keto: KetoClient, name: string): Promise<boolean> {
|
async function roleExists(keto: KetoClient, name: string): Promise<boolean> {
|
||||||
const page = await keto.listRelations({ namespace: ROLE_NS, object: name, relation: MEMBERS, pageSize: 1 });
|
const page = await keto.listRelations({ namespace: PERMISSION_NS, object: name, relation: GRANTED, pageSize: 1 });
|
||||||
return page.tuples.length > 0;
|
return page.tuples.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The distinct users who effectively hold the role (expand → flatten → label by email). Skipped for
|
// The distinct users who effectively hold the permission (expand → flatten → label by email). Skipped for
|
||||||
// an empty role (no member tuples) so we don't expand a non-existent Keto object.
|
// an empty permission (no member tuples) so we don't expand a non-existent Keto object.
|
||||||
async function effectiveUsers(keto: KetoClient, name: string, hasMembers: boolean, emailById: Map<string, string>): Promise<EffectiveUser[]> {
|
async function effectiveUsers(keto: KetoClient, name: string, hasMembers: boolean, emailById: Map<string, string>): Promise<EffectiveUser[]> {
|
||||||
if (!hasMembers) return [];
|
if (!hasMembers) return [];
|
||||||
const tree = await keto.expand({ namespace: ROLE_NS, object: name, relation: MEMBERS }, { maxDepth: EXPAND_MAX_DEPTH });
|
const tree = await keto.expand({ namespace: PERMISSION_NS, object: name, relation: GRANTED }, { maxDepth: EXPAND_MAX_DEPTH });
|
||||||
return expandToEffectiveUsers(tree)
|
return expandToEffectiveUsers(tree)
|
||||||
.map((id) => ({ label: emailById.get(id) ?? `identity:${id}` }))
|
.map((id) => ({ label: emailById.get(id) ?? `identity:${id}` }))
|
||||||
.sort((a, b) => a.label.localeCompare(b.label));
|
.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
@@ -268,7 +268,7 @@ function withRoles(inner: (deps: RolesDeps) => Promise<RouteResult>): RouteHandl
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same, plus the validated :name from ctx.params (an invalid role name → themed 404).
|
// Same, plus the validated :name from ctx.params (an invalid permission name → themed 404).
|
||||||
function withRoleName(inner: (deps: RolesDeps, name: string) => Promise<RouteResult>): RouteHandler {
|
function withRoleName(inner: (deps: RolesDeps, name: string) => Promise<RouteResult>): RouteHandler {
|
||||||
return withRoles((deps) => {
|
return withRoles((deps) => {
|
||||||
const name = deps.ctx.params["name"] ?? "";
|
const name = deps.ctx.params["name"] ?? "";
|
||||||
@@ -279,89 +279,89 @@ function withRoleName(inner: (deps: RolesDeps, name: string) => Promise<RouteRes
|
|||||||
|
|
||||||
const roleFormResult = async (deps: RolesDeps, extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
|
const roleFormResult = async (deps: RolesDeps, extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
|
||||||
const { options } = await memberCandidates(deps.keto, deps.kratosAdmin);
|
const { options } = await memberCandidates(deps.keto, deps.kratosAdmin);
|
||||||
return { data: { chrome: deps.ctx.chrome, model: buildRoleFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, ...extra }) }, view: "role-form" };
|
return { data: { chrome: deps.ctx.chrome, model: buildPermissionFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, ...extra }) }, view: "permission-form" };
|
||||||
};
|
};
|
||||||
|
|
||||||
// The role detail (members + effective access). With `error` set it's a 400 (a rejected action).
|
// The permission detail (members + effective access). With `error` set it's a 400 (a rejected action).
|
||||||
const roleDetailResult = async (deps: RolesDeps, name: string, error?: string): Promise<RouteResult> => {
|
const roleDetailResult = async (deps: RolesDeps, name: string, error?: string): Promise<RouteResult> => {
|
||||||
const { emailById, options } = await memberCandidates(deps.keto, deps.kratosAdmin);
|
const { emailById, options } = await memberCandidates(deps.keto, deps.kratosAdmin);
|
||||||
const tuples = await pagedTuples(deps.keto, { namespace: ROLE_NS, object: name, relation: MEMBERS });
|
const tuples = await pagedTuples(deps.keto, { namespace: PERMISSION_NS, object: name, relation: GRANTED });
|
||||||
const members = tuples.map((t) => memberView(t, emailById));
|
const members = tuples.map((t) => memberView(t, emailById));
|
||||||
const effective = await effectiveUsers(deps.keto, name, tuples.length > 0, emailById);
|
const effective = await effectiveUsers(deps.keto, name, tuples.length > 0, emailById);
|
||||||
const result: RouteResult = { data: { chrome: deps.ctx.chrome, model: buildRoleDetailModel({ candidates: options, csrfToken: deps.ctx.chrome.csrfToken, effective, members, role: { name }, ...(error ? { error } : {}) }) }, view: "role-detail" };
|
const result: RouteResult = { data: { chrome: deps.ctx.chrome, model: buildPermissionDetailModel({ candidates: options, csrfToken: deps.ctx.chrome.csrfToken, effective, members, permission: { name }, ...(error ? { error } : {}) }) }, view: "permission-detail" };
|
||||||
return error ? { ...result, status: 400 } : result;
|
return error ? { ...result, status: 400 } : result;
|
||||||
};
|
};
|
||||||
|
|
||||||
// GET /admin/roles — the list.
|
// GET /admin/permissions — the list.
|
||||||
export const rolesList = withRoles(async ({ ctx, keto }) => {
|
export const rolesList = withRoles(async ({ ctx, keto }) => {
|
||||||
const roles = rolesFromTuples(await pagedTuples(keto, { namespace: ROLE_NS, relation: MEMBERS }));
|
const permissions = permissionsFromTuples(await pagedTuples(keto, { namespace: PERMISSION_NS, relation: GRANTED }));
|
||||||
return { data: { chrome: ctx.chrome, model: buildRolesListModel({ csrfToken: ctx.chrome.csrfToken, roles, url: ctx.url }) }, view: "roles" };
|
return { data: { chrome: ctx.chrome, model: buildPermissionsListModel({ csrfToken: ctx.chrome.csrfToken, permissions, url: ctx.url }) }, view: "permissions" };
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /admin/roles — create + assign the first member (a *user* grant revokes their live tokens).
|
// POST /admin/permissions — create + assign the first member (a *user* grant revokes their live tokens).
|
||||||
export const rolesCreate = withRoles(async (deps) => {
|
export const rolesCreate = withRoles(async (deps) => {
|
||||||
const { ctx, keto, revoke, user } = deps;
|
const { ctx, keto, revoke, user } = deps;
|
||||||
const form = (await guardedForm(ctx))!;
|
const form = (await guardedForm(ctx))!;
|
||||||
const name = (form.get("name") ?? "").trim();
|
const name = (form.get("name") ?? "").trim();
|
||||||
const member = (form.get("member") ?? "").trim();
|
const member = (form.get("member") ?? "").trim();
|
||||||
const tuple = roleMemberTuple(name, member);
|
const tuple = permissionGrantTuple(name, member);
|
||||||
const reject = async (error: string): Promise<RouteResult> => ({ ...(await roleFormResult(deps, { error, values: { member, name } })), status: 400 });
|
const reject = async (error: string): Promise<RouteResult> => ({ ...(await roleFormResult(deps, { error, values: { member, name } })), status: 400 });
|
||||||
if (!isValidRoleName(name)) return reject("Role names use lowercase letters, digits, dashes and underscores.");
|
if (!isValidRoleName(name)) return reject("Permission names use lowercase letters, digits, dashes and underscores.");
|
||||||
if (!tuple) return reject("Pick a user or group to assign the role to.");
|
if (!tuple) return reject("Pick a user or group to assign the permission to.");
|
||||||
if (await roleExists(keto, name)) return reject("A role with that name already exists.");
|
if (await roleExists(keto, name)) return reject("A permission with that name already exists.");
|
||||||
await keto.writeTuple(tuple);
|
await keto.writeTuple(tuple);
|
||||||
revokeUserMember(revoke, member);
|
revokeUserMember(revoke, member);
|
||||||
ctx.log.info("admin: role created + first member assigned", { actor: user.id, member, role: name });
|
ctx.log.info("admin: permission created + first member assigned", { actor: user.id, member, permission: name });
|
||||||
return { redirect: detailHref(name) };
|
return { redirect: detailHref(name) };
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /admin/roles/new — the create form.
|
// GET /admin/permissions/new — the create form.
|
||||||
export const rolesNewForm = withRoles((deps) => roleFormResult(deps, {}));
|
export const rolesNewForm = withRoles((deps) => roleFormResult(deps, {}));
|
||||||
|
|
||||||
// GET /admin/roles/:name — the detail (members + effective access via Keto expand).
|
// GET /admin/permissions/:name — the detail (members + effective access via Keto expand).
|
||||||
export const rolesDetail = withRoleName((deps, name) => roleDetailResult(deps, name));
|
export const rolesDetail = withRoleName((deps, name) => roleDetailResult(deps, name));
|
||||||
|
|
||||||
// POST /admin/roles/:name/members — assign a user/group; a *user* grant revokes their live tokens.
|
// POST /admin/permissions/:name/members — assign a user/group; a *user* grant revokes their live tokens.
|
||||||
export const rolesAddMember = withRoleName(async (deps, name) => {
|
export const rolesAddMember = withRoleName(async (deps, name) => {
|
||||||
const { ctx, keto, revoke, user } = deps;
|
const { ctx, keto, revoke, user } = deps;
|
||||||
const form = (await guardedForm(ctx))!;
|
const form = (await guardedForm(ctx))!;
|
||||||
const member = (form.get("member") ?? "").trim();
|
const member = (form.get("member") ?? "").trim();
|
||||||
const tuple = roleMemberTuple(name, member); // the picker only offers real users/groups
|
const tuple = permissionGrantTuple(name, member); // the picker only offers real users/groups
|
||||||
if (tuple) { await keto.writeTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: role assigned", { actor: user.id, member, role: name }); }
|
if (tuple) { await keto.writeTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: permission assigned", { actor: user.id, member, permission: name }); }
|
||||||
return { redirect: detailHref(name) };
|
return { redirect: detailHref(name) };
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /admin/roles/:name/delete — confirm, except the admin role can't be deleted.
|
// GET /admin/permissions/:name/delete — confirm, except the admin permission can't be deleted.
|
||||||
export const rolesDeleteConfirm = withRoleName((deps, name) => {
|
export const rolesDeleteConfirm = withRoleName((deps, name) => {
|
||||||
if (name === ADMIN_ROLE) return roleDetailResult(deps, name, "The admin role can't be deleted — it would remove all admin access.");
|
if (name === ADMIN_PERMISSION) return roleDetailResult(deps, name, "The admin permission can't be deleted — it would remove all admin access.");
|
||||||
const base = detailHref(name);
|
const base = detailHref(name);
|
||||||
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
|
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
|
||||||
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { href: base, label: name }, { label: "Delete" }],
|
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Permissions" }, { href: base, label: name }, { label: "Delete" }],
|
||||||
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete role",
|
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete permission",
|
||||||
message: `Delete role ${name}? This revokes it from everyone it's assigned to.`, title: "Delete role",
|
message: `Delete permission ${name}? This revokes it from everyone it's assigned to.`, title: "Delete permission",
|
||||||
}) }, view: "confirm" });
|
}) }, view: "confirm" });
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /admin/roles/:name/delete — remove every member tuple (a whole-role delete lags per the
|
// POST /admin/permissions/:name/delete — remove every member tuple (a whole-permission delete lags per the
|
||||||
// documented instant-revoke tradeoff; the admin role is protected).
|
// documented instant-revoke tradeoff; the admin permission is protected).
|
||||||
export const rolesDelete = withRoleName(async (deps, name) => {
|
export const rolesDelete = withRoleName(async (deps, name) => {
|
||||||
const { ctx, keto, user } = deps;
|
const { ctx, keto, user } = deps;
|
||||||
await guardedForm(ctx); // CSRF-verify the POST
|
await guardedForm(ctx); // CSRF-verify the POST
|
||||||
if (name === ADMIN_ROLE) return roleDetailResult(deps, name, "The admin role can't be deleted — it would remove all admin access.");
|
if (name === ADMIN_PERMISSION) return roleDetailResult(deps, name, "The admin permission can't be deleted — it would remove all admin access.");
|
||||||
await keto.deleteTuple({ namespace: ROLE_NS, object: name, relation: MEMBERS });
|
await keto.deleteTuple({ namespace: PERMISSION_NS, object: name, relation: GRANTED });
|
||||||
ctx.log.info("admin: role deleted", { actor: user.id, role: name });
|
ctx.log.info("admin: permission deleted", { actor: user.id, permission: name });
|
||||||
return { redirect: ADMIN_ROLES_BASE };
|
return { redirect: ADMIN_PERMISSIONS_BASE };
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /admin/roles/:name/members/delete — unassign; a *user* unassign revokes their live tokens.
|
// POST /admin/permissions/:name/members/delete — unassign; a *user* unassign revokes their live tokens.
|
||||||
// Self-protection: an admin can't revoke their own *direct* admin grant (a group-held admin isn't
|
// Self-protection: an admin can't revoke their own *direct* admin grant (a group-held admin isn't
|
||||||
// covered — the robust "last effective admin" check is deferred).
|
// covered — the robust "last effective admin" check is deferred).
|
||||||
export const rolesRemoveMember = withRoleName(async (deps, name) => {
|
export const rolesRemoveMember = withRoleName(async (deps, name) => {
|
||||||
const { ctx, keto, revoke, user } = deps;
|
const { ctx, keto, revoke, user } = deps;
|
||||||
const form = (await guardedForm(ctx))!;
|
const form = (await guardedForm(ctx))!;
|
||||||
const member = (form.get("member") ?? "").trim();
|
const member = (form.get("member") ?? "").trim();
|
||||||
if (name === ADMIN_ROLE && member === `identity:${user.id}`) return roleDetailResult(deps, name, "You can't revoke your own admin access.");
|
if (name === ADMIN_PERMISSION && member === `identity:${user.id}`) return roleDetailResult(deps, name, "You can't revoke your own admin access.");
|
||||||
const tuple = roleMemberTuple(name, member);
|
const tuple = permissionGrantTuple(name, member);
|
||||||
if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: role unassigned", { actor: user.id, member, role: name }); }
|
if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: permission unassigned", { actor: user.id, member, permission: name }); }
|
||||||
return { redirect: detailHref(name) };
|
return { redirect: detailHref(name) };
|
||||||
});
|
});
|
||||||
@@ -7,10 +7,10 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|||||||
import { Readable } from "node:stream";
|
import { Readable } from "node:stream";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { GuardError, type Log, type PageChrome, type RequestContext, type SessionIdentity } from "#plugin-api";
|
import { GuardError, type Log, type PageChrome, type RequestContext, type SessionIdentity } from "#plugin-api";
|
||||||
import { ADMIN_NAV, ADMIN_ROLE, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts";
|
import { ADMIN_NAV, ADMIN_PERMISSION, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts";
|
||||||
|
|
||||||
const admin: SessionIdentity = { email: "ada@x.io", id: "u1", roles: ["admin"] };
|
const admin: SessionIdentity = { email: "ada@x.io", id: "u1", permissions: ["admin"] };
|
||||||
const member: SessionIdentity = { email: "bo@x.io", id: "u2", roles: ["scheduling:read"] };
|
const member: SessionIdentity = { email: "bo@x.io", id: "u2", permissions: ["scheduling:read"] };
|
||||||
const CHROME = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } } as PageChrome;
|
const CHROME = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } } as PageChrome;
|
||||||
|
|
||||||
function fakeCtx(opts: { body?: string; method?: string; user?: SessionIdentity | null; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
function fakeCtx(opts: { body?: string; method?: string; user?: SessionIdentity | null; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
||||||
@@ -19,7 +19,7 @@ function fakeCtx(opts: { body?: string; method?: string; user?: SessionIdentity
|
|||||||
req.method = opts.method ?? "GET";
|
req.method = opts.method ?? "GET";
|
||||||
return {
|
return {
|
||||||
chrome: CHROME, identity: opts.user ?? null, log: {} as Log, params: {}, query: url.searchParams, req, res: {} as ServerResponse,
|
chrome: CHROME, identity: opts.user ?? null, log: {} as Log, params: {}, query: url.searchParams, req, res: {} as ServerResponse,
|
||||||
roles: opts.user?.roles ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true),
|
permissions: opts.user?.permissions ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,11 +27,11 @@ function fakeCtx(opts: { body?: string; method?: string; user?: SessionIdentity
|
|||||||
|
|
||||||
test("ADMIN_NAV: a gated Admin header over the four screens; no per-request current/open state", () => {
|
test("ADMIN_NAV: a gated Admin header over the four screens; no per-request current/open state", () => {
|
||||||
assert.equal(ADMIN_NAV.id, "admin");
|
assert.equal(ADMIN_NAV.id, "admin");
|
||||||
assert.equal(ADMIN_NAV.role, ADMIN_ROLE); // gate on the header ⇒ composeNav drops the whole subtree for a non-admin
|
assert.equal(ADMIN_NAV.permission, ADMIN_PERMISSION); // gate on the header ⇒ composeNav drops the whole subtree for a non-admin
|
||||||
assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static
|
assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static
|
||||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/roles", "/admin/clients"]);
|
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/permissions", "/admin/clients"]);
|
||||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["Users", "Groups", "Roles", "OAuth2 clients"]);
|
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["Users", "Groups", "Permissions", "OAuth2 clients"]);
|
||||||
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined && c.role === undefined)); // the header's gate covers the subtree
|
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined && c.permission === undefined)); // the header's gate covers the subtree
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- auth gates ----
|
// ---- auth gates ----
|
||||||
|
|||||||
@@ -5,36 +5,36 @@
|
|||||||
|
|
||||||
import { can, CSRF_FIELD, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type SessionIdentity } from "#plugin-api";
|
import { can, CSRF_FIELD, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type SessionIdentity } from "#plugin-api";
|
||||||
|
|
||||||
export const ADMIN_ROLE = "admin"; // the role gating the whole admin section
|
export const ADMIN_PERMISSION = "admin"; // the permission gating the whole admin section
|
||||||
export const ADMIN_USERS_BASE = "/admin/users";
|
export const ADMIN_USERS_BASE = "/admin/users";
|
||||||
export const ADMIN_GROUPS_BASE = "/admin/groups";
|
export const ADMIN_GROUPS_BASE = "/admin/groups";
|
||||||
export const ADMIN_ROLES_BASE = "/admin/roles";
|
export const ADMIN_PERMISSIONS_BASE = "/admin/permissions";
|
||||||
export const ADMIN_CLIENTS_BASE = "/admin/clients";
|
export const ADMIN_CLIENTS_BASE = "/admin/clients";
|
||||||
|
|
||||||
export type AdminScreen = "clients" | "groups" | "roles" | "users";
|
export type AdminScreen = "clients" | "groups" | "permissions" | "users";
|
||||||
|
|
||||||
// The plugin's nav fragment: the gated "Admin" header + its four screens. The host composes it into
|
// The plugin's nav fragment: the gated "Admin" header + its four screens. The host composes it into
|
||||||
// the one global menu, filters per user (the header's `role` drops the whole subtree for a
|
// the one global menu, filters per user (the header's `permission` drops the whole subtree for a
|
||||||
// non-admin), and current-marks the active item — so there is no `current`/`open` state here.
|
// non-admin), and current-marks the active item — so there is no `current`/`open` state here.
|
||||||
export const ADMIN_NAV: NavNode = {
|
export const ADMIN_NAV: NavNode = {
|
||||||
children: [
|
children: [
|
||||||
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "Users" },
|
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "Users" },
|
||||||
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "Groups" },
|
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "Groups" },
|
||||||
{ href: ADMIN_ROLES_BASE, icon: "i-shield", id: "roles", label: "Roles" },
|
{ href: ADMIN_PERMISSIONS_BASE, icon: "i-shield", id: "permissions", label: "Permissions" },
|
||||||
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "OAuth2 clients" },
|
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "OAuth2 clients" },
|
||||||
],
|
],
|
||||||
icon: "i-shield",
|
icon: "i-shield",
|
||||||
id: "admin",
|
id: "admin",
|
||||||
label: "Admin",
|
label: "Admin",
|
||||||
role: ADMIN_ROLE,
|
permission: ADMIN_PERMISSION,
|
||||||
};
|
};
|
||||||
|
|
||||||
// The admin gate: a signed-in admin only. Each route already declares `role: "admin"`, so the
|
// The admin gate: a signed-in admin only. Each route already declares `permission: "admin"`, so the
|
||||||
// host enforces this before the handler runs; this is defence-in-depth and what a direct unit test
|
// host enforces this before the handler runs; this is defence-in-depth and what a direct unit test
|
||||||
// relies on. Returns the (non-null) user for the handler to thread on. GuardError → /login or 403.
|
// relies on. Returns the (non-null) user for the handler to thread on. GuardError → /login or 403.
|
||||||
export function requireAdmin(ctx: RequestContext): SessionIdentity {
|
export function requireAdmin(ctx: RequestContext): SessionIdentity {
|
||||||
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
|
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
|
||||||
if (!can(ctx, ADMIN_ROLE)) throw new GuardError(403, "admin role required");
|
if (!can(ctx, ADMIN_PERMISSION)) throw new GuardError(403, "admin permission required");
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ function readUserInput(form: URLSearchParams): UserInput {
|
|||||||
// the Kratos capability (else a themed 503). Each route below is a thin handler over these.
|
// the Kratos capability (else a themed 503). Each route below is a thin handler over these.
|
||||||
interface UsersDeps { ctx: RequestContext; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: SessionIdentity; }
|
interface UsersDeps { ctx: RequestContext; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: SessionIdentity; }
|
||||||
|
|
||||||
// Resolve the shared deps, then run `inner`. The route's `role: "admin"` already gated at the
|
// Resolve the shared deps, then run `inner`. The route's `permission: "admin"` already gated at the
|
||||||
// host; `requireAdmin` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it.
|
// host; `requireAdmin` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it.
|
||||||
function withUser(inner: (deps: UsersDeps) => Promise<RouteResult>): RouteHandler {
|
function withUser(inner: (deps: UsersDeps) => Promise<RouteResult>): RouteHandler {
|
||||||
return async (ctx) => {
|
return async (ctx) => {
|
||||||
|
|||||||
@@ -9,21 +9,21 @@
|
|||||||
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "#plugin-api";
|
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "#plugin-api";
|
||||||
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
|
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
|
||||||
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsRemoveMember } from "./admin-groups.ts";
|
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsRemoveMember } from "./admin-groups.ts";
|
||||||
import { rolesAddMember, rolesCreate, rolesDelete, rolesDeleteConfirm, rolesDetail, rolesList, rolesNewForm, rolesRemoveMember } from "./admin-roles.ts";
|
import { rolesAddMember, rolesCreate, rolesDelete, rolesDeleteConfirm, rolesDetail, rolesList, rolesNewForm, rolesRemoveMember } from "./admin-permissions.ts";
|
||||||
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
|
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
|
||||||
import { ADMIN_NAV, ADMIN_ROLE } from "./admin-shared.ts";
|
import { ADMIN_NAV, ADMIN_PERMISSION } from "./admin-shared.ts";
|
||||||
|
|
||||||
// Every admin route is gated by the one `admin` role — the host redirects an anonymous visitor
|
// Every admin route is gated by the one `admin` permission — the host redirects an anonymous visitor
|
||||||
// to /login, gives a signed-in non-admin the 403 page, and filters the nav the same way. Handlers are
|
// to /login, gives a signed-in non-admin the 403 page, and filters the nav the same way. Handlers are
|
||||||
// thin and keyed on ctx.params (the host extracts :id / :name), the idiomatic per-route style.
|
// thin and keyed on ctx.params (the host extracts :id / :name), the idiomatic per-route style.
|
||||||
const r = (method: HttpMethod, path: string, handler: RouteHandler): Route => ({ handler, method, path, role: ADMIN_ROLE });
|
const r = (method: HttpMethod, path: string, handler: RouteHandler): Route => ({ handler, method, path, permission: ADMIN_PERMISSION });
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||||
|
|
||||||
nav: [ADMIN_NAV],
|
nav: [ADMIN_NAV],
|
||||||
|
|
||||||
roles: [{ description: "Administer users, groups, roles, and OAuth2 clients", name: ADMIN_ROLE }],
|
permissions: [{ description: "Administer users, groups, permissions, and OAuth2 clients", name: ADMIN_PERMISSION }],
|
||||||
|
|
||||||
routes: [
|
routes: [
|
||||||
// Users
|
// Users
|
||||||
@@ -46,14 +46,14 @@ export default definePlugin({
|
|||||||
r("POST", "/groups/:name/delete", groupsDelete),
|
r("POST", "/groups/:name/delete", groupsDelete),
|
||||||
r("POST", "/groups/:name/members/delete", groupsRemoveMember),
|
r("POST", "/groups/:name/members/delete", groupsRemoveMember),
|
||||||
// Roles
|
// Roles
|
||||||
r("GET", "/roles", rolesList),
|
r("GET", "/permissions", rolesList),
|
||||||
r("POST", "/roles", rolesCreate),
|
r("POST", "/permissions", rolesCreate),
|
||||||
r("GET", "/roles/new", rolesNewForm),
|
r("GET", "/permissions/new", rolesNewForm),
|
||||||
r("GET", "/roles/:name", rolesDetail),
|
r("GET", "/permissions/:name", rolesDetail),
|
||||||
r("POST", "/roles/:name/members", rolesAddMember),
|
r("POST", "/permissions/:name/members", rolesAddMember),
|
||||||
r("GET", "/roles/:name/delete", rolesDeleteConfirm),
|
r("GET", "/permissions/:name/delete", rolesDeleteConfirm),
|
||||||
r("POST", "/roles/:name/delete", rolesDelete),
|
r("POST", "/permissions/:name/delete", rolesDelete),
|
||||||
r("POST", "/roles/:name/members/delete", rolesRemoveMember),
|
r("POST", "/permissions/:name/members/delete", rolesRemoveMember),
|
||||||
// OAuth2 clients
|
// OAuth2 clients
|
||||||
r("GET", "/clients", clientsList),
|
r("GET", "/clients", clientsList),
|
||||||
r("POST", "/clients", clientsCreate),
|
r("POST", "/clients", clientsCreate),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<%#
|
<%#
|
||||||
OAuth2 clients admin list: apps that log in *through* us (Hydra). Same building blocks as
|
OAuth2 clients admin list: apps that log in *through* us (Hydra). Same building blocks as
|
||||||
the Roles screen, around the shell, backed by live Hydra OAuth2 clients (admin-clients.ts).
|
the Permissions screen, around the shell, backed by live Hydra OAuth2 clients (admin-clients.ts).
|
||||||
%><%
|
%><%
|
||||||
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
const filters = include("partials/filter-bar", model.filterBar);
|
const filters = include("partials/filter-bar", model.filterBar);
|
||||||
|
|||||||
+12
-12
@@ -1,13 +1,13 @@
|
|||||||
<%#
|
<%#
|
||||||
Admin role detail body, captured into the shell content slot. Config:
|
Admin permission detail body, captured into the shell content slot. Config:
|
||||||
role { name }
|
permission { name }
|
||||||
members { action, rows: { kind:"group"|"identity", label, subject }[] } action = revoke endpoint
|
members { action, rows: { kind:"group"|"identity", label, subject }[] } action = revoke endpoint
|
||||||
effective { label }[] users who hold the role (expand)
|
effective { label }[] users who hold the permission (expand)
|
||||||
add { action, options: {label,value}[] } action = assign endpoint
|
add { action, options: {label,value}[] } action = assign endpoint
|
||||||
del { action } delete the whole role
|
del { action } delete the whole permission
|
||||||
csrfToken, error?
|
csrfToken, error?
|
||||||
%><%
|
%><%
|
||||||
const role = locals.role;
|
const permission = locals.permission;
|
||||||
const members = locals.members;
|
const members = locals.members;
|
||||||
const effective = locals.effective;
|
const effective = locals.effective;
|
||||||
const add = locals.add;
|
const add = locals.add;
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
<section class="form-card" aria-labelledby="members-h">
|
<section class="form-card" aria-labelledby="members-h">
|
||||||
<h2 class="card-title" id="members-h">Assigned to</h2>
|
<h2 class="card-title" id="members-h">Assigned to</h2>
|
||||||
<% if (members.rows.length) { -%>
|
<% if (members.rows.length) { -%>
|
||||||
<div class="table-wrap"><table class="table"><caption class="sr-only">Members of <%= role.name %></caption><thead><tr><th scope="col">Member</th><th scope="col">Type</th><th class="col-actions" scope="col"><span class="sr-only">Actions</span></th></tr></thead><tbody>
|
<div class="table-wrap"><table class="table"><caption class="sr-only">Members of <%= permission.name %></caption><thead><tr><th scope="col">Member</th><th scope="col">Type</th><th class="col-actions" scope="col"><span class="sr-only">Actions</span></th></tr></thead><tbody>
|
||||||
<% members.rows.forEach((m) => { -%>
|
<% members.rows.forEach((m) => { -%>
|
||||||
<tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? "Group" : "User" %></span></td><td class="col-actions"><form method="post" action="<%= members.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg>Revoke</button></form></td></tr>
|
<tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? "Group" : "User" %></span></td><td class="col-actions"><form method="post" action="<%= members.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg>Revoke</button></form></td></tr>
|
||||||
<% }) -%>
|
<% }) -%>
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
</section>
|
</section>
|
||||||
<section class="form-card" aria-labelledby="effective-h">
|
<section class="form-card" aria-labelledby="effective-h">
|
||||||
<h2 class="card-title" id="effective-h">Effective access</h2>
|
<h2 class="card-title" id="effective-h">Effective access</h2>
|
||||||
<p class="field-hint">Everyone who holds this role — directly or through a group (resolved by Keto).</p>
|
<p class="field-hint">Everyone who holds this permission — directly or through a group (resolved by Keto).</p>
|
||||||
<% if (effective.length) { -%>
|
<% if (effective.length) { -%>
|
||||||
<ul class="plain-list">
|
<ul class="plain-list">
|
||||||
<% effective.forEach((u) => { -%>
|
<% effective.forEach((u) => { -%>
|
||||||
@@ -40,18 +40,18 @@
|
|||||||
<% }) -%>
|
<% }) -%>
|
||||||
</ul>
|
</ul>
|
||||||
<% } else { -%>
|
<% } else { -%>
|
||||||
<p class="cell-muted">No users hold this role yet.</p>
|
<p class="cell-muted">No users hold this permission yet.</p>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
</section>
|
</section>
|
||||||
<section class="form-card" aria-labelledby="add-h">
|
<section class="form-card" aria-labelledby="add-h">
|
||||||
<h2 class="card-title" id="add-h">Assign the role</h2>
|
<h2 class="card-title" id="add-h">Assign the permission</h2>
|
||||||
<% if (add.options.length) { -%>
|
<% if (add.options.length) { -%>
|
||||||
<form class="inline-form" method="post" action="<%= add.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member">Member</label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected>Choose a user or group…</option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Assign</button></form>
|
<form class="inline-form" method="post" action="<%= add.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member">Member</label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected>Choose a user or group…</option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Assign</button></form>
|
||||||
<% } else { -%>
|
<% } else { -%>
|
||||||
<p class="cell-muted">All users and groups already have this role.</p>
|
<p class="cell-muted">All users and groups already have this permission.</p>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
</section>
|
</section>
|
||||||
<section class="form-card admin-actions" aria-label="Role actions">
|
<section class="form-card admin-actions" aria-label="Permission actions">
|
||||||
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete role</a>
|
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete permission</a>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
<%#
|
<%#
|
||||||
Admin role create form body, captured into the shell content slot. Config:
|
Admin permission create form body, captured into the shell content slot. Config:
|
||||||
form { action, csrfToken, submitLabel, cancelHref, nameField: field.ejs config,
|
form { action, csrfToken, submitLabel, cancelHref, nameField: field.ejs config,
|
||||||
memberOptions: {label,value}[], selectedMember }
|
memberOptions: {label,value}[], selectedMember }
|
||||||
error? string shown when a write was rejected
|
error? string shown when a write was rejected
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="member">Assign to</label>
|
<label for="member">Assign to</label>
|
||||||
<span class="select"><select id="member" name="member" required><option value="" disabled<% if (!form.selectedMember) { %> selected<% } %>>Choose a user or group…</option><% form.memberOptions.forEach((o) => { %><option value="<%= o.value %>"<% if (form.selectedMember === o.value) { %> selected<% } %>><%= o.label %></option><% }) %></select></span>
|
<span class="select"><select id="member" name="member" required><option value="" disabled<% if (!form.selectedMember) { %> selected<% } %>>Choose a user or group…</option><% form.memberOptions.forEach((o) => { %><option value="<%= o.value %>"<% if (form.selectedMember === o.value) { %> selected<% } %>><%= o.label %></option><% }) %></select></span>
|
||||||
<span class="field-hint">A role exists once assigned; add more users or groups after creating it.</span>
|
<span class="field-hint">A permission exists once assigned; add more users or groups after creating it.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
|
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<%#
|
||||||
|
Permission admin detail page: the permission-detail body (members · effective access) in the shell.
|
||||||
|
%><%
|
||||||
|
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
|
const body = include("partials/permission-detail-body", { add: model.add, csrfToken: model.csrfToken, del: model.delete, effective: model.effective, error: model.error, members: model.members, permission: model.permission });
|
||||||
|
-%>
|
||||||
|
<%- include("partials/shell", {
|
||||||
|
body,
|
||||||
|
brand: chrome.brand,
|
||||||
|
breadcrumbs: model.breadcrumbs,
|
||||||
|
csrfToken: chrome.csrfToken,
|
||||||
|
nav,
|
||||||
|
theme: chrome.theme,
|
||||||
|
title: model.title,
|
||||||
|
user: chrome.user,
|
||||||
|
}) %>
|
||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
<%#
|
<%#
|
||||||
Role admin create page: the role-form body captured into the app shell.
|
Permission admin create page: the permission-form body captured into the app shell.
|
||||||
%><%
|
%><%
|
||||||
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
const body = include("partials/role-form-body", { error: model.error, form: model.form });
|
const body = include("partials/permission-form-body", { error: model.error, form: model.form });
|
||||||
-%>
|
-%>
|
||||||
<%- include("partials/shell", {
|
<%- include("partials/shell", {
|
||||||
body,
|
body,
|
||||||
+3
-3
@@ -1,12 +1,12 @@
|
|||||||
<%#
|
<%#
|
||||||
Roles admin list: the same building blocks as the Groups screen, around the shell, backed
|
Permissions admin list: the same building blocks as the Groups screen, around the shell, backed
|
||||||
by live Keto Role subject sets (admin-roles.ts). Filter/sort/page round-trip the URL.
|
by live Keto Permission subject sets (admin-permissions.ts). Filter/sort/page round-trip the URL.
|
||||||
%><%
|
%><%
|
||||||
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
const filters = include("partials/filter-bar", model.filterBar);
|
const filters = include("partials/filter-bar", model.filterBar);
|
||||||
const table = include("partials/data-table", model.table);
|
const table = include("partials/data-table", model.table);
|
||||||
const pager = include("partials/pagination", model.pagination);
|
const pager = include("partials/pagination", model.pagination);
|
||||||
const actions = '<a class="btn btn-primary" href="/admin/roles/new"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Add role</a>';
|
const actions = '<a class="btn btn-primary" href="/admin/permissions/new"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Add permission</a>';
|
||||||
-%>
|
-%>
|
||||||
<%- include("partials/shell", {
|
<%- include("partials/shell", {
|
||||||
actions,
|
actions,
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<%#
|
|
||||||
Role admin detail page: the role-detail body (members · effective access) in the shell.
|
|
||||||
%><%
|
|
||||||
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
|
||||||
const body = include("partials/role-detail-body", { add: model.add, csrfToken: model.csrfToken, del: model.delete, effective: model.effective, error: model.error, members: model.members, role: model.role });
|
|
||||||
-%>
|
|
||||||
<%- include("partials/shell", {
|
|
||||||
body,
|
|
||||||
brand: chrome.brand,
|
|
||||||
breadcrumbs: model.breadcrumbs,
|
|
||||||
csrfToken: chrome.csrfToken,
|
|
||||||
nav,
|
|
||||||
theme: chrome.theme,
|
|
||||||
title: model.title,
|
|
||||||
user: chrome.user,
|
|
||||||
}) %>
|
|
||||||
@@ -15,7 +15,7 @@ What it demonstrates:
|
|||||||
`POST /scheduling/shifts` CSRF-verifies it (`ctx.verifyCsrf`) and forwards the create upstream,
|
`POST /scheduling/shifts` CSRF-verifies it (`ctx.verifyCsrf`) and forwards the create upstream,
|
||||||
then POST-redirect-GET. The form body lives in the plugin's own `views/partials/shift-form.ejs`,
|
then POST-redirect-GET. The form body lives in the plugin's own `views/partials/shift-form.ejs`,
|
||||||
reusing the core `field` partial.
|
reusing the core `field` partial.
|
||||||
- **Role-gated nav** — the "Shifts" nav leaf and routes are gated on `scheduling:read` /
|
- **Permission-gated nav** — the "Shifts" nav leaf and routes are gated on `scheduling:read` /
|
||||||
`scheduling:write`; the whole "Scheduling" section is invisible to anyone without the grant.
|
`scheduling:write`; the whole "Scheduling" section is invisible to anyone without the grant.
|
||||||
|
|
||||||
The plugin holds **no state** — data lives upstream (README → *Stateless*). Handlers are thin and
|
The plugin holds **no state** — data lives upstream (README → *Stateless*). Handlers are thin and
|
||||||
@@ -46,6 +46,6 @@ cosmetically) — normalise to your backend's format there if it matters.
|
|||||||
|
|
||||||
## Granting access
|
## Granting access
|
||||||
|
|
||||||
A user sees Scheduling once they hold the `scheduling:read` role in Keto (and `scheduling:write`
|
A user sees Scheduling once they hold the `scheduling:read` permission in Keto (and `scheduling:write`
|
||||||
to create). The one-command bootstrap grants both to the demo admin, so the seeded
|
to create). The one-command bootstrap grants both to the demo admin, so the seeded
|
||||||
`admin@plainpages.local` can use it immediately.
|
`admin@plainpages.local` can use it immediately.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Reference plugin: a worked example of the contract — a list page that fetches upstream
|
// Reference plugin: a worked example of the contract — a list page that fetches upstream
|
||||||
// data, a CSRF-guarded form that forwards a write upstream, and role-gated nav. Copy this
|
// data, a CSRF-guarded form that forwards a write upstream, and permission-gated nav. Copy this
|
||||||
// folder, rename it, point it at your own backend. Full contract: README.md → Building plugins.
|
// folder, rename it, point it at your own backend. Full contract: README.md → Building plugins.
|
||||||
|
|
||||||
import { definePlugin } from "#plugin-api";
|
import { definePlugin } from "#plugin-api";
|
||||||
@@ -23,7 +23,7 @@ export default definePlugin({
|
|||||||
nav: [{
|
nav: [{
|
||||||
children: [
|
children: [
|
||||||
{ href: SCHEDULING_PATH, id: "scheduling:overview", label: "Overview", public: true },
|
{ href: SCHEDULING_PATH, id: "scheduling:overview", label: "Overview", public: true },
|
||||||
{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "Shifts", role: READ },
|
{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "Shifts", permission: READ },
|
||||||
],
|
],
|
||||||
icon: "i-cal",
|
icon: "i-cal",
|
||||||
id: "scheduling",
|
id: "scheduling",
|
||||||
@@ -31,17 +31,17 @@ export default definePlugin({
|
|||||||
}],
|
}],
|
||||||
|
|
||||||
// Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
|
// Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
|
||||||
roles: [
|
permissions: [
|
||||||
{ description: "View shifts", name: READ },
|
{ description: "View shifts", name: READ },
|
||||||
{ description: "Create and edit shifts", name: WRITE },
|
{ description: "Create and edit shifts", name: WRITE },
|
||||||
],
|
],
|
||||||
|
|
||||||
// Mounted under /scheduling; `role` gates before the handler runs. The overview is `public`
|
// Mounted under /scheduling; `permission` gates before the handler runs. The overview is `public`
|
||||||
// (anyone may reach /scheduling, signed in or not); the rest need a role.
|
// (anyone may reach /scheduling, signed in or not); the rest need a permission.
|
||||||
routes: [
|
routes: [
|
||||||
{ handler: overview(), method: "GET", path: "/", public: true },
|
{ handler: overview(), method: "GET", path: "/", public: true },
|
||||||
{ handler: listShifts(upstream), method: "GET", path: "/shifts", role: READ },
|
{ handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ },
|
||||||
{ handler: newShiftForm(), method: "GET", path: "/shifts/new", role: WRITE },
|
{ handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE },
|
||||||
{ handler: createShift(upstream), method: "POST", path: "/shifts", role: WRITE },
|
{ handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ import {
|
|||||||
|
|
||||||
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } };
|
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } };
|
||||||
|
|
||||||
function fakeCtx(opts: { body?: string; roles?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
||||||
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
|
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
|
||||||
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
||||||
return {
|
return {
|
||||||
chrome: CHROME, identity: null, log: new Log("none"), params: {}, query: url.searchParams, req, res: {} as ServerResponse,
|
chrome: CHROME, identity: null, log: new Log("none"), params: {}, query: url.searchParams, req, res: {} as ServerResponse,
|
||||||
roles: opts.roles ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true),
|
permissions: opts.permissions ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,8 +93,8 @@ test("readInput trims; validate requires title + assignee", () => {
|
|||||||
|
|
||||||
// ---- list handler ----
|
// ---- list handler ----
|
||||||
|
|
||||||
test("listShifts renders the upstream rows; q filters; canWrite reflects the role", async () => {
|
test("listShifts renders the upstream rows; q filters; canWrite reflects the permission", async () => {
|
||||||
const r = asView(await listShifts(fakeUpstream())(fakeCtx({ roles: ["scheduling:write"] })));
|
const r = asView(await listShifts(fakeUpstream())(fakeCtx({ permissions: ["scheduling:write"] })));
|
||||||
assert.equal(r.view, "shifts");
|
assert.equal(r.view, "shifts");
|
||||||
const table = r.data["table"] as { rows: { name: string }[] };
|
const table = r.data["table"] as { rows: { name: string }[] };
|
||||||
assert.deepEqual(table.rows.map((x) => x.name), ["Morning desk", "Afternoon support"]);
|
assert.deepEqual(table.rows.map((x) => x.name), ["Morning desk", "Afternoon support"]);
|
||||||
@@ -112,15 +112,15 @@ test("listShifts degrades to a recoverable error page when the upstream is down
|
|||||||
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []);
|
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- public overview handler (a page anyone can reach, gated data stays behind the role) ----
|
// ---- public overview handler (a page anyone can reach, gated data stays behind the permission) ----
|
||||||
|
|
||||||
test("overview renders a public page for anyone; it links straight to Shifts only for a reader", async () => {
|
test("overview renders a public page for anyone; it links straight to Shifts only for a reader", async () => {
|
||||||
const anon = asView(await overview()(fakeCtx())); // user null, no roles
|
const anon = asView(await overview()(fakeCtx())); // user null, no permissions
|
||||||
assert.equal(anon.view, "overview");
|
assert.equal(anon.view, "overview");
|
||||||
assert.equal(anon.data["chrome"], CHROME);
|
assert.equal(anon.data["chrome"], CHROME);
|
||||||
assert.equal(anon.data["canRead"], false); // anonymous → prompt to sign in, no shifts link
|
assert.equal(anon.data["canRead"], false); // anonymous → prompt to sign in, no shifts link
|
||||||
|
|
||||||
const reader = asView(await overview()(fakeCtx({ roles: ["scheduling:read"] })));
|
const reader = asView(await overview()(fakeCtx({ permissions: ["scheduling:read"] })));
|
||||||
assert.equal(reader.data["canRead"], true); // a reader gets a link straight to the shifts list
|
assert.equal(reader.data["canRead"], true); // a reader gets a link straight to the shifts list
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import { can, CSRF_FIELD, GuardError, type PageChrome, parseListQuery, readFormB
|
|||||||
|
|
||||||
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page
|
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page
|
||||||
export const SHIFTS_PATH = "/scheduling/shifts";
|
export const SHIFTS_PATH = "/scheduling/shifts";
|
||||||
export const READ = "scheduling:read"; // the role gating the list + nav
|
export const READ = "scheduling:read"; // the permission gating the list + nav
|
||||||
export const WRITE = "scheduling:write"; // the role gating create
|
export const WRITE = "scheduling:write"; // the permission gating create
|
||||||
|
|
||||||
export interface Shift {
|
export interface Shift {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -188,7 +188,7 @@ export function newShiftForm(): RouteHandler {
|
|||||||
// Public overview: a page anyone may reach — its route + nav node are marked `public`, so the
|
// Public overview: a page anyone may reach — its route + nav node are marked `public`, so the
|
||||||
// gate lets an anonymous visitor through and the menu option shows for everyone. The real data
|
// gate lets an anonymous visitor through and the menu option shows for everyone. The real data
|
||||||
// (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone
|
// (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone
|
||||||
// else a prompt to sign in. ctx.identity may be null here, so read the role via can() (zero I/O).
|
// else a prompt to sign in. ctx.identity may be null here, so read the permission via can() (zero I/O).
|
||||||
export function overview(): RouteHandler {
|
export function overview(): RouteHandler {
|
||||||
return (ctx) => ({
|
return (ctx) => ({
|
||||||
data: { breadcrumbs: [{ label: "Overview" }], canRead: can(ctx, READ), chrome: ctx.chrome, shiftsHref: SHIFTS_PATH, title: "Scheduling" },
|
data: { breadcrumbs: [{ label: "Overview" }], canRead: can(ctx, READ), chrome: ctx.chrome, shiftsHref: SHIFTS_PATH, title: "Scheduling" },
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
-%>
|
-%>
|
||||||
<%- include("partials/shell", {
|
<%- include("partials/shell", {
|
||||||
actions: "",
|
actions: "",
|
||||||
body: '<div class="scheduling-page"><p>Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> role.</p>' + cta + '</div>',
|
body: '<div class="scheduling-page"><p>Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> permission.</p>' + cta + '</div>',
|
||||||
brand: chrome.brand,
|
brand: chrome.brand,
|
||||||
breadcrumbs,
|
breadcrumbs,
|
||||||
csrfToken: chrome.csrfToken,
|
csrfToken: chrome.csrfToken,
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# Ory Keto — authorization (ReBAC), the source of truth for roles/groups and the rare
|
# Ory Keto — authorization (ReBAC), the source of truth for permissions/groups and the rare
|
||||||
# fine-grained check (README: three tiers of "may I?"). The permission model lives in
|
# fine-grained check (README: three tiers of "may I?"). The permission model lives in
|
||||||
# namespaces.keto.ts (OPL); DSN comes from the env (the per-service keto DB). The web
|
# namespaces.keto.ts (OPL); DSN comes from the env (the per-service keto DB). The web
|
||||||
# app never connects directly — it calls the read (4466) / write (4467) APIs, the ports
|
# app never connects directly — it calls the read (4466) / write (4467) APIs, the ports
|
||||||
|
|||||||
@@ -7,26 +7,27 @@ import { Context, Namespace, SubjectSet } from "@ory/keto-namespace-types"
|
|||||||
// A Kratos identity. Subjects are written as `identity:<kratos-identity-id>`.
|
// A Kratos identity. Subjects are written as `identity:<kratos-identity-id>`.
|
||||||
class Identity implements Namespace {}
|
class Identity implements Namespace {}
|
||||||
|
|
||||||
// A subject set: a named collection of users (and nested groups), resolved transitively.
|
// A named set of identities (and nested groups), resolved transitively. The admin "Groups"
|
||||||
// The admin "Groups" screen manages membership; checks expand it automatically.
|
// screen manages membership; checks expand it automatically.
|
||||||
class Group implements Namespace {
|
class Group implements Namespace {
|
||||||
related: {
|
related: {
|
||||||
members: (Identity | SubjectSet<Group, "members">)[]
|
members: (Identity | SubjectSet<Group, "members">)[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A coarse role — the source of truth for the JWT `roles` claim. At login the app reads
|
// A coarse permission — an operation a route or menu item gates on, and the source of truth
|
||||||
// `Role:<name>#members@identity:<id>` from Keto and projects the result into the token
|
// for the JWT `permissions` claim. At login the app reads `Permission:<name>#granted@identity:<id>`
|
||||||
// (README: Login → session JWT). A group can hold a role, so members can be users or groups.
|
// from Keto and projects the result into the token (README: Login → session JWT). A group can
|
||||||
class Role implements Namespace {
|
// hold a permission, so grants go to an identity or to a whole group.
|
||||||
|
class Permission implements Namespace {
|
||||||
related: {
|
related: {
|
||||||
members: (Identity | SubjectSet<Group, "members">)[]
|
granted: (Identity | SubjectSet<Group, "members">)[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A fine-grained, relationship-checked resource — README's third "may I?" tier, the rare
|
// A fine-grained, relationship-checked resource — README's third "may I?" tier, the rare
|
||||||
// live Keto check (e.g. sharing/delegation). Permissions nest: owner ⊇ editor ⊇ viewer.
|
// live Keto check (e.g. sharing/delegation). Permits nest: owner ⊇ editor ⊇ viewer.
|
||||||
// Grants accept a user directly or any member of a group.
|
// Grants accept an identity directly or any member of a group.
|
||||||
class Resource implements Namespace {
|
class Resource implements Namespace {
|
||||||
related: {
|
related: {
|
||||||
owners: (Identity | SubjectSet<Group, "members">)[]
|
owners: (Identity | SubjectSet<Group, "members">)[]
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ selfservice:
|
|||||||
ui_url: http://localhost:3000/login
|
ui_url: http://localhost:3000/login
|
||||||
after:
|
after:
|
||||||
# After authenticating, land on our completion route — it mints the session JWT
|
# After authenticating, land on our completion route — it mints the session JWT
|
||||||
# (roles from Keto → metadata_public projection → tokenize) and sets our cookie.
|
# (permissions from Keto → metadata_public projection → tokenize) and sets our cookie.
|
||||||
default_browser_return_url: http://localhost:3000/auth/complete
|
default_browser_return_url: http://localhost:3000/auth/complete
|
||||||
registration:
|
registration:
|
||||||
ui_url: http://localhost:3000/registration
|
ui_url: http://localhost:3000/registration
|
||||||
@@ -94,7 +94,7 @@ session:
|
|||||||
same_site: Lax
|
same_site: Lax
|
||||||
# Session→JWT tokenizer: whoami(tokenize_as: plainpages) mints a short-lived,
|
# Session→JWT tokenizer: whoami(tokenize_as: plainpages) mints a short-lived,
|
||||||
# locally-verifiable JWT so the hot path never calls Ory. Claims come from the
|
# locally-verifiable JWT so the hot path never calls Ory. Claims come from the
|
||||||
# committed Jsonnet mapper (sub = identity id, email from traits, roles from the
|
# committed Jsonnet mapper (sub = identity id, email from traits, permissions from the
|
||||||
# metadata_public projection); signed with tokenizer/jwks.json.
|
# metadata_public projection); signed with tokenizer/jwks.json.
|
||||||
whoami:
|
whoami:
|
||||||
tokenizer:
|
tokenizer:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Session→JWT claims mapper for the `plainpages` tokenizer. Kratos exposes the
|
// Session→JWT claims mapper for the `plainpages` tokenizer. Kratos exposes the
|
||||||
// session as `session`; `sub` is set from the identity id (subject_source: id) and
|
// session as `session`; `sub` is set from the identity id (subject_source: id) and
|
||||||
// can't be overridden here. roles come from metadata_public — the per-login projection
|
// can't be overridden here. permissions come from metadata_public — the per-login projection
|
||||||
// of Keto roles the app refreshes at login (metadata_admin is NOT carried in the session
|
// of Keto permissions the app refreshes at login (metadata_admin is NOT carried in the session
|
||||||
// the tokenizer sees; metadata_public is). Absent on a fresh identity ⇒ empty list.
|
// the tokenizer sees; metadata_public is). Absent on a fresh identity ⇒ empty list.
|
||||||
local session = std.extVar('session');
|
local session = std.extVar('session');
|
||||||
local meta =
|
local meta =
|
||||||
@@ -12,6 +12,6 @@ local meta =
|
|||||||
{
|
{
|
||||||
claims: {
|
claims: {
|
||||||
email: session.identity.traits.email,
|
email: session.identity.traits.email,
|
||||||
roles: if std.objectHas(meta, 'roles') then meta.roles else [],
|
permissions: if std.objectHas(meta, 'permissions') then meta.permissions else [],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-24
@@ -1,11 +1,11 @@
|
|||||||
// One-command bootstrap: idempotent first-boot seeding. Guards the pure payload
|
// One-command bootstrap: idempotent first-boot seeding. Guards the pure payload
|
||||||
// builders (Kratos create-identity body + Keto role tuple), the idempotent seedAdmin
|
// builders (Kratos create-identity body + Keto permission tuple), the idempotent seedAdmin
|
||||||
// orchestration (fresh 201 vs existing 409 → reuse id), and the JWKS generate-if-absent
|
// orchestration (fresh 201 vs existing 409 → reuse id), and the JWKS generate-if-absent
|
||||||
// safety net. Live boot is verified by running the stack; these catch contract drift.
|
// safety net. Live boot is verified by running the stack; these catch contract drift.
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { ensureJwks, firstRunBanner, identityPayload, roleTuple, seedAdmin, seedRoles } from "./bootstrap.ts";
|
import { ensureJwks, firstRunBanner, identityPayload, permissionTuple, seedAdmin, seedPermissions } from "./bootstrap.ts";
|
||||||
|
|
||||||
const json = (status: number, body?: unknown) =>
|
const json = (status: number, body?: unknown) =>
|
||||||
new Response(body === undefined ? null : JSON.stringify(body), {
|
new Response(body === undefined ? null : JSON.stringify(body), {
|
||||||
@@ -20,27 +20,27 @@ test("identityPayload is a valid Kratos create-identity body with a password cre
|
|||||||
assert.equal(body.credentials.password.config.password, "admin");
|
assert.equal(body.credentials.password.config.password, "admin");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("roleTuple grants a role to identity:<id> in the Role namespace", () => {
|
test("permissionTuple grants a permission to identity:<id> in the Permission namespace", () => {
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
assert.deepEqual(roleTuple(id, "admin"), {
|
assert.deepEqual(permissionTuple(id, "admin"), {
|
||||||
namespace: "Role",
|
namespace: "Permission",
|
||||||
object: "admin",
|
object: "admin",
|
||||||
relation: "members",
|
relation: "granted",
|
||||||
subject_id: `identity:${id}`,
|
subject_id: `identity:${id}`,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("seedRoles unions ADMIN_ROLES (default 'admin') with the discovered plugins' declared roles", () => {
|
test("seedPermissions unions ADMIN_PERMISSIONS (default 'admin') with the discovered plugins' declared permissions", () => {
|
||||||
// Clean clone: no ADMIN_ROLES, the scheduling plugin declares its two tokens → the demo admin
|
// Clean clone: no ADMIN_PERMISSIONS, the scheduling plugin declares its two tokens → the demo admin
|
||||||
// gets exactly today's behaviour, but derived from discovery, not hardcoded in the host.
|
// gets exactly today's behaviour, but derived from discovery, not hardcoded in the host.
|
||||||
assert.deepEqual(seedRoles(undefined, ["scheduling:read", "scheduling:write"]), ["admin", "scheduling:read", "scheduling:write"]);
|
assert.deepEqual(seedPermissions(undefined, ["scheduling:read", "scheduling:write"]), ["admin", "scheduling:read", "scheduling:write"]);
|
||||||
assert.deepEqual(seedRoles(undefined, []), ["admin"]); // no plugins → just the base admin role
|
assert.deepEqual(seedPermissions(undefined, []), ["admin"]); // no plugins → just the base admin permission
|
||||||
assert.deepEqual(seedRoles("admin, ops ", ["inventory:read"]), ["admin", "ops", "inventory:read"]); // env trimmed + extended
|
assert.deepEqual(seedPermissions("admin, ops ", ["inventory:read"]), ["admin", "ops", "inventory:read"]); // env trimmed + extended
|
||||||
assert.deepEqual(seedRoles("admin,scheduling:read", ["scheduling:read"]), ["admin", "scheduling:read"]); // dedup, no double grant
|
assert.deepEqual(seedPermissions("admin,scheduling:read", ["scheduling:read"]), ["admin", "scheduling:read"]); // dedup, no double grant
|
||||||
assert.deepEqual(seedRoles("admin,, ", [" scheduling:read ", ""]), ["admin", "scheduling:read"]); // blanks dropped, tokens trimmed (both sides)
|
assert.deepEqual(seedPermissions("admin,, ", [" scheduling:read ", ""]), ["admin", "scheduling:read"]); // blanks dropped, tokens trimmed (both sides)
|
||||||
});
|
});
|
||||||
|
|
||||||
test("seedAdmin on a fresh stack creates the identity and grants every role (one tuple each)", async () => {
|
test("seedAdmin on a fresh stack creates the identity and grants every permission (one tuple each)", async () => {
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const calls: { method: string; url: string; body?: unknown }[] = [];
|
const calls: { method: string; url: string; body?: unknown }[] = [];
|
||||||
const fetchImpl = (async (url, init) => {
|
const fetchImpl = (async (url, init) => {
|
||||||
@@ -57,20 +57,20 @@ test("seedAdmin on a fresh stack creates the identity and grants every role (one
|
|||||||
ketoWriteUrl: "http://keto:4467",
|
ketoWriteUrl: "http://keto:4467",
|
||||||
kratosAdminUrl: "http://kratos:4434",
|
kratosAdminUrl: "http://kratos:4434",
|
||||||
password: "admin",
|
password: "admin",
|
||||||
roles: ["admin", "scheduling:read"],
|
permissions: ["admin", "scheduling:read"],
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(result, { created: true, id, roles: ["admin", "scheduling:read"] });
|
assert.deepEqual(result, { created: true, id, permissions: ["admin", "scheduling:read"] });
|
||||||
const puts = calls.filter((c) => c.url.includes("relation-tuples"));
|
const puts = calls.filter((c) => c.url.includes("relation-tuples"));
|
||||||
assert.equal(puts.length, 2); // one grant per role
|
assert.equal(puts.length, 2); // one grant per permission
|
||||||
assert.ok(puts.every((p) => p.method === "PUT"));
|
assert.ok(puts.every((p) => p.method === "PUT"));
|
||||||
assert.deepEqual(puts.map((p) => p.body), [
|
assert.deepEqual(puts.map((p) => p.body), [
|
||||||
{ namespace: "Role", object: "admin", relation: "members", subject_id: `identity:${id}` },
|
{ namespace: "Permission", object: "admin", relation: "granted", subject_id: `identity:${id}` },
|
||||||
{ namespace: "Role", object: "scheduling:read", relation: "members", subject_id: `identity:${id}` },
|
{ namespace: "Permission", object: "scheduling:read", relation: "granted", subject_id: `identity:${id}` },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants the role", async () => {
|
test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants the permission", async () => {
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
let granted: unknown;
|
let granted: unknown;
|
||||||
const fetchImpl = (async (url, init) => {
|
const fetchImpl = (async (url, init) => {
|
||||||
@@ -90,11 +90,11 @@ test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants
|
|||||||
ketoWriteUrl: "http://keto:4467",
|
ketoWriteUrl: "http://keto:4467",
|
||||||
kratosAdminUrl: "http://kratos:4434",
|
kratosAdminUrl: "http://kratos:4434",
|
||||||
password: "admin",
|
password: "admin",
|
||||||
roles: ["admin"],
|
permissions: ["admin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(result, { created: false, id, roles: ["admin"] });
|
assert.deepEqual(result, { created: false, id, permissions: ["admin"] });
|
||||||
assert.deepEqual(granted, { namespace: "Role", object: "admin", relation: "members", subject_id: `identity:${id}` });
|
assert.deepEqual(granted, { namespace: "Permission", object: "admin", relation: "granted", subject_id: `identity:${id}` });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("seedAdmin fails loud on an unexpected Kratos error", async () => {
|
test("seedAdmin fails loud on an unexpected Kratos error", async () => {
|
||||||
@@ -106,7 +106,7 @@ test("seedAdmin fails loud on an unexpected Kratos error", async () => {
|
|||||||
ketoWriteUrl: "http://keto:4467",
|
ketoWriteUrl: "http://keto:4467",
|
||||||
kratosAdminUrl: "http://kratos:4434",
|
kratosAdminUrl: "http://kratos:4434",
|
||||||
password: "admin",
|
password: "admin",
|
||||||
roles: ["admin"],
|
permissions: ["admin"],
|
||||||
}),
|
}),
|
||||||
/Kratos/,
|
/Kratos/,
|
||||||
);
|
);
|
||||||
|
|||||||
+23
-23
@@ -2,8 +2,8 @@
|
|||||||
// kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`:
|
// kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`:
|
||||||
// 1. generate the JWKS signing key if absent (committed dev key makes this a safety net);
|
// 1. generate the JWKS signing key if absent (committed dev key makes this a safety net);
|
||||||
// 2. seed a demo admin (admin@plainpages.local / admin) in Kratos;
|
// 2. seed a demo admin (admin@plainpages.local / admin) in Kratos;
|
||||||
// 3. grant it its roles in Keto so menu/role checks resolve out of the box — `admin` plus
|
// 3. grant it its permissions in Keto so menu/permission checks resolve out of the box — `admin` plus
|
||||||
// every discovered plugin's declared role names, so a dropped-in plugin is usable by
|
// every discovered plugin's declared permission names, so a dropped-in plugin is usable by
|
||||||
// the demo admin with no host config edit (the host stays plugin-agnostic).
|
// the demo admin with no host config edit (the host stays plugin-agnostic).
|
||||||
// Then prints a first-run banner; fails loud on any unexpected upstream error.
|
// Then prints a first-run banner; fails loud on any unexpected upstream error.
|
||||||
import { existsSync, writeFileSync } from "node:fs";
|
import { existsSync, writeFileSync } from "node:fs";
|
||||||
@@ -22,19 +22,19 @@ export function identityPayload(email: string, password: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coarse-role grant: `Role:<role>#members@identity:<id>`. Subject ids are `identity:<kratos-id>`
|
// Coarse-permission grant: `Permission:<permission>#members@identity:<id>`. Subject ids are `identity:<kratos-id>`
|
||||||
// (namespaces.keto.ts) — the source of truth the login flow projects into the JWT roles.
|
// (namespaces.keto.ts) — the source of truth the login flow projects into the JWT permissions.
|
||||||
export function roleTuple(identityId: string, role: string) {
|
export function permissionTuple(identityId: string, permission: string) {
|
||||||
return { namespace: "Role", object: role, relation: "members", subject_id: `identity:${identityId}` };
|
return { namespace: "Permission", object: permission, relation: "granted", subject_id: `identity:${identityId}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
// The roles to grant the demo admin = the configured base (ADMIN_ROLES, default just `admin`)
|
// The permissions to grant the demo admin = the configured base (ADMIN_PERMISSIONS, default just `admin`)
|
||||||
// unioned with every discovered plugin's declared role names (a route/nav `role` is a
|
// unioned with every discovered plugin's declared permission names (a route/nav `permission` is a
|
||||||
// coarse role — granted as a Keto `Role:<token>#members` tuple). So the host names no plugin, yet a
|
// coarse permission — granted as a Keto `Permission:<token>#members` tuple). So the host names no plugin, yet a
|
||||||
// dropped-in plugin's tokens are seeded out of the box. Deduped, order-stable, blanks dropped.
|
// dropped-in plugin's tokens are seeded out of the box. Deduped, order-stable, blanks dropped.
|
||||||
export function seedRoles(adminRolesEnv: string | undefined, declaredRoles: string[]): string[] {
|
export function seedPermissions(adminRolesEnv: string | undefined, declaredPermissions: string[]): string[] {
|
||||||
const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean);
|
const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean);
|
||||||
return [...new Set([...clean((adminRolesEnv ?? "admin").split(",")), ...clean(declaredRoles)])];
|
return [...new Set([...clean((adminRolesEnv ?? "admin").split(",")), ...clean(declaredPermissions)])];
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- JWKS safety net -----------------------------------------------------------------
|
// --- JWKS safety net -----------------------------------------------------------------
|
||||||
@@ -63,13 +63,13 @@ export interface SeedOptions {
|
|||||||
ketoWriteUrl: string;
|
ketoWriteUrl: string;
|
||||||
kratosAdminUrl: string;
|
kratosAdminUrl: string;
|
||||||
password: string;
|
password: string;
|
||||||
roles: string[];
|
permissions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SeedResult {
|
export interface SeedResult {
|
||||||
created: boolean;
|
created: boolean;
|
||||||
id: string;
|
id: string;
|
||||||
roles: string[];
|
permissions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function seedAdmin(opts: SeedOptions): Promise<SeedResult> {
|
export async function seedAdmin(opts: SeedOptions): Promise<SeedResult> {
|
||||||
@@ -93,17 +93,17 @@ export async function seedAdmin(opts: SeedOptions): Promise<SeedResult> {
|
|||||||
throw new Error(`bootstrap: Kratos create identity failed (${res.status}): ${await res.text()}`);
|
throw new Error(`bootstrap: Kratos create identity failed (${res.status}): ${await res.text()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grant each role in Keto. PUT is idempotent — re-running just re-asserts the tuple.
|
// Grant each permission in Keto. PUT is idempotent — re-running just re-asserts the tuple.
|
||||||
for (const role of opts.roles) {
|
for (const permission of opts.permissions) {
|
||||||
const grant = await http(`${opts.ketoWriteUrl}/admin/relation-tuples`, {
|
const grant = await http(`${opts.ketoWriteUrl}/admin/relation-tuples`, {
|
||||||
body: JSON.stringify(roleTuple(id, role)),
|
body: JSON.stringify(permissionTuple(id, permission)),
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
});
|
});
|
||||||
if (!grant.ok) throw new Error(`bootstrap: Keto grant role "${role}" failed (${grant.status}): ${await grant.text()}`);
|
if (!grant.ok) throw new Error(`bootstrap: Keto grant permission "${permission}" failed (${grant.status}): ${await grant.text()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { created, id, roles: opts.roles };
|
return { created, id, permissions: opts.permissions };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findIdentityId(http: typeof fetch, adminUrl: string, email: string): Promise<string> {
|
async function findIdentityId(http: typeof fetch, adminUrl: string, email: string): Promise<string> {
|
||||||
@@ -143,10 +143,10 @@ async function main() {
|
|||||||
await runWithLog(log, async () => {
|
await runWithLog(log, async () => {
|
||||||
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key");
|
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key");
|
||||||
|
|
||||||
// Seed `admin` (or ADMIN_ROLES) + every discovered plugin's declared role names, so the
|
// Seed `admin` (or ADMIN_PERMISSIONS) + every discovered plugin's declared permission names, so the
|
||||||
// shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
|
// shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
|
||||||
const declared = (await discoverPlugins()).flatMap((p) => (p.roles ?? []).map((d) => d.name));
|
const declared = (await discoverPlugins()).flatMap((p) => (p.permissions ?? []).map((d) => d.name));
|
||||||
const roles = seedRoles(env["ADMIN_ROLES"], declared);
|
const permissions = seedPermissions(env["ADMIN_PERMISSIONS"], declared);
|
||||||
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
|
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
|
||||||
const password = env["ADMIN_PASSWORD"] ?? "admin";
|
const password = env["ADMIN_PASSWORD"] ?? "admin";
|
||||||
const result = await seedAdmin({
|
const result = await seedAdmin({
|
||||||
@@ -155,9 +155,9 @@ async function main() {
|
|||||||
ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467",
|
ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467",
|
||||||
kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434",
|
kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434",
|
||||||
password,
|
password,
|
||||||
roles,
|
permissions,
|
||||||
});
|
});
|
||||||
log.info("admin seeded", { created: result.created, id: result.id, roles: result.roles.join(", ") });
|
log.info("admin seeded", { created: result.created, id: result.id, permissions: result.permissions.join(", ") });
|
||||||
// The banner is human-facing UX (the first-run "you're ready" block), not a log event — print raw.
|
// The banner is human-facing UX (the first-run "you're ready" block), not a log event — print raw.
|
||||||
console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password }));
|
console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password }));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
// Optional revocation denylist: instant role/session revoke without putting Keto
|
// Optional revocation denylist: instant permission/session revoke without putting Keto
|
||||||
// back on the hot path. Off by default — enable with REVOCATION_DENYLIST=true.
|
// back on the hot path. Off by default — enable with REVOCATION_DENYLIST=true.
|
||||||
//
|
//
|
||||||
// The hot path verifies a short-lived (~10m) session JWT in-process, so a revoked role or a
|
// The hot path verifies a short-lived (~10m) session JWT in-process, so a revoked permission or a
|
||||||
// killed session only takes effect when the token is next minted (re-login / TTL refresh) —
|
// killed session only takes effect when the token is next minted (re-login / TTL refresh) —
|
||||||
// up to one token TTL of lag. For security-critical revoke (offboarding, a compromised
|
// up to one token TTL of lag. For security-critical revoke (offboarding, a compromised
|
||||||
// account) that lag is too long. An admin action records the subject as revoked-now and the
|
// account) that lag is too long. An admin action records the subject as revoked-now and the
|
||||||
// hot path then rejects that subject's pre-revoke tokens at once, forcing a re-mint (which
|
// hot path then rejects that subject's pre-revoke tokens at once, forcing a re-mint (which
|
||||||
// re-reads roles from Keto, or clears a now-dead session).
|
// re-reads permissions from Keto, or clears a now-dead session).
|
||||||
//
|
//
|
||||||
// Cost & scope: an in-memory, auto-evicting Map — no database, like the JWKS cache, so it
|
// Cost & scope: an in-memory, auto-evicting Map — no database, like the JWKS cache, so it
|
||||||
// stays inside the stateless model. A token carries `iat`, so a *fresh* re-login (iat after
|
// stays inside the stateless model. A token carries `iat`, so a *fresh* re-login (iat after
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ test("rotateJwks --prune keeps only the newest (first) key, dropping superseded
|
|||||||
test("a JWS signed with a generated key verifies via our own verifier (reads what Kratos signs)", () => {
|
test("a JWS signed with a generated key verifies via our own verifier (reads what Kratos signs)", () => {
|
||||||
const key = generateJwks().keys[0]!;
|
const key = generateJwks().keys[0]!;
|
||||||
const head = b64url(JSON.stringify({ alg: "ES256", kid: key.kid }));
|
const head = b64url(JSON.stringify({ alg: "ES256", kid: key.kid }));
|
||||||
const body = b64url(JSON.stringify({ email: "a@b.c", roles: [], sub: key.kid }));
|
const body = b64url(JSON.stringify({ email: "a@b.c", permissions: [], sub: key.kid }));
|
||||||
const sig = sign("SHA256", Buffer.from(`${head}.${body}`), { dsaEncoding: "ieee-p1363", key: createPrivateKey({ key: key as unknown as JsonWebKey, format: "jwk" }) });
|
const sig = sign("SHA256", Buffer.from(`${head}.${body}`), { dsaEncoding: "ieee-p1363", key: createPrivateKey({ key: key as unknown as JsonWebKey, format: "jwk" }) });
|
||||||
const token = `${head}.${body}.${sig.toString("base64url")}`;
|
const token = `${head}.${body}.${sig.toString("base64url")}`;
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function ctxFor(user: SessionIdentity | null, url = "/"): RequestContext {
|
|||||||
return buildContext(req, new ServerResponse(req), { identity: user });
|
return buildContext(req, new ServerResponse(req), { identity: user });
|
||||||
}
|
}
|
||||||
|
|
||||||
const alice: SessionIdentity = { email: "a@b.c", id: "u1", roles: ["admin", "scheduling:read"] };
|
const alice: SessionIdentity = { email: "a@b.c", id: "u1", permissions: ["admin", "scheduling:read"] };
|
||||||
|
|
||||||
test("requireSession returns the user, or throws GuardError(401)→/login (preserving return_to) when anonymous", () => {
|
test("requireSession returns the user, or throws GuardError(401)→/login (preserving return_to) when anonymous", () => {
|
||||||
assert.equal(requireSession(ctxFor(alice)), alice);
|
assert.equal(requireSession(ctxFor(alice)), alice);
|
||||||
@@ -30,7 +30,7 @@ test("requireSession returns the user, or throws GuardError(401)→/login (prese
|
|||||||
err instanceof GuardError && err.location === "/login?return_to=%2Fscheduling%2Fshifts%3Fq%3D1");
|
err instanceof GuardError && err.location === "/login?return_to=%2Fscheduling%2Fshifts%3Fq%3D1");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("can reads a coarse role from the JWT claims; anonymous has none", () => {
|
test("can reads a coarse permission from the JWT claims; anonymous has none", () => {
|
||||||
assert.equal(can(ctxFor(alice), "admin"), true);
|
assert.equal(can(ctxFor(alice), "admin"), true);
|
||||||
assert.equal(can(ctxFor(alice), "billing:write"), false);
|
assert.equal(can(ctxFor(alice), "billing:write"), false);
|
||||||
assert.equal(can(ctxFor(null), "admin"), false);
|
assert.equal(can(ctxFor(null), "admin"), false);
|
||||||
|
|||||||
+4
-4
@@ -1,5 +1,5 @@
|
|||||||
// Auth guards: in-handler authorization, the imperative counterpart to the
|
// Auth guards: in-handler authorization, the imperative counterpart to the
|
||||||
// declarative route `role` gate. The middleware already verified the session JWT and put
|
// declarative route `permission` gate. The middleware already verified the session JWT and put
|
||||||
// the User on ctx; these read it. `requireSession` asserts (throws GuardError, which app.ts maps
|
// the User on ctx; these read it. `requireSession` asserts (throws GuardError, which app.ts maps
|
||||||
// to a response); `can`/`check` are predicates a handler branches on. `check` is the one live
|
// to a response); `can`/`check` are predicates a handler branches on. `check` is the one live
|
||||||
// Keto call — the fine-grained "may I?" tier (README), reserved for relationship rules.
|
// Keto call — the fine-grained "may I?" tier (README), reserved for relationship rules.
|
||||||
@@ -37,9 +37,9 @@ export function requireSession(ctx: RequestContext): SessionIdentity {
|
|||||||
return ctx.identity;
|
return ctx.identity;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coarse role check straight from the JWT claims — in-process, zero I/O. Anonymous ⇒ false.
|
// Coarse permission check straight from the JWT claims — in-process, zero I/O. Anonymous ⇒ false.
|
||||||
export function can(ctx: RequestContext, role: string): boolean {
|
export function can(ctx: RequestContext, permission: string): boolean {
|
||||||
return ctx.roles.includes(role);
|
return ctx.permissions.includes(permission);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Live Keto relationship check at the point of action. The subject is the current user;
|
// Live Keto relationship check at the point of action. The subject is the current user;
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ const jwk2: JsonWebKey = { ...(k2.publicKey.export({ format: "jwk" }) as JsonWeb
|
|||||||
const jwks = staticJwks([jwk1, jwk2]); // rotated set: two live keys
|
const jwks = staticJwks([jwk1, jwk2]); // rotated set: two live keys
|
||||||
|
|
||||||
const NOW = 1_700_000_000; // fixed clock for deterministic exp/nbf checks
|
const NOW = 1_700_000_000; // fixed clock for deterministic exp/nbf checks
|
||||||
const valid = { email: "a@b.c", exp: NOW + 600, roles: ["admin"], sub: "u1" };
|
const valid = { email: "a@b.c", exp: NOW + 600, permissions: ["admin"], sub: "u1" };
|
||||||
|
|
||||||
test("verifyToken: a valid token → User, selecting the verify key by kid across a rotated set", async () => {
|
test("verifyToken: a valid token → User, selecting the verify key by kid across a rotated set", async () => {
|
||||||
const user = await verifyToken(mint(k2.privateKey, "k2", valid), jwks, { now: NOW });
|
const user = await verifyToken(mint(k2.privateKey, "k2", valid), jwks, { now: NOW });
|
||||||
assert.deepEqual(user, { email: "a@b.c", id: "u1", roles: ["admin"] });
|
assert.deepEqual(user, { email: "a@b.c", id: "u1", permissions: ["admin"] });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("verifyToken requires exp, rejects expiry and future nbf, with clock-skew leeway", async () => {
|
test("verifyToken requires exp, rejects expiry and future nbf, with clock-skew leeway", async () => {
|
||||||
@@ -59,18 +59,18 @@ test("verifyToken rejects a bad signature and an unknown kid", async () => {
|
|||||||
await assert.rejects(verifyToken(mint(k1.privateKey, "nope", valid), jwks, { now: NOW }), /no JWKS key/);
|
await assert.rejects(verifyToken(mint(k1.privateKey, "nope", valid), jwks, { now: NOW }), /no JWKS key/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("claimsToIdentity requires sub + email, defaults roles to [], keeps only string roles", () => {
|
test("claimsToIdentity requires sub + email, defaults permissions to [], keeps only string permissions", () => {
|
||||||
assert.throws(() => claimsToIdentity({ email: "a@b.c", exp: NOW }), /sub/);
|
assert.throws(() => claimsToIdentity({ email: "a@b.c", exp: NOW }), /sub/);
|
||||||
assert.throws(() => claimsToIdentity({ email: "a@b.c", exp: NOW, sub: "" }), /sub/); // empty sub rejected too
|
assert.throws(() => claimsToIdentity({ email: "a@b.c", exp: NOW, sub: "" }), /sub/); // empty sub rejected too
|
||||||
assert.throws(() => claimsToIdentity({ exp: NOW, sub: "u" }), /email/);
|
assert.throws(() => claimsToIdentity({ exp: NOW, sub: "u" }), /email/);
|
||||||
assert.throws(() => claimsToIdentity({ email: "", exp: NOW, sub: "u" }), /email/); // empty email rejected (the shell keys signed-in vs anonymous off it)
|
assert.throws(() => claimsToIdentity({ email: "", exp: NOW, sub: "u" }), /email/); // empty email rejected (the shell keys signed-in vs anonymous off it)
|
||||||
assert.deepEqual(claimsToIdentity({ email: "a@b.c", sub: "u" }).roles, []); // roles absent
|
assert.deepEqual(claimsToIdentity({ email: "a@b.c", sub: "u" }).permissions, []); // permissions absent
|
||||||
assert.deepEqual(claimsToIdentity({ email: "a@b.c", roles: ["a", 1, "b"], sub: "u" }).roles, ["a", "b"]);
|
assert.deepEqual(claimsToIdentity({ email: "a@b.c", permissions: ["a", 1, "b"], sub: "u" }).permissions, ["a", "b"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("resolveSession classifies the cookie; authenticate is its fail-closed identity projection", async () => {
|
test("resolveSession classifies the cookie; authenticate is its fail-closed identity projection", async () => {
|
||||||
const cookie = (extra: Record<string, unknown> = {}, kid = "k1") => `${SESSION_COOKIE}=${mint(k1.privateKey, kid, { ...valid, ...extra })}`;
|
const cookie = (extra: Record<string, unknown> = {}, kid = "k1") => `${SESSION_COOKIE}=${mint(k1.privateKey, kid, { ...valid, ...extra })}`;
|
||||||
const identity = { email: "a@b.c", id: "u1", roles: ["admin"] };
|
const identity = { email: "a@b.c", id: "u1", permissions: ["admin"] };
|
||||||
|
|
||||||
// A valid token → the user, not expired.
|
// A valid token → the user, not expired.
|
||||||
assert.deepEqual(await resolveSession(cookie(), jwks, { now: NOW }), { expired: false, identity });
|
assert.deepEqual(await resolveSession(cookie(), jwks, { now: NOW }), { expired: false, identity });
|
||||||
@@ -96,6 +96,6 @@ test("verifyToken honours an optional denylist: a revoked subject's token reject
|
|||||||
await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 }), jwks, { denylist, now: NOW }), /revoked/);
|
await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 }), jwks, { denylist, now: NOW }), /revoked/);
|
||||||
assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=${mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 })}`, jwks, { denylist, now: NOW }), { expired: true, identity: null });
|
assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=${mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 })}`, jwks, { denylist, now: NOW }), { expired: true, identity: null });
|
||||||
// A token minted after the revoke (fresh login) is accepted; a different subject is untouched.
|
// A token minted after the revoke (fresh login) is accepted; a different subject is untouched.
|
||||||
assert.deepEqual(await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW + 5 }), jwks, { denylist, now: NOW }), { email: "a@b.c", id: "u1", roles: ["admin"] });
|
assert.deepEqual(await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW + 5 }), jwks, { denylist, now: NOW }), { email: "a@b.c", id: "u1", permissions: ["admin"] });
|
||||||
await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5, sub: "u2" }), jwks, { denylist, now: NOW });
|
await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5, sub: "u2" }), jwks, { denylist, now: NOW });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// the hot path that never calls Ory. Select the verify key by `kid` from the cached JWKS,
|
// the hot path that never calls Ory. Select the verify key by `kid` from the cached JWKS,
|
||||||
// check the signature (src/auth/jwt.ts), validate the time/issuer/audience claims, project the
|
// check the signature (src/auth/jwt.ts), validate the time/issuer/audience claims, project the
|
||||||
// User onto the request context. `authenticate` fails closed: any bad/expired token ⇒ null
|
// User onto the request context. `authenticate` fails closed: any bad/expired token ⇒ null
|
||||||
// (anonymous), so the route renders signed-out and the role gate denies.
|
// (anonymous), so the route renders signed-out and the permission gate denies.
|
||||||
import type { SessionIdentity } from "../http/context.ts";
|
import type { SessionIdentity } from "../http/context.ts";
|
||||||
import { parseCookies } from "../http/cookie.ts";
|
import { parseCookies } from "../http/cookie.ts";
|
||||||
import type { Denylist } from "./denylist.ts";
|
import type { Denylist } from "./denylist.ts";
|
||||||
@@ -59,15 +59,15 @@ export function validateClaims(payload: Record<string, unknown>, options: Verify
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map verified claims → the request User. sub/email are required and non-empty (the tokenizer
|
// Map verified claims → the request User. sub/email are required and non-empty (the tokenizer
|
||||||
// always sets them; an empty email would read as anonymous in the shell); roles defaults to [] and
|
// always sets them; an empty email would read as anonymous in the shell); permissions defaults to [] and
|
||||||
// keeps only string entries (defensive).
|
// keeps only string entries (defensive).
|
||||||
export function claimsToIdentity(payload: Record<string, unknown>): SessionIdentity {
|
export function claimsToIdentity(payload: Record<string, unknown>): SessionIdentity {
|
||||||
const sub = payload["sub"];
|
const sub = payload["sub"];
|
||||||
if (typeof sub !== "string" || sub === "") throw new TokenError("token missing sub");
|
if (typeof sub !== "string" || sub === "") throw new TokenError("token missing sub");
|
||||||
const email = payload["email"];
|
const email = payload["email"];
|
||||||
if (typeof email !== "string" || email === "") throw new TokenError("token missing email");
|
if (typeof email !== "string" || email === "") throw new TokenError("token missing email");
|
||||||
const roles = payload["roles"];
|
const permissions = payload["permissions"];
|
||||||
return { email, id: sub, roles: Array.isArray(roles) ? roles.filter((r): r is string => typeof r === "string") : [] };
|
return { email, id: sub, permissions: Array.isArray(permissions) ? permissions.filter((r): r is string => typeof r === "string") : [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify a session JWT end-to-end: select the key by `kid`, check the signature, validate
|
// Verify a session JWT end-to-end: select the key by `kid`, check the signature, validate
|
||||||
@@ -80,7 +80,7 @@ export async function verifyToken(token: string, jwks: JwksProvider, options: Ve
|
|||||||
validateClaims(verified.payload, options);
|
validateClaims(verified.payload, options);
|
||||||
const user = claimsToIdentity(verified.payload);
|
const user = claimsToIdentity(verified.payload);
|
||||||
// Instant revoke: a denylisted subject's pre-revoke token is rejected as *expired* so
|
// Instant revoke: a denylisted subject's pre-revoke token is rejected as *expired* so
|
||||||
// resolveSession routes it through the re-mint (fresh roles from Keto, or a cleared session).
|
// resolveSession routes it through the re-mint (fresh permissions from Keto, or a cleared session).
|
||||||
if (options.denylist?.isRevoked(user.id, num(verified.payload, "iat"))) throw new TokenError("token revoked", true);
|
if (options.denylist?.isRevoked(user.id, num(verified.payload, "iat"))) throw new TokenError("token revoked", true);
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ const rsaJwk = rsa.publicKey.export({ format: "jwk" }) as JsonWebKey;
|
|||||||
const ecJwk = ec.publicKey.export({ format: "jwk" }) as JsonWebKey;
|
const ecJwk = ec.publicKey.export({ format: "jwk" }) as JsonWebKey;
|
||||||
|
|
||||||
test("verifies an RS256 token, returning the decoded header + payload", () => {
|
test("verifies an RS256 token, returning the decoded header + payload", () => {
|
||||||
const token = makeJws("RS256", rsa.privateKey, { roles: ["admin"], sub: "u" });
|
const token = makeJws("RS256", rsa.privateKey, { permissions: ["admin"], sub: "u" });
|
||||||
const verified = verifyJws(token, rsaJwk);
|
const verified = verifyJws(token, rsaJwk);
|
||||||
assert.equal(verified.header.alg, "RS256");
|
assert.equal(verified.header.alg, "RS256");
|
||||||
assert.deepEqual(verified.payload, { roles: ["admin"], sub: "u" });
|
assert.deepEqual(verified.payload, { permissions: ["admin"], sub: "u" });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("verifies an ES256 token (raw r‖s signature)", () => {
|
test("verifies an ES256 token (raw r‖s signature)", () => {
|
||||||
@@ -35,10 +35,10 @@ test("verifies an ES256 token (raw r‖s signature)", () => {
|
|||||||
|
|
||||||
// All three reach and fail the signature check itself, not an earlier structural guard.
|
// All three reach and fail the signature check itself, not an earlier structural guard.
|
||||||
test("rejects a signature that fails verification (tampered payload, wrong key, empty)", () => {
|
test("rejects a signature that fails verification (tampered payload, wrong key, empty)", () => {
|
||||||
const token = makeJws("RS256", rsa.privateKey, { roles: ["user"], sub: "u" });
|
const token = makeJws("RS256", rsa.privateKey, { permissions: ["user"], sub: "u" });
|
||||||
const [header, payload, signature] = token.split(".");
|
const [header, payload, signature] = token.split(".");
|
||||||
|
|
||||||
const forged = `${header}.${b64url(JSON.stringify({ roles: ["admin"], sub: "u" }))}.${signature}`;
|
const forged = `${header}.${b64url(JSON.stringify({ permissions: ["admin"], sub: "u" }))}.${signature}`;
|
||||||
assert.throws(() => verifyJws(forged, rsaJwk), /invalid signature/);
|
assert.throws(() => verifyJws(forged, rsaJwk), /invalid signature/);
|
||||||
|
|
||||||
const otherJwk = generateKeyPairSync("rsa", { modulusLength: 2048 }).publicKey.export({ format: "jwk" }) as JsonWebKey;
|
const otherJwk = generateKeyPairSync("rsa", { modulusLength: 2048 }).publicKey.export({ format: "jwk" }) as JsonWebKey;
|
||||||
|
|||||||
@@ -29,13 +29,13 @@ const keto = (fetchImpl: typeof fetch) => createKetoClient({ fetchImpl, readUrl:
|
|||||||
|
|
||||||
test("check GETs the read API and returns the allowed boolean (true and false)", async () => {
|
test("check GETs the read API and returns the allowed boolean (true and false)", async () => {
|
||||||
const allow = recorder(() => res(200, { allowed: true }));
|
const allow = recorder(() => res(200, { allowed: true }));
|
||||||
assert.equal(await keto(allow.fetchImpl).check({ namespace: "Role", object: "admin", relation: "members", subject_id: USER }), true);
|
assert.equal(await keto(allow.fetchImpl).check({ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER }), true);
|
||||||
assert.match(allow.calls[0]!.url, /^http:\/\/keto:4466\/relation-tuples\/check\?/);
|
assert.match(allow.calls[0]!.url, /^http:\/\/keto:4466\/relation-tuples\/check\?/);
|
||||||
assert.match(allow.calls[0]!.url, /namespace=Role&object=admin&relation=members/);
|
assert.match(allow.calls[0]!.url, /namespace=Permission&object=admin&relation=granted/);
|
||||||
assert.match(allow.calls[0]!.url, new RegExp(`subject_id=${encodeURIComponent(USER).replace(/[.]/g, "\\.")}`));
|
assert.match(allow.calls[0]!.url, new RegExp(`subject_id=${encodeURIComponent(USER).replace(/[.]/g, "\\.")}`));
|
||||||
// A denied check is 403 {allowed:false} (not a 200) — both statuses carry the verdict.
|
// A denied check is 403 {allowed:false} (not a 200) — both statuses carry the verdict.
|
||||||
const deny = recorder(() => res(403, { allowed: false }));
|
const deny = recorder(() => res(403, { allowed: false }));
|
||||||
assert.equal(await keto(deny.fetchImpl).check({ namespace: "Role", object: "admin", relation: "members", subject_id: "identity:nobody" }), false);
|
assert.equal(await keto(deny.fetchImpl).check({ namespace: "Permission", object: "admin", relation: "granted", subject_id: "identity:nobody" }), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("check on a subject_set builds subject_set.* params and forwards max-depth", async () => {
|
test("check on a subject_set builds subject_set.* params and forwards max-depth", async () => {
|
||||||
@@ -51,20 +51,20 @@ test("check on a subject_set builds subject_set.* params and forwards max-depth"
|
|||||||
|
|
||||||
test("check throws a KetoError carrying the status on an unexpected response", async () => {
|
test("check throws a KetoError carrying the status on an unexpected response", async () => {
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
keto((async () => res(400, { error: "bad" })) as typeof fetch).check({ namespace: "Role", object: "admin", relation: "members", subject_id: USER }),
|
keto((async () => res(400, { error: "bad" })) as typeof fetch).check({ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER }),
|
||||||
(e: unknown) => e instanceof KetoError && e.status === 400,
|
(e: unknown) => e instanceof KetoError && e.status === 400,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("listRelations builds the filter query + pagination and parses next_page_token", async () => {
|
test("listRelations builds the filter query + pagination and parses next_page_token", async () => {
|
||||||
const tuples = [{ namespace: "Role", object: "admin", relation: "members", subject_id: USER }];
|
const tuples = [{ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER }];
|
||||||
const { calls, fetchImpl } = recorder(() => res(200, { next_page_token: "NEXT", relation_tuples: tuples }));
|
const { calls, fetchImpl } = recorder(() => res(200, { next_page_token: "NEXT", relation_tuples: tuples }));
|
||||||
const out = await keto(fetchImpl).listRelations({ namespace: "Role", object: "admin", pageSize: 10, pageToken: "CUR", relation: "members" });
|
const out = await keto(fetchImpl).listRelations({ namespace: "Permission", object: "admin", pageSize: 10, pageToken: "CUR", relation: "granted" });
|
||||||
assert.deepEqual(out.tuples, tuples);
|
assert.deepEqual(out.tuples, tuples);
|
||||||
assert.equal(out.nextPageToken, "NEXT");
|
assert.equal(out.nextPageToken, "NEXT");
|
||||||
const url = calls[0]!.url;
|
const url = calls[0]!.url;
|
||||||
assert.match(url, /^http:\/\/keto:4466\/relation-tuples\?/);
|
assert.match(url, /^http:\/\/keto:4466\/relation-tuples\?/);
|
||||||
assert.match(url, /namespace=Role&object=admin&relation=members/);
|
assert.match(url, /namespace=Permission&object=admin&relation=granted/);
|
||||||
assert.match(url, /page_size=10&page_token=CUR/);
|
assert.match(url, /page_size=10&page_token=CUR/);
|
||||||
// No Link header / token in the body ⇒ null, empty list ⇒ [].
|
// No Link header / token in the body ⇒ null, empty list ⇒ [].
|
||||||
const empty = await keto((async () => res(200, {})) as typeof fetch).listRelations();
|
const empty = await keto((async () => res(200, {})) as typeof fetch).listRelations();
|
||||||
@@ -72,16 +72,16 @@ test("listRelations builds the filter query + pagination and parses next_page_to
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("expand GETs the read API for a subject set and returns the tree (with max-depth)", async () => {
|
test("expand GETs the read API for a subject set and returns the tree (with max-depth)", async () => {
|
||||||
const tree = { children: [{ tuple: { namespace: "", object: "", relation: "", subject_id: USER }, type: "leaf" }], tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Role", object: "admin", relation: "members" } }, type: "union" };
|
const tree = { children: [{ tuple: { namespace: "", object: "", relation: "", subject_id: USER }, type: "leaf" }], tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Permission", object: "admin", relation: "granted" } }, type: "union" };
|
||||||
const { calls, fetchImpl } = recorder(() => res(200, tree));
|
const { calls, fetchImpl } = recorder(() => res(200, tree));
|
||||||
const out = await keto(fetchImpl).expand({ namespace: "Role", object: "admin", relation: "members" }, { maxDepth: 3 });
|
const out = await keto(fetchImpl).expand({ namespace: "Permission", object: "admin", relation: "granted" }, { maxDepth: 3 });
|
||||||
assert.deepEqual(out, tree);
|
assert.deepEqual(out, tree);
|
||||||
assert.match(calls[0]!.url, /^http:\/\/keto:4466\/relation-tuples\/expand\?/);
|
assert.match(calls[0]!.url, /^http:\/\/keto:4466\/relation-tuples\/expand\?/);
|
||||||
assert.match(calls[0]!.url, /namespace=Role&object=admin&relation=members&max-depth=3/);
|
assert.match(calls[0]!.url, /namespace=Permission&object=admin&relation=granted&max-depth=3/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("writeTuple PUTs the tuple as JSON to the write API (idempotent; non-2xx throws)", async () => {
|
test("writeTuple PUTs the tuple as JSON to the write API (idempotent; non-2xx throws)", async () => {
|
||||||
const tuple = { namespace: "Role", object: "admin", relation: "members", subject_id: USER };
|
const tuple = { namespace: "Permission", object: "admin", relation: "granted", subject_id: USER };
|
||||||
const { calls, fetchImpl } = recorder(() => res(201, tuple));
|
const { calls, fetchImpl } = recorder(() => res(201, tuple));
|
||||||
await keto(fetchImpl).writeTuple(tuple);
|
await keto(fetchImpl).writeTuple(tuple);
|
||||||
assert.equal(calls[0]!.method, "PUT");
|
assert.equal(calls[0]!.method, "PUT");
|
||||||
@@ -95,12 +95,12 @@ test("writeTuple PUTs the tuple as JSON to the write API (idempotent; non-2xx th
|
|||||||
|
|
||||||
test("deleteTuple DELETEs the write API by query params (204 resolves; non-204 throws)", async () => {
|
test("deleteTuple DELETEs the write API by query params (204 resolves; non-204 throws)", async () => {
|
||||||
const { calls, fetchImpl } = recorder(() => res(204));
|
const { calls, fetchImpl } = recorder(() => res(204));
|
||||||
await keto(fetchImpl).deleteTuple({ namespace: "Role", object: "admin", relation: "members", subject_id: USER });
|
await keto(fetchImpl).deleteTuple({ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER });
|
||||||
assert.equal(calls[0]!.method, "DELETE");
|
assert.equal(calls[0]!.method, "DELETE");
|
||||||
assert.match(calls[0]!.url, /^http:\/\/keto:4467\/admin\/relation-tuples\?/);
|
assert.match(calls[0]!.url, /^http:\/\/keto:4467\/admin\/relation-tuples\?/);
|
||||||
assert.match(calls[0]!.url, /namespace=Role&object=admin&relation=members/);
|
assert.match(calls[0]!.url, /namespace=Permission&object=admin&relation=granted/);
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
keto((async () => res(404)) as typeof fetch).deleteTuple({ namespace: "Role", object: "x", relation: "members", subject_id: USER }),
|
keto((async () => res(404)) as typeof fetch).deleteTuple({ namespace: "Permission", object: "x", relation: "granted", subject_id: USER }),
|
||||||
(e: unknown) => e instanceof KetoError && e.status === 404,
|
(e: unknown) => e instanceof KetoError && e.status === 404,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export interface SubjectSet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A relationship tuple — the wire shape for writes and the filter shape for reads. Subject
|
// A relationship tuple — the wire shape for writes and the filter shape for reads. Subject
|
||||||
// is `subject_id` xor `subject_set` (never both). Mirrors bootstrap.ts's roleTuple.
|
// is `subject_id` xor `subject_set` (never both). Mirrors bootstrap.ts's permissionTuple.
|
||||||
export interface RelationTuple {
|
export interface RelationTuple {
|
||||||
namespace: string;
|
namespace: string;
|
||||||
object: string;
|
object: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Kratos admin-API client: typed fetch wrappers over Ory Kratos' admin endpoints —
|
// Kratos admin-API client: typed fetch wrappers over Ory Kratos' admin endpoints —
|
||||||
// identity CRUD + the surgical metadata_public update the login flow projects roles into.
|
// identity CRUD + the surgical metadata_public update the login flow projects permissions into.
|
||||||
// Guards the request contracts (URLs, method, JSON-Patch body, query/pagination) and the
|
// Guards the request contracts (URLs, method, JSON-Patch body, query/pagination) and the
|
||||||
// result mapping (201/200/404/4xx). Live wiring is verified by login completion.
|
// result mapping (201/200/404/4xx). Live wiring is verified by login completion.
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
@@ -90,13 +90,13 @@ test("updateIdentity PUTs the full body to /admin/identities/<id> and returns th
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("updateMetadataPublic PATCHes a JSON-Patch `add /metadata_public` so it never clobbers traits", async () => {
|
test("updateMetadataPublic PATCHes a JSON-Patch `add /metadata_public` so it never clobbers traits", async () => {
|
||||||
const identity = { id: ID, metadata_public: { roles: ["admin"] } };
|
const identity = { id: ID, metadata_public: { permissions: ["admin"] } };
|
||||||
const { calls, fetchImpl } = recorder(() => res(200, identity));
|
const { calls, fetchImpl } = recorder(() => res(200, identity));
|
||||||
const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).updateMetadataPublic(ID, { roles: ["admin"] });
|
const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).updateMetadataPublic(ID, { permissions: ["admin"] });
|
||||||
assert.deepEqual(out, identity);
|
assert.deepEqual(out, identity);
|
||||||
assert.equal(calls[0]!.method, "PATCH");
|
assert.equal(calls[0]!.method, "PATCH");
|
||||||
assert.match(calls[0]!.url, new RegExp(`/admin/identities/${ID}$`));
|
assert.match(calls[0]!.url, new RegExp(`/admin/identities/${ID}$`));
|
||||||
assert.deepEqual(JSON.parse(calls[0]!.body!), [{ op: "add", path: "/metadata_public", value: { roles: ["admin"] } }]);
|
assert.deepEqual(JSON.parse(calls[0]!.body!), [{ op: "add", path: "/metadata_public", value: { permissions: ["admin"] } }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("createRecoveryCode POSTs the identity id to /admin/recovery/code → { code, link }", async () => {
|
test("createRecoveryCode POSTs the identity id to /admin/recovery/code → { code, link }", async () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Kratos admin-API client: typed `fetch` wrappers over Ory Kratos' admin endpoints
|
// Kratos admin-API client: typed `fetch` wrappers over Ory Kratos' admin endpoints
|
||||||
// (internal-only admin port) — identity CRUD + the surgical `metadata_public` update login
|
// (internal-only admin port) — identity CRUD + the surgical `metadata_public` update login
|
||||||
// completion projects Keto roles into (README). Built-in `fetch` only, no SDK dep (AGENTS.md);
|
// completion projects Keto permissions into (README). Built-in `fetch` only, no SDK dep (AGENTS.md);
|
||||||
// `fetchImpl`-injectable, reuses kratos-public.ts's `KratosError` (branch on `.status`).
|
// `fetchImpl`-injectable, reuses kratos-public.ts's `KratosError` (branch on `.status`).
|
||||||
import { KratosError } from "./kratos-public.ts";
|
import { KratosError } from "./kratos-public.ts";
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof
|
|||||||
},
|
},
|
||||||
|
|
||||||
// JSON Patch `add` sets metadata_public whether it's currently absent, null, or set, and
|
// JSON Patch `add` sets metadata_public whether it's currently absent, null, or set, and
|
||||||
// touches nothing else — so the login role projection never clobbers traits/state.
|
// touches nothing else — so the login permission projection never clobbers traits/state.
|
||||||
// (metadata_public, not _admin: the session the tokenizer sees carries only public metadata.)
|
// (metadata_public, not _admin: the session the tokenizer sees carries only public metadata.)
|
||||||
async updateMetadataPublic(id, metadata) {
|
async updateMetadataPublic(id, metadata) {
|
||||||
const patch = [{ op: "add", path: "/metadata_public", value: metadata }];
|
const patch = [{ op: "add", path: "/metadata_public", value: metadata }];
|
||||||
|
|||||||
+20
-20
@@ -1,4 +1,4 @@
|
|||||||
// Login completion: turn a Kratos session into our session JWT — read roles from Keto,
|
// Login completion: turn a Kratos session into our session JWT — read permissions from Keto,
|
||||||
// project them onto the identity, tokenize, build the cookie. Fakes the three Ory clients;
|
// project them onto the identity, tokenize, build the cookie. Fakes the three Ory clients;
|
||||||
// the live, full-stack login is verified by the Playwright E2E.
|
// the live, full-stack login is verified by the Playwright E2E.
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
@@ -6,10 +6,10 @@ import assert from "node:assert/strict";
|
|||||||
import type { KetoClient, RelationTuple } from "./keto-client.ts";
|
import type { KetoClient, RelationTuple } from "./keto-client.ts";
|
||||||
import type { Identity, KratosAdmin } from "./kratos-admin.ts";
|
import type { Identity, KratosAdmin } from "./kratos-admin.ts";
|
||||||
import type { KratosPublic, Session } from "./kratos-public.ts";
|
import type { KratosPublic, Session } from "./kratos-public.ts";
|
||||||
import { completeLogin, readRoles, remintSession, SESSION_COOKIE, sessionCookie } from "./login.ts";
|
import { completeLogin, readPermissions, remintSession, SESSION_COOKIE, sessionCookie } from "./login.ts";
|
||||||
|
|
||||||
const ID = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
|
const ID = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
|
||||||
const roleTuple = (object: string): RelationTuple => ({ namespace: "Role", object, relation: "members", subject_id: `identity:${ID}` });
|
const permissionTuple = (object: string): RelationTuple => ({ namespace: "Permission", object, relation: "granted", subject_id: `identity:${ID}` });
|
||||||
|
|
||||||
const ketoStub = (over: Partial<KetoClient> = {}): KetoClient => ({
|
const ketoStub = (over: Partial<KetoClient> = {}): KetoClient => ({
|
||||||
check: async () => false,
|
check: async () => false,
|
||||||
@@ -40,32 +40,32 @@ const publicStub = (over: Partial<KratosPublic> = {}): KratosPublic => ({
|
|||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
|
|
||||||
test("readRoles returns roles held directly OR transitively (enumerate defined roles → Keto-check each)", async () => {
|
test("readPermissions returns permissions held directly OR transitively (enumerate defined permissions → Keto-check each)", async () => {
|
||||||
const listQ: unknown[] = [];
|
const listQ: unknown[] = [];
|
||||||
const checked: string[] = [];
|
const checked: string[] = [];
|
||||||
const role = (object: string, subject: Partial<RelationTuple>): RelationTuple => ({ namespace: "Role", object, relation: "members", ...subject });
|
const permission = (object: string, subject: Partial<RelationTuple>): RelationTuple => ({ namespace: "Permission", object, relation: "granted", ...subject });
|
||||||
const keto = ketoStub({
|
const keto = ketoStub({
|
||||||
// Enumerate every Role tuple (paged, no subject filter) to find the distinct role names —
|
// Enumerate every Permission tuple (paged, no subject filter) to find the distinct permission names —
|
||||||
// subjects vary (a direct user, a group) and a name repeats across pages → de-duped.
|
// subjects vary (a direct user, a group) and a name repeats across pages → de-duped.
|
||||||
listRelations: async (q) => {
|
listRelations: async (q) => {
|
||||||
listQ.push(q);
|
listQ.push(q);
|
||||||
if (q?.pageToken === "p2") return { nextPageToken: null, tuples: [role("editor", { subject_id: "identity:other" })] };
|
if (q?.pageToken === "p2") return { nextPageToken: null, tuples: [permission("editor", { subject_id: "identity:other" })] };
|
||||||
return { nextPageToken: "p2", tuples: [
|
return { nextPageToken: "p2", tuples: [
|
||||||
role("editor", { subject_set: { namespace: "Group", object: "eng", relation: "members" } }),
|
permission("editor", { subject_set: { namespace: "Group", object: "eng", relation: "members" } }),
|
||||||
role("admin", { subject_id: `identity:${ID}` }),
|
permission("admin", { subject_id: `identity:${ID}` }),
|
||||||
role("viewer", { subject_id: "identity:stranger" }),
|
permission("viewer", { subject_id: "identity:stranger" }),
|
||||||
] };
|
] };
|
||||||
},
|
},
|
||||||
// Keto resolves transitively: the user holds editor (via a group) + admin (direct), not viewer.
|
// Keto resolves transitively: the user holds editor (via a group) + admin (direct), not viewer.
|
||||||
check: async (t) => { checked.push(t.object); return t.object === "admin" || t.object === "editor"; },
|
check: async (t) => { checked.push(t.object); return t.object === "admin" || t.object === "editor"; },
|
||||||
});
|
});
|
||||||
assert.deepEqual(await readRoles(keto, ID), ["admin", "editor"]);
|
assert.deepEqual(await readPermissions(keto, ID), ["admin", "editor"]);
|
||||||
assert.deepEqual(listQ[0], { namespace: "Role", relation: "members" }); // enumerate, not subject-filtered
|
assert.deepEqual(listQ[0], { namespace: "Permission", relation: "granted" }); // enumerate, not subject-filtered
|
||||||
assert.equal((listQ[1] as { pageToken?: string }).pageToken, "p2"); // second page follows the cursor
|
assert.equal((listQ[1] as { pageToken?: string }).pageToken, "p2"); // second page follows the cursor
|
||||||
assert.deepEqual(checked.sort(), ["admin", "editor", "viewer"]); // every distinct role checked for the user
|
assert.deepEqual(checked.sort(), ["admin", "editor", "viewer"]); // every distinct permission checked for the user
|
||||||
});
|
});
|
||||||
|
|
||||||
test("completeLogin: read roles → project onto metadata_public → tokenize → JWT (in that order)", async () => {
|
test("completeLogin: read permissions → project onto metadata_public → tokenize → JWT (in that order)", async () => {
|
||||||
const events: string[] = [];
|
const events: string[] = [];
|
||||||
let projected: unknown;
|
let projected: unknown;
|
||||||
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
|
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
|
||||||
@@ -76,11 +76,11 @@ test("completeLogin: read roles → project onto metadata_public → tokenize
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const kratosAdmin = adminStub({ updateMetadataPublic: async (_id, meta) => { events.push("project"); projected = meta; return identity; } });
|
const kratosAdmin = adminStub({ updateMetadataPublic: async (_id, meta) => { events.push("project"); projected = meta; return identity; } });
|
||||||
const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [roleTuple("admin")] }) });
|
const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [permissionTuple("admin")] }) });
|
||||||
|
|
||||||
const out = await completeLogin({ keto, kratosAdmin, kratosPublic }, "plainpages_session=s");
|
const out = await completeLogin({ keto, kratosAdmin, kratosPublic }, "plainpages_session=s");
|
||||||
assert.deepEqual(out, { email: "admin@plainpages.local", identityId: ID, jwt: "h.p.s", roles: ["admin"] });
|
assert.deepEqual(out, { email: "admin@plainpages.local", identityId: ID, jwt: "h.p.s", permissions: ["admin"] });
|
||||||
assert.deepEqual(projected, { roles: ["admin"] }); // Keto roles, projected for the tokenizer
|
assert.deepEqual(projected, { permissions: ["admin"] }); // Keto permissions, projected for the tokenizer
|
||||||
assert.deepEqual(events, ["whoami", "project", "tokenize"]); // projection MUST precede tokenize
|
assert.deepEqual(events, ["whoami", "project", "tokenize"]); // projection MUST precede tokenize
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,11 +101,11 @@ test("completeLogin maps a missing email trait to null and throws if the tokeniz
|
|||||||
test("remintSession: a live Kratos session → fresh cookie + refreshed user; a dead session → a clearing cookie + null", async () => {
|
test("remintSession: a live Kratos session → fresh cookie + refreshed user; a dead session → a clearing cookie + null", async () => {
|
||||||
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
|
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
|
||||||
const kratosPublic = publicStub({ whoami: async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session });
|
const kratosPublic = publicStub({ whoami: async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session });
|
||||||
const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [roleTuple("admin")] }) });
|
const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [permissionTuple("admin")] }) });
|
||||||
|
|
||||||
// TTL lapsed but the Kratos session lives → re-read roles from Keto, re-tokenize, fresh cookie.
|
// TTL lapsed but the Kratos session lives → re-read permissions from Keto, re-tokenize, fresh cookie.
|
||||||
const live = await remintSession({ keto, kratosAdmin: adminStub(), kratosPublic }, "plainpages_session=s");
|
const live = await remintSession({ keto, kratosAdmin: adminStub(), kratosPublic }, "plainpages_session=s");
|
||||||
assert.deepEqual(live.identity, { email: "admin@plainpages.local", id: ID, roles: ["admin"] });
|
assert.deepEqual(live.identity, { email: "admin@plainpages.local", id: ID, permissions: ["admin"] });
|
||||||
assert.match(live.setCookie, /^plainpages_jwt=h\.p\.s;.*Max-Age=2592000.*HttpOnly/);
|
assert.match(live.setCookie, /^plainpages_jwt=h\.p\.s;.*Max-Age=2592000.*HttpOnly/);
|
||||||
|
|
||||||
// Kratos session also gone → clear the stale JWT so the next request falls through to anonymous.
|
// Kratos session also gone → clear the stale JWT so the next request falls through to anonymous.
|
||||||
|
|||||||
+18
-18
@@ -1,9 +1,9 @@
|
|||||||
// Login completion: turn a fresh Kratos session into our locally-verifiable
|
// Login completion: turn a fresh Kratos session into our locally-verifiable
|
||||||
// session JWT — the one moment Ory is on the path (README: Login → session JWT):
|
// session JWT — the one moment Ory is on the path (README: Login → session JWT):
|
||||||
// 1. whoami(cookie) → the identity (id, email); no active session ⇒ null
|
// 1. whoami(cookie) → the identity (id, email); no active session ⇒ null
|
||||||
// 2. read roles from Keto → the source of truth for the `roles` claim
|
// 2. read permissions from Keto → the source of truth for the `permissions` claim
|
||||||
// 3. project onto metadata_public (admin API) so the tokenizer's mapper can read them
|
// 3. project onto metadata_public (admin API) so the tokenizer's mapper can read them
|
||||||
// 4. whoami(tokenize_as) → the signed JWT { sub, email, roles }, stored as our cookie
|
// 4. whoami(tokenize_as) → the signed JWT { sub, email, permissions }, stored as our cookie
|
||||||
// Order matters: the projection is written before tokenizing, because the claims mapper
|
// Order matters: the projection is written before tokenizing, because the claims mapper
|
||||||
// reads only the identity, never Keto.
|
// reads only the identity, never Keto.
|
||||||
import type { SessionIdentity } from "../http/context.ts";
|
import type { SessionIdentity } from "../http/context.ts";
|
||||||
@@ -34,26 +34,26 @@ export interface CompletedLogin {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
identityId: string;
|
identityId: string;
|
||||||
jwt: string;
|
jwt: string;
|
||||||
roles: string[];
|
permissions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// The coarse roles a user holds — directly (`Role:<name>#members@identity:<id>`) or transitively via a
|
// The coarse permissions a user holds — directly (`Permission:<name>#members@identity:<id>`) or transitively via a
|
||||||
// group that is a member of the role. Enumerates the defined roles (the distinct objects in the Role
|
// group that is a member of the permission. Enumerates the defined permissions (the distinct objects in the Permission
|
||||||
// namespace) and asks Keto to resolve each membership, so a role granted to a group reaches the JWT —
|
// namespace) and asks Keto to resolve each membership, so a permission granted to a group reaches the JWT —
|
||||||
// matching the OPL model and the admin "Effective access" view. At login/refresh only, never per
|
// matching the OPL model and the admin "Effective access" view. At login/refresh only, never per
|
||||||
// request; role count is small, so the per-role checks are cheap and run in parallel.
|
// request; permission count is small, so the per-permission checks are cheap and run in parallel.
|
||||||
export async function readRoles(keto: KetoClient, identityId: string): Promise<string[]> {
|
export async function readPermissions(keto: KetoClient, identityId: string): Promise<string[]> {
|
||||||
const subject_id = `identity:${identityId}`;
|
const subject_id = `identity:${identityId}`;
|
||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
let pageToken: string | undefined;
|
let pageToken: string | undefined;
|
||||||
do {
|
do {
|
||||||
const page = await keto.listRelations({ namespace: "Role", relation: "members", ...(pageToken ? { pageToken } : {}) });
|
const page = await keto.listRelations({ namespace: "Permission", relation: "granted", ...(pageToken ? { pageToken } : {}) });
|
||||||
for (const t of page.tuples) names.add(t.object);
|
for (const t of page.tuples) names.add(t.object);
|
||||||
pageToken = page.nextPageToken ?? undefined;
|
pageToken = page.nextPageToken ?? undefined;
|
||||||
} while (pageToken);
|
} while (pageToken);
|
||||||
const roles = [...names];
|
const permissions = [...names];
|
||||||
const held = await Promise.all(roles.map((object) => keto.check({ namespace: "Role", object, relation: "members", subject_id })));
|
const held = await Promise.all(permissions.map((object) => keto.check({ namespace: "Permission", object, relation: "granted", subject_id })));
|
||||||
return roles.filter((_, i) => held[i]).sort();
|
return permissions.filter((_, i) => held[i]).sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function completeLogin(deps: LoginDeps, cookie: string | undefined): Promise<CompletedLogin | null> {
|
export async function completeLogin(deps: LoginDeps, cookie: string | undefined): Promise<CompletedLogin | null> {
|
||||||
@@ -63,15 +63,15 @@ export async function completeLogin(deps: LoginDeps, cookie: string | undefined)
|
|||||||
const emailTrait = session.identity.traits?.["email"];
|
const emailTrait = session.identity.traits?.["email"];
|
||||||
const email = typeof emailTrait === "string" ? emailTrait : null;
|
const email = typeof emailTrait === "string" ? emailTrait : null;
|
||||||
|
|
||||||
const roles = await readRoles(deps.keto, identityId);
|
const permissions = await readPermissions(deps.keto, identityId);
|
||||||
await deps.kratosAdmin.updateMetadataPublic(identityId, { roles });
|
await deps.kratosAdmin.updateMetadataPublic(identityId, { permissions });
|
||||||
|
|
||||||
const tokenized = await deps.kratosPublic.whoami({ ...(cookie ? { cookie } : {}), tokenizeAs: TOKENIZE_AS });
|
const tokenized = await deps.kratosPublic.whoami({ ...(cookie ? { cookie } : {}), tokenizeAs: TOKENIZE_AS });
|
||||||
const jwt = tokenized?.tokenized;
|
const jwt = tokenized?.tokenized;
|
||||||
if (!jwt) throw new Error("login completion: Kratos tokenizer returned no JWT");
|
if (!jwt) throw new Error("login completion: Kratos tokenizer returned no JWT");
|
||||||
|
|
||||||
currentLog()?.info("session minted", { roles: roles.join(","), sub: identityId }); // login or TTL re-mint
|
currentLog()?.info("session minted", { permissions: permissions.join(","), sub: identityId }); // login or TTL re-mint
|
||||||
return { email, identityId, jwt, roles };
|
return { email, identityId, jwt, permissions };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Reminted {
|
export interface Reminted {
|
||||||
@@ -80,14 +80,14 @@ export interface Reminted {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Re-mint the session JWT on TTL expiry — "stay signed in" (README): the ~10m token lapsed but
|
// Re-mint the session JWT on TTL expiry — "stay signed in" (README): the ~10m token lapsed but
|
||||||
// the long-lived Kratos session may still be live. A live session ⇒ re-read roles from Keto,
|
// the long-lived Kratos session may still be live. A live session ⇒ re-read permissions from Keto,
|
||||||
// re-tokenize, fresh cookie + the refreshed user (the one moment authz recomputes). A dead
|
// re-tokenize, fresh cookie + the refreshed user (the one moment authz recomputes). A dead
|
||||||
// session ⇒ a cookie that *clears* the stale JWT, so later requests fall straight through to
|
// session ⇒ a cookie that *clears* the stale JWT, so later requests fall straight through to
|
||||||
// anonymous instead of re-hitting Ory on every one.
|
// anonymous instead of re-hitting Ory on every one.
|
||||||
export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise<Reminted> {
|
export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise<Reminted> {
|
||||||
const completed = await completeLogin(deps, cookie);
|
const completed = await completeLogin(deps, cookie);
|
||||||
if (!completed) return { setCookie: clearSessionCookie(options), identity: null };
|
if (!completed) return { setCookie: clearSessionCookie(options), identity: null };
|
||||||
return { setCookie: sessionCookie(completed.jwt, options), identity: { email: completed.email ?? "", id: completed.identityId, roles: completed.roles } };
|
return { setCookie: sessionCookie(completed.jwt, options), identity: { email: completed.email ?? "", id: completed.identityId, permissions: completed.permissions } };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is
|
// Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// /oauth2/consent?consent_challenge=… (hydra.yml urls.consent). A first-party client (or one
|
// /oauth2/consent?consent_challenge=… (hydra.yml urls.consent). A first-party client (or one
|
||||||
// Hydra already skipped) is auto-granted the requested scopes; a third-party client shows the
|
// Hydra already skipped) is auto-granted the requested scopes; a third-party client shows the
|
||||||
// themed consent screen, then accept (allow) / reject (deny). id_token claims (email/name) come
|
// themed consent screen, then accept (allow) / reject (deny). id_token claims (email/name) come
|
||||||
// from the Kratos identity. OAuth2-provider role only — no first-party page needs this (README).
|
// from the Kratos identity. OAuth2-provider permission only — no first-party page needs this (README).
|
||||||
import type { AcceptConsent, ConsentRequest, HydraAdmin, OAuth2Client } from "./hydra-admin.ts";
|
import type { AcceptConsent, ConsentRequest, HydraAdmin, OAuth2Client } from "./hydra-admin.ts";
|
||||||
import type { KratosPublic } from "./kratos-public.ts";
|
import type { KratosPublic } from "./kratos-public.ts";
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Hydra hands the browser to /oauth2/login?login_challenge=… (hydra.yml urls.login). We
|
// Hydra hands the browser to /oauth2/login?login_challenge=… (hydra.yml urls.login). We
|
||||||
// authenticate the user with their existing Kratos session and accept the request; Hydra then
|
// authenticate the user with their existing Kratos session and accept the request; Hydra then
|
||||||
// proceeds to consent and mints the tokens. No first-party page needs this — it's the OAuth2
|
// proceeds to consent and mints the tokens. No first-party page needs this — it's the OAuth2
|
||||||
// provider role only (README).
|
// provider permission only (README).
|
||||||
import type { HydraAdmin } from "./hydra-admin.ts";
|
import type { HydraAdmin } from "./hydra-admin.ts";
|
||||||
import type { KratosPublic } from "./kratos-public.ts";
|
import type { KratosPublic } from "./kratos-public.ts";
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -192,7 +192,7 @@ function oauthLogout(hydra: HydraAdmin): BuiltinRoute["handler"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Login completion: where Kratos lands the browser after authenticating (kratos.yml). Mint our
|
// Login completion: where Kratos lands the browser after authenticating (kratos.yml). Mint our
|
||||||
// session JWT — read roles from Keto, project onto the identity, tokenize — and store it as the
|
// session JWT — read permissions from Keto, project onto the identity, tokenize — and store it as the
|
||||||
// cookie; no active session bounces back to sign in.
|
// cookie; no active session bounces back to sign in.
|
||||||
function completeAuth(deps: { keto: KetoClient; kratosAdmin: KratosAdmin; kratosPublic: KratosPublic }, secureCookies: boolean): BuiltinRoute["handler"] {
|
function completeAuth(deps: { keto: KetoClient; kratosAdmin: KratosAdmin; kratosPublic: KratosPublic }, secureCookies: boolean): BuiltinRoute["handler"] {
|
||||||
return async (ctx: RequestContext): Promise<RouteResult> => {
|
return async (ctx: RequestContext): Promise<RouteResult> => {
|
||||||
|
|||||||
+2
-2
@@ -31,7 +31,7 @@ export interface Config {
|
|||||||
otlpEndpoint: string | undefined; // OTLP/HTTP collector base URI; unset ⇒ console-only (no export)
|
otlpEndpoint: string | undefined; // OTLP/HTTP collector base URI; unset ⇒ console-only (no export)
|
||||||
otlpProtocol: "http/json" | "http/protobuf"; // OTLP wire format (protobuf for json-averse collectors)
|
otlpProtocol: "http/json" | "http/protobuf"; // OTLP wire format (protobuf for json-averse collectors)
|
||||||
port: number;
|
port: number;
|
||||||
revocationDenylist: boolean; // enable the optional instant role/session revoke denylist
|
revocationDenylist: boolean; // enable the optional instant permission/session revoke denylist
|
||||||
revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew
|
revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew
|
||||||
secureCookies: boolean;
|
secureCookies: boolean;
|
||||||
serviceName: string; // OTLP service.name — an implementer brands their own logs/traces
|
serviceName: string; // OTLP service.name — an implementer brands their own logs/traces
|
||||||
@@ -157,7 +157,7 @@ export function loadConfig(env: Env = process.env): Config {
|
|||||||
otlpEndpoint: readOptionalUrl(env, "OTLP_ENDPOINT"),
|
otlpEndpoint: readOptionalUrl(env, "OTLP_ENDPOINT"),
|
||||||
otlpProtocol: readEnum(env, "OTLP_PROTOCOL", ["http/json", "http/protobuf"] as const, "http/json"),
|
otlpProtocol: readEnum(env, "OTLP_PROTOCOL", ["http/json", "http/protobuf"] as const, "http/json"),
|
||||||
port: readPort(env),
|
port: readPort(env),
|
||||||
// Optional instant-revoke, off by default. When on, an admin deactivate/delete or role
|
// Optional instant-revoke, off by default. When on, an admin deactivate/delete or permission
|
||||||
// change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m
|
// change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m
|
||||||
// tokenizer TTL + skew, so it outlasts any pre-revoke token).
|
// tokenizer TTL + skew, so it outlasts any pre-revoke token).
|
||||||
revocationDenylist: readBool(env, "REVOCATION_DENYLIST", false),
|
revocationDenylist: readBool(env, "REVOCATION_DENYLIST", false),
|
||||||
|
|||||||
+68
-68
@@ -40,9 +40,9 @@ function mintJwt(payload: Record<string, unknown>): string {
|
|||||||
const input = `${b64url(JSON.stringify({ alg: "ES256", kid: "test-kid", typ: "JWT" }))}.${b64url(JSON.stringify(payload))}`;
|
const input = `${b64url(JSON.stringify({ alg: "ES256", kid: "test-kid", typ: "JWT" }))}.${b64url(JSON.stringify(payload))}`;
|
||||||
return `${input}.${b64url(sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key: ec.privateKey }))}`;
|
return `${input}.${b64url(sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key: ec.privateKey }))}`;
|
||||||
}
|
}
|
||||||
// A session cookie carrying `roles`, valid for 10 min — the auth most tests need to reach a gated page.
|
// A session cookie carrying `permissions`, valid for 10 min — the auth most tests need to reach a gated page.
|
||||||
const session = (roles: string[] = []): string =>
|
const session = (permissions: string[] = []): string =>
|
||||||
`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, roles, sub: "u1" })}`;
|
`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, permissions, sub: "u1" })}`;
|
||||||
|
|
||||||
const server = createApp({ jwks: staticJwks([ecJwk]) });
|
const server = createApp({ jwks: staticJwks([ecJwk]) });
|
||||||
let base = "";
|
let base = "";
|
||||||
@@ -83,7 +83,7 @@ test("/ is the public landing: anonymous → 200 with intro + sign-in/register l
|
|||||||
const html = await res.text();
|
const html = await res.text();
|
||||||
assert.match(html, /href="\/login"/); // a prominent path to sign in
|
assert.match(html, /href="\/login"/); // a prominent path to sign in
|
||||||
assert.match(html, /href="\/registration"/); // and to register
|
assert.match(html, /href="\/registration"/); // and to register
|
||||||
// the same app shell every page renders — the menu shows even when signed out (role-filtered).
|
// the same app shell every page renders — the menu shows even when signed out (permission-filtered).
|
||||||
assert.match(html, /<aside class="sidebar"/);
|
assert.match(html, /<aside class="sidebar"/);
|
||||||
assert.match(html, /class="landing-title"/); // the landing hero owns the page's single <h1>
|
assert.match(html, /class="landing-title"/); // the landing hero owns the page's single <h1>
|
||||||
});
|
});
|
||||||
@@ -385,7 +385,7 @@ test("renders the 500 HTML page when a handler throws", async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// A test plugin exercising each RouteResult shape, a path param, and the role gate.
|
// A test plugin exercising each RouteResult shape, a path param, and the permission gate.
|
||||||
const demoPlugin: Plugin = {
|
const demoPlugin: Plugin = {
|
||||||
apiVersion: "1.0.0",
|
apiVersion: "1.0.0",
|
||||||
id: "demo",
|
id: "demo",
|
||||||
@@ -393,7 +393,7 @@ const demoPlugin: Plugin = {
|
|||||||
{ handler: (ctx) => ({ html: `<p>Hi ${ctx.params.name}</p>` }), method: "GET", path: "/hello/:name" },
|
{ handler: (ctx) => ({ html: `<p>Hi ${ctx.params.name}</p>` }), method: "GET", path: "/hello/:name" },
|
||||||
{ handler: () => ({ json: { ok: true } }), method: "GET", path: "/data" },
|
{ handler: () => ({ json: { ok: true } }), method: "GET", path: "/data" },
|
||||||
{ handler: () => ({ redirect: "/demo/hello/world" }), method: "POST", path: "/go" },
|
{ handler: () => ({ redirect: "/demo/hello/world" }), method: "POST", path: "/go" },
|
||||||
{ handler: () => ({ html: "secret" }), method: "GET", path: "/secret", role: "demo:read" },
|
{ handler: () => ({ html: "secret" }), method: "GET", path: "/secret", permission: "demo:read" },
|
||||||
{ handler: () => ({ html: "open to all" }), method: "GET", path: "/public-page", public: true }, // blessed public
|
{ handler: () => ({ html: "open to all" }), method: "GET", path: "/public-page", public: true }, // blessed public
|
||||||
{ handler: () => ({ data: { who: "Plainpages" }, view: "page" }), method: "GET", path: "/page" },
|
{ handler: () => ({ data: { who: "Plainpages" }, view: "page" }), method: "GET", path: "/page" },
|
||||||
],
|
],
|
||||||
@@ -406,7 +406,7 @@ async function startApp(t: TestContext, plugins: Plugin[], pluginsDir?: string):
|
|||||||
return `http://localhost:${(app.address() as AddressInfo).port}`;
|
return `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
test("mounts plugin routes: params, html/json/redirect/view results, and the role gate", async (t) => {
|
test("mounts plugin routes: params, html/json/redirect/view results, and the permission gate", async (t) => {
|
||||||
const dir = mkdtempSync(join(tmpdir(), "pp-plugins-"));
|
const dir = mkdtempSync(join(tmpdir(), "pp-plugins-"));
|
||||||
mkdirSync(join(dir, "demo", "views"), { recursive: true });
|
mkdirSync(join(dir, "demo", "views"), { recursive: true });
|
||||||
mkdirSync(join(dir, "demo", "public"), { recursive: true });
|
mkdirSync(join(dir, "demo", "public"), { recursive: true });
|
||||||
@@ -516,9 +516,9 @@ test("a plugin view renders the native chrome; its forms are CSRF-guarded via ct
|
|||||||
assert.equal(ok.status, 303);
|
assert.equal(ok.status, 303);
|
||||||
});
|
});
|
||||||
|
|
||||||
// JWT middleware: a verified session cookie populates ctx.identity/roles, which the gate reads.
|
// JWT middleware: a verified session cookie populates ctx.identity/permissions, which the gate reads.
|
||||||
// The key + mintJwt + session() helper are hoisted above the shared `server` (top of file).
|
// The key + mintJwt + session() helper are hoisted above the shared `server` (top of file).
|
||||||
test("a verified session JWT authorizes a role-gated route; no cookie / expired token → sign in", async (t) => {
|
test("a verified session JWT authorizes a permission-gated route; no cookie / expired token → sign in", async (t) => {
|
||||||
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [demoPlugin] });
|
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [demoPlugin] });
|
||||||
await new Promise<void>((r) => app.listen(0, r));
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
t.after(() => app.close());
|
t.after(() => app.close());
|
||||||
@@ -526,8 +526,8 @@ test("a verified session JWT authorizes a role-gated route; no cookie / expired
|
|||||||
const nowSec = Math.floor(Date.now() / 1000);
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
const secret = (cookie?: string) => fetch(url + "/demo/secret", { redirect: "manual", ...(cookie ? { headers: { cookie } } : {}) });
|
const secret = (cookie?: string) => fetch(url + "/demo/secret", { redirect: "manual", ...(cookie ? { headers: { cookie } } : {}) });
|
||||||
|
|
||||||
// Token carrying the gating role → the handler runs (200).
|
// Token carrying the gating permission → the handler runs (200).
|
||||||
const ok = await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["demo:read"], sub: "u1" })}`);
|
const ok = await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions: ["demo:read"], sub: "u1" })}`);
|
||||||
assert.equal(ok.status, 200);
|
assert.equal(ok.status, 200);
|
||||||
assert.equal(await ok.text(), "secret");
|
assert.equal(await ok.text(), "secret");
|
||||||
|
|
||||||
@@ -536,12 +536,12 @@ test("a verified session JWT authorizes a role-gated route; no cookie / expired
|
|||||||
const noCookie = await secret();
|
const noCookie = await secret();
|
||||||
assert.equal(noCookie.status, 303);
|
assert.equal(noCookie.status, 303);
|
||||||
assert.equal(noCookie.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret");
|
assert.equal(noCookie.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret");
|
||||||
assert.equal((await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}`)).status, 303);
|
assert.equal((await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, permissions: ["demo:read"], sub: "u1" })}`)).status, 303);
|
||||||
|
|
||||||
// The gated dashboard renders for any signed-in user; anonymous is bounced to sign in before any
|
// The gated dashboard renders for any signed-in user; anonymous is bounced to sign in before any
|
||||||
// page renders (gate on /dashboard). The Admin section links come from the admin plugin — its nav
|
// page renders (gate on /dashboard). The Admin section links come from the admin plugin — its nav
|
||||||
// composition + role-filtering is covered in the admin-screen tests below.
|
// composition + permission-filtering is covered in the admin-screen tests below.
|
||||||
const dash = await fetch(url + "/dashboard", { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["admin"], sub: "u1" })}` } });
|
const dash = await fetch(url + "/dashboard", { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions: ["admin"], sub: "u1" })}` } });
|
||||||
assert.equal(dash.status, 200);
|
assert.equal(dash.status, 200);
|
||||||
const anonDash = await fetch(url + "/dashboard", { redirect: "manual" });
|
const anonDash = await fetch(url + "/dashboard", { redirect: "manual" });
|
||||||
assert.equal(anonDash.status, 303);
|
assert.equal(anonDash.status, 303);
|
||||||
@@ -555,7 +555,7 @@ test("revocation denylist: a revoked subject's token stops authorizing on the ho
|
|||||||
t.after(() => app.close());
|
t.after(() => app.close());
|
||||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
const secret = (iat: number) => fetch(url + "/demo/secret", { redirect: "manual", headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, iat, roles: ["demo:read"], sub: "u1" })}` } });
|
const secret = (iat: number) => fetch(url + "/demo/secret", { redirect: "manual", headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, iat, permissions: ["demo:read"], sub: "u1" })}` } });
|
||||||
|
|
||||||
assert.equal((await secret(nowSec)).status, 200); // before any revoke, the token authorizes
|
assert.equal((await secret(nowSec)).status, 200); // before any revoke, the token authorizes
|
||||||
|
|
||||||
@@ -567,10 +567,10 @@ test("revocation denylist: a revoked subject's token stops authorizing on the ho
|
|||||||
test("session re-mint: an expired JWT backed by a live Kratos session is silently re-minted; a dead session clears it", async (t) => {
|
test("session re-mint: an expired JWT backed by a live Kratos session is silently re-minted; a dead session clears it", async (t) => {
|
||||||
const identity: Identity = { id: "u1", traits: { email: "a@b.c" } };
|
const identity: Identity = { id: "u1", traits: { email: "a@b.c" } };
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
const freshJwt = mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["demo:read"], sub: "u1" });
|
const freshJwt = mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions: ["demo:read"], sub: "u1" });
|
||||||
const live = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: freshJwt } : { active: true, identity }) as Session);
|
const live = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: freshJwt } : { active: true, identity }) as Session);
|
||||||
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "demo:read", relation: "members", subject_id: "identity:u1" }] }) });
|
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Permission", object: "demo:read", relation: "granted", subject_id: "identity:u1" }] }) });
|
||||||
const expired = `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}; plainpages_session=s`;
|
const expired = `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, permissions: ["demo:read"], sub: "u1" })}; plainpages_session=s`;
|
||||||
|
|
||||||
// Live Kratos session: the lapsed token is re-minted — the gated route runs AND a fresh cookie rides the response.
|
// Live Kratos session: the lapsed token is re-minted — the gated route runs AND a fresh cookie rides the response.
|
||||||
const app = createApp({ jwks: staticJwks([ecJwk]), keto, kratos: live, kratosAdmin: stubAdmin({}), plugins: [demoPlugin] });
|
const app = createApp({ jwks: staticJwks([ecJwk]), keto, kratos: live, kratosAdmin: stubAdmin({}), plugins: [demoPlugin] });
|
||||||
@@ -610,7 +610,7 @@ test("guards map to responses: requireSession → /login, a failed can/check →
|
|||||||
{ handler: (ctx) => ({ html: `hi ${requireSession(ctx).email}` }), method: "GET", path: "/me" },
|
{ handler: (ctx) => ({ html: `hi ${requireSession(ctx).email}` }), method: "GET", path: "/me" },
|
||||||
{ handler: (ctx) => { if (!can(ctx, "admin")) throw new GuardError(403, "no"); return { html: "ok" }; }, method: "GET", path: "/admin-only" },
|
{ handler: (ctx) => { if (!can(ctx, "admin")) throw new GuardError(403, "no"); return { html: "ok" }; }, method: "GET", path: "/admin-only" },
|
||||||
{ handler: async (ctx) => { if (!(await check(keto, ctx, { namespace: "Resource", object: ctx.params.id ?? "", relation: "view" }))) throw new GuardError(403, "no"); return { html: "seen" }; }, method: "GET", path: "/doc/:id" },
|
{ handler: async (ctx) => { if (!(await check(keto, ctx, { namespace: "Resource", object: ctx.params.id ?? "", relation: "view" }))) throw new GuardError(403, "no"); return { html: "seen" }; }, method: "GET", path: "/doc/:id" },
|
||||||
{ handler: () => ({ html: "gated" }), method: "GET", path: "/gated", role: "secret:read" }, // declarative route gate
|
{ handler: () => ({ html: "gated" }), method: "GET", path: "/gated", permission: "secret:read" }, // declarative route gate
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [guarded] });
|
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [guarded] });
|
||||||
@@ -618,7 +618,7 @@ test("guards map to responses: requireSession → /login, a failed can/check →
|
|||||||
t.after(() => app.close());
|
t.after(() => app.close());
|
||||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
const auth = (roles: string[]) => ({ headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles, sub: "u1" })}` } });
|
const auth = (permissions: string[]) => ({ headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions, sub: "u1" })}` } });
|
||||||
|
|
||||||
// requireSession: anonymous bounces to /login (remembering the page); a signed-in user reaches the handler.
|
// requireSession: anonymous bounces to /login (remembering the page); a signed-in user reaches the handler.
|
||||||
const anon = await fetch(url + "/guarded/me", { redirect: "manual" });
|
const anon = await fetch(url + "/guarded/me", { redirect: "manual" });
|
||||||
@@ -628,7 +628,7 @@ test("guards map to responses: requireSession → /login, a failed can/check →
|
|||||||
assert.equal(me.status, 200);
|
assert.equal(me.status, 200);
|
||||||
assert.match(await me.text(), /hi a@b\.c/);
|
assert.match(await me.text(), /hi a@b\.c/);
|
||||||
|
|
||||||
// can: signed-in but lacking the role → 403 page; carrying it → 200.
|
// can: signed-in but lacking the permission → 403 page; carrying it → 200.
|
||||||
assert.equal((await fetch(url + "/guarded/admin-only", auth([]))).status, 403);
|
assert.equal((await fetch(url + "/guarded/admin-only", auth([]))).status, 403);
|
||||||
assert.equal((await fetch(url + "/guarded/admin-only", auth(["admin"]))).status, 200);
|
assert.equal((await fetch(url + "/guarded/admin-only", auth(["admin"]))).status, 200);
|
||||||
|
|
||||||
@@ -636,7 +636,7 @@ test("guards map to responses: requireSession → /login, a failed can/check →
|
|||||||
assert.equal((await fetch(url + "/guarded/doc/open", auth([]))).status, 200);
|
assert.equal((await fetch(url + "/guarded/doc/open", auth([]))).status, 200);
|
||||||
assert.equal((await fetch(url + "/guarded/doc/shut", auth([]))).status, 403);
|
assert.equal((await fetch(url + "/guarded/doc/shut", auth([]))).status, 403);
|
||||||
|
|
||||||
// declarative route `role` gate: anonymous → sign in, signed-in-without-role → the 403 page, with → 200.
|
// declarative route `permission` gate: anonymous → sign in, signed-in-without-permission → the 403 page, with → 200.
|
||||||
const gAnon = await fetch(url + "/guarded/gated", { redirect: "manual" });
|
const gAnon = await fetch(url + "/guarded/gated", { redirect: "manual" });
|
||||||
assert.equal(gAnon.status, 303);
|
assert.equal(gAnon.status, 303);
|
||||||
assert.equal(gAnon.headers.get("location"), "/login?return_to=%2Fguarded%2Fgated");
|
assert.equal(gAnon.headers.get("location"), "/login?return_to=%2Fguarded%2Fgated");
|
||||||
@@ -717,7 +717,7 @@ test("themed auth GET: anonymous inits a flow (CSRF relay, stale→restart); a s
|
|||||||
assert.equal(stale.headers.get("location"), "/login");
|
assert.equal(stale.headers.get("location"), "/login");
|
||||||
|
|
||||||
// Already signed in → /login + /registration short-circuit to the app dashboard; /settings stays reachable.
|
// Already signed in → /login + /registration short-circuit to the app dashboard; /settings stays reachable.
|
||||||
const signedIn = { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, roles: [], sub: "u1" })}` }, redirect: "manual" as const };
|
const signedIn = { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, permissions: [], sub: "u1" })}` }, redirect: "manual" as const };
|
||||||
for (const path of ["/login", "/registration"]) {
|
for (const path of ["/login", "/registration"]) {
|
||||||
const res = await fetch(url + path, signedIn);
|
const res = await fetch(url + path, signedIn);
|
||||||
assert.equal(res.status, 303, `${path} while signed in → 303`);
|
assert.equal(res.status, 303, `${path} while signed in → 303`);
|
||||||
@@ -856,7 +856,7 @@ const fakeKeto = (tuples: RelationTuple[] = [], over: Partial<KetoClient> = {}):
|
|||||||
const withWhoami = (whoami: KratosPublic["whoami"]): KratosPublic => ({ ...mockKratos(async () => { throw new Error("unused"); }), whoami });
|
const withWhoami = (whoami: KratosPublic["whoami"]): KratosPublic => ({ ...mockKratos(async () => { throw new Error("unused"); }), whoami });
|
||||||
|
|
||||||
// Shared harness for the admin-screen HTTP tests: an app on a random port with an admin JWT +
|
// Shared harness for the admin-screen HTTP tests: an app on a random port with an admin JWT +
|
||||||
// CSRF cookie. get(path, roles)/post(path, body) carry them; `token` is the matching CSRF field.
|
// CSRF cookie. get(path, permissions)/post(path, body) carry them; `token` is the matching CSRF field.
|
||||||
const ADMIN_CSRF = "admin-secret";
|
const ADMIN_CSRF = "admin-secret";
|
||||||
async function adminHarness(t: TestContext, opts: AppOptions = {}) {
|
async function adminHarness(t: TestContext, opts: AppOptions = {}) {
|
||||||
const app = createApp({ csrfSecret: ADMIN_CSRF, jwks: staticJwks([ecJwk]), pluginsDir: examplesPluginsDir, plugins: [adminPlugin], ...opts });
|
const app = createApp({ csrfSecret: ADMIN_CSRF, jwks: staticJwks([ecJwk]), pluginsDir: examplesPluginsDir, plugins: [adminPlugin], ...opts });
|
||||||
@@ -865,14 +865,14 @@ async function adminHarness(t: TestContext, opts: AppOptions = {}) {
|
|||||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
const token = issueCsrfToken(ADMIN_CSRF);
|
const token = issueCsrfToken(ADMIN_CSRF);
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
const cookie = (roles: string[]) => `${SESSION_COOKIE}=${mintJwt({ email: "admin@x", exp: nowSec + 600, roles, sub: "admin1" })}; ${CSRF_COOKIE}=${token}`;
|
const cookie = (permissions: string[]) => `${SESSION_COOKIE}=${mintJwt({ email: "admin@x", exp: nowSec + 600, permissions, sub: "admin1" })}; ${CSRF_COOKIE}=${token}`;
|
||||||
const get = (path: string, roles: string[] = ["admin"]) => fetch(url + path, { headers: { cookie: cookie(roles) }, redirect: "manual" });
|
const get = (path: string, permissions: string[] = ["admin"]) => fetch(url + path, { headers: { cookie: cookie(permissions) }, redirect: "manual" });
|
||||||
const post = (path: string, body: string) =>
|
const post = (path: string, body: string) =>
|
||||||
fetch(url + path, { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookie(["admin"]) }, method: "POST", redirect: "manual" });
|
fetch(url + path, { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookie(["admin"]) }, method: "POST", redirect: "manual" });
|
||||||
return { get, post, token, url };
|
return { get, post, token, url };
|
||||||
}
|
}
|
||||||
// Every admin route is gated: anonymous → /login, a signed-in non-admin → 403.
|
// Every admin route is gated: anonymous → /login, a signed-in non-admin → 403.
|
||||||
async function assertAdminGate(url: string, get: (path: string, roles?: string[]) => Promise<Response>, path: string) {
|
async function assertAdminGate(url: string, get: (path: string, permissions?: string[]) => Promise<Response>, path: string) {
|
||||||
const anon = await fetch(url + path, { redirect: "manual" });
|
const anon = await fetch(url + path, { redirect: "manual" });
|
||||||
assert.equal(anon.status, 303);
|
assert.equal(anon.status, 303);
|
||||||
assert.equal(anon.headers.get("location"), `/login?return_to=${encodeURIComponent(path)}`); // remembers the page
|
assert.equal(anon.headers.get("location"), `/login?return_to=${encodeURIComponent(path)}`); // remembers the page
|
||||||
@@ -884,7 +884,7 @@ test("login completion (/auth/complete): a live session mints the JWT cookie; no
|
|||||||
let projected: unknown;
|
let projected: unknown;
|
||||||
const kratos = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session);
|
const kratos = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session);
|
||||||
const kratosAdmin = stubAdmin({ updateMetadataPublic: async (_id, meta) => { projected = meta; return identity; } });
|
const kratosAdmin = stubAdmin({ updateMetadataPublic: async (_id, meta) => { projected = meta; return identity; } });
|
||||||
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "admin", relation: "members", subject_id: `identity:${identity.id}` }] }) });
|
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Permission", object: "admin", relation: "granted", subject_id: `identity:${identity.id}` }] }) });
|
||||||
const complete = async (app: ReturnType<typeof createApp>, cookie?: string, returnTo?: string) => {
|
const complete = async (app: ReturnType<typeof createApp>, cookie?: string, returnTo?: string) => {
|
||||||
await new Promise<void>((r) => app.listen(0, r));
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
t.after(() => app.close());
|
t.after(() => app.close());
|
||||||
@@ -892,12 +892,12 @@ test("login completion (/auth/complete): a live session mints the JWT cookie; no
|
|||||||
return fetch(`http://localhost:${(app.address() as AddressInfo).port}/auth/complete${q}`, { headers: cookie ? { cookie } : {}, redirect: "manual" });
|
return fetch(`http://localhost:${(app.address() as AddressInfo).port}/auth/complete${q}`, { headers: cookie ? { cookie } : {}, redirect: "manual" });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Live Kratos session: roles from Keto → projection → tokenize → JWT cookie, land on the dashboard.
|
// Live Kratos session: permissions from Keto → projection → tokenize → JWT cookie, land on the dashboard.
|
||||||
const ok = await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s");
|
const ok = await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s");
|
||||||
assert.equal(ok.status, 303);
|
assert.equal(ok.status, 303);
|
||||||
assert.equal(ok.headers.get("location"), "/dashboard");
|
assert.equal(ok.headers.get("location"), "/dashboard");
|
||||||
assert.match(ok.headers.get("set-cookie") ?? "", /^plainpages_jwt=h\.p\.s;.*HttpOnly/);
|
assert.match(ok.headers.get("set-cookie") ?? "", /^plainpages_jwt=h\.p\.s;.*HttpOnly/);
|
||||||
assert.deepEqual(projected, { roles: ["admin"] }); // Keto roles projected onto the identity for the tokenizer
|
assert.deepEqual(projected, { permissions: ["admin"] }); // Keto permissions projected onto the identity for the tokenizer
|
||||||
|
|
||||||
// return_to: a safe host-relative target lands the user back where they were headed; an
|
// return_to: a safe host-relative target lands the user back where they were headed; an
|
||||||
// off-origin one is ignored (open-redirect guard) and falls back to the dashboard.
|
// off-origin one is ignored (open-redirect guard) and falls back to the dashboard.
|
||||||
@@ -1164,7 +1164,7 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
|
|||||||
assert.equal((await post(`/admin/users/admin1/state`, `_csrf=${token}`)).status, 400);
|
assert.equal((await post(`/admin/users/admin1/state`, `_csrf=${token}`)).status, 400);
|
||||||
assert.equal(store.find((x) => x.id === "admin1")!.state, "active");
|
assert.equal(store.find((x) => x.id === "admin1")!.state, "active");
|
||||||
|
|
||||||
// Unknown id → 404; malformed %-encoding → 404 (not a 500), matching groups/roles/clients.
|
// Unknown id → 404; malformed %-encoding → 404 (not a 500), matching groups/permissions/clients.
|
||||||
assert.equal((await get(`/admin/users/${randomUUID()}`)).status, 404);
|
assert.equal((await get(`/admin/users/${randomUUID()}`)).status, 404);
|
||||||
assert.equal((await get("/admin/users/%ZZ")).status, 404);
|
assert.equal((await get("/admin/users/%ZZ")).status, 404);
|
||||||
});
|
});
|
||||||
@@ -1235,10 +1235,10 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
|
|||||||
{ id: ada, schema_id: "default", state: "active", traits: { email: "ada@example.com" } },
|
{ id: ada, schema_id: "default", state: "active", traits: { email: "ada@example.com" } },
|
||||||
{ id: grace, schema_id: "default", state: "active", traits: { email: "grace@example.com" } },
|
{ id: grace, schema_id: "default", state: "active", traits: { email: "grace@example.com" } },
|
||||||
];
|
];
|
||||||
// grace is in the `eng` group; `editor` is an existing role whose only direct member is ada.
|
// grace is in the `eng` group; `editor` is an existing permission whose only direct member is ada.
|
||||||
const tuples: RelationTuple[] = [
|
const tuples: RelationTuple[] = [
|
||||||
{ namespace: "Group", object: "eng", relation: "members", subject_id: `identity:${grace}` },
|
{ namespace: "Group", object: "eng", relation: "members", subject_id: `identity:${grace}` },
|
||||||
{ namespace: "Role", object: "editor", relation: "members", subject_id: `identity:${ada}` },
|
{ namespace: "Permission", object: "editor", relation: "granted", subject_id: `identity:${ada}` },
|
||||||
];
|
];
|
||||||
// Mirror Keto's expand shape: the subject rides on `tuple`, set nodes carry members as children.
|
// Mirror Keto's expand shape: the subject rides on `tuple`, set nodes carry members as children.
|
||||||
const expandSet = (set: SubjectSet): ExpandTree => ({
|
const expandSet = (set: SubjectSet): ExpandTree => ({
|
||||||
@@ -1250,70 +1250,70 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
|
|||||||
});
|
});
|
||||||
const keto = fakeKeto(tuples, { expand: async (set) => expandSet(set) });
|
const keto = fakeKeto(tuples, { expand: async (set) => expandSet(set) });
|
||||||
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
|
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
const denylist = createDenylist(); // granting/revoking a *user's* role revokes their live tokens (a group change is transitive → left to lag)
|
const denylist = createDenylist(); // granting/revoking a *user's* permission revokes their live tokens (a group change is transitive → left to lag)
|
||||||
const { get, post, token, url } = await adminHarness(t, { denylist, keto, kratosAdmin });
|
const { get, post, token, url } = await adminHarness(t, { denylist, keto, kratosAdmin });
|
||||||
|
|
||||||
await assertAdminGate(url, get, "/admin/roles");
|
await assertAdminGate(url, get, "/admin/permissions");
|
||||||
|
|
||||||
// List: the existing role shows + the "add" link.
|
// List: the existing permission shows + the "add" link.
|
||||||
const listHtml = await (await get("/admin/roles")).text();
|
const listHtml = await (await get("/admin/permissions")).text();
|
||||||
assert.match(listHtml, /href="\/admin\/roles\/editor"/);
|
assert.match(listHtml, /href="\/admin\/permissions\/editor"/);
|
||||||
assert.match(listHtml, /href="\/admin\/roles\/new"/);
|
assert.match(listHtml, /href="\/admin\/permissions\/new"/);
|
||||||
|
|
||||||
// Create: a valid post writes the first-member tuple and redirects to the detail.
|
// Create: a valid post writes the first-member tuple and redirects to the detail.
|
||||||
assert.match(await (await get("/admin/roles/new")).text(), /Create role/);
|
assert.match(await (await get("/admin/permissions/new")).text(), /Create permission/);
|
||||||
const created = await post("/admin/roles", `_csrf=${token}&name=viewer&member=identity:${ada}`);
|
const created = await post("/admin/permissions", `_csrf=${token}&name=viewer&member=identity:${ada}`);
|
||||||
assert.equal(created.status, 303);
|
assert.equal(created.status, 303);
|
||||||
assert.equal(created.headers.get("location"), "/admin/roles/viewer");
|
assert.equal(created.headers.get("location"), "/admin/permissions/viewer");
|
||||||
assert.ok(tuples.some((tp) => tp.namespace === "Role" && tp.object === "viewer" && tp.subject_id === `identity:${ada}`));
|
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "viewer" && tp.subject_id === `identity:${ada}`));
|
||||||
assert.equal(denylist.isRevoked(ada, 0), true); // assigning a role to a user revokes their stale token so the grant lands now
|
assert.equal(denylist.isRevoked(ada, 0), true); // assigning a permission to a user revokes their stale token so the grant lands now
|
||||||
|
|
||||||
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written.
|
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written.
|
||||||
const before = tuples.length;
|
const before = tuples.length;
|
||||||
assert.equal((await post("/admin/roles", `_csrf=${token}&name=Bad Name&member=identity:${ada}`)).status, 400);
|
assert.equal((await post("/admin/permissions", `_csrf=${token}&name=Bad Name&member=identity:${ada}`)).status, 400);
|
||||||
assert.equal((await post("/admin/roles", `_csrf=${token}&name=editor&member=identity:${ada}`)).status, 400); // already exists
|
assert.equal((await post("/admin/permissions", `_csrf=${token}&name=editor&member=identity:${ada}`)).status, 400); // already exists
|
||||||
assert.equal((await post("/admin/roles", `name=x&member=identity:${ada}`)).status, 403);
|
assert.equal((await post("/admin/permissions", `name=x&member=identity:${ada}`)).status, 403);
|
||||||
assert.equal(tuples.length, before);
|
assert.equal(tuples.length, before);
|
||||||
|
|
||||||
// Detail: ada (direct) is in the effective-access list; grace (only reachable via a group) is not
|
// Detail: ada (direct) is in the effective-access list; grace (only reachable via a group) is not
|
||||||
// yet — though grace appears elsewhere as an assignable candidate, so target the effective <li>.
|
// yet — though grace appears elsewhere as an assignable candidate, so target the effective <li>.
|
||||||
const effectiveLi = (email: string) => new RegExp(`<li><span class="cell-strong">${email.replace(".", "\\.")}`);
|
const effectiveLi = (email: string) => new RegExp(`<li><span class="cell-strong">${email.replace(".", "\\.")}`);
|
||||||
const detail = await (await get("/admin/roles/editor")).text();
|
const detail = await (await get("/admin/permissions/editor")).text();
|
||||||
assert.match(detail, effectiveLi("ada@example.com"));
|
assert.match(detail, effectiveLi("ada@example.com"));
|
||||||
assert.doesNotMatch(detail, effectiveLi("grace@example.com"));
|
assert.doesNotMatch(detail, effectiveLi("grace@example.com"));
|
||||||
|
|
||||||
// Assign the `eng` group to the role → grace now holds it transitively (effective access via expand).
|
// Assign the `eng` group to the permission → grace now holds it transitively (effective access via expand).
|
||||||
await post("/admin/roles/editor/members", `_csrf=${token}&member=group:eng`);
|
await post("/admin/permissions/editor/members", `_csrf=${token}&member=group:eng`);
|
||||||
assert.ok(tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor" && tp.subject_set?.object === "eng"));
|
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "editor" && tp.subject_set?.object === "eng"));
|
||||||
const withGroup = await (await get("/admin/roles/editor")).text();
|
const withGroup = await (await get("/admin/permissions/editor")).text();
|
||||||
assert.match(withGroup, effectiveLi("grace@example.com"));
|
assert.match(withGroup, effectiveLi("grace@example.com"));
|
||||||
|
|
||||||
// Revoke the group membership.
|
// Revoke the group membership.
|
||||||
await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=group:eng`);
|
await post("/admin/permissions/editor/members/delete", `_csrf=${token}&member=group:eng`);
|
||||||
assert.ok(!tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor" && tp.subject_set?.object === "eng"));
|
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.object === "editor" && tp.subject_set?.object === "eng"));
|
||||||
|
|
||||||
// Unassigning a *user* membership likewise revokes that user's live token, so the loss of access is immediate.
|
// Unassigning a *user* membership likewise revokes that user's live token, so the loss of access is immediate.
|
||||||
await post("/admin/roles/editor/members", `_csrf=${token}&member=identity:${grace}`);
|
await post("/admin/permissions/editor/members", `_csrf=${token}&member=identity:${grace}`);
|
||||||
await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=identity:${grace}`);
|
await post("/admin/permissions/editor/members/delete", `_csrf=${token}&member=identity:${grace}`);
|
||||||
assert.equal(denylist.isRevoked(grace, 0), true);
|
assert.equal(denylist.isRevoked(grace, 0), true);
|
||||||
|
|
||||||
// Delete the role: a confirm step (GET) then the POST removes every member tuple, back to the list.
|
// Delete the permission: a confirm step (GET) then the POST removes every member tuple, back to the list.
|
||||||
assert.match(await (await get("/admin/roles/editor/delete")).text(), /Cancel/);
|
assert.match(await (await get("/admin/permissions/editor/delete")).text(), /Cancel/);
|
||||||
const del = await post("/admin/roles/editor/delete", `_csrf=${token}`);
|
const del = await post("/admin/permissions/editor/delete", `_csrf=${token}`);
|
||||||
assert.equal(del.status, 303);
|
assert.equal(del.status, 303);
|
||||||
assert.equal(del.headers.get("location"), "/admin/roles");
|
assert.equal(del.headers.get("location"), "/admin/permissions");
|
||||||
assert.ok(!tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor"));
|
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.object === "editor"));
|
||||||
|
|
||||||
// Self-protection: the admin role can't be deleted, nor can you revoke your own admin (sub admin1).
|
// Self-protection: the admin permission can't be deleted, nor can you revoke your own admin (sub admin1).
|
||||||
tuples.push({ namespace: "Role", object: "admin", relation: "members", subject_id: "identity:admin1" });
|
tuples.push({ namespace: "Permission", object: "admin", relation: "granted", subject_id: "identity:admin1" });
|
||||||
assert.equal((await post("/admin/roles/admin/delete", `_csrf=${token}`)).status, 400);
|
assert.equal((await post("/admin/permissions/admin/delete", `_csrf=${token}`)).status, 400);
|
||||||
assert.ok(tuples.some((tp) => tp.object === "admin"));
|
assert.ok(tuples.some((tp) => tp.object === "admin"));
|
||||||
assert.equal((await post("/admin/roles/admin/members/delete", `_csrf=${token}&member=identity:admin1`)).status, 400);
|
assert.equal((await post("/admin/permissions/admin/members/delete", `_csrf=${token}&member=identity:admin1`)).status, 400);
|
||||||
assert.ok(tuples.some((tp) => tp.object === "admin" && tp.subject_id === "identity:admin1"));
|
assert.ok(tuples.some((tp) => tp.object === "admin" && tp.subject_id === "identity:admin1"));
|
||||||
|
|
||||||
// An invalid role name in the path → 404; malformed %-encoding doesn't 500.
|
// An invalid permission name in the path → 404; malformed %-encoding doesn't 500.
|
||||||
assert.equal((await get("/admin/roles/Bad%20Name")).status, 404);
|
assert.equal((await get("/admin/permissions/Bad%20Name")).status, 404);
|
||||||
assert.equal((await get("/admin/roles/%ZZ")).status, 404);
|
assert.equal((await get("/admin/permissions/%ZZ")).status, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Built-in OAuth2 clients admin screen: gate + list/register/detail/delete over HTTP against an
|
// Built-in OAuth2 clients admin screen: gate + list/register/detail/delete over HTTP against an
|
||||||
|
|||||||
+7
-7
@@ -40,7 +40,7 @@ export interface AppOptions {
|
|||||||
csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted
|
csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted
|
||||||
denylist?: Denylist; // optional instant-revoke; the hot path rejects revoked subjects, admin writes record revokes
|
denylist?: Denylist; // optional instant-revoke; the hot path rejects revoked subjects, admin writes record revokes
|
||||||
hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge
|
hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge
|
||||||
jwks?: JwksProvider; // verify the session JWT → ctx.identity/roles; absent ⇒ always anonymous
|
jwks?: JwksProvider; // verify the session JWT → ctx.identity/permissions; absent ⇒ always anonymous
|
||||||
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion
|
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion
|
||||||
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes
|
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes
|
||||||
kratosAdmin?: KratosAdmin; // Kratos admin client; with kratos+keto enables login completion
|
kratosAdmin?: KratosAdmin; // Kratos admin client; with kratos+keto enables login completion
|
||||||
@@ -186,9 +186,9 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the session JWT once (cached JWKS) → ctx.identity/roles; none/invalid ⇒ anonymous.
|
// Verify the session JWT once (cached JWKS) → ctx.identity/permissions; none/invalid ⇒ anonymous.
|
||||||
// If the token has lapsed but a live Kratos session still backs it (and we have the Ory
|
// If the token has lapsed but a live Kratos session still backs it (and we have the Ory
|
||||||
// clients), silently re-mint it — "stay signed in": re-read roles from Keto, re-tokenize,
|
// clients), silently re-mint it — "stay signed in": re-read permissions from Keto, re-tokenize,
|
||||||
// and set the fresh cookie via setHeader so it rides whatever response this request produces
|
// and set the fresh cookie via setHeader so it rides whatever response this request produces
|
||||||
// (a dead session clears the stale cookie). This is the only place the hot path touches Ory.
|
// (a dead session clears the stale cookie). This is the only place the hot path touches Ory.
|
||||||
let user: SessionIdentity | null = null;
|
let user: SessionIdentity | null = null;
|
||||||
@@ -240,17 +240,17 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plugin routes (any method): gate on the route's role, then run the handler. The
|
// Plugin routes (any method): gate on the route's permission, then run the handler. The
|
||||||
// handler gets ctx.chrome (native app shell) + ctx.verifyCsrf (guard its own forms); a fresh
|
// handler gets ctx.chrome (native app shell) + ctx.verifyCsrf (guard its own forms); a fresh
|
||||||
// CSRF cookie is set so those forms have a valid double-submit token.
|
// CSRF cookie is set so those forms have a valid double-submit token.
|
||||||
const match = matchRoute(plugins, method, pathname);
|
const match = matchRoute(plugins, method, pathname);
|
||||||
if (match) {
|
if (match) {
|
||||||
const routeCtx = buildContext(req, res, { chrome, identity: user, log: reqLog, params: match.params, verifyCsrf, ...(system ? { system } : {}) });
|
const routeCtx = buildContext(req, res, { chrome, identity: user, log: reqLog, params: match.params, verifyCsrf, ...(system ? { system } : {}) });
|
||||||
if (!isAuthorized(match.route, routeCtx.roles)) {
|
if (!isAuthorized(match.route, routeCtx.permissions)) {
|
||||||
// Anonymous → sign in (like the built-in screens' requireSession), remembering the page as
|
// Anonymous → sign in (like the built-in screens' requireSession), remembering the page as
|
||||||
// return_to; a signed-in user who simply lacks the role gets the 403 page.
|
// return_to; a signed-in user who simply lacks the permission gets the 403 page.
|
||||||
if (!routeCtx.identity) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
|
if (!routeCtx.identity) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
|
||||||
reqLog.warn("forbidden: missing role", { path: pathname, required: match.route.role ?? "", sub: routeCtx.identity.id });
|
reqLog.warn("forbidden: missing permission", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.identity.id });
|
||||||
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
|
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ test("buildContext parses the URL, exposes query, and defaults to an anonymous u
|
|||||||
assert.equal(ctx.query.get("q"), "ann");
|
assert.equal(ctx.query.get("q"), "ann");
|
||||||
assert.equal(ctx.query.get("page"), "2");
|
assert.equal(ctx.query.get("page"), "2");
|
||||||
assert.equal(ctx.identity, null);
|
assert.equal(ctx.identity, null);
|
||||||
assert.deepEqual(ctx.roles, []);
|
assert.deepEqual(ctx.permissions, []);
|
||||||
assert.deepEqual(ctx.params, {});
|
assert.deepEqual(ctx.params, {});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -33,12 +33,12 @@ test("buildContext threads path params supplied by the router", () => {
|
|||||||
assert.equal(ctx.params.id, "42");
|
assert.equal(ctx.params.id, "42");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("buildContext threads the user and derives roles from it", () => {
|
test("buildContext threads the user and derives permissions from it", () => {
|
||||||
const { req, res } = reqRes("/");
|
const { req, res } = reqRes("/");
|
||||||
const user: SessionIdentity = { email: "a@b.c", id: "u1", roles: ["admin", "editor"] };
|
const user: SessionIdentity = { email: "a@b.c", id: "u1", permissions: ["admin", "editor"] };
|
||||||
const ctx = buildContext(req, res, { identity: user });
|
const ctx = buildContext(req, res, { identity: user });
|
||||||
assert.equal(ctx.identity, user);
|
assert.equal(ctx.identity, user);
|
||||||
assert.equal(ctx.roles, user.roles); // same reference, never a divergent copy — buildContext is the only writer
|
assert.equal(ctx.permissions, user.permissions); // same reference, never a divergent copy — buildContext is the only writer
|
||||||
});
|
});
|
||||||
|
|
||||||
test("buildContext defaults a missing request URL to /", () => {
|
test("buildContext defaults a missing request URL to /", () => {
|
||||||
|
|||||||
+4
-4
@@ -8,11 +8,11 @@ import { createLogger, type Log } from "../logger.ts";
|
|||||||
// middleware supplies `identity` (null until then). The host's single handler argument.
|
// middleware supplies `identity` (null until then). The host's single handler argument.
|
||||||
|
|
||||||
// The authenticated Kratos identity, projected from verified session JWT claims:
|
// The authenticated Kratos identity, projected from verified session JWT claims:
|
||||||
// `id` = `sub`, plus `email` and the coarse `roles` carried in the token.
|
// `id` = `sub`, plus `email` and the coarse `permissions` carried in the token.
|
||||||
export interface SessionIdentity {
|
export interface SessionIdentity {
|
||||||
email: string;
|
email: string;
|
||||||
id: string;
|
id: string;
|
||||||
roles: string[];
|
permissions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RequestContext {
|
export interface RequestContext {
|
||||||
@@ -29,7 +29,7 @@ export interface RequestContext {
|
|||||||
query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q")
|
query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q")
|
||||||
req: IncomingMessage;
|
req: IncomingMessage;
|
||||||
res: ServerResponse;
|
res: ServerResponse;
|
||||||
roles: string[]; // identity?.roles ?? [] — coarse gate without a null-check
|
permissions: string[]; // identity?.permissions ?? [] — coarse gate without a null-check
|
||||||
// Privileged host services (Ory admin clients + instant-revoke) for a system plugin. Undefined
|
// Privileged host services (Ory admin clients + instant-revoke) for a system plugin. Undefined
|
||||||
// unless the host wired them; every field optional. Ordinary domain plugins ignore it.
|
// unless the host wired them; every field optional. Ordinary domain plugins ignore it.
|
||||||
system?: SystemCapabilities;
|
system?: SystemCapabilities;
|
||||||
@@ -74,7 +74,7 @@ export function buildContext(
|
|||||||
query: url.searchParams,
|
query: url.searchParams,
|
||||||
req,
|
req,
|
||||||
res,
|
res,
|
||||||
roles: identity?.roles ?? [],
|
permissions: identity?.permissions ?? [],
|
||||||
...(options.system ? { system: options.system } : {}),
|
...(options.system ? { system: options.system } : {}),
|
||||||
url,
|
url,
|
||||||
verifyCsrf: options.verifyCsrf ?? (() => false), // fail-closed unless the host binds the secret
|
verifyCsrf: options.verifyCsrf ?? (() => false), // fail-closed unless the host binds the secret
|
||||||
|
|||||||
+6
-6
@@ -1,6 +1,6 @@
|
|||||||
// Guards the Ory Keto config: migrations run before the server (keto-migrate →
|
// Guards the Ory Keto config: migrations run before the server (keto-migrate →
|
||||||
// keto), the DSN targets the keto database, read/write APIs serve on the ports config.ts
|
// keto), the DSN targets the keto database, read/write APIs serve on the ports config.ts
|
||||||
// points at, and the OPL declares the identity/role/group/resource namespaces. Version pinning is
|
// points at, and the OPL declares the identity/permission/group/resource namespaces. Version pinning is
|
||||||
// in compose.test.ts. Real boot is verified by running the stack; this catches edits.
|
// in compose.test.ts. Real boot is verified by running the stack; this catches edits.
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
@@ -35,12 +35,12 @@ test("keto loads the OPL namespaces from the mounted file", () => {
|
|||||||
"namespaces come from the committed OPL");
|
"namespaces come from the committed OPL");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the OPL declares role, group and a resource namespace over identity subjects", () => {
|
test("the OPL declares permission, group and a resource namespace over identity subjects", () => {
|
||||||
for (const ns of ["Identity", "Group", "Role", "Resource"])
|
for (const ns of ["Identity", "Group", "Permission", "Resource"])
|
||||||
assert.match(opl, new RegExp(`class ${ns} implements Namespace`), `defines ${ns}`);
|
assert.match(opl, new RegExp(`class ${ns} implements Namespace`), `defines ${ns}`);
|
||||||
// role + group are subject sets read at login → JWT roles claim (README).
|
// permission + group are subject sets read at login → JWT permissions claim (README).
|
||||||
assert.match(opl, /class Role implements Namespace\s*{\s*related:\s*{\s*members:/,
|
assert.match(opl, /class Permission implements Namespace\s*{\s*related:\s*{\s*granted:/,
|
||||||
"Role has a members relation");
|
"Permission has a granted relation");
|
||||||
assert.match(opl, /class Group implements Namespace\s*{\s*related:\s*{\s*members:/,
|
assert.match(opl, /class Group implements Namespace\s*{\s*related:\s*{\s*members:/,
|
||||||
"Group has a members relation");
|
"Group has a members relation");
|
||||||
});
|
});
|
||||||
|
|||||||
+4
-4
@@ -52,7 +52,7 @@ test("self-service flows return to our themed pages (on the localhost dev host)"
|
|||||||
|
|
||||||
test("after a successful login Kratos returns to our /auth/complete route to mint the JWT", () => {
|
test("after a successful login Kratos returns to our /auth/complete route to mint the JWT", () => {
|
||||||
assert.match(kratosYml, /default_browser_return_url:\s*http:\/\/localhost:3000\/auth\/complete/,
|
assert.match(kratosYml, /default_browser_return_url:\s*http:\/\/localhost:3000\/auth\/complete/,
|
||||||
"login completion (read roles → project → tokenize → set cookie) runs at /auth/complete");
|
"login completion (read permissions → project → tokenize → set cookie) runs at /auth/complete");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("recovery + verification run on email code, delivered by a courier", () => {
|
test("recovery + verification run on email code, delivered by a courier", () => {
|
||||||
@@ -79,12 +79,12 @@ test("session tokenizer template 'plainpages' mints a short-lived signed JWT", (
|
|||||||
"claims via the committed mapper");
|
"claims via the committed mapper");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the tokenizer claims mapper emits email + roles from the metadata_public projection", () => {
|
test("the tokenizer claims mapper emits email + permissions from the metadata_public projection", () => {
|
||||||
// metadata_public, not _admin: the session Kratos hands the tokenizer carries only public
|
// metadata_public, not _admin: the session Kratos hands the tokenizer carries only public
|
||||||
// metadata (admin metadata is stripped), so the roles projection must live in metadata_public.
|
// metadata (admin metadata is stripped), so the permissions projection must live in metadata_public.
|
||||||
const mapper = read("ory/kratos/tokenizer/plainpages.jsonnet");
|
const mapper = read("ory/kratos/tokenizer/plainpages.jsonnet");
|
||||||
assert.match(mapper, /email:\s*session\.identity\.traits\.email/, "email ← identity trait");
|
assert.match(mapper, /email:\s*session\.identity\.traits\.email/, "email ← identity trait");
|
||||||
assert.match(mapper, /metadata_public/, "roles ← metadata_public (the per-login Keto projection)");
|
assert.match(mapper, /metadata_public/, "permissions ← metadata_public (the per-login Keto projection)");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("social sign-in is off by default — a clean clone stays password-only", () => {
|
test("social sign-in is off by default — a clean clone stays password-only", () => {
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
|
|||||||
{ name: "non-function dashboard", files: { "weirddash/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: "nope" };` }, match: /weirddash.*dashboard.*function/s },
|
{ name: "non-function dashboard", files: { "weirddash/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: "nope" };` }, match: /weirddash.*dashboard.*function/s },
|
||||||
{ name: "reserved dashboard id shadows the gated dashboard", files: { "dashboard/plugin.ts": full("dashboard") }, match: /dashboard.*reserved/s },
|
{ name: "reserved dashboard id shadows the gated dashboard", files: { "dashboard/plugin.ts": full("dashboard") }, match: /dashboard.*reserved/s },
|
||||||
{ name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ },
|
{ name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ },
|
||||||
{ name: "a route marked public AND role is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", public: true, role: "x", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*role/s },
|
{ name: "a route marked public AND permission is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", public: true, permission: "x", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*permission/s },
|
||||||
{ name: "a nav node marked public AND role is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", public: true, role: "x" }] };` }, match: /contranav.*public.*role/s },
|
{ name: "a nav node marked public AND permission is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", public: true, permission: "x" }] };` }, match: /contranav.*public.*permission/s },
|
||||||
{ name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "b" }) };` }, match: /home/ },
|
{ name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "b" }) };` }, match: /home/ },
|
||||||
{ name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ },
|
{ name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ },
|
||||||
];
|
];
|
||||||
@@ -85,12 +85,12 @@ test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard)
|
|||||||
assert.equal(typeof plugins[0]?.dashboard, "function");
|
assert.equal(typeof plugins[0]?.dashboard, "function");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a shared role name only warns — both plugins still load", async (t) => {
|
test("a shared permission name only warns — both plugins still load", async (t) => {
|
||||||
const shared = `export default { apiVersion: "1.0.0", roles: [{ name: "shared:read" }] };`;
|
const shared = `export default { apiVersion: "1.0.0", permissions: [{ name: "shared:read" }] };`;
|
||||||
const dir = scaffold(t, { "x/plugin.ts": shared, "y/plugin.ts": shared });
|
const dir = scaffold(t, { "x/plugin.ts": shared, "y/plugin.ts": shared });
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const plugins = await discoverPlugins({ dir, logger: { warn: (m) => warnings.push(String(m)) } });
|
const plugins = await discoverPlugins({ dir, logger: { warn: (m) => warnings.push(String(m)) } });
|
||||||
|
|
||||||
assert.equal(plugins.length, 2);
|
assert.equal(plugins.length, 2);
|
||||||
assert.ok(warnings.some((w) => /shared:read/.test(w)), "expected a role-conflict warning");
|
assert.ok(warnings.some((w) => /shared:read/.test(w)), "expected a permission-conflict warning");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// validate it, assemble the loaded Plugin[]. The imperative shell over plugin.ts's pure rules
|
// validate it, assemble the loaded Plugin[]. The imperative shell over plugin.ts's pure rules
|
||||||
// (isValidPluginId, checkApiVersion, findConflicts). Fails loud: every per-plugin problem and
|
// (isValidPluginId, checkApiVersion, findConflicts). Fails loud: every per-plugin problem and
|
||||||
// error-level conflict is collected into one boot-stopping Error; warn-level diagnostics
|
// error-level conflict is collected into one boot-stopping Error; warn-level diagnostics
|
||||||
// (older-minor apiVersion, shared role name) log and load continues. Folder name = id.
|
// (older-minor apiVersion, shared permission name) log and load continues. Folder name = id.
|
||||||
|
|
||||||
import { existsSync, readdirSync } from "node:fs";
|
import { existsSync, readdirSync } from "node:fs";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
@@ -85,7 +85,7 @@ function asManifest(value: unknown): PluginManifest | null {
|
|||||||
|
|
||||||
// The collection fields feed findConflicts, which iterates them — a non-array crashes it opaquely.
|
// The collection fields feed findConflicts, which iterates them — a non-array crashes it opaquely.
|
||||||
function shapeError(manifest: PluginManifest): string | null {
|
function shapeError(manifest: PluginManifest): string | null {
|
||||||
for (const field of ["nav", "roles", "routes"] as const) {
|
for (const field of ["nav", "permissions", "routes"] as const) {
|
||||||
if (manifest[field] !== undefined && !Array.isArray(manifest[field])) return `"${field}" must be an array`;
|
if (manifest[field] !== undefined && !Array.isArray(manifest[field])) return `"${field}" must be an array`;
|
||||||
}
|
}
|
||||||
// `home` / `dashboard` (the landing-page overrides) are route handlers; the host calls them, so
|
// `home` / `dashboard` (the landing-page overrides) are route handlers; the host calls them, so
|
||||||
@@ -93,20 +93,20 @@ function shapeError(manifest: PluginManifest): string | null {
|
|||||||
for (const slot of ["home", "dashboard"] as const) {
|
for (const slot of ["home", "dashboard"] as const) {
|
||||||
if (manifest[slot] !== undefined && typeof manifest[slot] !== "function") return `"${slot}" must be a function (a route handler)`;
|
if (manifest[slot] !== undefined && typeof manifest[slot] !== "function") return `"${slot}" must be a function (a route handler)`;
|
||||||
}
|
}
|
||||||
// `public` and `role` are contradictory on the same route/nav node — "open to all" vs
|
// `public` and `permission` are contradictory on the same route/nav node — "open to all" vs
|
||||||
// "needs this role". Refuse rather than silently pick one, so the author's intent is unambiguous.
|
// "needs this permission". Refuse rather than silently pick one, so the author's intent is unambiguous.
|
||||||
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
|
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
|
||||||
if (route?.public === true && route.role != null) return `route "${route.method} ${route.path}" sets both public and role — they are mutually exclusive`;
|
if (route?.public === true && route.permission != null) return `route "${route.method} ${route.path}" sets both public and permission — they are mutually exclusive`;
|
||||||
}
|
}
|
||||||
const navContradiction = findPublicNavContradiction(manifest.nav);
|
const navContradiction = findPublicNavContradiction(manifest.nav);
|
||||||
if (navContradiction) return navContradiction;
|
if (navContradiction) return navContradiction;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recurse the nav fragment: a node that is both `public` and `role`-gated is contradictory.
|
// Recurse the nav fragment: a node that is both `public` and `permission`-gated is contradictory.
|
||||||
function findPublicNavContradiction(nodes: PluginManifest["nav"]): string | null {
|
function findPublicNavContradiction(nodes: PluginManifest["nav"]): string | null {
|
||||||
for (const node of Array.isArray(nodes) ? nodes : []) {
|
for (const node of Array.isArray(nodes) ? nodes : []) {
|
||||||
if (node?.public === true && node.role != null) return `nav node "${node.label ?? node.id ?? "?"}" sets both public and role — they are mutually exclusive`;
|
if (node?.public === true && node.permission != null) return `nav node "${node.label ?? node.id ?? "?"}" sets both public and permission — they are mutually exclusive`;
|
||||||
const inChild = findPublicNavContradiction(node?.children);
|
const inChild = findPublicNavContradiction(node?.children);
|
||||||
if (inChild) return inChild;
|
if (inChild) return inChild;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// a plugin should import from here, never reach into deeper modules. See README.md → Building plugins.
|
// a plugin should import from here, never reach into deeper modules. See README.md → Building plugins.
|
||||||
|
|
||||||
export { definePlugin } from "./plugin.ts";
|
export { definePlugin } from "./plugin.ts";
|
||||||
export type { HttpMethod, Plugin, PluginHooks, PluginManifest, RoleDecl, Route, RouteHandler, RouteResult } from "./plugin.ts";
|
export type { HttpMethod, Plugin, PluginHooks, PluginManifest, PermissionDecl, Route, RouteHandler, RouteResult } from "./plugin.ts";
|
||||||
export type { RequestContext, SessionIdentity } from "../http/context.ts";
|
export type { RequestContext, SessionIdentity } from "../http/context.ts";
|
||||||
export type { PageChrome } from "../ui/chrome.ts";
|
export type { PageChrome } from "../ui/chrome.ts";
|
||||||
export type { NavNode } from "../ui/nav.ts";
|
export type { NavNode } from "../ui/nav.ts";
|
||||||
|
|||||||
@@ -21,13 +21,13 @@ const scheduling: PluginManifest = definePlugin({
|
|||||||
apiVersion: "1.0.0",
|
apiVersion: "1.0.0",
|
||||||
hooks: { onBoot: () => {} },
|
hooks: { onBoot: () => {} },
|
||||||
nav: [{
|
nav: [{
|
||||||
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", role: "scheduling:read" }],
|
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }],
|
||||||
icon: "i-cal", id: "scheduling:root", label: "Scheduling",
|
icon: "i-cal", id: "scheduling:root", label: "Scheduling",
|
||||||
}],
|
}],
|
||||||
roles: [{ description: "View shifts", name: "scheduling:read" }],
|
permissions: [{ description: "View shifts", name: "scheduling:read" }],
|
||||||
routes: [
|
routes: [
|
||||||
{ handler: () => ({ data: { rows: [] }, view: "shifts" }), method: "GET", path: "/shifts", role: "scheduling:read" },
|
{ handler: () => ({ data: { rows: [] }, view: "shifts" }), method: "GET", path: "/shifts", permission: "scheduling:read" },
|
||||||
{ handler: () => ({ redirect: "/scheduling/shifts" }), method: "POST", path: "/shifts", role: "scheduling:write" },
|
{ handler: () => ({ redirect: "/scheduling/shifts" }), method: "POST", path: "/shifts", permission: "scheduling:write" },
|
||||||
{ handler: (ctx) => void ctx.res.end("raw"), method: "GET", path: "/raw" }, // void = handler wrote res itself
|
{ handler: (ctx) => void ctx.res.end("raw"), method: "GET", path: "/raw" }, // void = handler wrote res itself
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -87,19 +87,19 @@ test("findConflicts: a duplicate id and a colliding route are loud errors", () =
|
|||||||
assert.ok(dupRoute.some((c) => c.kind === "route" && c.level === "error" && c.message.includes("/a/t")));
|
assert.ok(dupRoute.some((c) => c.kind === "route" && c.level === "error" && c.message.includes("/a/t")));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("findConflicts: duplicate nav id is an error, a shared role name only warns", () => {
|
test("findConflicts: duplicate nav id is an error, a shared permission name only warns", () => {
|
||||||
const navDup = findConflicts([
|
const navDup = findConflicts([
|
||||||
p({ id: "a", nav: [{ id: "dup", label: "A" }] }),
|
p({ id: "a", nav: [{ id: "dup", label: "A" }] }),
|
||||||
p({ id: "b", nav: [{ id: "dup", label: "B" }] }),
|
p({ id: "b", nav: [{ id: "dup", label: "B" }] }),
|
||||||
]);
|
]);
|
||||||
assert.ok(navDup.some((c) => c.kind === "nav-id" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
assert.ok(navDup.some((c) => c.kind === "nav-id" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
||||||
|
|
||||||
// Sharing a role across plugins is legitimate → warn, not error.
|
// Sharing a permission across plugins is legitimate → warn, not error.
|
||||||
const roleDup = findConflicts([
|
const permissionDup = findConflicts([
|
||||||
p({ id: "a", roles: [{ name: "shared:read" }] }),
|
p({ id: "a", permissions: [{ name: "shared:read" }] }),
|
||||||
p({ id: "b", roles: [{ name: "shared:read" }] }),
|
p({ id: "b", permissions: [{ name: "shared:read" }] }),
|
||||||
]);
|
]);
|
||||||
assert.ok(roleDup.some((c) => c.kind === "role" && c.level === "warn"));
|
assert.ok(permissionDup.some((c) => c.kind === "permission" && c.level === "warn"));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("findConflicts: each single slot (`home`/`dashboard`) may have one owner — two is a loud error", () => {
|
test("findConflicts: each single slot (`home`/`dashboard`) may have one owner — two is a loud error", () => {
|
||||||
|
|||||||
+10
-10
@@ -29,16 +29,16 @@ export interface Route {
|
|||||||
handler: RouteHandler;
|
handler: RouteHandler;
|
||||||
method: HttpMethod;
|
method: HttpMethod;
|
||||||
path: string; // relative to the plugin's mount path `/<id>`; ":name" segments → ctx.params.name
|
path: string; // relative to the plugin's mount path `/<id>`; ":name" segments → ctx.params.name
|
||||||
role?: string; // coarse gate — the Keto Role the caller must hold; checked before the handler runs
|
permission?: string; // coarse gate — the Keto Permission the caller must hold; checked before the handler runs
|
||||||
// Mark the page reachable by anyone, signed in or not. The same as omitting `role`
|
// Mark the page reachable by anyone, signed in or not. The same as omitting `permission`
|
||||||
// — an ungated route is already open — but stated outright, so "public" is a deliberate
|
// — an ungated route is already open — but stated outright, so "public" is a deliberate
|
||||||
// choice, not an accident. Mutually exclusive with `role` (discovery refuses both).
|
// choice, not an accident. Mutually exclusive with `permission` (discovery refuses both).
|
||||||
public?: boolean;
|
public?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A Keto Role this plugin gates on — declared for docs/seeding. Role names are a shared
|
// A Keto Permission this plugin gates on — declared for docs/seeding. Permission names are a shared
|
||||||
// global namespace (so an operator grants them once in Keto); namespace as `<id>:<action>`.
|
// global namespace (so an operator grants them once in Keto); namespace as `<id>:<action>`.
|
||||||
export interface RoleDecl {
|
export interface PermissionDecl {
|
||||||
description?: string;
|
description?: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
@@ -63,7 +63,7 @@ export interface PluginManifest {
|
|||||||
home?: RouteHandler;
|
home?: RouteHandler;
|
||||||
hooks?: PluginHooks;
|
hooks?: PluginHooks;
|
||||||
nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/ui/icons.ts), node ids must be globally unique
|
nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/ui/icons.ts), node ids must be globally unique
|
||||||
roles?: RoleDecl[];
|
permissions?: PermissionDecl[];
|
||||||
routes?: Route[];
|
routes?: Route[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +147,7 @@ export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HO
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginConflict {
|
export interface PluginConflict {
|
||||||
kind: "dashboard" | "home" | "id" | "nav-id" | "role" | "route";
|
kind: "dashboard" | "home" | "id" | "nav-id" | "permission" | "route";
|
||||||
level: "error" | "warn";
|
level: "error" | "warn";
|
||||||
message: string;
|
message: string;
|
||||||
plugins: string[]; // unique ids involved
|
plugins: string[]; // unique ids involved
|
||||||
@@ -155,7 +155,7 @@ export interface PluginConflict {
|
|||||||
|
|
||||||
// The conflict rules: defined, loud resolution — never last-write-wins. Pure over the discovered
|
// The conflict rules: defined, loud resolution — never last-write-wins. Pure over the discovered
|
||||||
// plugins; discovery throws on any "error" and logs every "warn". Mount-path (`/<id>`) uniqueness
|
// plugins; discovery throws on any "error" and logs every "warn". Mount-path (`/<id>`) uniqueness
|
||||||
// is structural — it follows from the id check, so it needs no rule of its own. Shared role
|
// is structural — it follows from the id check, so it needs no rule of its own. Shared permission
|
||||||
// names are the one intentional overlap, so they warn rather than error.
|
// names are the one intentional overlap, so they warn rather than error.
|
||||||
export function findConflicts(plugins: Plugin[]): PluginConflict[] {
|
export function findConflicts(plugins: Plugin[]): PluginConflict[] {
|
||||||
const out: PluginConflict[] = [];
|
const out: PluginConflict[] = [];
|
||||||
@@ -184,9 +184,9 @@ export function findConflicts(plugins: Plugin[]): PluginConflict[] {
|
|||||||
});
|
});
|
||||||
|
|
||||||
collect(plugins, (plugin, push) => {
|
collect(plugins, (plugin, push) => {
|
||||||
for (const decl of plugin.roles ?? []) push(decl.name);
|
for (const decl of plugin.permissions ?? []) push(decl.name);
|
||||||
}).forEach((owners, name) => {
|
}).forEach((owners, name) => {
|
||||||
if (owners.length > 1) out.push({ kind: "role", level: "warn", message: `role "${name}" declared by ${uniq(owners).length} plugins; namespace as "<id>:<action>" unless shared on purpose`, plugins: uniq(owners) });
|
if (owners.length > 1) out.push({ kind: "permission", level: "warn", message: `permission "${name}" declared by ${uniq(owners).length} plugins; namespace as "<id>:<action>" unless shared on purpose`, plugins: uniq(owners) });
|
||||||
});
|
});
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
@@ -55,13 +55,13 @@ test("allowedMethods lists methods at a path (GET implies HEAD); empty when the
|
|||||||
assert.deepEqual(allowedMethods(plugins, "/x/missing"), []);
|
assert.deepEqual(allowedMethods(plugins, "/x/missing"), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("isAuthorized: open routes pass; gated routes require the role token; public is explicitly open", () => {
|
test("isAuthorized: open routes pass; gated routes require the permission token; public is explicitly open", () => {
|
||||||
const open: Route = { handler: noop, method: "GET", path: "/" };
|
const open: Route = { handler: noop, method: "GET", path: "/" };
|
||||||
const gated: Route = { handler: noop, method: "GET", path: "/", role: "x:read" };
|
const gated: Route = { handler: noop, method: "GET", path: "/", permission: "x:read" };
|
||||||
const pub: Route = { handler: noop, method: "GET", path: "/", public: true }; // blessed public alias
|
const pub: Route = { handler: noop, method: "GET", path: "/", public: true }; // blessed public alias
|
||||||
assert.equal(isAuthorized(open, []), true);
|
assert.equal(isAuthorized(open, []), true);
|
||||||
assert.equal(isAuthorized(gated, []), false);
|
assert.equal(isAuthorized(gated, []), false);
|
||||||
assert.equal(isAuthorized(gated, ["x:read"]), true);
|
assert.equal(isAuthorized(gated, ["x:read"]), true);
|
||||||
assert.equal(isAuthorized(gated, ["other"]), false);
|
assert.equal(isAuthorized(gated, ["other"]), false);
|
||||||
assert.equal(isAuthorized(pub, []), true); // open to anonymous, like omitting role — but stated outright
|
assert.equal(isAuthorized(pub, []), true); // open to anonymous, like omitting permission — but stated outright
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -74,9 +74,9 @@ export function allowedMethods(plugins: Plugin[], pathname: string): string[] {
|
|||||||
return [...methods].sort();
|
return [...methods].sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coarse role gate: a route marked `public` (or one with no `role`) is open; otherwise
|
// Coarse permission gate: a route marked `public` (or one with no `permission`) is open; otherwise
|
||||||
// the user's roles (from the session JWT) must include the token. The same rule composeNav uses
|
// the user's permissions (from the session JWT) must include the token. The same rule composeNav uses
|
||||||
// for the menu. `public` and `role` are mutually exclusive (discovery refuses both).
|
// for the menu. `public` and `permission` are mutually exclusive (discovery refuses both).
|
||||||
export function isAuthorized(route: Route, roles: string[]): boolean {
|
export function isAuthorized(route: Route, permissions: string[]): boolean {
|
||||||
return route.public === true || route.role == null || roles.includes(route.role);
|
return route.public === true || route.permission == null || permissions.includes(route.permission);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -31,7 +31,7 @@ const hydra = createHydraAdmin({ baseUrl: config.hydraAdminUrl, fetchImpl: oryFe
|
|||||||
// or fetched http), then served from cache with TTL refresh + rotation-on-miss.
|
// or fetched http), then served from cache with TTL refresh + rotation-on-miss.
|
||||||
const jwks = await createJwksProvider(config.jwksUrl, { fetchImpl: oryFetch }); // bound an http JWKS fetch too
|
const jwks = await createJwksProvider(config.jwksUrl, { fetchImpl: oryFetch }); // bound an http JWKS fetch too
|
||||||
// Optional instant-revoke, off unless REVOCATION_DENYLIST=true: an in-memory denylist the
|
// Optional instant-revoke, off unless REVOCATION_DENYLIST=true: an in-memory denylist the
|
||||||
// hot path consults and the admin screens populate on deactivate/delete/role-change.
|
// hot path consults and the admin screens populate on deactivate/delete/permission-change.
|
||||||
const denylist = config.revocationDenylist ? createDenylist({ ttlSec: config.revocationTtlSec }) : undefined;
|
const denylist = config.revocationDenylist ? createDenylist({ ttlSec: config.revocationTtlSec }) : undefined;
|
||||||
|
|
||||||
const plugins = await discoverPlugins(); // scans plugins/, validates — fails loud on a bad plugin
|
const plugins = await discoverPlugins(); // scans plugins/, validates — fails loud on a bad plugin
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ const scheduling: Plugin = {
|
|||||||
apiVersion: "1.0.0",
|
apiVersion: "1.0.0",
|
||||||
id: "scheduling",
|
id: "scheduling",
|
||||||
nav: [{
|
nav: [{
|
||||||
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", role: "scheduling:read" }],
|
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }],
|
||||||
icon: "i-cal", id: "scheduling", label: "Scheduling",
|
icon: "i-cal", id: "scheduling", label: "Scheduling",
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
// A plugin with a public nav node (reachable by anyone, signed in or not).
|
// A plugin with a public nav node (reachable by anyone, signed in or not).
|
||||||
const portal: Plugin = { apiVersion: "1.0.0", id: "portal", nav: [{ href: "/portal", id: "portal", label: "Portal", public: true }] };
|
const portal: Plugin = { apiVersion: "1.0.0", id: "portal", nav: [{ href: "/portal", id: "portal", label: "Portal", public: true }] };
|
||||||
// A gated section fragment like the admin plugin's nav: the header carries the role, so
|
// A gated section fragment like the admin plugin's nav: the header carries the permission, so
|
||||||
// composeNav drops the whole subtree for a non-holder (the admin screens ship as a drop-in plugin).
|
// composeNav drops the whole subtree for a non-holder (the admin screens ship as a drop-in plugin).
|
||||||
const adminLike: Plugin = {
|
const adminLike: Plugin = {
|
||||||
apiVersion: "1.0.0", id: "admin",
|
apiVersion: "1.0.0", id: "admin",
|
||||||
@@ -24,7 +24,7 @@ const adminLike: Plugin = {
|
|||||||
{ href: "/admin/users", id: "users", label: "Users" },
|
{ href: "/admin/users", id: "users", label: "Users" },
|
||||||
{ href: "/admin/groups", id: "groups", label: "Groups" },
|
{ href: "/admin/groups", id: "groups", label: "Groups" },
|
||||||
],
|
],
|
||||||
icon: "i-shield", id: "admin", label: "Admin", role: "admin",
|
icon: "i-shield", id: "admin", label: "Admin", permission: "admin",
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,10 +45,10 @@ test("anonymous shell Sign-in link carries the current page as return_to", () =>
|
|||||||
assert.equal(buildPluginChrome({ currentPath: "/portal", menu: DEFAULT_MENU }).signInHref, "/login?return_to=%2Fportal");
|
assert.equal(buildPluginChrome({ currentPath: "/portal", menu: DEFAULT_MENU }).signInHref, "/login?return_to=%2Fportal");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a role holder sees the Dashboard link + plugin nav; current path opens the active leaf", () => {
|
test("a permission holder sees the Dashboard link + plugin nav; current path opens the active leaf", () => {
|
||||||
const chrome = buildPluginChrome({
|
const chrome = buildPluginChrome({
|
||||||
currentPath: "/scheduling/shifts", menu: DEFAULT_MENU, plugins: [scheduling],
|
currentPath: "/scheduling/shifts", menu: DEFAULT_MENU, plugins: [scheduling],
|
||||||
identity: { email: "ada@x.io", id: "u1", roles: ["scheduling:read"] },
|
identity: { email: "ada@x.io", id: "u1", permissions: ["scheduling:read"] },
|
||||||
});
|
});
|
||||||
assert.deepEqual(labels(chrome.nav), ["Dashboard", "Scheduling"]); // Dashboard shown to a signed-in user
|
assert.deepEqual(labels(chrome.nav), ["Dashboard", "Scheduling"]); // Dashboard shown to a signed-in user
|
||||||
const section = chrome.nav.find((n) => n.label === "Scheduling")!;
|
const section = chrome.nav.find((n) => n.label === "Scheduling")!;
|
||||||
@@ -58,7 +58,7 @@ test("a role holder sees the Dashboard link + plugin nav; current path opens the
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("a gated section (like the admin plugin) shows to a holder; a sub-path marks its base leaf current", () => {
|
test("a gated section (like the admin plugin) shows to a holder; a sub-path marks its base leaf current", () => {
|
||||||
const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, plugins: [adminLike], identity: { email: "a@b.c", id: "u1", roles: ["admin"] } });
|
const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, plugins: [adminLike], identity: { email: "a@b.c", id: "u1", permissions: ["admin"] } });
|
||||||
const admin = chrome.nav.find((n) => n.label === "Admin")!;
|
const admin = chrome.nav.find((n) => n.label === "Admin")!;
|
||||||
assert.ok(admin); // gated section visible to an admin
|
assert.ok(admin); // gated section visible to an admin
|
||||||
assert.equal(admin.open, true); // ancestor of the current leaf opened
|
assert.equal(admin.open, true); // ancestor of the current leaf opened
|
||||||
|
|||||||
+3
-3
@@ -18,7 +18,7 @@ const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashbo
|
|||||||
export interface PageChrome {
|
export interface PageChrome {
|
||||||
brand: { logo?: string; name: string; sub?: string };
|
brand: { logo?: string; name: string; sub?: string };
|
||||||
csrfToken: string; // double-submit token for the shell's Sign-out form + a plugin's own forms
|
csrfToken: string; // double-submit token for the shell's Sign-out form + a plugin's own forms
|
||||||
nav: NavNode[]; // global menu, composed + role-filtered + current-marked, ready for nav-tree.ejs
|
nav: NavNode[]; // global menu, composed + permission-filtered + current-marked, ready for nav-tree.ejs
|
||||||
signInHref: string; // where the shell's anonymous "Sign in" link points — carries this page as return_to
|
signInHref: string; // where the shell's anonymous "Sign in" link points — carries this page as return_to
|
||||||
theme?: string;
|
theme?: string;
|
||||||
user: ShellUser;
|
user: ShellUser;
|
||||||
@@ -39,8 +39,8 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
|||||||
const fragments: NavNode[][] = opts.identity ? [[DASHBOARD_NAV]] : [];
|
const fragments: NavNode[][] = opts.identity ? [[DASHBOARD_NAV]] : [];
|
||||||
for (const p of opts.plugins ?? []) if (p.nav?.length) fragments.push(p.nav);
|
for (const p of opts.plugins ?? []) if (p.nav?.length) fragments.push(p.nav);
|
||||||
|
|
||||||
const roles = opts.identity?.roles ?? [];
|
const permissions = opts.identity?.permissions ?? [];
|
||||||
const nav = composeNav(fragments, opts.menu.override, roles);
|
const nav = composeNav(fragments, opts.menu.override, permissions);
|
||||||
if (opts.currentPath) {
|
if (opts.currentPath) {
|
||||||
// Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like
|
// Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like
|
||||||
// /admin/users/new marks the Users base leaf (/admin/users) and the dashboard marks Dashboard.
|
// /admin/users/new marks the Users base leaf (/admin/users) and the dashboard marks Dashboard.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { NavNode } from "./nav.ts";
|
|||||||
const NAV: NavNode[] = [{ href: "/dashboard", label: "Dashboard" }, { children: [{ href: "/admin/users", label: "Users" }], label: "Admin" }];
|
const NAV: NavNode[] = [{ href: "/dashboard", label: "Dashboard" }, { children: [{ href: "/admin/users", label: "Users" }], label: "Admin" }];
|
||||||
|
|
||||||
test("dashboard model: titled shell, passes the unified nav + csrf + user through", () => {
|
test("dashboard model: titled shell, passes the unified nav + csrf + user through", () => {
|
||||||
const m = buildDashboardModel({ csrfToken: "tok.sig", identity: { email: "ada@x.io", id: "u1", roles: ["admin"] }, nav: NAV });
|
const m = buildDashboardModel({ csrfToken: "tok.sig", identity: { email: "ada@x.io", id: "u1", permissions: ["admin"] }, nav: NAV });
|
||||||
assert.equal(m.shell.title, "Dashboard");
|
assert.equal(m.shell.title, "Dashboard");
|
||||||
assert.equal(m.shell.csrfToken, "tok.sig");
|
assert.equal(m.shell.csrfToken, "tok.sig");
|
||||||
assert.equal(m.shell.user.name, "ada"); // real signed-in identity, not a demo profile
|
assert.equal(m.shell.user.name, "ada"); // real signed-in identity, not a demo profile
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Central menu config: config/menu.ts lets an operator set branding (app name, logo,
|
// Central menu config: config/menu.ts lets an operator set branding (app name, logo,
|
||||||
// default theme) and reorder/rename/group/hide nav nodes across all plugins. The reorder/rename/
|
// default theme) and reorder/rename/group/hide nav nodes across all plugins. The reorder/rename/
|
||||||
// group/hide part is the NavOverride composeNav already applies (the override always wins, before
|
// group/hide part is the NavOverride composeNav already applies (the override always wins, before
|
||||||
// the per-user role filter). Authored as TypeScript (defineMenu types it); loaded once at
|
// the per-user permission filter). Authored as TypeScript (defineMenu types it); loaded once at
|
||||||
// boot — fail-loud on a malformed file, defaults when absent (clean clone needs no config).
|
// boot — fail-loud on a malformed file, defaults when absent (clean clone needs no config).
|
||||||
|
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
|
|||||||
+3
-3
@@ -18,8 +18,8 @@ test("menu renders trigger, positioning, the item matrix and check groups", asyn
|
|||||||
{ label: "Docs", href: "/docs" }, // link
|
{ label: "Docs", href: "/docs" }, // link
|
||||||
{ sep: true },
|
{ sep: true },
|
||||||
{ label: "Sign out", icon: "i-logout", danger: true },
|
{ label: "Sign out", icon: "i-logout", danger: true },
|
||||||
{ group: { legend: "Role", name: "role", control: "radio", options: [
|
{ group: { legend: "Permission", name: "permission", control: "radio", options: [
|
||||||
{ value: "", label: "Any role", checked: true },
|
{ value: "", label: "Any permission", checked: true },
|
||||||
{ value: "admin", label: "Admin" },
|
{ value: "admin", label: "Admin" },
|
||||||
] } },
|
] } },
|
||||||
{ group: { name: "col", options: [{ value: "name", label: "Name", checked: true }] } }, // checkbox default, no legend
|
{ group: { name: "col", options: [{ value: "name", label: "Name", checked: true }] } }, // checkbox default, no legend
|
||||||
@@ -38,7 +38,7 @@ test("menu renders trigger, positioning, the item matrix and check groups", asyn
|
|||||||
assert.match(html, /<button class="menu-item danger" type="button"><svg class="ico"><use href="#i-logout"\s*\/?><\/svg>Sign out<\/button>/);
|
assert.match(html, /<button class="menu-item danger" type="button"><svg class="ico"><use href="#i-logout"\s*\/?><\/svg>Sign out<\/button>/);
|
||||||
|
|
||||||
// Check group: radios reflect `checked`; legend optional; control defaults to checkbox.
|
// Check group: radios reflect `checked`; legend optional; control defaults to checkbox.
|
||||||
assert.match(html, /<fieldset class="menu-field"><legend class="menu-head">Role<\/legend><label class="menu-check"><input type="radio" name="role" value="" checked>Any role<\/label><label class="menu-check"><input type="radio" name="role" value="admin">Admin<\/label><\/fieldset>/);
|
assert.match(html, /<fieldset class="menu-field"><legend class="menu-head">Permission<\/legend><label class="menu-check"><input type="radio" name="permission" value="" checked>Any permission<\/label><label class="menu-check"><input type="radio" name="permission" value="admin">Admin<\/label><\/fieldset>/);
|
||||||
assert.match(html, /<fieldset class="menu-field"><label class="menu-check"><input type="checkbox" name="col" value="name" checked>Name<\/label><\/fieldset>/);
|
assert.match(html, /<fieldset class="menu-field"><label class="menu-check"><input type="checkbox" name="col" value="name" checked>Name<\/label><\/fieldset>/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const nodes = [
|
|||||||
{ label: "Webhooks (soon)" }, // leaf · static
|
{ label: "Webhooks (soon)" }, // leaf · static
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ label: "Roles & Access", children: [{ label: "Roles", href: "/roles" }] }, // header · static · closed
|
{ label: "Permissions & Access", children: [{ label: "Permissions", href: "/permissions" }] }, // header · static · closed
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -59,8 +59,8 @@ test("nav-tree renders the header/leaf × clickable/static matrix with counts, i
|
|||||||
assert.match(html, /<span class="nav-self"><span class="nav-label">Webhooks \(soon\)<\/span><\/span>/);
|
assert.match(html, /<span class="nav-self"><span class="nav-label">Webhooks \(soon\)<\/span><\/span>/);
|
||||||
|
|
||||||
// Header · static · closed (no [open]) + label escaping in both label and aria-label.
|
// Header · static · closed (no [open]) + label escaping in both label and aria-label.
|
||||||
assert.match(html, /<details class="nav-disc"><summary class="nav-tog" aria-label="Toggle Roles & Access">/);
|
assert.match(html, /<details class="nav-disc"><summary class="nav-tog" aria-label="Toggle Permissions & Access">/);
|
||||||
assert.match(html, /<span class="nav-label">Roles & Access<\/span>/);
|
assert.match(html, /<span class="nav-label">Permissions & Access<\/span>/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("nav-tree renders an empty root list with no nodes and never throws", async () => {
|
test("nav-tree renders an empty root list with no nodes and never throws", async () => {
|
||||||
|
|||||||
+16
-16
@@ -2,32 +2,32 @@ import assert from "node:assert/strict";
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { composeNav, type NavNode } from "./nav.ts";
|
import { composeNav, type NavNode } from "./nav.ts";
|
||||||
|
|
||||||
// Two plugin fragments; ids let the override target nodes, `role` gates per role.
|
// Two plugin fragments; ids let the override target nodes, `permission` gates per permission.
|
||||||
const fragments: NavNode[][] = [
|
const fragments: NavNode[][] = [
|
||||||
[{
|
[{
|
||||||
icon: "i-cal", id: "sched", label: "Scheduling",
|
icon: "i-cal", id: "sched", label: "Scheduling",
|
||||||
children: [
|
children: [
|
||||||
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", role: "scheduling:read" },
|
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" },
|
||||||
{ href: "/scheduling/manage", id: "manage", label: "Manage", role: "scheduling:admin" },
|
{ href: "/scheduling/manage", id: "manage", label: "Manage", permission: "scheduling:admin" },
|
||||||
],
|
],
|
||||||
}],
|
}],
|
||||||
[{ href: "/reports", id: "reports", label: "Reports", role: "reports:read" }],
|
[{ href: "/reports", id: "reports", label: "Reports", permission: "reports:read" }],
|
||||||
];
|
];
|
||||||
|
|
||||||
test("composeNav merges fragments, filters by role, and emits clean render nodes", () => {
|
test("composeNav merges fragments, filters by permission, and emits clean render nodes", () => {
|
||||||
const tree = composeNav(fragments, {}, ["scheduling:read"]);
|
const tree = composeNav(fragments, {}, ["scheduling:read"]);
|
||||||
|
|
||||||
// Reports gone (no reports:read), Manage gone (no scheduling:admin), header kept with Shifts.
|
// Reports gone (no reports:read), Manage gone (no scheduling:admin), header kept with Shifts.
|
||||||
// Output carries no `id`/`role` and omits absent fields — ready for nav-tree.ejs.
|
// Output carries no `id`/`permission` and omits absent fields — ready for nav-tree.ejs.
|
||||||
assert.deepEqual(tree, [
|
assert.deepEqual(tree, [
|
||||||
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling/shifts", label: "Shifts" }] },
|
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling/shifts", label: "Shifts" }] },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("composeNav drops gated subtrees, empty headers, and (with no roles) all gated nodes", () => {
|
test("composeNav drops gated subtrees, empty headers, and (with no permissions) all gated nodes", () => {
|
||||||
// A header the user can't reach takes its whole subtree, even visible children.
|
// A header the user can't reach takes its whole subtree, even visible children.
|
||||||
const gatedHeader: NavNode[][] = [[
|
const gatedHeader: NavNode[][] = [[
|
||||||
{ id: "admin", label: "Admin", role: "admin", children: [{ href: "/u", id: "u", label: "Users" }] },
|
{ id: "admin", label: "Admin", permission: "admin", children: [{ href: "/u", id: "u", label: "Users" }] },
|
||||||
{ id: "free", label: "Free", children: [{ href: "/d", id: "d", label: "Docs" }] },
|
{ id: "free", label: "Free", children: [{ href: "/d", id: "d", label: "Docs" }] },
|
||||||
]];
|
]];
|
||||||
assert.deepEqual(composeNav(gatedHeader, {}, []), [
|
assert.deepEqual(composeNav(gatedHeader, {}, []), [
|
||||||
@@ -36,26 +36,26 @@ test("composeNav drops gated subtrees, empty headers, and (with no roles) all ga
|
|||||||
|
|
||||||
// A pure header whose children are all filtered is dropped; a header with an href survives as a leaf.
|
// A pure header whose children are all filtered is dropped; a header with an href survives as a leaf.
|
||||||
const emptyHeader: NavNode[][] = [[
|
const emptyHeader: NavNode[][] = [[
|
||||||
{ id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", role: "x" }] },
|
{ id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", permission: "x" }] },
|
||||||
{ href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", role: "y" }] },
|
{ href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", permission: "y" }] },
|
||||||
]];
|
]];
|
||||||
assert.deepEqual(composeNav(emptyHeader, {}, []), [{ href: "/hub", label: "Hub" }]);
|
assert.deepEqual(composeNav(emptyHeader, {}, []), [{ href: "/hub", label: "Hub" }]);
|
||||||
|
|
||||||
// No fragments / no roles → empty tree, never throws.
|
// No fragments / no permissions → empty tree, never throws.
|
||||||
assert.deepEqual(composeNav(), []);
|
assert.deepEqual(composeNav(), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("composeNav keeps a node marked public for everyone — the blessed public alias", () => {
|
test("composeNav keeps a node marked public for everyone — the blessed public alias", () => {
|
||||||
// A header with one public child + one gated child: with no roles, the public child keeps the
|
// A header with one public child + one gated child: with no permissions, the public child keeps the
|
||||||
// header alive (the gated child is filtered out) — so a plugin can show a public menu option to all.
|
// header alive (the gated child is filtered out) — so a plugin can show a public menu option to all.
|
||||||
const frag: NavNode[][] = [[{
|
const frag: NavNode[][] = [[{
|
||||||
icon: "i-cal", id: "sched", label: "Scheduling",
|
icon: "i-cal", id: "sched", label: "Scheduling",
|
||||||
children: [
|
children: [
|
||||||
{ href: "/scheduling", id: "overview", label: "Overview", public: true },
|
{ href: "/scheduling", id: "overview", label: "Overview", public: true },
|
||||||
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", role: "scheduling:read" },
|
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" },
|
||||||
],
|
],
|
||||||
}]];
|
}]];
|
||||||
// `public` is filter-only (like id/role) — never rendered into the output node.
|
// `public` is filter-only (like id/permission) — never rendered into the output node.
|
||||||
assert.deepEqual(composeNav(frag, {}, []), [
|
assert.deepEqual(composeNav(frag, {}, []), [
|
||||||
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling", label: "Overview" }] },
|
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling", label: "Overview" }] },
|
||||||
]);
|
]);
|
||||||
@@ -66,7 +66,7 @@ test("composeNav applies the override: rename, group, order, hide (then filters)
|
|||||||
{ href: "/a", id: "a", label: "Alpha" },
|
{ href: "/a", id: "a", label: "Alpha" },
|
||||||
{ href: "/b", id: "b", label: "Beta" },
|
{ href: "/b", id: "b", label: "Beta" },
|
||||||
{ href: "/c", id: "c", label: "Gamma" },
|
{ href: "/c", id: "c", label: "Gamma" },
|
||||||
{ href: "/secret", id: "secret", label: "Secret", role: "root" },
|
{ href: "/secret", id: "secret", label: "Secret", permission: "root" },
|
||||||
]];
|
]];
|
||||||
|
|
||||||
const tree = composeNav(base, {
|
const tree = composeNav(base, {
|
||||||
@@ -76,7 +76,7 @@ test("composeNav applies the override: rename, group, order, hide (then filters)
|
|||||||
hide: ["c"], // remove c from inside the group
|
hide: ["c"], // remove c from inside the group
|
||||||
}, ["root"]);
|
}, ["root"]);
|
||||||
|
|
||||||
// grp emitted (b only, c hidden), reordered before a; Secret kept now that role "root" is present.
|
// grp emitted (b only, c hidden), reordered before a; Secret kept now that permission "root" is present.
|
||||||
assert.deepEqual(tree, [
|
assert.deepEqual(tree, [
|
||||||
{ icon: "i-box", label: "Group", open: true, children: [{ href: "/b", label: "Beta" }] },
|
{ icon: "i-box", label: "Group", open: true, children: [{ href: "/b", label: "Beta" }] },
|
||||||
{ href: "/a", label: "First" },
|
{ href: "/a", label: "First" },
|
||||||
|
|||||||
+13
-13
@@ -1,10 +1,10 @@
|
|||||||
// composeNav: merge each plugin's nav fragment into one tree, apply the central
|
// composeNav: merge each plugin's nav fragment into one tree, apply the central
|
||||||
// override, then role-filter per user. Pure and I/O-free — menu gating reads the JWT
|
// override, then permission-filter per user. Pure and I/O-free — menu gating reads the JWT
|
||||||
// `roles` claim (README "The menu system"), never Keto. A node is visible iff it is `public`, or
|
// `permissions` claim (README "The menu system"), never Keto. A node is visible iff it is `public`, or
|
||||||
// declares no `role`, or `roles` includes that role name; a gated header hides its whole
|
// declares no `permission`, or `permissions` includes that permission name; a gated header hides its whole
|
||||||
// subtree, and a pure header left with no children is dropped. The config/menu.ts supplies
|
// subtree, and a pure header left with no children is dropped. The config/menu.ts supplies
|
||||||
// the override (+ branding); this helper only transforms data, so its result is per-deployment
|
// the override (+ branding); this helper only transforms data, so its result is per-deployment
|
||||||
// up to the final role filter and emits clean nodes ready for nav-tree.ejs (no id/role).
|
// up to the final permission filter and emits clean nodes ready for nav-tree.ejs (no id/permission).
|
||||||
|
|
||||||
export interface NavNode {
|
export interface NavNode {
|
||||||
id?: string; // stable key for override targeting; stripped from the rendered tree
|
id?: string; // stable key for override targeting; stripped from the rendered tree
|
||||||
@@ -15,12 +15,12 @@ export interface NavNode {
|
|||||||
icon?: string;
|
icon?: string;
|
||||||
label: string;
|
label: string;
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
role?: string; // required role token; consumed by the filter, never rendered
|
permission?: string; // required permission token; consumed by the filter, never rendered
|
||||||
public?: boolean; // show to everyone, signed in or not — the blessed alias for "no role", stated outright; consumed by the filter, never rendered. Mutually exclusive with role (discovery refuses both).
|
public?: boolean; // show to everyone, signed in or not — the blessed alias for "no permission", stated outright; consumed by the filter, never rendered. Mutually exclusive with permission (discovery refuses both).
|
||||||
}
|
}
|
||||||
|
|
||||||
// Central override (config/menu.ts). Targets nodes by `id`; applied rename → group →
|
// Central override (config/menu.ts). Targets nodes by `id`; applied rename → group →
|
||||||
// order → hide, then the per-user role filter runs last.
|
// order → hide, then the per-user permission filter runs last.
|
||||||
export interface NavOverride {
|
export interface NavOverride {
|
||||||
groups?: NavGroupSpec[]; // wrap top-level nodes (by id) under a new header
|
groups?: NavGroupSpec[]; // wrap top-level nodes (by id) under a new header
|
||||||
hide?: string[]; // remove nodes by id, at any depth (incl. a group's id)
|
hide?: string[]; // remove nodes by id, at any depth (incl. a group's id)
|
||||||
@@ -39,14 +39,14 @@ export interface NavGroupSpec {
|
|||||||
export function composeNav(
|
export function composeNav(
|
||||||
fragments: NavNode[][] = [],
|
fragments: NavNode[][] = [],
|
||||||
override: NavOverride = {},
|
override: NavOverride = {},
|
||||||
roles: string[] = [],
|
permissions: string[] = [],
|
||||||
): NavNode[] {
|
): NavNode[] {
|
||||||
let nodes: NavNode[] = fragments.flat();
|
let nodes: NavNode[] = fragments.flat();
|
||||||
if (override.rename) nodes = renameTree(nodes, override.rename);
|
if (override.rename) nodes = renameTree(nodes, override.rename);
|
||||||
if (override.groups?.length) nodes = applyGroups(nodes, override.groups);
|
if (override.groups?.length) nodes = applyGroups(nodes, override.groups);
|
||||||
if (override.order?.length) nodes = applyOrder(nodes, override.order);
|
if (override.order?.length) nodes = applyOrder(nodes, override.order);
|
||||||
if (override.hide?.length) nodes = hideTree(nodes, new Set(override.hide));
|
if (override.hide?.length) nodes = hideTree(nodes, new Set(override.hide));
|
||||||
return filterByRoles(nodes, new Set(roles)).map(toRenderNode);
|
return filterByRoles(nodes, new Set(permissions)).map(toRenderNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renameTree(nodes: NavNode[], rename: Record<string, string>): NavNode[] {
|
function renameTree(nodes: NavNode[], rename: Record<string, string>): NavNode[] {
|
||||||
@@ -103,19 +103,19 @@ function hideTree(nodes: NavNode[], hide: Set<string>): NavNode[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterByRoles(nodes: NavNode[], roles: Set<string>): NavNode[] {
|
function filterByRoles(nodes: NavNode[], permissions: Set<string>): NavNode[] {
|
||||||
const out: NavNode[] = [];
|
const out: NavNode[] = [];
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
if (n.public !== true && n.role != null && !roles.has(n.role)) continue; // gated → drop node + subtree (public always shows)
|
if (n.public !== true && n.permission != null && !permissions.has(n.permission)) continue; // gated → drop node + subtree (public always shows)
|
||||||
if (!n.children) { out.push(n); continue; }
|
if (!n.children) { out.push(n); continue; }
|
||||||
const children = filterByRoles(n.children, roles);
|
const children = filterByRoles(n.children, permissions);
|
||||||
if (children.length === 0 && n.href == null) continue; // empty pure header → drop
|
if (children.length === 0 && n.href == null) continue; // empty pure header → drop
|
||||||
out.push({ ...n, children });
|
out.push({ ...n, children });
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip the helper-only fields (id/role) and drop absent ones, so the tree is exactly
|
// Strip the helper-only fields (id/permission) and drop absent ones, so the tree is exactly
|
||||||
// what nav-tree.ejs reads.
|
// what nav-tree.ejs reads.
|
||||||
function toRenderNode(n: NavNode): NavNode {
|
function toRenderNode(n: NavNode): NavNode {
|
||||||
const out: NavNode = { label: n.label };
|
const out: NavNode = { label: n.label };
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { buildShellContext, shellUser } from "./shell-context.ts";
|
|||||||
test("shellUser derives the profile from the real user; anonymous → Guest", () => {
|
test("shellUser derives the profile from the real user; anonymous → Guest", () => {
|
||||||
assert.deepEqual(shellUser(null), { email: "", initials: "G", name: "Guest" });
|
assert.deepEqual(shellUser(null), { email: "", initials: "G", name: "Guest" });
|
||||||
// Real user: name = email local part, email kept, initials = first two letters of the local part.
|
// Real user: name = email local part, email kept, initials = first two letters of the local part.
|
||||||
assert.deepEqual(shellUser({ email: "ada@example.com", id: "u1", roles: [] }), { email: "ada@example.com", initials: "AD", name: "ada" });
|
assert.deepEqual(shellUser({ email: "ada@example.com", id: "u1", permissions: [] }), { email: "ada@example.com", initials: "AD", name: "ada" });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("buildShellContext maps branding + breadcrumbs, omitting unset optional fields", () => {
|
test("buildShellContext maps branding + breadcrumbs, omitting unset optional fields", () => {
|
||||||
@@ -22,7 +22,7 @@ test("buildShellContext maps branding + breadcrumbs, omitting unset optional fie
|
|||||||
menu: { branding: { logo: "/l.svg", name: "Acme", sub: "Ops", theme: "dark" }, override: {} },
|
menu: { branding: { logo: "/l.svg", name: "Acme", sub: "Ops", theme: "dark" }, override: {} },
|
||||||
signInHref: "/login?return_to=%2Fx",
|
signInHref: "/login?return_to=%2Fx",
|
||||||
title: "Users",
|
title: "Users",
|
||||||
identity: { email: "a@b.c", id: "u1", roles: ["admin"] },
|
identity: { email: "a@b.c", id: "u1", permissions: ["admin"] },
|
||||||
});
|
});
|
||||||
assert.deepEqual(full.brand, { logo: "/l.svg", name: "Acme", sub: "Ops" });
|
assert.deepEqual(full.brand, { logo: "/l.svg", name: "Acme", sub: "Ops" });
|
||||||
assert.equal(full.theme, "dark");
|
assert.equal(full.theme, "dark");
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Shell view-model builder: the brand/theme/user/title block every app-shell page
|
// Shell view-model builder: the brand/theme/user/title block every app-shell page
|
||||||
// (the home dashboard, the built-in admin screens) hands to shell.ejs. Pure. Extracted so the
|
// (the home dashboard, the built-in admin screens) hands to shell.ejs. Pure. Extracted so the
|
||||||
// shell user is the *real* signed-in identity — no hardcoded demo profile — and branding is
|
// shell user is the *real* signed-in identity — no hardcoded demo profile — and branding is
|
||||||
// read from one place. The User carries no display name (the JWT holds only id/email/roles), so
|
// read from one place. The User carries no display name (the JWT holds only id/email/permissions), so
|
||||||
// the profile shows the email's local part as the name with the full email beneath, initials from
|
// the profile shows the email's local part as the name with the full email beneath, initials from
|
||||||
// the local part; anonymous ⇒ "Guest".
|
// the local part; anonymous ⇒ "Guest".
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
Themed Kratos self-service page inside the unified app shell: sign-in / register /
|
Themed Kratos self-service page inside the unified app shell: sign-in / register /
|
||||||
reset / verify / settings. Renders a FlowView (src/auth/flow-view.ts) into the shell content, reusing the
|
reset / verify / settings. Renders a FlowView (src/auth/flow-view.ts) into the shell content, reusing the
|
||||||
auth-card + field partials. The form posts straight to flow.ui.action — Kratos owns its CSRF. The
|
auth-card + field partials. The form posts straight to flow.ui.action — Kratos owns its CSRF. The
|
||||||
shell's menu is role-filtered (anonymous ⇒ public items + Sign in); the topbar carries no heading,
|
shell's menu is permission-filtered (anonymous ⇒ public items + Sign in); the topbar carries no heading,
|
||||||
so the card's own <h1> is the page's single heading. Data: chrome (PageChrome), flow (FlowView).
|
so the card's own <h1> is the page's single heading. Data: chrome (PageChrome), flow (FlowView).
|
||||||
%><%
|
%><%
|
||||||
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
<%#
|
<%#
|
||||||
Public landing page: the ungated "/", rendered inside the unified app shell so the menu
|
Public landing page: the ungated "/", rendered inside the unified app shell so the menu
|
||||||
shows (role-filtered — anonymous ⇒ public items + Sign in). A brief intro + a prominent way in, or a
|
shows (permission-filtered — anonymous ⇒ public items + Sign in). A brief intro + a prominent way in, or a
|
||||||
dashboard link when already signed in. A plugin may replace this via its `home` handler.
|
dashboard link when already signed in. A plugin may replace this via its `home` handler.
|
||||||
Data: chrome (PageChrome), user (or null).
|
Data: chrome (PageChrome), user (or null).
|
||||||
%><%
|
%><%
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<%#
|
<%#
|
||||||
App shell: sidebar (brand + nav slot + footer) · topbar · content slot. The one chrome every page
|
App shell: sidebar (brand + nav slot + footer) · topbar · content slot. The one chrome every page
|
||||||
renders — dashboard, admin, plugin, login/registration/front — so the menu is identical
|
renders — dashboard, admin, plugin, login/registration/front — so the menu is identical
|
||||||
everywhere, role-filtered to the visitor (anonymous ⇒ public items + Sign in).
|
everywhere, permission-filtered to the visitor (anonymous ⇒ public items + Sign in).
|
||||||
Slots are pre-rendered HTML locals — `nav` (sidebar tree, see nav-tree partial),
|
Slots are pre-rendered HTML locals — `nav` (sidebar tree, see nav-tree partial),
|
||||||
`actions` (topbar buttons), `body` (page content); `styles` is an optional array of
|
`actions` (topbar buttons), `body` (page content); `styles` is an optional array of
|
||||||
extra stylesheet hrefs (e.g. a plugin's own /public/<id>/x.css). Text locals: `title`
|
extra stylesheet hrefs (e.g. a plugin's own /public/<id>/x.css). Text locals: `title`
|
||||||
|
|||||||
Reference in New Issue
Block a user