34 Commits

Author SHA1 Message Date
lilleman 6d0f5a99bf Fix a stale ctx.identity reference in the file map
CI / full-gate (push) Successful in 2m34s
2026-08-03 17:16:19 +02:00
lilleman 9097552065 Say user throughout, noting Ory's identity naming in the docs
CI / full-gate (push) Successful in 2m32s
2026-08-03 17:13:20 +02:00
lilleman 076d1f6926 Record the authorization vocabulary decision in AGENTS.md
CI / full-gate (push) Successful in 2m37s
2026-08-03 17:04:17 +02:00
lilleman 925debbd51 Rename the coarse gate from role to permission, matching RBAC 2026-08-03 17:02:47 +02:00
lilleman 41c568796c Cut the security-model threat table down to what the code cannot show
CI / full-gate (push) Successful in 2m32s
2026-08-03 13:28:56 +02:00
lilleman f7d70cfff8 Rename the Keto User namespace to Identity, matching Kratos
CI / full-gate (push) Successful in 2m34s
2026-08-03 12:19:12 +02:00
lilleman ca3d49ec45 Document the users, groups and roles model in README
CI / full-gate (push) Successful in 2m35s
2026-08-03 11:39:26 +02:00
lilleman acce2d2556 Rename the plugin-API permission gate to role 2026-08-03 11:31:04 +02:00
lilleman 702e42de09 Reflow the security-model prose to the file's wrap width
CI / full-gate (push) Successful in 2m41s
2026-08-02 17:34:57 +02:00
lilleman 98bdd2c1b5 Record the open CSRF token-binding decision as a todo item
CI / full-gate (push) Successful in 2m35s
2026-08-02 17:33:44 +02:00
lilleman 12913402a6 Add the seeded admin login to the production secrets checklist
CI / full-gate (push) Successful in 2m37s
2026-08-02 17:33:04 +02:00
lilleman 7dee80a976 Review fixes: denylist-conditional revoke, Ory secret wiring, exp guard test
CI / full-gate (push) Successful in 2m40s
2026-08-02 17:25:07 +02:00
lilleman c0fe8b0a82 Review fixes: re-mint path, real session lifetime, full secret checklist
CI / full-gate (push) Successful in 6s
2026-08-02 17:14:20 +02:00
lilleman fb7d20a7db Document the auth security model in README
CI / full-gate (push) Successful in 5s
2026-08-02 17:03:09 +02:00
lilleman 0919adf4ef Review fixes: plain depends_on merge, positive secret assertion
CI / full-gate (push) Successful in 2m46s
Mirror / github-mirror (push) Successful in 5s
2026-08-02 16:10:35 +02:00
lilleman f5f4455b81 Browser E2E for the admin OAuth2-clients screen
CI / full-gate (push) Successful in 2m50s
2026-08-02 16:03:30 +02:00
lilleman 3f30f889c3 README: note GITHUB_ secret-name prefix is rejected too
CI / full-gate (push) Successful in 2m41s
Mirror / github-mirror (push) Successful in 5s
2026-08-02 15:48:04 +02:00
lilleman ae1479f55b Renovate: set GITHUB_COM_TOKEN for authenticated github.com lookups
CI / full-gate (push) Successful in 2m33s
2026-08-02 15:43:38 +02:00
lilleman bb6021adf7 Update lockfile for typescript 7.0.2
CI / full-gate (push) Successful in 2m51s
Mirror / github-mirror (push) Successful in 5s
2026-08-02 15:29:13 +02:00
renovate-bot c4e6212189 chore(deps): update dependency typescript to v7
Release-Bump: major
2026-08-02 15:29:03 +02:00
lilleman 6f6aafad39 Import ejs as default export — the v6 ESM build exports only default
CI / full-gate (push) Successful in 2m52s
Mirror / github-mirror (push) Successful in 6s
2026-08-02 15:25:51 +02:00
lilleman 4aada6eed9 Update lockfile for ejs 6.0.1 2026-08-02 15:25:51 +02:00
renovate-bot a574effb37 fix(deps): update dependency ejs to v6
Release-Bump: major
2026-08-02 15:25:51 +02:00
lilleman c259497627 Update lockfile for @types/node 24.13.3
CI / full-gate (push) Successful in 2m36s
Mirror / github-mirror (push) Successful in 5s
2026-08-02 15:22:25 +02:00
renovate-bot 8e74532a77 chore(deps): update node.js to v24.18.1
Release-Bump: minor
2026-08-02 15:22:25 +02:00
lilleman 62f95afe63 Merge branch 'main' into remove-stability-auto-review
CI / full-gate (push) Successful in 5s
Mirror / github-mirror (push) Successful in 6s
2026-08-02 15:19:13 +02:00
lilleman ffeec70f8f Update lockfile and regenerate icons.ejs for lucide-static 1.28.0
CI / full-gate (push) Successful in 2m44s
Mirror / github-mirror (push) Successful in 6s
2026-08-02 15:15:12 +02:00
renovate-bot 62d4c8b7cd fix(deps): update dependency lucide-static to v1.28.0
Release-Bump: minor
2026-08-02 15:15:12 +02:00
lilleman 12cc2d54c2 Clarify todo.md reference and record manual review policy
CI / full-gate (push) Successful in 5s
2026-08-02 15:12:22 +02:00
lilleman bb6adc40af Remove stability reviewer auto-run from AGENTS.md
CI / full-gate (push) Successful in 6s
2026-08-02 15:06:48 +02:00
lilleman c64156a9d5 Update e2e-tests lockfile for @playwright/test 1.62.1
CI / full-gate (push) Successful in 2m42s
Mirror / github-mirror (push) Successful in 5s
2026-08-02 15:06:47 +02:00
renovate-bot de2ad42f5a chore(deps): update playwright to v1.62.1
Release-Bump: minor
2026-08-02 15:06:47 +02:00
lilleman 9213e5a0de Make the CI web-image rebuild its own step; note the shared-workspace image-tag race
CI / full-gate (push) Successful in 2m37s
Mirror / github-mirror (push) Successful in 5s
2026-08-02 15:06:21 +02:00
lilleman 45054db5e6 Build the web image in ci.sh so typecheck and tests run the branch's own deps 2026-08-02 15:06:21 +02:00
98 changed files with 1417 additions and 871 deletions
+1 -1
View File
@@ -19,4 +19,4 @@ jobs:
run: | run: |
docker run --rm -v "$PWD:/repo" -w /repo \ docker run --rm -v "$PWD:/repo" -w /repo \
-e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \ -e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \
node:24.18.0-alpine3.24 node registry-cleanup/cleanup.ts node:24.18.1-alpine3.24 node registry-cleanup/cleanup.ts
+3 -1
View File
@@ -10,9 +10,11 @@ jobs:
steps: steps:
- name: Run Renovate against this repo - name: Run Renovate against this repo
env: env:
GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_TOKEN }}
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }} RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
run: | run: |
docker run --rm \ docker run --rm \
-e GITHUB_COM_TOKEN \
-e LOG_LEVEL=info \ -e LOG_LEVEL=info \
-e RENOVATE_ENDPOINT=https://gitea.larvit.se/api/v1 \ -e RENOVATE_ENDPOINT=https://gitea.larvit.se/api/v1 \
-e RENOVATE_GIT_AUTHOR="Renovate Bot <renovate@larvit.se>" \ -e RENOVATE_GIT_AUTHOR="Renovate Bot <renovate@larvit.se>" \
@@ -52,7 +54,7 @@ jobs:
fi fi
BUMPS=$(git log "${LATEST}..origin/main" --author='renovate@larvit.se' \ BUMPS=$(git log "${LATEST}..origin/main" --author='renovate@larvit.se' \
--format='%(trailers:key=Release-Bump,valueonly)' | { grep -vx '' || true; }) --format='%(trailers:key=Release-Bump,valueonly)' | { grep -vx '' || true; })
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.16.0-alpine3.24 \ NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.18.1-alpine3.24 \
node auto-release/next-version.ts "$LATEST" $BUMPS) node auto-release/next-version.ts "$LATEST" $BUMPS)
echo "Releasing $LATEST -> $NEXT" echo "Releasing $LATEST -> $NEXT"
git tag "$NEXT" origin/main git tag "$NEXT" origin/main
+30 -11
View File
@@ -7,7 +7,7 @@ commands and layout.
Use the file `todo.md`. Use the file `todo.md`.
For each todo item, interview the user extensively to deeply understand the scope and goal of each. When done, run the stability reviewer agent in a loop and address all feedback until there is none. If you are not very confident of how to address it, ask the user. Check the completed task in this file. Commit all changes and push to a new branch, create a PR and merge it when the CI/CD turns green. For each todo item, interview the user extensively to deeply understand the scope and goal of each. When done, check the completed task in `todo.md`. Commit all changes and push to a new branch, create a PR and merge it when the CI/CD turns green.
## Project priorities (do not erode) ## Project priorities (do not erode)
@@ -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
@@ -86,11 +86,26 @@ them. Revisit only if the stated reason stops holding.
`tsconfig.include` and resolve the host surface via `#`-imports, so each example typechecks `tsconfig.include` and resolve the host surface via `#`-imports, so each example typechecks
in place *and* copies across unchanged. Never commit real plugins/config into the root in place *and* copies across unchanged. Never commit real plugins/config into the root
mount dirs (`plugins/`, `config/`) — they ship empty (`.gitkeep`, git-ignored otherwise). mount dirs (`plugins/`, `config/`) — they ship empty (`.gitkeep`, git-ignored otherwise).
- **Authorization vocabulary: `User``Group``Permission`, and there is no `Role`.** Keto ships
no namespaces — all four in `ory/keto/namespaces.keto.ts` are ours. `Permission` follows RBAC,
where a permission is one operation ("read shifts") and a role is a *bundle* of them; a route
gates on one operation, so it gates on a permission, and a bundle is just a group with several
grants (groups nest). Ory's own "permission" (the `Resource` `permits`: view/edit/delete) is the
separate per-row tier.
- **Plainpages says "user" everywhere; Ory's word for it is "identity".** Kratos calls the record
an identity, but Ory's own docs state it uses that term *interchangeably* with "users" and
"accounts" — so this is house style, not a renamed concept, and "user" is the word readers
already know (Nielsen's heuristic #2: match between the system and the real world). One note in
README → Auth records the mapping so nobody has to rediscover it. The single exception is the
`Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors Kratos' wire shape and keeps Ory's
name — don't rename that one.
- **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so - **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so
`docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`: `docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`:
concurrent jobs can race (one job's logout can 401 another's push — recover by re-running), concurrent jobs can race (one job's logout can 401 another's push — recover by re-running),
and tokens sit in that file between login and logout. Accepted for a single-maintainer and tokens sit in that file between login and logout. Same class: concurrent runs share the
cadence; serialize with a workflow `concurrency` group if it ever bites. workspace dir, so ci.sh's web-image build races another run's container creation on the
`<project>-web` tag. Accepted for a single-maintainer cadence; serialize with a workflow
`concurrency` group if it ever bites.
## Docker only — no host tooling ## Docker only — no host tooling
@@ -118,10 +133,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 →
Building plugins → menu/blocks/interactivity → Configuration → Auth → Email → Users, groups & permissions → Building plugins → menu/blocks/interactivity →
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 & permissions precedes Building plugins** because a manifest's
`permission:` gate is unreadable without the model, and operators need it as much as plugin
authors. It is the one home for that model — the plugin and auth sections link to it rather
than restating it.
When editing: put content in the section it belongs to (don't prepend rationale above Quick 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
@@ -168,9 +188,8 @@ Same test before adding a row to a table or the file map — a clause, not a par
that re-parses `ctx.url.pathname`: it duplicates the URL shape, ignores the router's params, and that re-parses `ctx.url.pathname`: it duplicates the URL shape, ignores the router's params, and
has to re-handle HEAD. Factor shared per-request setup (auth gate, `ctx.system` capability has to re-handle HEAD. Factor shared per-request setup (auth gate, `ctx.system` capability
resolution, target fetch) into a small `withX` wrapper — see `examples/plugins/admin/`. resolution, target fetch) into a small `withX` wrapper — see `examples/plugins/admin/`.
- Run the stability reviewer agent after every implementation of something that can be like - Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer
a PR. That includes any change pushed directly to main. agents. Decided 2026-08-02, replacing the earlier run-after-every-implementation rule.
Skip this if the changes are purely documentation and/or comments.
- Use well formed, standard compliant, rich URIs. Prefer state in the URL over POST:ing in for - Use well formed, standard compliant, rich URIs. Prefer state in the URL over POST:ing in for
for example list pages with filters and pagination. Do: "ids=x&ids=y" and not "ids[]=x&ids[]=y" for example list pages with filters and pagination. Do: "ids=x&ids=y" and not "ids[]=x&ids[]=y"
and not "ids=x,y". and not "ids=x,y".
+1 -1
View File
@@ -1,5 +1,5 @@
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag. # Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
FROM node:24.18.0-alpine3.24 FROM node:24.18.1-alpine3.24
WORKDIR /app WORKDIR /app
+294 -104
View File
@@ -28,14 +28,14 @@ docker compose up -d # http://localhost:3000, live-reloads on source chan
**`admin@plainpages.local` / `admin`**. **`admin@plainpages.local` / `admin`**.
**3. Enable user & group admin (optional).** The core ships **no admin GUI** — the Users / Groups **3. Enable user & group admin (optional).** The core ships **no admin GUI** — the Users / Groups
/ Roles / OAuth2-clients screens are a drop-in plugin. Copy it in to mount them at `/admin/*`: / Permissions / OAuth2-clients screens are a drop-in plugin. Copy it in to mount them at `/admin/*`:
```bash ```bash
cp -r examples/plugins/admin plugins/admin cp -r examples/plugins/admin plugins/admin
docker compose restart web docker compose restart web
``` ```
The seeded admin already holds the `admin` role, so the **Admin** section now shows in the menu. The seeded admin already holds the `admin` permission, so the **Admin** section now shows in the menu.
See [`examples/plugins/admin/`](examples/plugins/admin/). See [`examples/plugins/admin/`](examples/plugins/admin/).
**4. Add your first plugin.** The clone is bind-mounted into the container, so a new **4. Add your first plugin.** The clone is bind-mounted into the container, so a new
@@ -69,6 +69,10 @@ 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 & permissions](#users-groups--permissions)
- [a worked example](#a-worked-example)
- [granting a permission](#granting-a-permission)
- [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)
- [the manifest](#the-manifest) - [the manifest](#the-manifest)
@@ -76,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 & permissions](#nav--permissions) - [nav & permission gates](#nav--permission-gates)
- [versioning](#contract-versioning) - [versioning](#contract-versioning)
- [conflict rules](#conflict-rules) - [conflict rules](#conflict-rules)
- [hooks](#hooks) - [hooks](#hooks)
@@ -89,11 +93,12 @@ From here, render real pages against the app shell and fetch upstream data — s
- [canonical host](#canonical-host-one-public-url) - [canonical host](#canonical-host-one-public-url)
- [what you must supply](#what-you-must-supply-the-only-manual-prep) - [what you must supply](#what-you-must-supply-the-only-manual-prep)
- [SSO](#social-sign-in-sso) - [SSO](#social-sign-in-sso)
- [Auth, sessions & permissions](#auth-sessions--permissions) - [Auth, sessions & access](#auth-sessions--access)
- [login & the session JWT](#login-and-the-session-jwt) - [login & the session JWT](#login-and-the-session-jwt)
- [instant revoke](#instant-revoke-the-optional-denylist) - [instant revoke](#instant-revoke-the-optional-denylist)
- [three tiers](#three-tiers-of-may-i) - [three tiers](#three-tiers-of-may-i)
- [OAuth2 (Hydra)](#oauth2-provider-hydra) - [OAuth2 (Hydra)](#oauth2-provider-hydra)
- [security model](#security-model)
- [Email](#email) - [Email](#email)
- [Architecture](#architecture) - [Architecture](#architecture)
- [Stateless](#stateless) - [Stateless](#stateless)
@@ -123,14 +128,14 @@ and operational tools, dashboards, portals, or public sites with a gated area
use or for a client. You know HTTP, Docker, and identity use or for a client. You know HTTP, Docker, and identity
providers, and you'd rather assemble pages from building blocks than fight a framework or providers, and you'd rather assemble pages from building blocks than fight a framework or
hand-roll auth for the tenth time. It's not a no-code tool and doesn't hide its moving hand-roll auth for the tenth time. It's not a no-code tool and doesn't hide its moving
parts: if "Ory is down ⇒ no logins" (see [Auth](#auth-sessions--permissions)) reads as parts: if "Ory is down ⇒ no logins" (see [Auth](#auth-sessions--access)) reads as
obvious rather than surprising, you're the audience. obvious rather than surprising, you're the audience.
**Included vs. what you add.** **Included vs. what you add.**
- **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
@@ -189,10 +194,142 @@ Plainpages sits relative to them:
| **Themed auth UI on Ory** — Kratos self-service UIs (`ory/kratos-selfservice-ui-node`, `kratos-admin-ui`) | the **login / registration screens** over Ory | The one *slice* with a direct off-the-shelf alternative: Plainpages reimplements it inside its own shell, so you could swap it out to avoid maintaining that part. | | **Themed auth UI on Ory** — Kratos self-service UIs (`ory/kratos-selfservice-ui-node`, `kratos-admin-ui`) | the **login / registration screens** over Ory | The one *slice* with a direct off-the-shelf alternative: Plainpages reimplements it inside its own shell, so you could swap it out to avoid maintaining that part. |
No family combines the whole set: **[drop-in plugin folders](#building-plugins)**, a **zero-JS No family combines the whole set: **[drop-in plugin folders](#building-plugins)**, a **zero-JS
server-rendered** design system, **[optional auth](#auth-sessions--permissions)** (any page server-rendered** design system, **[optional auth](#auth-sessions--access)** (any page
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 & permissions
Authorization here is two hops: a **user** — directly, or through a **group** — is granted a
**permission**, and that permission's *name* is exactly the string a plugin gates on.
- **Group** answers *who* — a reusable set of people. Optional: a permission can be granted
straight to a user.
- **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@user:<id>`, or
`@Group:<name>#members`.
- **Resource** answers *which row* — a live check, run only where a plugin explicitly asks for it.
| Entity | Lives in | Answers | Example |
| --- | --- | --- | --- |
| **User** | Kratos | who you are | `user:0198f2c1-…` |
| **Group** | Keto | who — a reusable set | `Group:support` |
| **Permission** | Keto | what you may do | `Permission:scheduling:read` |
| **Resource** | Keto | which specific row | `Resource:shift-4471` |
Users live in Kratos; every authorization edge is a Keto relation tuple. The app itself
stores none of it — it is [stateless](#stateless).
**Keto ships no entities of its own.** Its entire model is one primitive —
`namespace:object#relation@subject` — so the four namespaces above are *ours*, declared in
`ory/keto/namespaces.keto.ts`; Keto only supplies the machinery that resolves them (including
transitively, through nested groups).
> **Ory calls a user an "identity".** Kratos owns that record and names it so: its API is
> `/admin/identities`, and a session carries `session.identity`. Plainpages says **user**
> everywhere, because that is the word readers already know — and Ory's own documentation states
> it uses "identity" interchangeably with "users" and "accounts". You will meet Ory's spelling in
> exactly two places: the Kratos API itself, and the `Identity` type in `src/auth/kratos-admin.ts`
> that mirrors it.
> **There is no `Role`.** In RBAC a permission is a single operation ("read shifts") and a role is
> a *bundle* of them ("IT Support staff"). A route gates on one operation, so it gates on a
> **permission**. When you want the bundle, make a group and grant it several — groups nest, so a
> group of groups works too.
### A worked example
Alice works support and leads scheduling; Bob works support; Carol administers the system.
```
people groups permissions
────── ────── ───────────
alice ──┬─────────> Group:support ────┐
│ ├──> Group:staff ──> Permission:scheduling:read
bob ────┘ │
alice ────────────> Group:sched-leads ┴──> Permission:scheduling:write
carol ───────────────────────────────────────────────> Permission:admin
```
At login the host asks Keto which permissions the user holds, walking those arrows
transitively, and bakes the answer into the session JWT (see [Login and the session
JWT](#login-and-the-session-jwt)):
```
alice → permissions: ["scheduling:read", "scheduling:write"]
bob → permissions: ["scheduling:read"]
carol → permissions: ["admin"]
```
Note what Carol does *not* have. **Permissions do not nest, and there is no superuser**`admin`
is just another name, granting nothing except where a route gates on `admin` itself.
Against the reference plugins' actual routes:
| Request | Gate | alice | bob | carol | anonymous |
| --- | --- | --- | --- | --- | --- |
| `GET /scheduling` | `public: true` | ✅ | ✅ | ✅ | ✅ |
| `GET /scheduling/shifts` | `scheduling:read` | ✅ | ✅ | 403 | → `/login` |
| `GET /scheduling/shifts/new` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
| `POST /scheduling/shifts` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
| `GET /admin/users` | `admin` | 403 | 403 | ✅ | → `/login` |
Bob reaches the shifts list with no direct grant: he is in `support`, support's members are
`staff`, and staff holds `scheduling:read` — two hops, resolved by Keto at his login. He is
refused the new-shift form because `scheduling:write` hangs off `sched-leads`, which he is not in.
An anonymous visitor gets a **redirect**, not a 403, carrying `return_to` so signing in lands them
on the page they asked for; a signed-in user who merely lacks the permission gets the 403 page,
because there is nothing to sign in *as* that would help. The menu is filtered by the same
permissions, so nobody is shown a door they cannot open.
### Granting a permission
Write the tuple. The admin plugin's **Groups** and **Permissions** screens do exactly this, or use
Keto's write API directly:
```bash
# everyone in sched-leads may write shifts
curl -X PUT http://keto:4467/admin/relation-tuples -H 'content-type: application/json' -d '{
"namespace": "Permission", "object": "scheduling:write", "relation": "granted",
"subject_set": { "namespace": "Group", "object": "sched-leads", "relation": "members" }
}'
```
Permissions are authored **only in Keto** — nothing else writes them. Their names are a shared
global namespace on purpose, so an operator grants `scheduling:read` once and every plugin
referencing it is gated consistently; namespace yours as `<id>:<action>`.
A change takes effect on the user's **next login or JWT re-mint** (~10 min) — see [Instant
revoke](#instant-revoke-the-optional-denylist) when you need it sooner.
### Fine-grained, per-row access
The `Resource` namespace covers what a coarse permission cannot express: *this* row, shared with
*this* person. It is a separate mechanism — a `Resource` carries Keto `permits` (`view`, `edit`,
`delete`, which nest as `owner``editor``viewer`) and never appears in the JWT.
**A per-row grant never widens a coarse gate.** The route's `permission` is checked *before* the
handler runs, so a user rejected there never reaches the check. Gate the route on something they
hold, then narrow inside the handler:
```ts
{ method: "POST", path: "/shifts/:id", permission: READ, handler: editShift }
async function editShift(ctx) {
if (!(await check(keto, ctx, { namespace: "Resource", object: ctx.params.id, relation: "editors" })))
throw new GuardError(403, "not an editor of this shift");
}
```
Reserve this tier for relationship rules (sharing, delegation, inheritance). Ownership and tenant
rules belong in the upstream service that holds the row — see [Three tiers of "may
I?"](#three-tiers-of-may-i).
## Building plugins ## Building plugins
A plugin is a self-contained folder under `plugins/` that the host discovers at boot — no A plugin is a self-contained folder under `plugins/` that the host discovers at boot — no
@@ -205,7 +342,7 @@ 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
permission-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.
@@ -236,7 +373,7 @@ single `plugin.ts`.
must be **URL/path-safe** (`isValidPluginId`: lowercase `az`, digits, and dashes — dashes must be **URL/path-safe** (`isValidPluginId`: lowercase `az`, digits, and dashes — dashes
anywhere; no uppercase, underscores, dots, or slashes); the host rejects a malformed folder name anywhere; no uppercase, underscores, dots, or slashes); the host rejects a malformed folder name
at discovery. The id also namespaces the plugin's `views/`, its `/public/<id>/` assets, and (by at discovery. The id also namespaces the plugin's `views/`, its `/public/<id>/` assets, and (by
convention) its nav/permission tokens. 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`),
@@ -272,10 +409,10 @@ export default definePlugin({
// `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", permission: "things:read" }], nav: [{ href: "/things", icon: "i-cal", id: "things:list", label: "Things", permission: "things:read" }],
// Permission tokens this plugin introduces. Optional — see Nav & permissions. // Permissions this plugin gates on. Optional — see Nav & permission gates.
permissions: [ permissions: [
{ token: "things:read", description: "View things" }, { description: "View things", name: "things:read" },
{ token: "things:write", description: "Create and edit things" }, { description: "Create and edit things", name: "things:write" },
], ],
// Route handlers, mounted under the plugin's path (/things). `permission` gates first. // Route handlers, mounted under the plugin's path (/things). `permission` gates first.
@@ -298,7 +435,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. |
| `permissions` | no | Tokens this plugin introduces. See [Nav & permissions](#nav--permissions). | | `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). |
@@ -309,11 +446,11 @@ A plugin may be routes-only, nav-only, or hooks-only — every collection field
A route is `{ method, path, permission?, 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 `permission` gate (a coarse JWT-claim check — see [Nav & permissions](#nav--permissions)), 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)).
@@ -356,7 +493,7 @@ export async function listThings(ctx: RequestContext) {
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 `permission`** 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.
@@ -412,7 +549,7 @@ 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.user` is non-null; For the gated `dashboard`, the host enforces the session gate first, so `ctx.user` 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 permission — 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`.)
@@ -429,15 +566,15 @@ 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
user: User | 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[]; // user?.roles ?? [] — coarse gate without a null-check permissions: string[]; // user?.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;
user: User | null; // { id, email, roles } from the verified session JWT, or null
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
} }
``` ```
@@ -446,7 +583,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 —
@@ -455,10 +592,11 @@ reference `examples/plugins/scheduling/views/overview.ejs` does; a value you for
shell default (e.g. a bare `/login`), it does not error. **`ctx.verifyCsrf(submitted)`** guards a shell default (e.g. a bare `/login`), it does not error. **`ctx.verifyCsrf(submitted)`** guards a
state-changing form: render `chrome.csrfToken` in a hidden `_csrf` field, then on POST read your own state-changing form: render `chrome.csrfToken` in a hidden `_csrf` field, then on POST read your own
body and `if (!ctx.verifyCsrf(form.get("_csrf"))) throw new GuardError(403, …)`. The host owns the body and `if (!ctx.verifyCsrf(form.get("_csrf"))) throw new GuardError(403, …)`. The host owns the
secret and sets the cookie; the plugin never touches it. (See the reference: `examples/plugins/scheduling/`.) secret and sets the cookie; the plugin never touches it. It is **opt-in per handler** — a route
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.
@@ -475,7 +613,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)
@@ -489,7 +627,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)
} }
@@ -499,20 +637,20 @@ 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.
This is a **privileged** surface — it hands a plugin the keys to identity and permissions. It's meant This is a **privileged** surface — it hands a plugin the keys to identity and authorization. It's meant
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 & permissions ### 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 `permission`, or the user's roles include that token. 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.
@@ -520,30 +658,26 @@ 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 `permission` (a no-permission 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 `permission` 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.user` is `null`, the shell shows a **Sign in** link (`chrome.signInHref`, returning to this page) `ctx.user` 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`.
**A `permission` token is a coarse role.** The route/nav gate passes iff the user's JWT `roles` The gate passes iff the user's JWT `permissions` include that name. How permissions are granted, why their
include the token; those roles come from Keto at login, so an operator grants a token by writing the names are a shared global namespace, and the fine-grained per-row tier are all covered in
Keto tuple `Role:<token>#members@user:<id>` (or to a group) — the admin **Roles** screen does this. [Users, groups & permissions](#users-groups--permissions).
(The fine-grained, per-row tier is the separate Keto `Resource` namespace — see
[Three tiers of "may I?"](#three-tiers-of-may-i); it is not what a route `permission` checks.)
Permission tokens are a **shared global namespace** — that's deliberate, so an operator grants Declaring the ones you gate on in `permissions` is **optional but recommended**: it documents them,
`scheduling:read` once in Keto and every plugin referencing it is gated consistently. Namespace feeds conflict detection, and lets the one-command bootstrap seed them — the demo admin is
your tokens as `<id>:<action>` to avoid accidental clashes. Declaring them in `permissions` is granted every discovered plugin's declared permissions, so a dropped-in plugin works out of the box
optional but recommended: it documents them, feeds conflict detection, and lets the one-command without editing host config.
bootstrap seed them — the demo admin is granted every discovered plugin's declared tokens, so
a dropped-in plugin works out of the box without editing host config.
### Contract versioning ### Contract versioning
@@ -578,7 +712,7 @@ 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)). |
| `permission` | warn | A permission token is declared by more than one plugin. Sharing is legitimate (shared role); 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. `permission` is the one intentional overlap, so it warns uniqueness follows from the id check. `permission` is the one intentional overlap, so it warns
@@ -713,11 +847,11 @@ The menu is **driven entirely by config** and assembled from two sources:
``` ```
Every nav item may carry a `permission`; 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 & permissions](#auth-sessions--permissions)), 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 (a no-permission 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 `permission`). 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 —
@@ -743,7 +877,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).
@@ -785,13 +919,13 @@ The app is **environment-agnostic**: there is no `NODE_ENV`. Behaviour that used
| `OTLP_ENDPOINT` | _unset_ | OpenTelemetry Collector HTTP base URI; set ⇒ export logs + traces (unset ⇒ console only) | | `OTLP_ENDPOINT` | _unset_ | OpenTelemetry Collector HTTP base URI; set ⇒ export logs + traces (unset ⇒ console only) |
| `OTLP_PROTOCOL` | `http/json` | OTLP wire format: `http/json` or `http/protobuf` | | `OTLP_PROTOCOL` | `http/json` | OTLP wire format: `http/json` or `http/protobuf` |
| `KRATOS_PUBLIC_URL` / `KRATOS_ADMIN_URL` | `http://kratos:4433` / `:4434` | identity (self-service / admin) | | `KRATOS_PUBLIC_URL` / `KRATOS_ADMIN_URL` | `http://kratos:4433` / `:4434` | identity (self-service / admin) |
| `KETO_READ_URL` / `KETO_WRITE_URL` | `http://keto:4466` / `:4467` | permission check / write | | `KETO_READ_URL` / `KETO_WRITE_URL` | `http://keto:4466` / `:4467` | authorization check / write |
| `HYDRA_ADMIN_URL` | `http://hydra:4445` | OAuth2 provider admin API (login/consent handshake) | | `HYDRA_ADMIN_URL` | `http://hydra:4445` | OAuth2 provider admin API (login/consent handshake) |
| `JWKS_URL` | `file://…/tokenizer/jwks.json` | the Kratos tokenizer signing key; verifies the session JWT | | `JWKS_URL` | `file://…/tokenizer/jwks.json` | the Kratos tokenizer signing key; verifies the session JWT |
| `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` |
@@ -836,20 +970,35 @@ both default to `localhost` (the dev override sets `APP_URL=http://localhost:300
A clean clone needs **none** of the above — `docker compose up` brings up the whole stack A clean clone needs **none** of the above — `docker compose up` brings up the whole stack
with dev-throwaway secrets, an auto-generated signing key, and a seeded admin (see with dev-throwaway secrets, an auto-generated signing key, and a seeded admin (see
[Quick start](#quick-start)). Exactly **two** things can't be auto-generated, and **both [Quick start](#quick-start)). What can't be auto-generated is **production-only** — none of it
are production-only** — neither blocks a clean clone: blocks a clean clone:
1. **Production secrets** — every value below ships as a committed dev throwaway that works
out of the box and **must** be replaced before a deploy faces the internet. Only the first
is enforced: `REQUIRE_SECURE_SECRETS=true` refuses to boot on a missing or throwaway
`CSRF_SECRET` and **nothing else** — the rest fail silently, so treat this as a checklist.
| Secret | Where | Protects |
| --- | --- | --- |
| `CSRF_SECRET` | web env | signs our double-submit CSRF token |
| JWT signing key | mount a real `jwks.json` or set `…_JWKS_URL` | mints/verifies the session JWT — see [rotation](#jwt-signing-key--rotation) |
| `SECRETS_COOKIE` | kratos env | signs Kratos' session + anti-CSRF cookies |
| `SECRETS_CIPHER` | kratos env (32 chars) | encrypts credentials at rest |
| `SECRETS_SYSTEM` | hydra env | encrypts OAuth2 tokens + consent at rest |
| `POSTGRES_USER` / `POSTGRES_PASSWORD` | compose env | the Ory databases (default `ory`/`ory`) |
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` | bootstrap env | the seeded first admin login (default `admin@plainpages.local` / `admin`) |
`CSRF_SECRET`, the Postgres pair and the admin pair are interpolated from the host
environment. The three Ory secrets are **not**: `compose.yml` passes only `DSN` to
`kratos`/`hydra`, so add them to those services' `environment:` (or an `env_file:`) or they
silently stay on the throwaways.
1. **Production secrets** — replace the committed dev throwaway `CSRF_SECRET` (env), plus
the **JWT signing key** (mount a real `jwks.json` or set `…_JWKS_URL` — see
[JWT signing key & rotation](#jwt-signing-key--rotation)). Set
`REQUIRE_SECURE_SECRETS=true` and the app refuses to boot until `CSRF_SECRET` is supplied
and differs from the throwaway.
2. **SSO provider client id/secret** — **optional**; password login works without them. 2. **SSO provider client id/secret** — **optional**; password login works without them.
Supplying a provider's creds via env activates it; no creds ⇒ no SSO button (see Supplying a provider's creds via env activates it; no creds ⇒ no SSO button (see
[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)
@@ -864,7 +1013,7 @@ button, and the whole SSO section disappears when none are configured — no cod
add or remove one. Open-source Kratos has **no native SAML** — front it with an OIDC bridge add or remove one. Open-source Kratos has **no native SAML** — front it with an OIDC bridge
(Ory Polis) and register that bridge as a generic OIDC provider the same way. (Ory Polis) and register that bridge as a generic OIDC provider the same way.
## Auth, sessions & permissions ## Auth, sessions & access
Identity comes from **Kratos**; the hot path stays I/O-free by carrying coarse authorization Identity comes from **Kratos**; the hot path stays I/O-free by carrying coarse authorization
in a **locally-validated JWT**, and **Keto** is reserved for the rare fine-grained, in a **locally-validated JWT**, and **Keto** is reserved for the rare fine-grained,
@@ -883,32 +1032,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@user:alice`); the admin screens write them *only* to Keto. But the `Permission:admin#granted@user: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
@@ -920,8 +1069,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
@@ -934,12 +1083,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
@@ -949,10 +1098,13 @@ 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 & permissions](#users-groups--permissions) covers *what* the entities are; this is where each
**kind** of rule belongs.
``` ```
coarse (menu / route / feature) → JWT claim · in-process, zero I/O coarse (menu / route / feature) → JWT claim · in-process, zero I/O
fine + attribute (owner / tenant / …) → upstream service that owns the row fine + attribute (owner / tenant / …) → upstream service that owns the row
@@ -967,10 +1119,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 built-in users / groups / permissions screens write authorization **only to Keto** — The admin plugin's users / groups / permissions screens write authorization **only to Keto** — coarse
coarse roles and fine-grained relationships alike. Roles reach the JWT by being read from permissions and fine-grained relationships alike.
Keto at login and projected through the tokenizer (above); nothing authors them anywhere
else.
### OAuth2 provider (Hydra) ### OAuth2 provider (Hydra)
@@ -997,6 +1147,42 @@ generated `client_secret` **once**, on the confirmation page — confidential cl
delete. Confidential vs public (PKCE) and the first-party auto-consent flag are set at registration; delete. Confidential vs public (PKCE) and the first-party auto-consent flag are set at registration;
writes go only to Hydra. writes go only to Hydra.
### Security model
Everything above is *how* auth works. These are the few things the code won't tell you quickly,
and that get a deployment wrong if you don't know them.
**The private container network is the *only* thing guarding the Ory APIs.** Kratos admin
(`4434`), Hydra admin (`4445`) and Keto write (`4467`) authenticate no one — reaching them *is*
full identity and authorization control. Keto **read** (`4466`) cannot write, but discloses the
entire authorization graph, so treat it the same. `compose.yml` publishes none of the six Ory
ports (guarded by `src/compose.test.ts`); dev publishes only the two a browser must reach. Never
expose one, and never front one with a proxy that lacks its own auth.
**The JWT is signed, not encrypted.** Claims are base64: a signed-in user can read their own
`sub`, `email` and `permissions`. `HttpOnly` keeps page JavaScript out of the cookie, not the user.
Never put anything in a claim you wouldn't show them.
**The JWT's ~10m TTL is not the session lifetime.** The browser also holds Kratos'
`plainpages_session` cookie (30 days, sliding), and *that* is what silently re-mints a lapsed
JWT. So a stolen cookie jar is worth 30 days of re-mintable access, not ten minutes. Only our
two cookies obey `SECURE_COOKIES`; the Kratos one takes its flags from Kratos' own config.
**Offboarding is not instant by default.** An expired JWT re-mints off that live Kratos session,
re-reading permissions from Keto — so a revoked permission, or a deactivated identity, lands within one
token TTL rather than immediately. With the
[denylist](#instant-revoke-the-optional-denylist) on (it is off by default), both take effect at
once, on the instance that handled the change.
**Not guaranteed** — accepted, and stated where each mechanism is: permission changes
[lag up to one token TTL and sign-in needs Ory up](#two-trade-offs--both-deliberate), and the
denylist is [single-instance and skips group changes](#instant-revoke-the-optional-denylist).
Hardening a real deploy is `REQUIRE_SECURE_SECRETS=true`, `SECURE_COOKIES=true`, and replacing
**every** committed dev secret — see
[what you must supply](#what-you-must-supply-the-only-manual-prep). `REQUIRE_SECURE_SECRETS`
guards only `CSRF_SECRET`; nothing fails loud if you ship Ory's, Postgres' or the demo admin's
throwaways.
## Email ## Email
The only emails are the **recovery** and **verification** codes from Kratos' self-service The only emails are the **recovery** and **verification** codes from Kratos' self-service
@@ -1025,7 +1211,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. |
@@ -1035,12 +1221,12 @@ 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
using Node's built-in `fetch`** — no SDK dependency. See using Node's built-in `fetch`** — no SDK dependency. See
[Auth, sessions & permissions](#auth-sessions--permissions). [Auth, sessions & access](#auth-sessions--access).
In **dev** the host-facing Ory ports are published — Kratos public `4433` (where the browser In **dev** the host-facing Ory ports are published — Kratos public `4433` (where the browser
POSTs self-service flows) and Hydra public `4444`; **prod** (`docker compose -f compose.yml POSTs self-service flows) and Hydra public `4444`; **prod** (`docker compose -f compose.yml
@@ -1096,7 +1282,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
@@ -1118,8 +1304,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, a `e2e-tests/mock-oidc.ts`), **menu filtering by permission**, the **users/groups/permissions** admin CRUD, the
permission-gated **plugin page**, and **logout**. Because the themed form posts straight to **OAuth2-clients** admin screen (register → one-time secret → delete; Hydra is part of this stack
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.
@@ -1163,7 +1350,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
@@ -1249,8 +1436,11 @@ Renovate merges it once `CI / full-gate (push)` is green (rebasing stale branche
fast-forward-only merge still holds) — routine bumps land untouched; only a red gate needs a fast-forward-only merge still holds) — routine bumps land untouched; only a red gate needs a
human. One-time setup: reuse the shared `renovate@larvit.se` bot — give it write access to human. One-time setup: reuse the shared `renovate@larvit.se` bot — give it write access to
this repo and store its Gitea PAT as the Actions **secret** `RENOVATE_TOKEN`. Until it this repo and store its Gitea PAT as the Actions **secret** `RENOVATE_TOKEN`. Until it
exists, the nightly job fails loud (and, like the other secrets, a `GITEA_` prefix is exists, the nightly job fails loud (and, like the other secrets, a `GITEA_`/`GITHUB_`
rejected). prefix is rejected). Also store a **scopeless** (read-only) github.com PAT as the secret
`RENOVATE_GITHUB_TOKEN` — the workflow hands it to Renovate as `GITHUB_COM_TOKEN`, so
lookups of github.com-hosted deps (actions, Playwright, changelogs) run authenticated
instead of tripping the anonymous 60-requests/hour limit.
**Auto-release on dependency updates** — a second job in `renovate.yml` (`auto-release`, `needs: **Auto-release on dependency updates** — a second job in `renovate.yml` (`auto-release`, `needs:
renovate`) cuts **one** `vX.Y.Z` tag per run covering the renovate-bot commits merged to `main` renovate`) cuts **one** `vX.Y.Z` tag per run covering the renovate-bot commits merged to `main`
@@ -1310,8 +1500,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 permission tokens in Keto, so permission 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.**
@@ -1332,7 +1522,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.
@@ -1393,7 +1583,7 @@ container-relative; with the dev bind-mount they edit the real file).
2. **Restart Kratos** so it signs with the new first key: `docker compose restart kratos`. 2. **Restart Kratos** so it signs with the new first key: `docker compose restart kratos`.
(web needs no restart — it hot-reloads the file. The hot path verifies JWTs locally, so a (web needs no restart — it hot-reloads the file. The hot path verifies JWTs locally, so a
brief Kratos blip only touches login/re-mint.) brief Kratos blip only touches login/re-mint.)
3. **Verify** new logins mint the new `kid` — decode the `plainpages_session` cookie's JWT 3. **Verify** new logins mint the new `kid` — decode the `plainpages_jwt` cookie's JWT
header, or watch web's logs for a `jwks reload on kid miss` debug line as old clients header, or watch web's logs for a `jwks reload on kid miss` debug line as old clients
present the new key. present the new key.
4. **Wait ~12 min**, then **prune** the superseded key: 4. **Wait ~12 min**, then **prune** the superseded key:
@@ -1444,10 +1634,10 @@ 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.user/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.user/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 permission 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)
@@ -1455,9 +1645,9 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *.
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
@@ -1475,19 +1665,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 + permission-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
+6
View File
@@ -40,6 +40,12 @@ pkg=$(grep -oE '"@playwright/test": "[0-9.]+"' e2e-tests/package.json | grep -oE
[ -n "$img" ] && [ "$img" = "$pkg" ] || { echo "Playwright pin mismatch/unreadable: image v$img vs @playwright/test $pkg"; exit 1; } [ -n "$img" ] && [ "$img" = "$pkg" ] || { echo "Playwright pin mismatch/unreadable: image v$img vs @playwright/test $pkg"; exit 1; }
echo "ok ($img)" echo "ok ($img)"
# Explicit rebuild: without it a stale web image from a previous branch supplies node_modules
# (the source is bind-mounted but deps are baked in), so a dep bump gets typechecked/tested
# against the OLD packages. Cheap when deps are unchanged (npm ci layer is cache-keyed).
step "Build web image"
docker compose build web
step "Typecheck" step "Typecheck"
docker compose run --rm --no-deps web npm run typecheck docker compose run --rm --no-deps web npm run typecheck
+1 -1
View File
@@ -27,7 +27,7 @@ services:
# backs it (SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service — # backs it (SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
# stdlib-only, in-memory, no auth. Prod points SCHEDULING_UPSTREAM at the real backend instead. # stdlib-only, in-memory, no auth. Prod points SCHEDULING_UPSTREAM at the real backend instead.
shifts-upstream: shifts-upstream:
image: node:24.18.0-alpine3.24 image: node:24.18.1-alpine3.24
command: node /srv/server.ts command: node /srv/server.ts
restart: unless-stopped restart: unless-stopped
volumes: volumes:
+3 -3
View File
@@ -130,9 +130,9 @@ services:
environment: environment:
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local} ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
# Base roles for the demo admin; bootstrap also grants every discovered plugin's declared # Base permissions for the demo admin; bootstrap also grants every discovered plugin's declared
# permission tokens (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
+1 -1
View File
@@ -1,6 +1,6 @@
# Playwright runner — browsers preinstalled, pinned to match @playwright/test in e2e-tests/. # Playwright runner — browsers preinstalled, pinned to match @playwright/test in e2e-tests/.
# Built/run via e2e-tests/compose.visual.yml; targets the `web` service over the network. # Built/run via e2e-tests/compose.visual.yml; targets the `web` service over the network.
FROM mcr.microsoft.com/playwright:v1.49.1-noble FROM mcr.microsoft.com/playwright:v1.62.1-noble
WORKDIR /e2e-tests WORKDIR /e2e-tests
+6 -6
View File
@@ -9,7 +9,7 @@ import { expect, test } from "@playwright/test";
const WEB = process.env.BASE_URL ?? "http://web:3000"; const WEB = process.env.BASE_URL ?? "http://web:3000";
const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433"; const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433";
const KRATOS_ADMIN = process.env.KRATOS_ADMIN_URL ?? "http://kratos:4434"; const KRATOS_ADMIN = process.env.KRATOS_ADMIN_URL ?? "http://kratos:4434";
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap; admin role granted in Keto const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap; admin permission granted in Keto
const ADMIN_PASSWORD = "admin"; const ADMIN_PASSWORD = "admin";
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms)); const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
@@ -29,8 +29,8 @@ function relayCookies(res: Response): string {
.filter((kv) => kv.split("=")[1] !== "") .filter((kv) => kv.split("=")[1] !== "")
.join("; "); .join("; ");
} }
// Read a JWT's claims without verifying (web already verified it; we only inspect exp/roles). // Read a JWT's claims without verifying (web already verified it; we only inspect exp/permissions).
function jwtClaims(jwt: string): { email: string; exp: number; roles: string[]; sub: string } { function jwtClaims(jwt: string): { email: string; exp: number; permissions: string[]; sub: string } {
return JSON.parse(Buffer.from(jwt.split(".")[1]!, "base64url").toString()); return JSON.parse(Buffer.from(jwt.split(".")[1]!, "base64url").toString());
} }
@@ -72,7 +72,7 @@ async function awaitJwtSetCookie(session: string, jwt: string): Promise<string>
test("an expired session JWT is silently re-minted while Kratos lives, then cleared once it dies", async () => { test("an expired session JWT is silently re-minted while Kratos lives, then cleared once it dies", async () => {
test.setTimeout(90_000); // two short-TTL windows (8s each) + Ory round-trips test.setTimeout(90_000); // two short-TTL windows (8s each) + Ory round-trips
// 1. Log in for real, then complete login on web → our session JWT (roles read from Keto). // 1. Log in for real, then complete login on web → our session JWT (permissions read from Keto).
const session = await kratosLogin(); const session = await kratosLogin();
const complete = await fetch(`${WEB}/auth/complete`, { headers: { cookie: `plainpages_session=${session}` }, redirect: "manual" }); const complete = await fetch(`${WEB}/auth/complete`, { headers: { cookie: `plainpages_session=${session}` }, redirect: "manual" });
expect(complete.status, "auth/complete redirects home").toBe(303); expect(complete.status, "auth/complete redirects home").toBe(303);
@@ -83,7 +83,7 @@ test("an expired session JWT is silently re-minted while Kratos lives, then clea
const claims1 = jwtClaims(jwt1); const claims1 = jwtClaims(jwt1);
expect(claims1.email).toBe(ADMIN_EMAIL); expect(claims1.email).toBe(ADMIN_EMAIL);
expect(claims1.sub, "sub is the Kratos identity id").toBeTruthy(); expect(claims1.sub, "sub is the Kratos identity id").toBeTruthy();
expect(claims1.roles, "roles are projected from Keto").toContain("admin"); expect(claims1.permissions, "permissions are projected from Keto").toContain("admin");
// 2. Token timeout → refresh: once the 8s TTL lapses, the next request re-mints a fresh JWT. // 2. Token timeout → refresh: once the 8s TTL lapses, the next request re-mints a fresh JWT.
const jwt2Line = await awaitJwtSetCookie(session, jwt1); const jwt2Line = await awaitJwtSetCookie(session, jwt1);
@@ -91,7 +91,7 @@ test("an expired session JWT is silently re-minted while Kratos lives, then clea
expect(jwt2, "a different token was minted").not.toBe(jwt1); expect(jwt2, "a different token was minted").not.toBe(jwt1);
const claims2 = jwtClaims(jwt2); const claims2 = jwtClaims(jwt2);
expect(claims2.exp, "the new token expires later").toBeGreaterThan(claims1.exp); expect(claims2.exp, "the new token expires later").toBeGreaterThan(claims1.exp);
expect(claims2.roles, "re-mint re-reads roles from Keto").toContain("admin"); expect(claims2.permissions, "re-mint re-reads permissions from Keto").toContain("admin");
// 3. Kill the Kratos session: now the lapsed token cannot refresh — the cookie is cleared. // 3. Kill the Kratos session: now the lapsed token cannot refresh — the cookie is cleared.
const revoke = await fetch(`${KRATOS_ADMIN}/admin/identities/${claims1.sub}/sessions`, { method: "DELETE" }); const revoke = await fetch(`${KRATOS_ADMIN}/admin/identities/${claims1.sub}/sessions`, { method: "DELETE" });
+16 -17
View File
@@ -1,21 +1,16 @@
# Full browser E2E — the real Playwright UI flow against the live stack: password + # Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO
# mocked-SSO login, menu filtering by role, users/groups/roles CRUD, a plugin page, logout. A tiny # login, menu filtering by permission, users/groups/permissions/OAuth2-clients CRUD, a plugin page, logout. A
# 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
# docker compose -f compose.yml -f e2e-tests/compose.full.yml down -v # tear down after # docker compose -f compose.yml -f e2e-tests/compose.full.yml down -v # tear down after
services: services:
web: web:
# First-party + SSO flows need Kratos + Keto + bootstrap, not Hydra — drop it so the stack is # The base's full depends_on applies (Hydra included — the admin plugin's OAuth2-clients
# leaner. SSO is enabled here only (clean clone stays password-only): the mock provider's whole # screen needs it); only the reference plugin's upstream is added. SSO is enabled here only
# array is the env-settable form Kratos offers, mapped through the committed claims jsonnet. # (clean clone stays password-only): the mock provider's whole array is the env-settable form
depends_on: !override # Kratos offers, mapped through the committed claims jsonnet.
bootstrap: depends_on:
condition: service_completed_successfully
kratos:
condition: service_healthy
keto:
condition: service_healthy
shifts-upstream: shifts-upstream:
condition: service_healthy condition: service_healthy
environment: environment:
@@ -35,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 permission tokens, 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:
@@ -52,9 +47,13 @@ services:
SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS: >- SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS: >-
[{"id":"mock","provider":"generic","label":"Mock SSO","client_id":"plainpages-e2e","client_secret":"e2e-secret","issuer_url":"http://mock-oidc:9000","scope":["openid","email"],"mapper_url":"file:///etc/config/kratos/oidc/claims.jsonnet"}] [{"id":"mock","provider":"generic","label":"Mock SSO","client_id":"plainpages-e2e","client_secret":"e2e-secret","issuer_url":"http://mock-oidc:9000","scope":["openid","email"],"mapper_url":"file:///etc/config/kratos/oidc/claims.jsonnet"}]
# --dev permits the http issuer (the base file drops it for an https prod issuer).
hydra:
command: serve all --dev -c /etc/config/hydra/hydra.yml
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows. # The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
shifts-upstream: shifts-upstream:
image: node:24.18.0-alpine3.24 image: node:24.18.1-alpine3.24
command: ["node", "/server.ts"] command: ["node", "/server.ts"]
volumes: volumes:
- ./examples/shifts-upstream/server.ts:/server.ts:ro - ./examples/shifts-upstream/server.ts:/server.ts:ro
@@ -67,7 +66,7 @@ services:
# Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos # Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos
# verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos. # verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos.
mock-oidc: mock-oidc:
image: node:24.18.0-alpine3.24 image: node:24.18.1-alpine3.24
command: ["node", "/mock-oidc.ts"] command: ["node", "/mock-oidc.ts"]
environment: environment:
ISSUER: http://mock-oidc:9000 ISSUER: http://mock-oidc:9000
@@ -82,7 +81,7 @@ services:
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts). # Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
proxy: proxy:
image: node:24.18.0-alpine3.24 image: node:24.18.1-alpine3.24
command: ["node", "/proxy.ts"] command: ["node", "/proxy.ts"]
depends_on: depends_on:
web: web:
+38 -9
View File
@@ -9,7 +9,7 @@ import { randomUUID } from "node:crypto";
// journey and the standalone SSO test run in parallel (fullyParallel) but stay independent: each // journey and the standalone SSO test run in parallel (fullyParallel) but stay independent: each
// uses its own browser context, and only the SSO test writes the mock-OIDC identity — keep it so // uses its own browser context, and only the SSO test writes the mock-OIDC identity — keep it so
// (no cross-group shared backend writes) or serialise the file if that ever changes. // (no cross-group shared backend writes) or serialise the file if that ever changes.
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap, holds the admin role in Keto const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap, holds the admin permission in Keto
const ADMIN_PASSWORD = "admin"; const ADMIN_PASSWORD = "admin";
const SSO_EMAIL = "sso-user@plainpages.local"; // minted by the mock OIDC provider on first SSO login const SSO_EMAIL = "sso-user@plainpages.local"; // minted by the mock OIDC provider on first SSO login
const suffix = randomUUID().slice(0, 8); // unique per run so re-runs don't collide on names const suffix = randomUUID().slice(0, 8); // unique per run so re-runs don't collide on names
@@ -36,7 +36,7 @@ test.describe.serial("authenticated admin journey", () => {
}); });
test.afterAll(async () => { await page.context().close(); }); test.afterAll(async () => { await page.context().close(); });
test("menu filters by role: an admin sees the gated Admin section + the plugin", async () => { test("menu filters by permission: an admin sees the gated Admin section + the plugin", async () => {
// The signed-in admin holds admin + scheduling:read/write, so both gated sections are present // The signed-in admin holds admin + scheduling:read/write, so both gated sections are present
// in the menu (collapsed by default → assert they're in the DOM, not necessarily visible). // in the menu (collapsed by default → assert they're in the DOM, not necessarily visible).
await page.goto("/dashboard"); await page.goto("/dashboard");
@@ -65,7 +65,7 @@ test.describe.serial("authenticated admin journey", () => {
await expect(page.locator("tr", { hasText: email })).toHaveCount(0); await expect(page.locator("tr", { hasText: email })).toHaveCount(0);
}); });
test("groups + roles CRUD: create one of each (writes go to Keto) and see them listed", async () => { test("groups + permissions CRUD: create one of each (writes go to Keto) and see them listed", async () => {
// A Keto set exists only while it has ≥1 member, so create needs a first member (the form // A Keto set exists only while it has ≥1 member, so create needs a first member (the form
// enforces it); pick the first option (a user) from the required picker. // enforces it); pick the first option (a user) from the required picker.
const group = `e2e-grp-${suffix}`; const group = `e2e-grp-${suffix}`;
@@ -76,13 +76,42 @@ 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 () => {
const name = `e2e-client-${suffix}`;
await page.goto("/admin/clients");
await page.getByRole("link", { name: "Register client" }).click();
await page.fill('input[name="name"]', name);
await page.fill('textarea[name="redirectUris"]', "https://app.example.com/callback");
await page.locator('.form-card button[type="submit"]').click();
// Hydra returns the secret exactly once, so the POST renders the detail directly (no PRG).
await expect(page.locator("h1")).toHaveText("Client registered");
const clientId = await page.locator("#cid").inputValue();
expect(clientId).toBeTruthy();
await expect(page.locator("#csecret")).toHaveValue(/.+/);
// Listed; the row header links to the plain detail, which never shows the secret again.
await page.goto("/admin/clients");
const row = page.locator("tr", { hasText: name });
await expect(row).toBeVisible();
await row.getByRole("link", { name }).click();
await expect(page).toHaveURL(new RegExp(`/admin/clients/${clientId}`));
await expect(page.locator("#csecret")).toHaveCount(0);
// Delete through the confirm interstitial (danger link on the detail → confirm form's button).
await page.getByRole("link", { name: "Delete client" }).click();
await page.getByRole("button", { name: "Delete client" }).click();
await expect(page).toHaveURL(/\/admin\/clients(\?|$)/);
await expect(page.locator("tr", { hasText: name })).toHaveCount(0);
}); });
test("plugin page: the reference plugin renders its upstream shifts inside the native shell", async () => { test("plugin page: the reference plugin renders its upstream shifts inside the native shell", async () => {
@@ -124,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);
}); });
+15 -15
View File
@@ -8,23 +8,23 @@
"name": "plainpages-e2e", "name": "plainpages-e2e",
"version": "0.1.0", "version": "0.1.0",
"devDependencies": { "devDependencies": {
"@playwright/test": "1.49.1" "@playwright/test": "1.62.1"
} }
}, },
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.49.1", "version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==", "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright": "1.49.1" "playwright": "1.62.1"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=20"
} }
}, },
"node_modules/fsevents": { "node_modules/fsevents": {
@@ -43,35 +43,35 @@
} }
}, },
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.49.1", "version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==", "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.49.1" "playwright-core": "1.62.1"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=20"
}, },
"optionalDependencies": { "optionalDependencies": {
"fsevents": "2.3.2" "fsevents": "2.3.2"
} }
}, },
"node_modules/playwright-core": { "node_modules/playwright-core": {
"version": "1.49.1", "version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==", "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"playwright-core": "cli.js" "playwright-core": "cli.js"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=20"
} }
} }
} }
+1 -1
View File
@@ -8,6 +8,6 @@
"test": "playwright test" "test": "playwright test"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.49.1" "@playwright/test": "1.62.1"
} }
} }
+5 -5
View File
@@ -13,19 +13,19 @@ const shot = (page: Page, name: string): Promise<Buffer> =>
// Sign a session JWT with the committed dev tokenizer key (bind-mounted at /repo/jwks.json), so the // Sign a session JWT with the committed dev tokenizer key (bind-mounted at /repo/jwks.json), so the
// gated dashboard renders for a "signed-in" user without standing up Ory — web verifies it // gated dashboard renders for a "signed-in" user without standing up Ory — web verifies it
// with the same key by `kid`, exactly as it verifies a real Kratos-tokenizer JWT. // with the same key by `kid`, exactly as it verifies a real Kratos-tokenizer JWT.
function devSession(roles: string[] = []): string { function devSession(permissions: string[] = []): string {
const jwk = JSON.parse(readFileSync("/repo/jwks.json", "utf8")).keys[0]; const jwk = JSON.parse(readFileSync("/repo/jwks.json", "utf8")).keys[0];
const key = createPrivateKey({ format: "jwk", key: jwk }); const key = createPrivateKey({ format: "jwk", key: jwk });
const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url"); const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url");
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const input = `${b64({ alg: "ES256", kid: jwk.kid, typ: "JWT" })}.${b64({ email: "demo@plainpages.local", exp: now + 3600, iat: now, roles, sub: "visual-demo" })}`; const input = `${b64({ alg: "ES256", kid: jwk.kid, typ: "JWT" })}.${b64({ email: "demo@plainpages.local", exp: now + 3600, iat: now, permissions, sub: "visual-demo" })}`;
return `${input}.${sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key }).toString("base64url")}`; return `${input}.${sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key }).toString("base64url")}`;
} }
test.beforeAll(async () => { await mkdir(SHOTS, { recursive: true }); }); test.beforeAll(async () => { await mkdir(SHOTS, { recursive: true }); });
// The dashboard is gated: a page navigation needs a session. Plant one per test — a plain // The dashboard is gated: a page navigation needs a session. Plant one per test — a plain
// member (no roles) so the gated scheduling nav stays filtered out. // member (no permissions) so the gated scheduling nav stays filtered out.
test.beforeEach(async ({ context }) => { test.beforeEach(async ({ context }) => {
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]); await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]);
}); });
@@ -99,7 +99,7 @@ test("the public landing at / is ungated and links to sign in + register", async
await context.clearCookies(); // visit "/" as a logged-out visitor (drop the beforeEach session) await context.clearCookies(); // visit "/" as a logged-out visitor (drop the beforeEach session)
await page.goto("/"); await page.goto("/");
await expect(page.locator(".landing")).toBeVisible(); await expect(page.locator(".landing")).toBeVisible();
// the same app shell every page renders — the menu shows even signed out (role-filtered). // the same app shell every page renders — the menu shows even signed out (permission-filtered).
await expect(page.locator(".sidebar")).toBeVisible(); await expect(page.locator(".sidebar")).toBeVisible();
await expect(page.getByRole("link", { name: "Log in" })).toHaveAttribute("href", "/login"); await expect(page.getByRole("link", { name: "Log in" })).toHaveAttribute("href", "/login");
await expect(page.getByRole("link", { name: "Create account" })).toHaveAttribute("href", "/registration"); await expect(page.getByRole("link", { name: "Create account" })).toHaveAttribute("href", "/registration");
@@ -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
+1 -1
View File
@@ -6,6 +6,6 @@ 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 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/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. |
+1 -1
View File
@@ -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)
}, },
+6 -6
View File
@@ -1,6 +1,6 @@
# Admin — the system-administration plugin # Admin — the system-administration plugin
The Users / Groups / Roles / OAuth2-clients screens for running Plainpages itself. These used to be The Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. These used to be
built into the core; they now ship as a **drop-in example plugin** so a fresh clone has no admin GUI built into the core; they now ship as a **drop-in example plugin** so a fresh clone has no admin GUI
until you opt in. Copy this folder into `plugins/` (it keeps the id and mount path `admin`, so the until you opt in. Copy this folder into `plugins/` (it keeps the id and mount path `admin`, so the
screens live at `/admin/*`) and restart: screens live at `/admin/*`) and restart:
@@ -10,7 +10,7 @@ cp -r examples/plugins/admin plugins/admin
docker compose restart web docker compose restart web
``` ```
The seeded `admin@plainpages.local` already holds the `admin` role, so the section appears in the The seeded `admin@plainpages.local` already holds the `admin` permission, so the section appears in the
menu and the screens work immediately. menu and the screens work immediately.
## What it demonstrates — a *system* plugin ## What it demonstrates — a *system* plugin
@@ -20,10 +20,10 @@ 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
@@ -32,9 +32,9 @@ gated per route by `permission: "admin"`, rendering the core building blocks in
## Layout ## Layout
- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` permission token, 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 `permission: "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.
@@ -0,0 +1,106 @@
// Built-in Roles admin screen: the pure view-model + Keto builders. A permission is a
// Keto subject set (Permission:<name>#members); members are users (subject_id) or groups (subject_set) —
// "assign permissions to users/groups". The "effective access" view flattens a Keto `expand` tree into the
// distinct set of users who hold the permission directly or transitively via a group. The HTTP
// routing/gate/CSRF + live Keto/Kratos calls are exercised over HTTP in app.test.ts.
import assert from "node:assert/strict";
import { test } from "node:test";
import { memberView } from "./admin-groups.ts";
import {
buildPermissionDetailModel,
buildPermissionFormModel,
buildPermissionsListModel,
expandToEffectiveUsers,
isValidRoleName,
permissionGrantTuple,
} from "./admin-permissions.ts";
import type { ExpandTree, RelationTuple } from "#plugin-api";
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
const userTuple = (permission: string, n: number): RelationTuple =>
({ namespace: "Permission", object: permission, relation: "granted", subject_id: `user:${uid(n)}` });
const groupTuple = (permission: string, group: string): RelationTuple =>
({ namespace: "Permission", object: permission, relation: "granted", subject_set: { namespace: "Group", object: group, relation: "members" } });
test("isValidRoleName + permissionGrantTuple map the form value to a Permission tuple over a user/group (else null)", () => {
for (const ok of ["admin", "editor", "team-a", "a1_b9"]) assert.equal(isValidRoleName(ok), true, ok);
for (const bad of ["", "Admin", "a b", "-bad", "a".repeat(65)]) assert.equal(isValidRoleName(bad), false, bad);
assert.deepEqual(permissionGrantTuple("editor", `user:${uid(2)}`), { namespace: "Permission", object: "editor", relation: "granted", subject_id: `user:${uid(2)}` });
assert.deepEqual(permissionGrantTuple("editor", "group:eng"), { namespace: "Permission", object: "editor", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
for (const bad of ["", "user: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", () => {
// The subject rides on each node's `tuple` (Keto v26.2.0 shape, verified live).
const leaf = (n: number): ExpandTree => ({ tuple: { namespace: "", object: "", relation: "", subject_id: `user:${uid(n)}` }, type: "leaf" });
const tree: ExpandTree = {
children: [
leaf(1), // direct
{
children: [leaf(2), leaf(1)], // via group + dup
tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Group", object: "eng", relation: "members" } }, // a member group, not a user
type: "union",
},
],
tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Permission", object: "admin", relation: "granted" } },
type: "union",
};
assert.deepEqual(expandToEffectiveUsers(tree), [uid(1), uid(2)]);
assert.deepEqual(expandToEffectiveUsers(null), []);
assert.deepEqual(expandToEffectiveUsers({ type: "leaf" }), []); // an empty permission
});
test("buildPermissionsListModel filters by search, sorts, paginates; the name links to the detail page", () => {
const permissions = Array.from({ length: 30 }, (_, i) => ({ memberCount: i + 1, name: `permission-${String(i).padStart(2, "0")}` }));
const all = buildPermissionsListModel({ permissions, url: "http://x/admin/permissions" });
assert.equal(all.pagination.summary.total, 30);
assert.equal(all.table.rows.length, 25); // default page size
assert.equal(all.title, "Permissions");
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
assert.equal(first.rowHeader.text, "permission-00");
assert.equal(first.rowHeader.href, "/admin/permissions/permission-00");
const one = buildPermissionsListModel({ permissions, url: "http://x/admin/permissions?q=permission-07" });
assert.equal(one.pagination.summary.total, 1);
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
const desc = buildPermissionsListModel({ permissions, url: "http://x/admin/permissions?sort=-members" });
assert.equal((desc.table.rows[0]!.cells[0] as { rowHeader: { text: string } }).rowHeader.text, "permission-29");
});
test("buildPermissionFormModel: a create form with a required name field + member options (user or group)", () => {
const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
const m = buildPermissionFormModel({ csrfToken: "tok.sig", memberOptions: options });
assert.equal(m.title, "New permission");
assert.equal(m.form.action, "/admin/permissions");
assert.equal(m.form.submitLabel, "Create permission");
assert.equal(m.form.csrfToken, "tok.sig");
assert.equal(m.form.nameField.required, true);
assert.deepEqual(m.form.memberOptions, options);
const err = buildPermissionFormModel({ error: "That name is taken.", memberOptions: options, values: { member: "group:eng", name: "Admin" } });
assert.equal(err.error, "That name is taken.");
assert.equal(err.form.nameField.value, "Admin");
assert.equal(err.form.selectedMember, "group:eng");
});
test("buildPermissionDetailModel: members → rows, add-options exclude current members, effective access listed, actions wired", () => {
const members = [memberView(userTuple("admin", 1), new Map([[uid(1), "ada@example.com"]])), memberView(groupTuple("admin", "eng"), new Map())];
const candidates = [
{ label: "ada@example.com", value: `user:${uid(1)}` }, // already a member → excluded
{ label: "grace@example.com", value: `user:${uid(2)}` },
{ label: "eng (group)", value: "group:eng" }, // already a member → excluded
{ label: "ops (group)", value: "group:ops" },
];
const effective = [{ label: "ada@example.com" }, { label: "grace@example.com" }]; // ada direct, grace via eng
const m = buildPermissionDetailModel({ candidates, effective, members, permission: { name: "admin" } });
assert.equal(m.title, "admin");
assert.equal(m.members.rows.length, 2);
assert.equal(m.members.action, "/admin/permissions/admin/members/delete");
assert.equal(m.add.action, "/admin/permissions/admin/members");
assert.deepEqual(m.add.options.map((o) => o.value), [`user:${uid(2)}`, "group:ops"]);
assert.deepEqual(m.effective.map((e) => e.label), ["ada@example.com", "grace@example.com"]);
assert.equal(m.delete.action, "/admin/permissions/admin/delete");
});
@@ -1,15 +1,15 @@
// Roles & permissions 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 User } from "#plugin-api"; import { type ExpandTree, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type User } from "#plugin-api";
import { ADMIN_PERMISSION, 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 `user:<id>` member must take effect now, so revoke that // instant-revoke: a permission change for a `user:<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("user:")) revoke(member.slice("user:".length)); if (revoke && member.startsWith("user:")) revoke(member.slice("user:".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) ?? `user:${id}` })) .map((id) => ({ label: emailById.get(id) ?? `user:${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 permissionDetailResult = 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) => permissionDetailResult(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_PERMISSION) return roleDetailResult(deps, name, "The admin role can't be deleted — it would remove all admin access."); if (name === ADMIN_PERMISSION) return permissionDetailResult(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_PERMISSION) return roleDetailResult(deps, name, "The admin role can't be deleted — it would remove all admin access."); if (name === ADMIN_PERMISSION) return permissionDetailResult(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_PERMISSION && member === `user:${user.id}`) return roleDetailResult(deps, name, "You can't revoke your own admin access."); if (name === ADMIN_PERMISSION && member === `user:${user.id}`) return permissionDetailResult(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) };
}); });
-106
View File
@@ -1,106 +0,0 @@
// Built-in Roles & permissions admin screen: the pure view-model + Keto builders. A role is a
// Keto subject set (Role:<name>#members); members are users (subject_id) or groups (subject_set) —
// "assign roles to users/groups". The "effective access" view flattens a Keto `expand` tree into the
// distinct set of users who hold the role directly or transitively via a group. The HTTP
// routing/gate/CSRF + live Keto/Kratos calls are exercised over HTTP in app.test.ts.
import assert from "node:assert/strict";
import { test } from "node:test";
import { memberView } from "./admin-groups.ts";
import {
buildRoleDetailModel,
buildRoleFormModel,
buildRolesListModel,
expandToEffectiveUsers,
isValidRoleName,
roleMemberTuple,
} from "./admin-roles.ts";
import type { ExpandTree, RelationTuple } from "#plugin-api";
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
const userTuple = (role: string, n: number): RelationTuple =>
({ namespace: "Role", object: role, relation: "members", subject_id: `user:${uid(n)}` });
const groupTuple = (role: string, group: string): RelationTuple =>
({ namespace: "Role", object: role, relation: "members", 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)", () => {
for (const ok of ["admin", "editor", "team-a", "a1_b9"]) assert.equal(isValidRoleName(ok), true, ok);
for (const bad of ["", "Admin", "a b", "-bad", "a".repeat(65)]) assert.equal(isValidRoleName(bad), false, bad);
assert.deepEqual(roleMemberTuple("editor", `user:${uid(2)}`), { namespace: "Role", object: "editor", relation: "members", subject_id: `user:${uid(2)}` });
assert.deepEqual(roleMemberTuple("editor", "group:eng"), { namespace: "Role", object: "editor", relation: "members", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
for (const bad of ["", "user:not-a-uuid", "group:Bad Name", "nope:x"]) assert.equal(roleMemberTuple("editor", bad), null, bad);
});
test("expandToEffectiveUsers flattens an expand tree → sorted distinct user ids, transitive through groups", () => {
// The subject rides on each node's `tuple` (Keto v26.2.0 shape, verified live).
const leaf = (n: number): ExpandTree => ({ tuple: { namespace: "", object: "", relation: "", subject_id: `user:${uid(n)}` }, type: "leaf" });
const tree: ExpandTree = {
children: [
leaf(1), // direct
{
children: [leaf(2), leaf(1)], // via group + dup
tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Group", object: "eng", relation: "members" } }, // a member group, not a user
type: "union",
},
],
tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Role", object: "admin", relation: "members" } },
type: "union",
};
assert.deepEqual(expandToEffectiveUsers(tree), [uid(1), uid(2)]);
assert.deepEqual(expandToEffectiveUsers(null), []);
assert.deepEqual(expandToEffectiveUsers({ type: "leaf" }), []); // an empty role
});
test("buildRolesListModel filters by search, sorts, paginates; the name links to the detail page", () => {
const roles = Array.from({ length: 30 }, (_, i) => ({ memberCount: i + 1, name: `role-${String(i).padStart(2, "0")}` }));
const all = buildRolesListModel({ roles, url: "http://x/admin/roles" });
assert.equal(all.pagination.summary.total, 30);
assert.equal(all.table.rows.length, 25); // default page size
assert.equal(all.title, "Roles");
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
assert.equal(first.rowHeader.text, "role-00");
assert.equal(first.rowHeader.href, "/admin/roles/role-00");
const one = buildRolesListModel({ roles, url: "http://x/admin/roles?q=role-07" });
assert.equal(one.pagination.summary.total, 1);
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
const desc = buildRolesListModel({ roles, url: "http://x/admin/roles?sort=-members" });
assert.equal((desc.table.rows[0]!.cells[0] as { rowHeader: { text: string } }).rowHeader.text, "role-29");
});
test("buildRoleFormModel: a create form with a required name field + member options (user or group)", () => {
const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
const m = buildRoleFormModel({ csrfToken: "tok.sig", memberOptions: options });
assert.equal(m.title, "New role");
assert.equal(m.form.action, "/admin/roles");
assert.equal(m.form.submitLabel, "Create role");
assert.equal(m.form.csrfToken, "tok.sig");
assert.equal(m.form.nameField.required, true);
assert.deepEqual(m.form.memberOptions, options);
const err = buildRoleFormModel({ error: "That name is taken.", memberOptions: options, values: { member: "group:eng", name: "Admin" } });
assert.equal(err.error, "That name is taken.");
assert.equal(err.form.nameField.value, "Admin");
assert.equal(err.form.selectedMember, "group:eng");
});
test("buildRoleDetailModel: members → rows, add-options exclude current members, effective access listed, actions wired", () => {
const members = [memberView(userTuple("admin", 1), new Map([[uid(1), "ada@example.com"]])), memberView(groupTuple("admin", "eng"), new Map())];
const candidates = [
{ label: "ada@example.com", value: `user:${uid(1)}` }, // already a member → excluded
{ label: "grace@example.com", value: `user:${uid(2)}` },
{ label: "eng (group)", value: "group:eng" }, // already a member → excluded
{ label: "ops (group)", value: "group:ops" },
];
const effective = [{ label: "ada@example.com" }, { label: "grace@example.com" }]; // ada direct, grace via eng
const m = buildRoleDetailModel({ candidates, effective, members, role: { name: "admin" } });
assert.equal(m.title, "admin");
assert.equal(m.members.rows.length, 2);
assert.equal(m.members.action, "/admin/roles/admin/members/delete");
assert.equal(m.add.action, "/admin/roles/admin/members");
assert.deepEqual(m.add.options.map((o) => o.value), [`user:${uid(2)}`, "group:ops"]);
assert.deepEqual(m.effective.map((e) => e.label), ["ada@example.com", "grace@example.com"]);
assert.equal(m.delete.action, "/admin/roles/admin/delete");
});
+6 -6
View File
@@ -9,8 +9,8 @@ import { test } from "node:test";
import { GuardError, type Log, type PageChrome, type RequestContext, type User } from "#plugin-api"; import { GuardError, type Log, type PageChrome, type RequestContext, type User } from "#plugin-api";
import { ADMIN_NAV, ADMIN_PERMISSION, 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: User = { email: "ada@x.io", id: "u1", roles: ["admin"] }; const admin: User = { email: "ada@x.io", id: "u1", permissions: ["admin"] };
const member: User = { email: "bo@x.io", id: "u2", roles: ["scheduling:read"] }; const member: User = { 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?: User | null; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext { function fakeCtx(opts: { body?: string; method?: string; user?: User | null; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
@@ -18,8 +18,8 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver
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;
req.method = opts.method ?? "GET"; req.method = opts.method ?? "GET";
return { return {
chrome: CHROME, log: {} as Log, params: {}, query: url.searchParams, req, res: {} as ServerResponse, chrome: CHROME, user: opts.user ?? null, log: {} as Log, params: {}, query: url.searchParams, req, res: {} as ServerResponse,
roles: opts.user?.roles ?? [], url, user: opts.user ?? null, verifyCsrf: opts.verifyCsrf ?? (() => true), permissions: opts.user?.permissions ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true),
}; };
} }
@@ -29,8 +29,8 @@ test("ADMIN_NAV: a gated Admin header over the four screens; no per-request curr
assert.equal(ADMIN_NAV.id, "admin"); assert.equal(ADMIN_NAV.id, "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.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.permission === 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
}); });
+5 -5
View File
@@ -5,13 +5,13 @@
import { can, CSRF_FIELD, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type User } from "#plugin-api"; import { can, CSRF_FIELD, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type User } from "#plugin-api";
export const ADMIN_PERMISSION = "admin"; // role token 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 `permission` drops the whole subtree for a // the one global menu, filters per user (the header's `permission` drops the whole subtree for a
@@ -20,7 +20,7 @@ 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",
@@ -34,7 +34,7 @@ export const ADMIN_NAV: NavNode = {
// 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): User { export function requireAdmin(ctx: RequestContext): User {
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept) const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
if (!can(ctx, ADMIN_PERMISSION)) throw new GuardError(403, "admin role required"); if (!can(ctx, ADMIN_PERMISSION)) throw new GuardError(403, "admin permission required");
return user; return user;
} }
+10 -10
View File
@@ -9,7 +9,7 @@
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_PERMISSION } from "./admin-shared.ts"; import { ADMIN_NAV, ADMIN_PERMISSION } from "./admin-shared.ts";
@@ -23,7 +23,7 @@ export default definePlugin({
nav: [ADMIN_NAV], nav: [ADMIN_NAV],
permissions: [{ description: "Administer users, groups, roles, and OAuth2 clients", token: ADMIN_PERMISSION }], permissions: [{ description: "Administer users, groups, permissions, and OAuth2 clients", name: ADMIN_PERMISSION }],
routes: [ routes: [
// Users // Users
@@ -46,14 +46,14 @@ export default definePlugin({
r("POST", "/groups/:name/delete", groupsDelete), r("POST", "/groups/:name/delete", groupsDelete),
r("POST", "/groups/:name/members/delete", groupsRemoveMember), r("POST", "/groups/:name/members/delete", groupsRemoveMember),
// Roles // Roles
r("GET", "/roles", rolesList), r("GET", "/permissions", rolesList),
r("POST", "/roles", rolesCreate), r("POST", "/permissions", rolesCreate),
r("GET", "/roles/new", rolesNewForm), r("GET", "/permissions/new", rolesNewForm),
r("GET", "/roles/:name", rolesDetail), r("GET", "/permissions/:name", rolesDetail),
r("POST", "/roles/:name/members", rolesAddMember), r("POST", "/permissions/:name/members", rolesAddMember),
r("GET", "/roles/:name/delete", rolesDeleteConfirm), r("GET", "/permissions/:name/delete", rolesDeleteConfirm),
r("POST", "/roles/:name/delete", rolesDelete), r("POST", "/permissions/:name/delete", rolesDelete),
r("POST", "/roles/:name/members/delete", rolesRemoveMember), r("POST", "/permissions/:name/members/delete", rolesRemoveMember),
// OAuth2 clients // OAuth2 clients
r("GET", "/clients", clientsList), r("GET", "/clients", clientsList),
r("POST", "/clients", clientsCreate), r("POST", "/clients", clientsCreate),
+1 -1
View File
@@ -1,6 +1,6 @@
<%# <%#
OAuth2 clients admin list: apps that log in *through* us (Hydra). Same building blocks as OAuth2 clients admin list: apps that log in *through* us (Hydra). Same building blocks as
the Roles screen, around the shell, backed by live Hydra OAuth2 clients (admin-clients.ts). the Permissions screen, around the shell, backed by live Hydra OAuth2 clients (admin-clients.ts).
%><% %><%
const nav = include("partials/nav-tree", { nodes: chrome.nav }); const nav = include("partials/nav-tree", { nodes: chrome.nav });
const filters = include("partials/filter-bar", model.filterBar); const filters = include("partials/filter-bar", model.filterBar);
@@ -1,7 +1,7 @@
<%# <%#
Admin group membership body, captured into the shell content slot. Config: Admin group membership body, captured into the shell content slot. Config:
group { name } group { name }
members { action, rows: { kind:"group"|"user", label, subject }[] } action = remove-member endpoint members { action, rows: { kind:"group"|"identity", label, subject }[] } action = remove-member endpoint
add { action, options: {label,value}[] } action = add-member endpoint add { action, options: {label,value}[] } action = add-member endpoint
del { action } delete the whole group del { action } delete the whole group
csrfToken, error? csrfToken, error?
@@ -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"|"user", label, subject }[] } action = revoke endpoint members { action, rows: { kind:"group"|"identity", label, subject }[] } action = revoke endpoint
effective { label }[] users who hold the role (expand) effective { label }[] users who hold the permission (expand)
add { action, options: {label,value}[] } action = assign endpoint add { action, options: {label,value}[] } action = assign endpoint
del { action } delete the whole role del { action } delete the whole permission
csrfToken, error? csrfToken, error?
%><% %><%
const role = locals.role; const permission = locals.permission;
const members = locals.members; const members = locals.members;
const effective = locals.effective; const effective = locals.effective;
const add = locals.add; const add = locals.add;
@@ -21,7 +21,7 @@
<section class="form-card" aria-labelledby="members-h"> <section class="form-card" aria-labelledby="members-h">
<h2 class="card-title" id="members-h">Assigned to</h2> <h2 class="card-title" id="members-h">Assigned to</h2>
<% if (members.rows.length) { -%> <% if (members.rows.length) { -%>
<div class="table-wrap"><table class="table"><caption class="sr-only">Members of <%= role.name %></caption><thead><tr><th scope="col">Member</th><th scope="col">Type</th><th class="col-actions" scope="col"><span class="sr-only">Actions</span></th></tr></thead><tbody> <div class="table-wrap"><table class="table"><caption class="sr-only">Members of <%= permission.name %></caption><thead><tr><th scope="col">Member</th><th scope="col">Type</th><th class="col-actions" scope="col"><span class="sr-only">Actions</span></th></tr></thead><tbody>
<% members.rows.forEach((m) => { -%> <% members.rows.forEach((m) => { -%>
<tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? "Group" : "User" %></span></td><td class="col-actions"><form method="post" action="<%= members.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg>Revoke</button></form></td></tr> <tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? "Group" : "User" %></span></td><td class="col-actions"><form method="post" action="<%= members.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg>Revoke</button></form></td></tr>
<% }) -%> <% }) -%>
@@ -32,7 +32,7 @@
</section> </section>
<section class="form-card" aria-labelledby="effective-h"> <section class="form-card" aria-labelledby="effective-h">
<h2 class="card-title" id="effective-h">Effective access</h2> <h2 class="card-title" id="effective-h">Effective access</h2>
<p class="field-hint">Everyone who holds this role — directly or through a group (resolved by Keto).</p> <p class="field-hint">Everyone who holds this permission — directly or through a group (resolved by Keto).</p>
<% if (effective.length) { -%> <% if (effective.length) { -%>
<ul class="plain-list"> <ul class="plain-list">
<% effective.forEach((u) => { -%> <% effective.forEach((u) => { -%>
@@ -40,18 +40,18 @@
<% }) -%> <% }) -%>
</ul> </ul>
<% } else { -%> <% } else { -%>
<p class="cell-muted">No users hold this role yet.</p> <p class="cell-muted">No users hold this permission yet.</p>
<% } -%> <% } -%>
</section> </section>
<section class="form-card" aria-labelledby="add-h"> <section class="form-card" aria-labelledby="add-h">
<h2 class="card-title" id="add-h">Assign the role</h2> <h2 class="card-title" id="add-h">Assign the permission</h2>
<% if (add.options.length) { -%> <% if (add.options.length) { -%>
<form class="inline-form" method="post" action="<%= add.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member">Member</label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected>Choose a user or group…</option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Assign</button></form> <form class="inline-form" method="post" action="<%= add.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member">Member</label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected>Choose a user or group…</option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Assign</button></form>
<% } else { -%> <% } else { -%>
<p class="cell-muted">All users and groups already have this role.</p> <p class="cell-muted">All users and groups already have this permission.</p>
<% } -%> <% } -%>
</section> </section>
<section class="form-card admin-actions" aria-label="Role actions"> <section class="form-card admin-actions" aria-label="Permission actions">
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete role</a> <a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete permission</a>
</section> </section>
</div> </div>
@@ -1,5 +1,5 @@
<%# <%#
Admin role create form body, captured into the shell content slot. Config: Admin permission create form body, captured into the shell content slot. Config:
form { action, csrfToken, submitLabel, cancelHref, nameField: field.ejs config, form { action, csrfToken, submitLabel, cancelHref, nameField: field.ejs config,
memberOptions: {label,value}[], selectedMember } memberOptions: {label,value}[], selectedMember }
error? string shown when a write was rejected error? string shown when a write was rejected
@@ -16,7 +16,7 @@
<div class="field"> <div class="field">
<label for="member">Assign to</label> <label for="member">Assign to</label>
<span class="select"><select id="member" name="member" required><option value="" disabled<% if (!form.selectedMember) { %> selected<% } %>>Choose a user or group…</option><% form.memberOptions.forEach((o) => { %><option value="<%= o.value %>"<% if (form.selectedMember === o.value) { %> selected<% } %>><%= o.label %></option><% }) %></select></span> <span class="select"><select id="member" name="member" required><option value="" disabled<% if (!form.selectedMember) { %> selected<% } %>>Choose a user or group…</option><% form.memberOptions.forEach((o) => { %><option value="<%= o.value %>"<% if (form.selectedMember === o.value) { %> selected<% } %>><%= o.label %></option><% }) %></select></span>
<span class="field-hint">A role exists once assigned; add more users or groups after creating it.</span> <span class="field-hint">A permission exists once assigned; add more users or groups after creating it.</span>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<a class="btn" href="<%= form.cancelHref %>">Cancel</a> <a class="btn" href="<%= form.cancelHref %>">Cancel</a>
@@ -0,0 +1,16 @@
<%#
Permission admin detail page: the permission-detail body (members · effective access) in the shell.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/permission-detail-body", { add: model.add, csrfToken: model.csrfToken, del: model.delete, effective: model.effective, error: model.error, members: model.members, permission: model.permission });
-%>
<%- include("partials/shell", {
body,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
@@ -1,8 +1,8 @@
<%# <%#
Role admin create page: the role-form body captured into the app shell. Permission admin create page: the permission-form body captured into the app shell.
%><% %><%
const nav = include("partials/nav-tree", { nodes: chrome.nav }); const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/role-form-body", { error: model.error, form: model.form }); const body = include("partials/permission-form-body", { error: model.error, form: model.form });
-%> -%>
<%- include("partials/shell", { <%- include("partials/shell", {
body, body,
@@ -1,12 +1,12 @@
<%# <%#
Roles admin list: the same building blocks as the Groups screen, around the shell, backed Permissions admin list: the same building blocks as the Groups screen, around the shell, backed
by live Keto Role subject sets (admin-roles.ts). Filter/sort/page round-trip the URL. by live Keto Permission subject sets (admin-permissions.ts). Filter/sort/page round-trip the URL.
%><% %><%
const nav = include("partials/nav-tree", { nodes: chrome.nav }); const nav = include("partials/nav-tree", { nodes: chrome.nav });
const filters = include("partials/filter-bar", model.filterBar); const filters = include("partials/filter-bar", model.filterBar);
const table = include("partials/data-table", model.table); const table = include("partials/data-table", model.table);
const pager = include("partials/pagination", model.pagination); const pager = include("partials/pagination", model.pagination);
const actions = '<a class="btn btn-primary" href="/admin/roles/new"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Add role</a>'; const actions = '<a class="btn btn-primary" href="/admin/permissions/new"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Add permission</a>';
-%> -%>
<%- include("partials/shell", { <%- include("partials/shell", {
actions, actions,
@@ -1,16 +0,0 @@
<%#
Role admin detail page: the role-detail body (members · effective access) in the shell.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/role-detail-body", { add: model.add, csrfToken: model.csrfToken, del: model.delete, effective: model.effective, error: model.error, members: model.members, role: model.role });
-%>
<%- include("partials/shell", {
body,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
+1 -1
View File
@@ -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.
+4 -4
View File
@@ -30,14 +30,14 @@ export default definePlugin({
label: "Scheduling", label: "Scheduling",
}], }],
// Tokens this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`. // Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
permissions: [ permissions: [
{ description: "View shifts", token: READ }, { description: "View shifts", name: READ },
{ description: "Create and edit shifts", token: WRITE }, { description: "Create and edit shifts", name: WRITE },
], ],
// Mounted under /scheduling; `permission` 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", permission: READ }, { handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ },
+8 -8
View File
@@ -12,12 +12,12 @@ import {
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } }; const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } };
function fakeCtx(opts: { body?: string; roles?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext { function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts"); const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage; const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
return { return {
chrome: CHROME, log: new Log("none"), params: {}, query: url.searchParams, req, res: {} as ServerResponse, chrome: CHROME, user: null, log: new Log("none"), params: {}, query: url.searchParams, req, res: {} as ServerResponse,
roles: opts.roles ?? [], url, user: null, verifyCsrf: opts.verifyCsrf ?? (() => true), permissions: opts.permissions ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true),
}; };
} }
@@ -93,8 +93,8 @@ test("readInput trims; validate requires title + assignee", () => {
// ---- list handler ---- // ---- list handler ----
test("listShifts renders the upstream rows; q filters; canWrite reflects the role", async () => { test("listShifts renders the upstream rows; q filters; canWrite reflects the permission", async () => {
const r = asView(await listShifts(fakeUpstream())(fakeCtx({ roles: ["scheduling:write"] }))); const r = asView(await listShifts(fakeUpstream())(fakeCtx({ permissions: ["scheduling:write"] })));
assert.equal(r.view, "shifts"); assert.equal(r.view, "shifts");
const table = r.data["table"] as { rows: { name: string }[] }; const table = r.data["table"] as { rows: { name: string }[] };
assert.deepEqual(table.rows.map((x) => x.name), ["Morning desk", "Afternoon support"]); assert.deepEqual(table.rows.map((x) => x.name), ["Morning desk", "Afternoon support"]);
@@ -112,15 +112,15 @@ test("listShifts degrades to a recoverable error page when the upstream is down
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []); assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []);
}); });
// ---- public overview handler (a page anyone can reach, gated data stays behind the role) ---- // ---- public overview handler (a page anyone can reach, gated data stays behind the permission) ----
test("overview renders a public page for anyone; it links straight to Shifts only for a reader", async () => { test("overview renders a public page for anyone; it links straight to Shifts only for a reader", async () => {
const anon = asView(await overview()(fakeCtx())); // user null, no roles const anon = asView(await overview()(fakeCtx())); // user null, no permissions
assert.equal(anon.view, "overview"); assert.equal(anon.view, "overview");
assert.equal(anon.data["chrome"], CHROME); assert.equal(anon.data["chrome"], CHROME);
assert.equal(anon.data["canRead"], false); // anonymous → prompt to sign in, no shifts link assert.equal(anon.data["canRead"], false); // anonymous → prompt to sign in, no shifts link
const reader = asView(await overview()(fakeCtx({ roles: ["scheduling:read"] }))); const reader = asView(await overview()(fakeCtx({ permissions: ["scheduling:read"] })));
assert.equal(reader.data["canRead"], true); // a reader gets a link straight to the shifts list assert.equal(reader.data["canRead"], true); // a reader gets a link straight to the shifts list
}); });
+3 -3
View File
@@ -10,8 +10,8 @@ import { can, CSRF_FIELD, GuardError, type PageChrome, parseListQuery, readFormB
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page
export const SHIFTS_PATH = "/scheduling/shifts"; export const SHIFTS_PATH = "/scheduling/shifts";
export const READ = "scheduling:read"; // permission token gating the list + nav export const READ = "scheduling:read"; // the permission gating the list + nav
export const WRITE = "scheduling:write"; // permission token 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.user may be null here, so read the role via can() (zero I/O). // else a prompt to sign in. ctx.user may be null here, so read the permission via can() (zero I/O).
export function overview(): RouteHandler { export function overview(): RouteHandler {
return (ctx) => ({ return (ctx) => ({
data: { breadcrumbs: [{ label: "Overview" }], canRead: can(ctx, READ), chrome: ctx.chrome, shiftsHref: SHIFTS_PATH, title: "Scheduling" }, data: { breadcrumbs: [{ label: "Overview" }], canRead: can(ctx, READ), chrome: ctx.chrome, shiftsHref: SHIFTS_PATH, title: "Scheduling" },
@@ -12,7 +12,7 @@
-%> -%>
<%- include("partials/shell", { <%- include("partials/shell", {
actions: "", actions: "",
body: '<div class="scheduling-page"><p>Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> role.</p>' + cta + '</div>', body: '<div class="scheduling-page"><p>Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> permission.</p>' + cta + '</div>',
brand: chrome.brand, brand: chrome.brand,
breadcrumbs, breadcrumbs,
csrfToken: chrome.csrfToken, csrfToken: chrome.csrfToken,
+1 -1
View File
@@ -1,4 +1,4 @@
# Ory Keto — authorization (ReBAC), the source of truth for roles/groups and the rare # Ory Keto — authorization (ReBAC), the source of truth for permissions/groups and the rare
# fine-grained check (README: three tiers of "may I?"). The permission model lives in # fine-grained check (README: three tiers of "may I?"). The permission model lives in
# namespaces.keto.ts (OPL); DSN comes from the env (the per-service keto DB). The web # namespaces.keto.ts (OPL); DSN comes from the env (the per-service keto DB). The web
# app never connects directly — it calls the read (4466) / write (4467) APIs, the ports # app never connects directly — it calls the read (4466) / write (4467) APIs, the ports
+11 -9
View File
@@ -4,28 +4,30 @@
// identity ids (== the JWT `sub`). // identity ids (== the JWT `sub`).
import { Context, Namespace, SubjectSet } from "@ory/keto-namespace-types" import { Context, Namespace, SubjectSet } from "@ory/keto-namespace-types"
// A human identity. Subjects are written as `user:<kratos-identity-id>`. // A person. Ory calls this an "identity" (Kratos owns the record); Plainpages says "user"
// throughout. Subjects are written as `user:<kratos-identity-id>`.
class User implements Namespace {} class User implements Namespace {}
// A subject set: a named collection of users (and nested groups), resolved transitively. // A named set of users (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: (User | SubjectSet<Group, "members">)[] members: (User | 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@user:<id>` from Keto and projects the result into the token // for the JWT `permissions` claim. At login the app reads `Permission:<name>#granted@user:<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 a user or to a whole group.
class Permission implements Namespace {
related: { related: {
members: (User | SubjectSet<Group, "members">)[] granted: (User | 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 a user directly or any member of a group.
class Resource implements Namespace { class Resource implements Namespace {
related: { related: {
+2 -2
View File
@@ -41,7 +41,7 @@ selfservice:
ui_url: http://localhost:3000/login ui_url: http://localhost:3000/login
after: after:
# After authenticating, land on our completion route — it mints the session JWT # After authenticating, land on our completion route — it mints the session JWT
# (roles from Keto → metadata_public projection → tokenize) and sets our cookie. # (permissions from Keto → metadata_public projection → tokenize) and sets our cookie.
default_browser_return_url: http://localhost:3000/auth/complete default_browser_return_url: http://localhost:3000/auth/complete
registration: registration:
ui_url: http://localhost:3000/registration ui_url: http://localhost:3000/registration
@@ -94,7 +94,7 @@ session:
same_site: Lax same_site: Lax
# Session→JWT tokenizer: whoami(tokenize_as: plainpages) mints a short-lived, # Session→JWT tokenizer: whoami(tokenize_as: plainpages) mints a short-lived,
# locally-verifiable JWT so the hot path never calls Ory. Claims come from the # locally-verifiable JWT so the hot path never calls Ory. Claims come from the
# committed Jsonnet mapper (sub = identity id, email from traits, roles from the # committed Jsonnet mapper (sub = identity id, email from traits, permissions from the
# metadata_public projection); signed with tokenizer/jwks.json. # metadata_public projection); signed with tokenizer/jwks.json.
whoami: whoami:
tokenizer: tokenizer:
+3 -3
View File
@@ -1,7 +1,7 @@
// Session→JWT claims mapper for the `plainpages` tokenizer. Kratos exposes the // Session→JWT claims mapper for the `plainpages` tokenizer. Kratos exposes the
// session as `session`; `sub` is set from the identity id (subject_source: id) and // session as `session`; `sub` is set from the identity id (subject_source: id) and
// can't be overridden here. roles come from metadata_public — the per-login projection // can't be overridden here. permissions come from metadata_public — the per-login projection
// of Keto roles the app refreshes at login (metadata_admin is NOT carried in the session // of Keto permissions the app refreshes at login (metadata_admin is NOT carried in the session
// the tokenizer sees; metadata_public is). Absent on a fresh identity ⇒ empty list. // the tokenizer sees; metadata_public is). Absent on a fresh identity ⇒ empty list.
local session = std.extVar('session'); local session = std.extVar('session');
local meta = local meta =
@@ -12,6 +12,6 @@ local meta =
{ {
claims: { claims: {
email: session.identity.traits.email, email: session.identity.traits.email,
roles: if std.objectHas(meta, 'roles') then meta.roles else [], permissions: if std.objectHas(meta, 'permissions') then meta.permissions else [],
}, },
} }
+377 -84
View File
@@ -9,13 +9,13 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@larvit/log": "2.3.0", "@larvit/log": "2.3.0",
"ejs": "3.1.10", "ejs": "6.0.1",
"lucide-static": "1.18.0" "lucide-static": "1.28.0"
}, },
"devDependencies": { "devDependencies": {
"@types/ejs": "3.1.5", "@types/ejs": "3.1.5",
"@types/node": "24.13.2", "@types/node": "24.13.3",
"typescript": "5.9.3" "typescript": "7.0.2"
}, },
"engines": { "engines": {
"node": ">=24" "node": ">=24"
@@ -38,113 +38,406 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.13.2", "version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~7.18.0" "undici-types": "~7.18.0"
} }
}, },
"node_modules/async": { "node_modules/@typescript/typescript-aix-ppc64": {
"version": "3.2.6", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
"license": "MIT" "cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=16.20.0"
}
}, },
"node_modules/balanced-match": { "node_modules/@typescript/typescript-darwin-arm64": {
"version": "1.0.2", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
"license": "MIT" "cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
}, },
"node_modules/brace-expansion": { "node_modules/@typescript/typescript-darwin-x64": {
"version": "2.1.1", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
"license": "MIT", "cpu": [
"dependencies": { "x64"
"balanced-match": "^1.0.0" ],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-loong64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-mips64el": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-riscv64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-s390x": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-sunos-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
} }
}, },
"node_modules/ejs": { "node_modules/ejs": {
"version": "3.1.10", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", "resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz",
"integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", "integrity": "sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": {
"jake": "^10.8.5"
},
"bin": { "bin": {
"ejs": "bin/cli.js" "ejs": "bin/cli.js"
}, },
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.12.18"
}
},
"node_modules/filelist": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
"integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==",
"license": "Apache-2.0",
"dependencies": {
"minimatch": "^5.0.1"
}
},
"node_modules/jake": {
"version": "10.9.4",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
"integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
"license": "Apache-2.0",
"dependencies": {
"async": "^3.2.6",
"filelist": "^1.0.4",
"picocolors": "^1.1.1"
},
"bin": {
"jake": "bin/cli.js"
},
"engines": {
"node": ">=10"
} }
}, },
"node_modules/lucide-static": { "node_modules/lucide-static": {
"version": "1.18.0", "version": "1.28.0",
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.18.0.tgz", "resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.28.0.tgz",
"integrity": "sha512-0WRXLQnjbte5SXuzom6yfeGlVSFsEsC9rzxn66DZN0pXows3+N34CQHy3BHI1qA3uH7u/SUzx8LQhjeAnxd8JQ==", "integrity": "sha512-dC3VJwRFsjEVX7Iaq4rY88pm7Fi2OmOb8P0WRzXsUMgbt7sCmFX8bLhaDBeNW6JdRjuele+jKqqFaam4yr+Ygg==",
"license": "ISC"
},
"node_modules/minimatch": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc"
"tsserver": "bin/tsserver"
}, },
"engines": { "engines": {
"node": ">=14.17" "node": ">=16.20.0"
},
"optionalDependencies": {
"@typescript/typescript-aix-ppc64": "7.0.2",
"@typescript/typescript-darwin-arm64": "7.0.2",
"@typescript/typescript-darwin-x64": "7.0.2",
"@typescript/typescript-freebsd-arm64": "7.0.2",
"@typescript/typescript-freebsd-x64": "7.0.2",
"@typescript/typescript-linux-arm": "7.0.2",
"@typescript/typescript-linux-arm64": "7.0.2",
"@typescript/typescript-linux-loong64": "7.0.2",
"@typescript/typescript-linux-mips64el": "7.0.2",
"@typescript/typescript-linux-ppc64": "7.0.2",
"@typescript/typescript-linux-riscv64": "7.0.2",
"@typescript/typescript-linux-s390x": "7.0.2",
"@typescript/typescript-linux-x64": "7.0.2",
"@typescript/typescript-netbsd-arm64": "7.0.2",
"@typescript/typescript-netbsd-x64": "7.0.2",
"@typescript/typescript-openbsd-arm64": "7.0.2",
"@typescript/typescript-openbsd-x64": "7.0.2",
"@typescript/typescript-sunos-x64": "7.0.2",
"@typescript/typescript-win32-arm64": "7.0.2",
"@typescript/typescript-win32-x64": "7.0.2"
} }
}, },
"node_modules/undici-types": { "node_modules/undici-types": {
+4 -4
View File
@@ -19,12 +19,12 @@
}, },
"dependencies": { "dependencies": {
"@larvit/log": "2.3.0", "@larvit/log": "2.3.0",
"ejs": "3.1.10", "ejs": "6.0.1",
"lucide-static": "1.18.0" "lucide-static": "1.28.0"
}, },
"devDependencies": { "devDependencies": {
"@types/ejs": "3.1.5", "@types/ejs": "3.1.5",
"@types/node": "24.13.2", "@types/node": "24.13.3",
"typescript": "5.9.3" "typescript": "7.0.2"
} }
} }
+24 -24
View File
@@ -1,11 +1,11 @@
// One-command bootstrap: idempotent first-boot seeding. Guards the pure payload // One-command bootstrap: idempotent first-boot seeding. Guards the pure payload
// builders (Kratos create-identity body + Keto role tuple), the idempotent seedAdmin // builders (Kratos create-identity body + Keto permission tuple), the idempotent seedAdmin
// orchestration (fresh 201 vs existing 409 → reuse id), and the JWKS generate-if-absent // orchestration (fresh 201 vs existing 409 → reuse id), and the JWKS generate-if-absent
// safety net. Live boot is verified by running the stack; these catch contract drift. // safety net. Live boot is verified by running the stack; these catch contract drift.
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { ensureJwks, firstRunBanner, identityPayload, roleTuple, seedAdmin, seedRoles } from "./bootstrap.ts"; import { ensureJwks, firstRunBanner, identityPayload, permissionTuple, seedAdmin, seedPermissions } from "./bootstrap.ts";
const json = (status: number, body?: unknown) => const json = (status: number, body?: unknown) =>
new Response(body === undefined ? null : JSON.stringify(body), { new Response(body === undefined ? null : JSON.stringify(body), {
@@ -20,27 +20,27 @@ test("identityPayload is a valid Kratos create-identity body with a password cre
assert.equal(body.credentials.password.config.password, "admin"); assert.equal(body.credentials.password.config.password, "admin");
}); });
test("roleTuple grants a role to user:<id> in the Role namespace", () => { test("permissionTuple grants a permission to user:<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: `user:${id}`, subject_id: `user:${id}`,
}); });
}); });
test("seedRoles unions ADMIN_ROLES (default 'admin') with the discovered plugins' declared tokens", () => { 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: `user:${id}` }, { namespace: "Permission", object: "admin", relation: "granted", subject_id: `user:${id}` },
{ namespace: "Role", object: "scheduling:read", relation: "members", subject_id: `user:${id}` }, { namespace: "Permission", object: "scheduling:read", relation: "granted", subject_id: `user:${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: `user:${id}` }); assert.deepEqual(granted, { namespace: "Permission", object: "admin", relation: "granted", subject_id: `user:${id}` });
}); });
test("seedAdmin fails loud on an unexpected Kratos error", async () => { test("seedAdmin fails loud on an unexpected Kratos error", async () => {
@@ -106,7 +106,7 @@ test("seedAdmin fails loud on an unexpected Kratos error", async () => {
ketoWriteUrl: "http://keto:4467", ketoWriteUrl: "http://keto:4467",
kratosAdminUrl: "http://kratos:4434", kratosAdminUrl: "http://kratos:4434",
password: "admin", password: "admin",
roles: ["admin"], permissions: ["admin"],
}), }),
/Kratos/, /Kratos/,
); );
+23 -23
View File
@@ -2,8 +2,8 @@
// kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`: // kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`:
// 1. generate the JWKS signing key if absent (committed dev key makes this a safety net); // 1. generate the JWKS signing key if absent (committed dev key makes this a safety net);
// 2. seed a demo admin (admin@plainpages.local / admin) in Kratos; // 2. seed a demo admin (admin@plainpages.local / admin) in Kratos;
// 3. grant it its roles in Keto so menu/permission 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 permission tokens, 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@user:<id>`. Subject ids are `user:<kratos-id>` // Coarse-permission grant: `Permission:<permission>#members@user:<id>`. Subject ids are `user:<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(userId: string, permission: string) {
return { namespace: "Role", object: role, relation: "members", subject_id: `user:${identityId}` }; return { namespace: "Permission", object: permission, relation: "granted", subject_id: `user:${userId}` };
} }
// 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 permission tokens (a route/nav `permission` 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, declaredTokens: 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(declaredTokens)])]; 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 permission tokens, 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.permissions ?? []).map((d) => d.token)); const declared = (await discoverPlugins()).flatMap((p) => (p.permissions ?? []).map((d) => d.name));
const roles = seedRoles(env["ADMIN_ROLES"], declared); const permissions = seedPermissions(env["ADMIN_PERMISSIONS"], declared);
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local"; const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
const password = env["ADMIN_PASSWORD"] ?? "admin"; const password = env["ADMIN_PASSWORD"] ?? "admin";
const result = await seedAdmin({ const result = await seedAdmin({
@@ -155,9 +155,9 @@ async function main() {
ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467", ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467",
kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434", kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434",
password, password,
roles, permissions,
}); });
log.info("admin seeded", { created: result.created, id: result.id, roles: result.roles.join(", ") }); log.info("admin seeded", { created: result.created, id: result.id, permissions: result.permissions.join(", ") });
// The banner is human-facing UX (the first-run "you're ready" block), not a log event — print raw. // The banner is human-facing UX (the first-run "you're ready" block), not a log event — print raw.
console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password })); console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password }));
}); });
+3 -3
View File
@@ -1,12 +1,12 @@
// Optional revocation denylist: instant role/session revoke without putting Keto // Optional revocation denylist: instant permission/session revoke without putting Keto
// back on the hot path. Off by default — enable with REVOCATION_DENYLIST=true. // back on the hot path. Off by default — enable with REVOCATION_DENYLIST=true.
// //
// The hot path verifies a short-lived (~10m) session JWT in-process, so a revoked role or a // The hot path verifies a short-lived (~10m) session JWT in-process, so a revoked permission or a
// killed session only takes effect when the token is next minted (re-login / TTL refresh) — // killed session only takes effect when the token is next minted (re-login / TTL refresh) —
// up to one token TTL of lag. For security-critical revoke (offboarding, a compromised // up to one token TTL of lag. For security-critical revoke (offboarding, a compromised
// account) that lag is too long. An admin action records the subject as revoked-now and the // account) that lag is too long. An admin action records the subject as revoked-now and the
// hot path then rejects that subject's pre-revoke tokens at once, forcing a re-mint (which // hot path then rejects that subject's pre-revoke tokens at once, forcing a re-mint (which
// re-reads roles from Keto, or clears a now-dead session). // re-reads permissions from Keto, or clears a now-dead session).
// //
// Cost & scope: an in-memory, auto-evicting Map — no database, like the JWKS cache, so it // Cost & scope: an in-memory, auto-evicting Map — no database, like the JWKS cache, so it
// stays inside the stateless model. A token carries `iat`, so a *fresh* re-login (iat after // stays inside the stateless model. A token carries `iat`, so a *fresh* re-login (iat after
+1 -1
View File
@@ -48,7 +48,7 @@ test("rotateJwks --prune keeps only the newest (first) key, dropping superseded
test("a JWS signed with a generated key verifies via our own verifier (reads what Kratos signs)", () => { test("a JWS signed with a generated key verifies via our own verifier (reads what Kratos signs)", () => {
const key = generateJwks().keys[0]!; const key = generateJwks().keys[0]!;
const head = b64url(JSON.stringify({ alg: "ES256", kid: key.kid })); const head = b64url(JSON.stringify({ alg: "ES256", kid: key.kid }));
const body = b64url(JSON.stringify({ email: "a@b.c", roles: [], sub: key.kid })); const body = b64url(JSON.stringify({ email: "a@b.c", permissions: [], sub: key.kid }));
const sig = sign("SHA256", Buffer.from(`${head}.${body}`), { dsaEncoding: "ieee-p1363", key: createPrivateKey({ key: key as unknown as JsonWebKey, format: "jwk" }) }); const sig = sign("SHA256", Buffer.from(`${head}.${body}`), { dsaEncoding: "ieee-p1363", key: createPrivateKey({ key: key as unknown as JsonWebKey, format: "jwk" }) });
const token = `${head}.${body}.${sig.toString("base64url")}`; const token = `${head}.${body}.${sig.toString("base64url")}`;
+2 -2
View File
@@ -12,7 +12,7 @@ function ctxFor(user: User | null, url = "/"): RequestContext {
return buildContext(req, new ServerResponse(req), { user }); return buildContext(req, new ServerResponse(req), { user });
} }
const alice: User = { email: "a@b.c", id: "u1", roles: ["admin", "scheduling:read"] }; const alice: User = { 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);
+3 -3
View File
@@ -37,9 +37,9 @@ export function requireSession(ctx: RequestContext): User {
return ctx.user; return ctx.user;
} }
// 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;
+10 -8
View File
@@ -22,15 +22,17 @@ 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 rejects expiry and future nbf, with clock-skew leeway", async () => { test("verifyToken requires exp, rejects expiry and future nbf, with clock-skew leeway", async () => {
const opts = { clockSkewSec: 60, now: NOW }; const opts = { clockSkewSec: 60, now: NOW };
// No exp ⇒ rejected outright: an exp-less token must never read as eternal.
await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: undefined }), jwks, opts), /missing exp/);
await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: NOW - 120 }), jwks, opts), /expired/); await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: NOW - 120 }), jwks, opts), /expired/);
// exp 30s in the past but inside the 60s skew → still accepted. // exp 30s in the past but inside the 60s skew → still accepted.
await verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: NOW - 30 }), jwks, opts); await verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: NOW - 30 }), jwks, opts);
@@ -57,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("claimsToUser requires sub + email, defaults roles to [], keeps only string roles", () => { test("claimsToUser requires sub + email, defaults permissions to [], keeps only string permissions", () => {
assert.throws(() => claimsToUser({ email: "a@b.c", exp: NOW }), /sub/); assert.throws(() => claimsToUser({ email: "a@b.c", exp: NOW }), /sub/);
assert.throws(() => claimsToUser({ email: "a@b.c", exp: NOW, sub: "" }), /sub/); // empty sub rejected too assert.throws(() => claimsToUser({ email: "a@b.c", exp: NOW, sub: "" }), /sub/); // empty sub rejected too
assert.throws(() => claimsToUser({ exp: NOW, sub: "u" }), /email/); assert.throws(() => claimsToUser({ exp: NOW, sub: "u" }), /email/);
assert.throws(() => claimsToUser({ email: "", exp: NOW, sub: "u" }), /email/); // empty email rejected (the shell keys signed-in vs anonymous off it) assert.throws(() => claimsToUser({ email: "", exp: NOW, sub: "u" }), /email/); // empty email rejected (the shell keys signed-in vs anonymous off it)
assert.deepEqual(claimsToUser({ email: "a@b.c", sub: "u" }).roles, []); // roles absent assert.deepEqual(claimsToUser({ email: "a@b.c", sub: "u" }).permissions, []); // permissions absent
assert.deepEqual(claimsToUser({ email: "a@b.c", roles: ["a", 1, "b"], sub: "u" }).roles, ["a", "b"]); assert.deepEqual(claimsToUser({ email: "a@b.c", permissions: ["a", 1, "b"], sub: "u" }).permissions, ["a", "b"]);
}); });
test("resolveSession classifies the cookie; authenticate is its fail-closed user projection", async () => { test("resolveSession classifies the cookie; authenticate is its fail-closed user 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 user = { email: "a@b.c", id: "u1", roles: ["admin"] }; const user = { 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, user }); assert.deepEqual(await resolveSession(cookie(), jwks, { now: NOW }), { expired: false, user });
@@ -94,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, user: null }); assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=${mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 })}`, jwks, { denylist, now: NOW }), { expired: true, user: 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 });
}); });
+4 -4
View File
@@ -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 claimsToUser(payload: Record<string, unknown>): User { export function claimsToUser(payload: Record<string, unknown>): User {
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 = claimsToUser(verified.payload); const user = claimsToUser(verified.payload);
// Instant revoke: a denylisted subject's pre-revoke token is rejected as *expired* so // Instant revoke: a denylisted subject's pre-revoke token is rejected as *expired* so
// resolveSession routes it through the re-mint (fresh roles from Keto, or a cleared session). // resolveSession routes it through the re-mint (fresh permissions from Keto, or a cleared session).
if (options.denylist?.isRevoked(user.id, num(verified.payload, "iat"))) throw new TokenError("token revoked", true); if (options.denylist?.isRevoked(user.id, num(verified.payload, "iat"))) throw new TokenError("token revoked", true);
return user; return user;
} }
+4 -4
View File
@@ -22,10 +22,10 @@ const rsaJwk = rsa.publicKey.export({ format: "jwk" }) as JsonWebKey;
const ecJwk = ec.publicKey.export({ format: "jwk" }) as JsonWebKey; const ecJwk = ec.publicKey.export({ format: "jwk" }) as JsonWebKey;
test("verifies an RS256 token, returning the decoded header + payload", () => { test("verifies an RS256 token, returning the decoded header + payload", () => {
const token = makeJws("RS256", rsa.privateKey, { roles: ["admin"], sub: "u" }); const token = makeJws("RS256", rsa.privateKey, { permissions: ["admin"], sub: "u" });
const verified = verifyJws(token, rsaJwk); const verified = verifyJws(token, rsaJwk);
assert.equal(verified.header.alg, "RS256"); assert.equal(verified.header.alg, "RS256");
assert.deepEqual(verified.payload, { roles: ["admin"], sub: "u" }); assert.deepEqual(verified.payload, { permissions: ["admin"], sub: "u" });
}); });
test("verifies an ES256 token (raw r‖s signature)", () => { test("verifies an ES256 token (raw r‖s signature)", () => {
@@ -35,10 +35,10 @@ test("verifies an ES256 token (raw r‖s signature)", () => {
// All three reach and fail the signature check itself, not an earlier structural guard. // All three reach and fail the signature check itself, not an earlier structural guard.
test("rejects a signature that fails verification (tampered payload, wrong key, empty)", () => { test("rejects a signature that fails verification (tampered payload, wrong key, empty)", () => {
const token = makeJws("RS256", rsa.privateKey, { roles: ["user"], sub: "u" }); const token = makeJws("RS256", rsa.privateKey, { permissions: ["user"], sub: "u" });
const [header, payload, signature] = token.split("."); const [header, payload, signature] = token.split(".");
const forged = `${header}.${b64url(JSON.stringify({ roles: ["admin"], sub: "u" }))}.${signature}`; const forged = `${header}.${b64url(JSON.stringify({ permissions: ["admin"], sub: "u" }))}.${signature}`;
assert.throws(() => verifyJws(forged, rsaJwk), /invalid signature/); assert.throws(() => verifyJws(forged, rsaJwk), /invalid signature/);
const otherJwk = generateKeyPairSync("rsa", { modulusLength: 2048 }).publicKey.export({ format: "jwk" }) as JsonWebKey; const otherJwk = generateKeyPairSync("rsa", { modulusLength: 2048 }).publicKey.export({ format: "jwk" }) as JsonWebKey;
+14 -14
View File
@@ -29,13 +29,13 @@ const keto = (fetchImpl: typeof fetch) => createKetoClient({ fetchImpl, readUrl:
test("check GETs the read API and returns the allowed boolean (true and false)", async () => { test("check GETs the read API and returns the allowed boolean (true and false)", async () => {
const allow = recorder(() => res(200, { allowed: true })); const allow = recorder(() => res(200, { allowed: true }));
assert.equal(await keto(allow.fetchImpl).check({ namespace: "Role", object: "admin", relation: "members", subject_id: USER }), true); assert.equal(await keto(allow.fetchImpl).check({ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER }), true);
assert.match(allow.calls[0]!.url, /^http:\/\/keto:4466\/relation-tuples\/check\?/); assert.match(allow.calls[0]!.url, /^http:\/\/keto:4466\/relation-tuples\/check\?/);
assert.match(allow.calls[0]!.url, /namespace=Role&object=admin&relation=members/); assert.match(allow.calls[0]!.url, /namespace=Permission&object=admin&relation=granted/);
assert.match(allow.calls[0]!.url, new RegExp(`subject_id=${encodeURIComponent(USER).replace(/[.]/g, "\\.")}`)); assert.match(allow.calls[0]!.url, new RegExp(`subject_id=${encodeURIComponent(USER).replace(/[.]/g, "\\.")}`));
// A denied check is 403 {allowed:false} (not a 200) — both statuses carry the verdict. // A denied check is 403 {allowed:false} (not a 200) — both statuses carry the verdict.
const deny = recorder(() => res(403, { allowed: false })); const deny = recorder(() => res(403, { allowed: false }));
assert.equal(await keto(deny.fetchImpl).check({ namespace: "Role", object: "admin", relation: "members", subject_id: "user:nobody" }), false); assert.equal(await keto(deny.fetchImpl).check({ namespace: "Permission", object: "admin", relation: "granted", subject_id: "user:nobody" }), false);
}); });
test("check on a subject_set builds subject_set.* params and forwards max-depth", async () => { test("check on a subject_set builds subject_set.* params and forwards max-depth", async () => {
@@ -51,20 +51,20 @@ test("check on a subject_set builds subject_set.* params and forwards max-depth"
test("check throws a KetoError carrying the status on an unexpected response", async () => { test("check throws a KetoError carrying the status on an unexpected response", async () => {
await assert.rejects( await assert.rejects(
keto((async () => res(400, { error: "bad" })) as typeof fetch).check({ namespace: "Role", object: "admin", relation: "members", subject_id: USER }), keto((async () => res(400, { error: "bad" })) as typeof fetch).check({ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER }),
(e: unknown) => e instanceof KetoError && e.status === 400, (e: unknown) => e instanceof KetoError && e.status === 400,
); );
}); });
test("listRelations builds the filter query + pagination and parses next_page_token", async () => { test("listRelations builds the filter query + pagination and parses next_page_token", async () => {
const tuples = [{ namespace: "Role", object: "admin", relation: "members", subject_id: USER }]; const tuples = [{ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER }];
const { calls, fetchImpl } = recorder(() => res(200, { next_page_token: "NEXT", relation_tuples: tuples })); const { calls, fetchImpl } = recorder(() => res(200, { next_page_token: "NEXT", relation_tuples: tuples }));
const out = await keto(fetchImpl).listRelations({ namespace: "Role", object: "admin", pageSize: 10, pageToken: "CUR", relation: "members" }); const out = await keto(fetchImpl).listRelations({ namespace: "Permission", object: "admin", pageSize: 10, pageToken: "CUR", relation: "granted" });
assert.deepEqual(out.tuples, tuples); assert.deepEqual(out.tuples, tuples);
assert.equal(out.nextPageToken, "NEXT"); assert.equal(out.nextPageToken, "NEXT");
const url = calls[0]!.url; const url = calls[0]!.url;
assert.match(url, /^http:\/\/keto:4466\/relation-tuples\?/); assert.match(url, /^http:\/\/keto:4466\/relation-tuples\?/);
assert.match(url, /namespace=Role&object=admin&relation=members/); assert.match(url, /namespace=Permission&object=admin&relation=granted/);
assert.match(url, /page_size=10&page_token=CUR/); assert.match(url, /page_size=10&page_token=CUR/);
// No Link header / token in the body ⇒ null, empty list ⇒ []. // No Link header / token in the body ⇒ null, empty list ⇒ [].
const empty = await keto((async () => res(200, {})) as typeof fetch).listRelations(); const empty = await keto((async () => res(200, {})) as typeof fetch).listRelations();
@@ -72,16 +72,16 @@ test("listRelations builds the filter query + pagination and parses next_page_to
}); });
test("expand GETs the read API for a subject set and returns the tree (with max-depth)", async () => { test("expand GETs the read API for a subject set and returns the tree (with max-depth)", async () => {
const tree = { children: [{ tuple: { namespace: "", object: "", relation: "", subject_id: USER }, type: "leaf" }], tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Role", object: "admin", relation: "members" } }, type: "union" }; const tree = { children: [{ tuple: { namespace: "", object: "", relation: "", subject_id: USER }, type: "leaf" }], tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Permission", object: "admin", relation: "granted" } }, type: "union" };
const { calls, fetchImpl } = recorder(() => res(200, tree)); const { calls, fetchImpl } = recorder(() => res(200, tree));
const out = await keto(fetchImpl).expand({ namespace: "Role", object: "admin", relation: "members" }, { maxDepth: 3 }); const out = await keto(fetchImpl).expand({ namespace: "Permission", object: "admin", relation: "granted" }, { maxDepth: 3 });
assert.deepEqual(out, tree); assert.deepEqual(out, tree);
assert.match(calls[0]!.url, /^http:\/\/keto:4466\/relation-tuples\/expand\?/); assert.match(calls[0]!.url, /^http:\/\/keto:4466\/relation-tuples\/expand\?/);
assert.match(calls[0]!.url, /namespace=Role&object=admin&relation=members&max-depth=3/); assert.match(calls[0]!.url, /namespace=Permission&object=admin&relation=granted&max-depth=3/);
}); });
test("writeTuple PUTs the tuple as JSON to the write API (idempotent; non-2xx throws)", async () => { test("writeTuple PUTs the tuple as JSON to the write API (idempotent; non-2xx throws)", async () => {
const tuple = { namespace: "Role", object: "admin", relation: "members", subject_id: USER }; const tuple = { namespace: "Permission", object: "admin", relation: "granted", subject_id: USER };
const { calls, fetchImpl } = recorder(() => res(201, tuple)); const { calls, fetchImpl } = recorder(() => res(201, tuple));
await keto(fetchImpl).writeTuple(tuple); await keto(fetchImpl).writeTuple(tuple);
assert.equal(calls[0]!.method, "PUT"); assert.equal(calls[0]!.method, "PUT");
@@ -95,12 +95,12 @@ test("writeTuple PUTs the tuple as JSON to the write API (idempotent; non-2xx th
test("deleteTuple DELETEs the write API by query params (204 resolves; non-204 throws)", async () => { test("deleteTuple DELETEs the write API by query params (204 resolves; non-204 throws)", async () => {
const { calls, fetchImpl } = recorder(() => res(204)); const { calls, fetchImpl } = recorder(() => res(204));
await keto(fetchImpl).deleteTuple({ namespace: "Role", object: "admin", relation: "members", subject_id: USER }); await keto(fetchImpl).deleteTuple({ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER });
assert.equal(calls[0]!.method, "DELETE"); assert.equal(calls[0]!.method, "DELETE");
assert.match(calls[0]!.url, /^http:\/\/keto:4467\/admin\/relation-tuples\?/); assert.match(calls[0]!.url, /^http:\/\/keto:4467\/admin\/relation-tuples\?/);
assert.match(calls[0]!.url, /namespace=Role&object=admin&relation=members/); assert.match(calls[0]!.url, /namespace=Permission&object=admin&relation=granted/);
await assert.rejects( await assert.rejects(
keto((async () => res(404)) as typeof fetch).deleteTuple({ namespace: "Role", object: "x", relation: "members", subject_id: USER }), keto((async () => res(404)) as typeof fetch).deleteTuple({ namespace: "Permission", object: "x", relation: "granted", subject_id: USER }),
(e: unknown) => e instanceof KetoError && e.status === 404, (e: unknown) => e instanceof KetoError && e.status === 404,
); );
}); });
+1 -1
View File
@@ -13,7 +13,7 @@ export interface SubjectSet {
} }
// A relationship tuple — the wire shape for writes and the filter shape for reads. Subject // A relationship tuple — the wire shape for writes and the filter shape for reads. Subject
// is `subject_id` xor `subject_set` (never both). Mirrors bootstrap.ts's roleTuple. // is `subject_id` xor `subject_set` (never both). Mirrors bootstrap.ts's permissionTuple.
export interface RelationTuple { export interface RelationTuple {
namespace: string; namespace: string;
object: string; object: string;
+4 -4
View File
@@ -1,5 +1,5 @@
// Kratos admin-API client: typed fetch wrappers over Ory Kratos' admin endpoints — // Kratos admin-API client: typed fetch wrappers over Ory Kratos' admin endpoints —
// identity CRUD + the surgical metadata_public update the login flow projects roles into. // identity CRUD + the surgical metadata_public update the login flow projects permissions into.
// Guards the request contracts (URLs, method, JSON-Patch body, query/pagination) and the // Guards the request contracts (URLs, method, JSON-Patch body, query/pagination) and the
// result mapping (201/200/404/4xx). Live wiring is verified by login completion. // result mapping (201/200/404/4xx). Live wiring is verified by login completion.
import { test } from "node:test"; import { test } from "node:test";
@@ -90,13 +90,13 @@ test("updateIdentity PUTs the full body to /admin/identities/<id> and returns th
}); });
test("updateMetadataPublic PATCHes a JSON-Patch `add /metadata_public` so it never clobbers traits", async () => { test("updateMetadataPublic PATCHes a JSON-Patch `add /metadata_public` so it never clobbers traits", async () => {
const identity = { id: ID, metadata_public: { roles: ["admin"] } }; const identity = { id: ID, metadata_public: { permissions: ["admin"] } };
const { calls, fetchImpl } = recorder(() => res(200, identity)); const { calls, fetchImpl } = recorder(() => res(200, identity));
const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).updateMetadataPublic(ID, { roles: ["admin"] }); const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).updateMetadataPublic(ID, { permissions: ["admin"] });
assert.deepEqual(out, identity); assert.deepEqual(out, identity);
assert.equal(calls[0]!.method, "PATCH"); assert.equal(calls[0]!.method, "PATCH");
assert.match(calls[0]!.url, new RegExp(`/admin/identities/${ID}$`)); assert.match(calls[0]!.url, new RegExp(`/admin/identities/${ID}$`));
assert.deepEqual(JSON.parse(calls[0]!.body!), [{ op: "add", path: "/metadata_public", value: { roles: ["admin"] } }]); assert.deepEqual(JSON.parse(calls[0]!.body!), [{ op: "add", path: "/metadata_public", value: { permissions: ["admin"] } }]);
}); });
test("createRecoveryCode POSTs the identity id to /admin/recovery/code → { code, link }", async () => { test("createRecoveryCode POSTs the identity id to /admin/recovery/code → { code, link }", async () => {
+5 -5
View File
@@ -1,6 +1,6 @@
// Kratos admin-API client: typed `fetch` wrappers over Ory Kratos' admin endpoints // Kratos admin-API client: typed `fetch` wrappers over Ory Kratos' admin endpoints
// (internal-only admin port) — identity CRUD + the surgical `metadata_public` update login // (internal-only admin port) — identity CRUD + the surgical `metadata_public` update login
// completion projects Keto roles into (README). Built-in `fetch` only, no SDK dep (AGENTS.md); // completion projects Keto permissions into (README). Built-in `fetch` only, no SDK dep (AGENTS.md);
// `fetchImpl`-injectable, reuses kratos-public.ts's `KratosError` (branch on `.status`). // `fetchImpl`-injectable, reuses kratos-public.ts's `KratosError` (branch on `.status`).
import { KratosError } from "./kratos-public.ts"; import { KratosError } from "./kratos-public.ts";
@@ -33,7 +33,7 @@ export interface RecoveryCode {
export interface KratosAdmin { export interface KratosAdmin {
createIdentity(payload: unknown): Promise<Identity>; createIdentity(payload: unknown): Promise<Identity>;
createRecoveryCode(identityId: string, opts?: { expiresIn?: string }): Promise<RecoveryCode>; createRecoveryCode(userId: string, opts?: { expiresIn?: string }): Promise<RecoveryCode>;
deleteIdentity(id: string): Promise<void>; deleteIdentity(id: string): Promise<void>;
getIdentity(id: string): Promise<Identity | null>; getIdentity(id: string): Promise<Identity | null>;
listIdentities(opts?: ListOptions): Promise<IdentityList>; listIdentities(opts?: ListOptions): Promise<IdentityList>;
@@ -67,8 +67,8 @@ export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof
// Mint a recovery code for an identity (admin "trigger recovery") — the link is mailed to the // Mint a recovery code for an identity (admin "trigger recovery") — the link is mailed to the
// user by Kratos; the code/link are also returned so an operator can hand them over directly. // user by Kratos; the code/link are also returned so an operator can hand them over directly.
async createRecoveryCode(identityId, opts = {}) { async createRecoveryCode(userId, opts = {}) {
const body: Record<string, unknown> = { identity_id: identityId }; const body: Record<string, unknown> = { identity_id: userId };
if (opts.expiresIn) body.expires_in = opts.expiresIn; if (opts.expiresIn) body.expires_in = opts.expiresIn;
const res = await http(`${base}/admin/recovery/code`, { body: JSON.stringify(body), headers: json, method: "POST" }); const res = await http(`${base}/admin/recovery/code`, { body: JSON.stringify(body), headers: json, method: "POST" });
if (res.status !== 200 && res.status !== 201) return fail("create recovery code", res); if (res.status !== 200 && res.status !== 201) return fail("create recovery code", res);
@@ -106,7 +106,7 @@ export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof
}, },
// JSON Patch `add` sets metadata_public whether it's currently absent, null, or set, and // JSON Patch `add` sets metadata_public whether it's currently absent, null, or set, and
// touches nothing else — so the login role projection never clobbers traits/state. // touches nothing else — so the login permission projection never clobbers traits/state.
// (metadata_public, not _admin: the session the tokenizer sees carries only public metadata.) // (metadata_public, not _admin: the session the tokenizer sees carries only public metadata.)
async updateMetadataPublic(id, metadata) { async updateMetadataPublic(id, metadata) {
const patch = [{ op: "add", path: "/metadata_public", value: metadata }]; const patch = [{ op: "add", path: "/metadata_public", value: metadata }];
+20 -20
View File
@@ -1,4 +1,4 @@
// Login completion: turn a Kratos session into our session JWT — read roles from Keto, // Login completion: turn a Kratos session into our session JWT — read permissions from Keto,
// project them onto the identity, tokenize, build the cookie. Fakes the three Ory clients; // project them onto the identity, tokenize, build the cookie. Fakes the three Ory clients;
// the live, full-stack login is verified by the Playwright E2E. // the live, full-stack login is verified by the Playwright E2E.
import { test } from "node:test"; import { test } from "node:test";
@@ -6,10 +6,10 @@ import assert from "node:assert/strict";
import type { KetoClient, RelationTuple } from "./keto-client.ts"; import type { KetoClient, RelationTuple } from "./keto-client.ts";
import type { Identity, KratosAdmin } from "./kratos-admin.ts"; import type { Identity, KratosAdmin } from "./kratos-admin.ts";
import type { KratosPublic, Session } from "./kratos-public.ts"; import type { KratosPublic, Session } from "./kratos-public.ts";
import { completeLogin, readRoles, remintSession, SESSION_COOKIE, sessionCookie } from "./login.ts"; import { completeLogin, readPermissions, remintSession, SESSION_COOKIE, sessionCookie } from "./login.ts";
const ID = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55"; const ID = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
const roleTuple = (object: string): RelationTuple => ({ namespace: "Role", object, relation: "members", subject_id: `user:${ID}` }); const permissionTuple = (object: string): RelationTuple => ({ namespace: "Permission", object, relation: "granted", subject_id: `user:${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: "user:other" })] }; if (q?.pageToken === "p2") return { nextPageToken: null, tuples: [permission("editor", { subject_id: "user: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: `user:${ID}` }), permission("admin", { subject_id: `user:${ID}` }),
role("viewer", { subject_id: "user:stranger" }), permission("viewer", { subject_id: "user: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", userId: 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.user, { email: "admin@plainpages.local", id: ID, roles: ["admin"] }); assert.deepEqual(live.user, { 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.
+21 -21
View File
@@ -1,9 +1,9 @@
// Login completion: turn a fresh Kratos session into our locally-verifiable // Login completion: turn a fresh Kratos session into our locally-verifiable
// session JWT — the one moment Ory is on the path (README: Login → session JWT): // session JWT — the one moment Ory is on the path (README: Login → session JWT):
// 1. whoami(cookie) → the identity (id, email); no active session ⇒ null // 1. whoami(cookie) → the identity (id, email); no active session ⇒ null
// 2. read roles from Keto → the source of truth for the `roles` claim // 2. read permissions from Keto → the source of truth for the `permissions` claim
// 3. project onto metadata_public (admin API) so the tokenizer's mapper can read them // 3. project onto metadata_public (admin API) so the tokenizer's mapper can read them
// 4. whoami(tokenize_as) → the signed JWT { sub, email, roles }, stored as our cookie // 4. whoami(tokenize_as) → the signed JWT { sub, email, permissions }, stored as our cookie
// Order matters: the projection is written before tokenizing, because the claims mapper // Order matters: the projection is written before tokenizing, because the claims mapper
// reads only the identity, never Keto. // reads only the identity, never Keto.
import type { User } from "../http/context.ts"; import type { User } from "../http/context.ts";
@@ -32,46 +32,46 @@ export interface LoginDeps {
export interface CompletedLogin { export interface CompletedLogin {
email: string | null; email: string | null;
identityId: string; userId: string;
jwt: string; jwt: string;
roles: string[]; permissions: string[];
} }
// The coarse roles a user holds — directly (`Role:<name>#members@user:<id>`) or transitively via a // The coarse permissions a user holds — directly (`Permission:<name>#members@user:<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, userId: string): Promise<string[]> {
const subject_id = `user:${identityId}`; const subject_id = `user:${userId}`;
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> {
const session = await deps.kratosPublic.whoami(cookie ? { cookie } : {}); const session = await deps.kratosPublic.whoami(cookie ? { cookie } : {});
if (!session?.identity) return null; if (!session?.identity) return null;
const identityId = session.identity.id; const userId = session.identity.id;
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, userId);
await deps.kratosAdmin.updateMetadataPublic(identityId, { roles }); await deps.kratosAdmin.updateMetadataPublic(userId, { 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: userId }); // login or TTL re-mint
return { email, identityId, jwt, roles }; return { email, userId, 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), user: null }; if (!completed) return { setCookie: clearSessionCookie(options), user: null };
return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email ?? "", id: completed.identityId, roles: completed.roles } }; return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email ?? "", id: completed.userId, permissions: completed.permissions } };
} }
// Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is // Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is
+1 -1
View File
@@ -2,7 +2,7 @@
// /oauth2/consent?consent_challenge=… (hydra.yml urls.consent). A first-party client (or one // /oauth2/consent?consent_challenge=… (hydra.yml urls.consent). A first-party client (or one
// Hydra already skipped) is auto-granted the requested scopes; a third-party client shows the // Hydra already skipped) is auto-granted the requested scopes; a third-party client shows the
// themed consent screen, then accept (allow) / reject (deny). id_token claims (email/name) come // themed consent screen, then accept (allow) / reject (deny). id_token claims (email/name) come
// from the Kratos identity. OAuth2-provider role only — no first-party page needs this (README). // from the Kratos identity. OAuth2-provider permission only — no first-party page needs this (README).
import type { AcceptConsent, ConsentRequest, HydraAdmin, OAuth2Client } from "./hydra-admin.ts"; import type { AcceptConsent, ConsentRequest, HydraAdmin, OAuth2Client } from "./hydra-admin.ts";
import type { KratosPublic } from "./kratos-public.ts"; import type { KratosPublic } from "./kratos-public.ts";
+1 -1
View File
@@ -2,7 +2,7 @@
// Hydra hands the browser to /oauth2/login?login_challenge=… (hydra.yml urls.login). We // Hydra hands the browser to /oauth2/login?login_challenge=… (hydra.yml urls.login). We
// authenticate the user with their existing Kratos session and accept the request; Hydra then // authenticate the user with their existing Kratos session and accept the request; Hydra then
// proceeds to consent and mints the tokens. No first-party page needs this — it's the OAuth2 // proceeds to consent and mints the tokens. No first-party page needs this — it's the OAuth2
// provider role only (README). // provider permission only (README).
import type { HydraAdmin } from "./hydra-admin.ts"; import type { HydraAdmin } from "./hydra-admin.ts";
import type { KratosPublic } from "./kratos-public.ts"; import type { KratosPublic } from "./kratos-public.ts";
+1 -1
View File
@@ -192,7 +192,7 @@ function oauthLogout(hydra: HydraAdmin): BuiltinRoute["handler"] {
} }
// Login completion: where Kratos lands the browser after authenticating (kratos.yml). Mint our // Login completion: where Kratos lands the browser after authenticating (kratos.yml). Mint our
// session JWT — read roles from Keto, project onto the identity, tokenize — and store it as the // session JWT — read permissions from Keto, project onto the identity, tokenize — and store it as the
// cookie; no active session bounces back to sign in. // cookie; no active session bounces back to sign in.
function completeAuth(deps: { keto: KetoClient; kratosAdmin: KratosAdmin; kratosPublic: KratosPublic }, secureCookies: boolean): BuiltinRoute["handler"] { function completeAuth(deps: { keto: KetoClient; kratosAdmin: KratosAdmin; kratosPublic: KratosPublic }, secureCookies: boolean): BuiltinRoute["handler"] {
return async (ctx: RequestContext): Promise<RouteResult> => { return async (ctx: RequestContext): Promise<RouteResult> => {
+2 -2
View File
@@ -31,7 +31,7 @@ export interface Config {
otlpEndpoint: string | undefined; // OTLP/HTTP collector base URI; unset ⇒ console-only (no export) otlpEndpoint: string | undefined; // OTLP/HTTP collector base URI; unset ⇒ console-only (no export)
otlpProtocol: "http/json" | "http/protobuf"; // OTLP wire format (protobuf for json-averse collectors) otlpProtocol: "http/json" | "http/protobuf"; // OTLP wire format (protobuf for json-averse collectors)
port: number; port: number;
revocationDenylist: boolean; // enable the optional instant role/session revoke denylist revocationDenylist: boolean; // enable the optional instant permission/session revoke denylist
revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew
secureCookies: boolean; secureCookies: boolean;
serviceName: string; // OTLP service.name — an implementer brands their own logs/traces serviceName: string; // OTLP service.name — an implementer brands their own logs/traces
@@ -157,7 +157,7 @@ export function loadConfig(env: Env = process.env): Config {
otlpEndpoint: readOptionalUrl(env, "OTLP_ENDPOINT"), otlpEndpoint: readOptionalUrl(env, "OTLP_ENDPOINT"),
otlpProtocol: readEnum(env, "OTLP_PROTOCOL", ["http/json", "http/protobuf"] as const, "http/json"), otlpProtocol: readEnum(env, "OTLP_PROTOCOL", ["http/json", "http/protobuf"] as const, "http/json"),
port: readPort(env), port: readPort(env),
// Optional instant-revoke, off by default. When on, an admin deactivate/delete or role // Optional instant-revoke, off by default. When on, an admin deactivate/delete or permission
// change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m // change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m
// tokenizer TTL + skew, so it outlasts any pre-revoke token). // tokenizer TTL + skew, so it outlasts any pre-revoke token).
revocationDenylist: readBool(env, "REVOCATION_DENYLIST", false), revocationDenylist: readBool(env, "REVOCATION_DENYLIST", false),
+65 -65
View File
@@ -40,9 +40,9 @@ function mintJwt(payload: Record<string, unknown>): string {
const input = `${b64url(JSON.stringify({ alg: "ES256", kid: "test-kid", typ: "JWT" }))}.${b64url(JSON.stringify(payload))}`; const input = `${b64url(JSON.stringify({ alg: "ES256", kid: "test-kid", typ: "JWT" }))}.${b64url(JSON.stringify(payload))}`;
return `${input}.${b64url(sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key: ec.privateKey }))}`; return `${input}.${b64url(sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key: ec.privateKey }))}`;
} }
// A session cookie carrying `roles`, valid for 10 min — the auth most tests need to reach a gated page. // A session cookie carrying `permissions`, valid for 10 min — the auth most tests need to reach a gated page.
const session = (roles: string[] = []): string => const session = (permissions: string[] = []): string =>
`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, roles, sub: "u1" })}`; `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, permissions, sub: "u1" })}`;
const server = createApp({ jwks: staticJwks([ecJwk]) }); const server = createApp({ jwks: staticJwks([ecJwk]) });
let base = ""; let base = "";
@@ -83,7 +83,7 @@ test("/ is the public landing: anonymous → 200 with intro + sign-in/register l
const html = await res.text(); const html = await res.text();
assert.match(html, /href="\/login"/); // a prominent path to sign in assert.match(html, /href="\/login"/); // a prominent path to sign in
assert.match(html, /href="\/registration"/); // and to register assert.match(html, /href="\/registration"/); // and to register
// the same app shell every page renders — the menu shows even when signed out (role-filtered). // the same app shell every page renders — the menu shows even when signed out (permission-filtered).
assert.match(html, /<aside class="sidebar"/); assert.match(html, /<aside class="sidebar"/);
assert.match(html, /class="landing-title"/); // the landing hero owns the page's single <h1> assert.match(html, /class="landing-title"/); // the landing hero owns the page's single <h1>
}); });
@@ -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.user/roles, which the gate reads. // JWT middleware: a verified session cookie populates ctx.user/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: "user:u1" }] }) }); const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Permission", object: "demo:read", relation: "granted", subject_id: "user: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] });
@@ -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 `permission` 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: `user:${identity.id}` }] }) }); const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Permission", object: "admin", relation: "granted", subject_id: `user:${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);
}); });
@@ -1225,7 +1225,7 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
assert.equal((await get("/admin/groups/%ZZ")).status, 404); assert.equal((await get("/admin/groups/%ZZ")).status, 404);
}); });
// Built-in Roles & permissions admin screen: gate + list/create/assign/revoke/delete over HTTP // Built-in Roles admin screen: gate + list/create/assign/revoke/delete over HTTP
// against a fake in-memory Keto whose `expand` mirrors Keto's transitive resolution, so the // against a fake in-memory Keto whose `expand` mirrors Keto's transitive resolution, so the
// effective-access view surfaces a user reachable only through a group. // effective-access view surfaces a user reachable only through a group.
test("admin Roles screen: gate, list, create, assign user/group, effective access (expand), revoke, delete", async (t) => { test("admin Roles screen: gate, list, create, assign user/group, effective access (expand), revoke, delete", async (t) => {
@@ -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: `user:${grace}` }, { namespace: "Group", object: "eng", relation: "members", subject_id: `user:${grace}` },
{ namespace: "Role", object: "editor", relation: "members", subject_id: `user:${ada}` }, { namespace: "Permission", object: "editor", relation: "granted", subject_id: `user:${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=user:${ada}`); const created = await post("/admin/permissions", `_csrf=${token}&name=viewer&member=user:${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 === `user:${ada}`)); assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "viewer" && tp.subject_id === `user:${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=user:${ada}`)).status, 400); assert.equal((await post("/admin/permissions", `_csrf=${token}&name=Bad Name&member=user:${ada}`)).status, 400);
assert.equal((await post("/admin/roles", `_csrf=${token}&name=editor&member=user:${ada}`)).status, 400); // already exists assert.equal((await post("/admin/permissions", `_csrf=${token}&name=editor&member=user:${ada}`)).status, 400); // already exists
assert.equal((await post("/admin/roles", `name=x&member=user:${ada}`)).status, 403); assert.equal((await post("/admin/permissions", `name=x&member=user:${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=user:${grace}`); await post("/admin/permissions/editor/members", `_csrf=${token}&member=user:${grace}`);
await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=user:${grace}`); await post("/admin/permissions/editor/members/delete", `_csrf=${token}&member=user:${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: "user:admin1" }); tuples.push({ namespace: "Permission", object: "admin", relation: "granted", subject_id: "user: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=user:admin1`)).status, 400); assert.equal((await post("/admin/permissions/admin/members/delete", `_csrf=${token}&member=user:admin1`)).status, 400);
assert.ok(tuples.some((tp) => tp.object === "admin" && tp.subject_id === "user:admin1")); assert.ok(tuples.some((tp) => tp.object === "admin" && tp.subject_id === "user: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
+10 -10
View File
@@ -2,7 +2,7 @@ import { randomBytes, randomUUID } from "node:crypto";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import * as ejs from "ejs"; import ejs from "ejs";
import { type BuiltinRoute, matchBuiltinRoute, type RequestCsrf } from "./builtin-routes.ts"; import { type BuiltinRoute, matchBuiltinRoute, type RequestCsrf } from "./builtin-routes.ts";
import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts"; import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
import { buildContext, type RequestContext, type User } from "./context.ts"; import { buildContext, type RequestContext, type User } from "./context.ts";
@@ -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.user/roles; absent ⇒ always anonymous jwks?: JwksProvider; // verify the session JWT → ctx.user/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
@@ -141,7 +141,7 @@ export function createApp(options: AppOptions = {}): Server {
await sendResult(ctx.res, result, (view, data) => renderView(dashboardPlugin.id, view, data)); await sendResult(ctx.res, result, (view, data) => renderView(dashboardPlugin.id, view, data));
return null; return null;
} }
return { data: { model: buildDashboardModel({ csrfToken: csrf.token, menu, nav: ctx.chrome.nav, user: ctx.user }) }, view: "index" }; return { data: { model: buildDashboardModel({ csrfToken: csrf.token, menu, user: ctx.user, nav: ctx.chrome.nav }) }, view: "index" };
}; };
// The internal route table, matched after plugin routes: the auth/OAuth2 group (src/auth/ // The internal route table, matched after plugin routes: the auth/OAuth2 group (src/auth/
@@ -186,9 +186,9 @@ export function createApp(options: AppOptions = {}): Server {
} }
} }
// Verify the session JWT once (cached JWKS) → ctx.user/roles; none/invalid ⇒ anonymous. // Verify the session JWT once (cached JWKS) → ctx.user/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: User | null = null; let user: User | null = null;
@@ -226,7 +226,7 @@ export function createApp(options: AppOptions = {}): Server {
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, user })); const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, user }));
// base context (no route params yet); reused for onRequest hooks and the landing routes. // base context (no route params yet); reused for onRequest hooks and the landing routes.
const ctx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf, ...(system ? { system } : {}) }); const ctx = buildContext(req, res, { chrome, user, log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
// Plugin onRequest hooks run before routing and may short-circuit the request. // Plugin onRequest hooks run before routing and may short-circuit the request.
if (anyRequestHooks) { if (anyRequestHooks) {
@@ -245,12 +245,12 @@ export function createApp(options: AppOptions = {}): Server {
// 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, log: reqLog, params: match.params, user, verifyCsrf, ...(system ? { system } : {}) }); const routeCtx = buildContext(req, res, { chrome, 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.user) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; } if (!routeCtx.user) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
reqLog.warn("forbidden: missing role", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.user.id }); reqLog.warn("forbidden: missing permission", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.user.id });
sendHtml(res, 403, await render("403", { title: "Forbidden" })); sendHtml(res, 403, await render("403", { title: "Forbidden" }));
return; return;
} }
+4 -4
View File
@@ -23,7 +23,7 @@ test("buildContext parses the URL, exposes query, and defaults to an anonymous u
assert.equal(ctx.query.get("q"), "ann"); assert.equal(ctx.query.get("q"), "ann");
assert.equal(ctx.query.get("page"), "2"); assert.equal(ctx.query.get("page"), "2");
assert.equal(ctx.user, null); assert.equal(ctx.user, 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: User = { email: "a@b.c", id: "u1", roles: ["admin", "editor"] }; const user: User = { email: "a@b.c", id: "u1", permissions: ["admin", "editor"] };
const ctx = buildContext(req, res, { user }); const ctx = buildContext(req, res, { user });
assert.equal(ctx.user, user); assert.equal(ctx.user, 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 /", () => {
+9 -8
View File
@@ -7,12 +7,13 @@ import { createLogger, type Log } from "../logger.ts";
// per request by `buildContext`: the router supplies matched path `params`, the JWT // per request by `buildContext`: the router supplies matched path `params`, the JWT
// middleware supplies `user` (null until then). The host's single handler argument. // middleware supplies `user` (null until then). The host's single handler argument.
// The authenticated user, projected from verified session JWT claims: // The signed-in user, projected from verified session JWT claims. Ory calls this record an
// `id` = `sub`, plus `email` and the coarse `roles` carried in the token. // "identity" (see README); Plainpages says user throughout.
// `id` = `sub`, plus `email` and the coarse `permissions` carried in the token.
export interface User { export interface User {
email: string; email: string;
id: string; id: string;
roles: string[]; permissions: string[];
} }
export interface RequestContext { export interface RequestContext {
@@ -24,15 +25,15 @@ export interface RequestContext {
// requestId. Additive, stable per the contract; defaults to a silent logger off the request path. // requestId. Additive, stable per the contract; defaults to a silent logger off the request path.
log: Log; log: Log;
params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id } params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id }
permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check
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[]; // user?.roles ?? [] — 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;
url: URL; url: URL;
user: User | null; user: User | null; // the signed-in user, or null when anonymous
// Gate a first-party form submission: true iff `submitted` matches this request's signed CSRF // Gate a first-party form submission: true iff `submitted` matches this request's signed CSRF
// cookie (double-submit). The host binds the secret; a plugin calls it after reading its body. // cookie (double-submit). The host binds the secret; a plugin calls it after reading its body.
verifyCsrf(submitted: string | null | undefined): boolean; verifyCsrf(submitted: string | null | undefined): boolean;
@@ -43,10 +44,10 @@ export interface BuildContextOptions {
// ctx.chrome (a json/redirect handler, or the public "/" with a standalone home, pays nothing). // ctx.chrome (a json/redirect handler, or the public "/" with a standalone home, pays nothing).
// The host's factory is memoised, so the menu composes at most once per request across contexts. // The host's factory is memoised, so the menu composes at most once per request across contexts.
chrome?: () => PageChrome; chrome?: () => PageChrome;
user?: User | null;
log?: Log; log?: Log;
params?: Record<string, string>; params?: Record<string, string>;
system?: SystemCapabilities; system?: SystemCapabilities;
user?: User | null;
verifyCsrf?: (submitted: string | null | undefined) => boolean; verifyCsrf?: (submitted: string | null | undefined) => boolean;
} }
@@ -67,15 +68,15 @@ export function buildContext(
let chromeMemo: PageChrome | undefined; // resolve the factory at most once per context let chromeMemo: PageChrome | undefined; // resolve the factory at most once per context
return { return {
get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); }, get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
user,
log: options.log ?? SILENT_LOG, log: options.log ?? SILENT_LOG,
params: options.params ?? {}, params: options.params ?? {},
query: url.searchParams, query: url.searchParams,
req, req,
res, res,
roles: user?.roles ?? [], permissions: user?.permissions ?? [],
...(options.system ? { system: options.system } : {}), ...(options.system ? { system: options.system } : {}),
url, url,
user,
verifyCsrf: options.verifyCsrf ?? (() => false), // fail-closed unless the host binds the secret verifyCsrf: options.verifyCsrf ?? (() => false), // fail-closed unless the host binds the secret
}; };
} }
+6 -6
View File
@@ -1,6 +1,6 @@
// Guards the Ory Keto config: migrations run before the server (keto-migrate → // Guards the Ory Keto config: migrations run before the server (keto-migrate →
// keto), the DSN targets the keto database, read/write APIs serve on the ports config.ts // keto), the DSN targets the keto database, read/write APIs serve on the ports config.ts
// points at, and the OPL declares the role/group/resource namespaces. Version pinning is // points at, and the OPL declares the user/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 user subjects", () => { test("the OPL declares permission, group and a resource namespace over user subjects", () => {
for (const ns of ["User", "Group", "Role", "Resource"]) for (const ns of ["User", "Group", "Permission", "Resource"])
assert.match(opl, new RegExp(`class ${ns} implements Namespace`), `defines ${ns}`); assert.match(opl, new RegExp(`class ${ns} implements Namespace`), `defines ${ns}`);
// role + group are subject sets read at login → JWT roles claim (README). // permission + group are subject sets read at login → JWT permissions claim (README).
assert.match(opl, /class Role implements Namespace\s*{\s*related:\s*{\s*members:/, assert.match(opl, /class Permission implements Namespace\s*{\s*related:\s*{\s*granted:/,
"Role has a members relation"); "Permission has a granted relation");
assert.match(opl, /class Group implements Namespace\s*{\s*related:\s*{\s*members:/, assert.match(opl, /class Group implements Namespace\s*{\s*related:\s*{\s*members:/,
"Group has a members relation"); "Group has a members relation");
}); });
+4 -4
View File
@@ -52,7 +52,7 @@ test("self-service flows return to our themed pages (on the localhost dev host)"
test("after a successful login Kratos returns to our /auth/complete route to mint the JWT", () => { test("after a successful login Kratos returns to our /auth/complete route to mint the JWT", () => {
assert.match(kratosYml, /default_browser_return_url:\s*http:\/\/localhost:3000\/auth\/complete/, assert.match(kratosYml, /default_browser_return_url:\s*http:\/\/localhost:3000\/auth\/complete/,
"login completion (read roles → project → tokenize → set cookie) runs at /auth/complete"); "login completion (read permissions → project → tokenize → set cookie) runs at /auth/complete");
}); });
test("recovery + verification run on email code, delivered by a courier", () => { test("recovery + verification run on email code, delivered by a courier", () => {
@@ -79,12 +79,12 @@ test("session tokenizer template 'plainpages' mints a short-lived signed JWT", (
"claims via the committed mapper"); "claims via the committed mapper");
}); });
test("the tokenizer claims mapper emits email + roles from the metadata_public projection", () => { test("the tokenizer claims mapper emits email + permissions from the metadata_public projection", () => {
// metadata_public, not _admin: the session Kratos hands the tokenizer carries only public // metadata_public, not _admin: the session Kratos hands the tokenizer carries only public
// metadata (admin metadata is stripped), so the roles projection must live in metadata_public. // metadata (admin metadata is stripped), so the permissions projection must live in metadata_public.
const mapper = read("ory/kratos/tokenizer/plainpages.jsonnet"); const mapper = read("ory/kratos/tokenizer/plainpages.jsonnet");
assert.match(mapper, /email:\s*session\.identity\.traits\.email/, "email ← identity trait"); assert.match(mapper, /email:\s*session\.identity\.traits\.email/, "email ← identity trait");
assert.match(mapper, /metadata_public/, "roles ← metadata_public (the per-login Keto projection)"); assert.match(mapper, /metadata_public/, "permissions ← metadata_public (the per-login Keto projection)");
}); });
test("social sign-in is off by default — a clean clone stays password-only", () => { test("social sign-in is off by default — a clean clone stays password-only", () => {
+3 -3
View File
@@ -85,9 +85,9 @@ 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 permission token only warns — both plugins still load", async (t) => { test("a shared permission name only warns — both plugins still load", async (t) => {
const perm = `export default { apiVersion: "1.0.0", permissions: [{ token: "shared:read" }] };`; const shared = `export default { apiVersion: "1.0.0", permissions: [{ name: "shared:read" }] };`;
const dir = scaffold(t, { "x/plugin.ts": perm, "y/plugin.ts": perm }); 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)) } });
+2 -2
View File
@@ -2,7 +2,7 @@
// validate it, assemble the loaded Plugin[]. The imperative shell over plugin.ts's pure rules // validate it, assemble the loaded Plugin[]. The imperative shell over plugin.ts's pure rules
// (isValidPluginId, checkApiVersion, findConflicts). Fails loud: every per-plugin problem and // (isValidPluginId, checkApiVersion, findConflicts). Fails loud: every per-plugin problem and
// error-level conflict is collected into one boot-stopping Error; warn-level diagnostics // error-level conflict is collected into one boot-stopping Error; warn-level diagnostics
// (older-minor apiVersion, shared permission token) 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";
@@ -94,7 +94,7 @@ function shapeError(manifest: PluginManifest): string | null {
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 `permission` 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.permission != null) return `route "${route.method} ${route.path}" sets both public and permission — 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`;
} }
+7 -7
View File
@@ -24,7 +24,7 @@ const scheduling: PluginManifest = definePlugin({
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "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",
}], }],
permissions: [{ description: "View shifts", token: "scheduling:read" }], permissions: [{ description: "View shifts", name: "scheduling:read" }],
routes: [ routes: [
{ handler: () => ({ data: { rows: [] }, view: "shifts" }), method: "GET", path: "/shifts", permission: "scheduling:read" }, { handler: () => ({ data: { rows: [] }, view: "shifts" }), method: "GET", path: "/shifts", permission: "scheduling:read" },
{ handler: () => ({ redirect: "/scheduling/shifts" }), method: "POST", path: "/shifts", permission: "scheduling:write" }, { handler: () => ({ redirect: "/scheduling/shifts" }), method: "POST", path: "/shifts", permission: "scheduling:write" },
@@ -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 permission token 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 permission across plugins is legitimate (shared role) → warn, not error. // Sharing a permission across plugins is legitimate → warn, not error.
const permDup = findConflicts([ const permissionDup = findConflicts([
p({ id: "a", permissions: [{ token: "shared:read" }] }), p({ id: "a", permissions: [{ name: "shared:read" }] }),
p({ id: "b", permissions: [{ token: "shared:read" }] }), p({ id: "b", permissions: [{ name: "shared:read" }] }),
]); ]);
assert.ok(permDup.some((c) => c.kind === "permission" && 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", () => {
+9 -9
View File
@@ -29,18 +29,18 @@ 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
permission?: string; // coarse gate (a role token); 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 `permission` // Mark the page reachable by anyone, signed in or not. The same as omitting `permission`
// — a no-permission 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 `permission` (discovery refuses both). // choice, not an accident. Mutually exclusive with `permission` (discovery refuses both).
public?: boolean; public?: boolean;
} }
// A permission token this plugin introduces — declared for docs/seeding. Tokens 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 in Keto); namespace as `<id>:<action>`. // global namespace (so an operator grants them once in Keto); namespace as `<id>:<action>`.
export interface PermissionDecl { export interface PermissionDecl {
description?: string; description?: string;
token: string; name: string;
} }
// Optional hooks on system actions. Crash-isolation is a non-goal — a throwing hook fails loud. // Optional hooks on system actions. Crash-isolation is a non-goal — a throwing hook fails loud.
@@ -156,7 +156,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 permission // is structural — it follows from the id check, so it needs no rule of its own. Shared permission
// tokens 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.permissions ?? []) push(decl.token); for (const decl of plugin.permissions ?? []) push(decl.name);
}).forEach((owners, token) => { }).forEach((owners, name) => {
if (owners.length > 1) out.push({ kind: "permission", level: "warn", message: `permission "${token}" 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;
+1 -1
View File
@@ -55,7 +55,7 @@ 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: "/", permission: "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
+3 -3
View File
@@ -75,8 +75,8 @@ export function allowedMethods(plugins: Plugin[], pathname: string): string[] {
} }
// Coarse permission gate: a route marked `public` (or one with no `permission`) 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 `permission` 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.permission == null || roles.includes(route.permission); return route.public === true || route.permission == null || permissions.includes(route.permission);
} }
+1 -1
View File
@@ -5,7 +5,7 @@
// plugin may deliberately shadow a core partial). The router calls this for a `view` RouteResult. // plugin may deliberately shadow a core partial). The router calls this for a `view` RouteResult.
import { isAbsolute, join, relative } from "node:path"; import { isAbsolute, join, relative } from "node:path";
import * as ejs from "ejs"; import ejs from "ejs";
const CONTROL_CHARS = /[\x00-\x1f]/; const CONTROL_CHARS = /[\x00-\x1f]/;
+1 -1
View File
@@ -31,7 +31,7 @@ const hydra = createHydraAdmin({ baseUrl: config.hydraAdminUrl, fetchImpl: oryFe
// or fetched http), then served from cache with TTL refresh + rotation-on-miss. // or fetched http), then served from cache with TTL refresh + rotation-on-miss.
const jwks = await createJwksProvider(config.jwksUrl, { fetchImpl: oryFetch }); // bound an http JWKS fetch too const jwks = await createJwksProvider(config.jwksUrl, { fetchImpl: oryFetch }); // bound an http JWKS fetch too
// Optional instant-revoke, off unless REVOCATION_DENYLIST=true: an in-memory denylist the // Optional instant-revoke, off unless REVOCATION_DENYLIST=true: an in-memory denylist the
// hot path consults and the admin screens populate on deactivate/delete/role-change. // hot path consults and the admin screens populate on deactivate/delete/permission-change.
const denylist = config.revocationDenylist ? createDenylist({ ttlSec: config.revocationTtlSec }) : undefined; const denylist = config.revocationDenylist ? createDenylist({ ttlSec: config.revocationTtlSec }) : undefined;
const plugins = await discoverPlugins(); // scans plugins/, validates — fails loud on a bad plugin const plugins = await discoverPlugins(); // scans plugins/, validates — fails loud on a bad plugin
+1 -1
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { test } from "node:test"; import { test } from "node:test";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import * as ejs from "ejs"; import ejs from "ejs";
const authCard = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "auth-card.ejs"); const authCard = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "auth-card.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(authCard, data); const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(authCard, data);
+2 -2
View File
@@ -48,7 +48,7 @@ test("anonymous shell Sign-in link carries the current page as return_to", () =>
test("a permission 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],
user: { email: "ada@x.io", id: "u1", roles: ["scheduling:read"] }, user: { 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 permission holder sees the Dashboard link + plugin nav; current path ope
}); });
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], user: { email: "a@b.c", id: "u1", roles: ["admin"] } }); const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, plugins: [adminLike], user: { email: "a@b.c", id: "u1", permissions: ["admin"] } });
const admin = chrome.nav.find((n) => n.label === "Admin")!; const admin = chrome.nav.find((n) => n.label === "Admin")!;
assert.ok(admin); // gated section visible to an admin assert.ok(admin); // gated section visible to an admin
assert.equal(admin.open, true); // ancestor of the current leaf opened assert.equal(admin.open, true); // ancestor of the current leaf opened
+3 -3
View File
@@ -18,7 +18,7 @@ const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashbo
export interface PageChrome { export interface PageChrome {
brand: { logo?: string; name: string; sub?: string }; brand: { logo?: string; name: string; sub?: string };
csrfToken: string; // double-submit token for the shell's Sign-out form + a plugin's own forms csrfToken: string; // double-submit token for the shell's Sign-out form + a plugin's own forms
nav: NavNode[]; // global menu, composed + role-filtered + current-marked, ready for nav-tree.ejs nav: NavNode[]; // global menu, composed + permission-filtered + current-marked, ready for nav-tree.ejs
signInHref: string; // where the shell's anonymous "Sign in" link points — carries this page as return_to signInHref: string; // where the shell's anonymous "Sign in" link points — carries this page as return_to
theme?: string; theme?: string;
user: ShellUser; user: ShellUser;
@@ -39,8 +39,8 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
const fragments: NavNode[][] = opts.user ? [[DASHBOARD_NAV]] : []; const fragments: NavNode[][] = opts.user ? [[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.user?.roles ?? []; const permissions = opts.user?.permissions ?? [];
const nav = composeNav(fragments, opts.menu.override, roles); const nav = composeNav(fragments, opts.menu.override, permissions);
if (opts.currentPath) { if (opts.currentPath) {
// Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like // Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like
// /admin/users/new marks the Users base leaf (/admin/users) and the dashboard marks Dashboard. // /admin/users/new marks the Users base leaf (/admin/users) and the dashboard marks Dashboard.
+1 -1
View File
@@ -8,7 +8,7 @@ import type { NavNode } from "./nav.ts";
const NAV: NavNode[] = [{ href: "/dashboard", label: "Dashboard" }, { children: [{ href: "/admin/users", label: "Users" }], label: "Admin" }]; const NAV: NavNode[] = [{ href: "/dashboard", label: "Dashboard" }, { children: [{ href: "/admin/users", label: "Users" }], label: "Admin" }];
test("dashboard model: titled shell, passes the unified nav + csrf + user through", () => { test("dashboard model: titled shell, passes the unified nav + csrf + user through", () => {
const m = buildDashboardModel({ csrfToken: "tok.sig", nav: NAV, user: { email: "ada@x.io", id: "u1", roles: ["admin"] } }); const m = buildDashboardModel({ csrfToken: "tok.sig", user: { email: "ada@x.io", id: "u1", permissions: ["admin"] }, nav: NAV });
assert.equal(m.shell.title, "Dashboard"); assert.equal(m.shell.title, "Dashboard");
assert.equal(m.shell.csrfToken, "tok.sig"); assert.equal(m.shell.csrfToken, "tok.sig");
assert.equal(m.shell.user.name, "ada"); // real signed-in identity, not a demo profile assert.equal(m.shell.user.name, "ada"); // real signed-in identity, not a demo profile
+1 -1
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { test } from "node:test"; import { test } from "node:test";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import * as ejs from "ejs"; import ejs from "ejs";
const dataTable = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "data-table.ejs"); const dataTable = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "data-table.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(dataTable, data); const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(dataTable, data);
+1 -1
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { test } from "node:test"; import { test } from "node:test";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import * as ejs from "ejs"; import ejs from "ejs";
const field = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "field.ejs"); const field = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "field.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(field, data); const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(field, data);
+1 -1
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { test } from "node:test"; import { test } from "node:test";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import * as ejs from "ejs"; import ejs from "ejs";
const filterBar = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "filter-bar.ejs"); const filterBar = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "filter-bar.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(filterBar, data); const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(filterBar, data);
+1 -1
View File
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { test } from "node:test"; import { test } from "node:test";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import * as ejs from "ejs"; import ejs from "ejs";
import { ICON_NAMES, buildIconSprite } from "./icons.ts"; import { ICON_NAMES, buildIconSprite } from "./icons.ts";
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
+4 -4
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { test } from "node:test"; import { test } from "node:test";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import * as ejs from "ejs"; import ejs from "ejs";
const menu = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "menu.ejs"); const menu = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "menu.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(menu, data); const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(menu, data);
@@ -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>/);
}); });
+4 -4
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { test } from "node:test"; import { test } from "node:test";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import * as ejs from "ejs"; import ejs from "ejs";
const navTree = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "nav-tree.ejs"); const navTree = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "nav-tree.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(navTree, data); const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(navTree, data);
@@ -25,7 +25,7 @@ const nodes = [
{ label: "Webhooks (soon)" }, // leaf · static { label: "Webhooks (soon)" }, // leaf · static
], ],
}, },
{ label: "Roles & Access", children: [{ label: "Roles", href: "/roles" }] }, // header · static · closed { label: "Permissions & Access", children: [{ label: "Permissions", href: "/permissions" }] }, // header · static · closed
], ],
}, },
]; ];
@@ -59,8 +59,8 @@ test("nav-tree renders the header/leaf × clickable/static matrix with counts, i
assert.match(html, /<span class="nav-self"><span class="nav-label">Webhooks \(soon\)<\/span><\/span>/); assert.match(html, /<span class="nav-self"><span class="nav-label">Webhooks \(soon\)<\/span><\/span>/);
// Header · static · closed (no [open]) + label escaping in both label and aria-label. // Header · static · closed (no [open]) + label escaping in both label and aria-label.
assert.match(html, /<details class="nav-disc"><summary class="nav-tog" aria-label="Toggle Roles &amp; Access">/); assert.match(html, /<details class="nav-disc"><summary class="nav-tog" aria-label="Toggle Permissions &amp; Access">/);
assert.match(html, /<span class="nav-label">Roles &amp; Access<\/span>/); assert.match(html, /<span class="nav-label">Permissions &amp; Access<\/span>/);
}); });
test("nav-tree renders an empty root list with no nodes and never throws", async () => { test("nav-tree renders an empty root list with no nodes and never throws", async () => {
+6 -6
View File
@@ -2,7 +2,7 @@ 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, `permission` 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",
@@ -14,7 +14,7 @@ const fragments: NavNode[][] = [
[{ href: "/reports", id: "reports", label: "Reports", permission: "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.
@@ -24,7 +24,7 @@ test("composeNav merges fragments, filters by role, and emits clean render nodes
]); ]);
}); });
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", permission: "admin", children: [{ href: "/u", id: "u", label: "Users" }] }, { id: "admin", label: "Admin", permission: "admin", children: [{ href: "/u", id: "u", label: "Users" }] },
@@ -41,12 +41,12 @@ test("composeNav drops gated subtrees, empty headers, and (with no roles) all ga
]]; ]];
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",
@@ -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" },
+9 -9
View File
@@ -1,10 +1,10 @@
// composeNav: merge each plugin's nav fragment into one tree, apply the central // composeNav: merge each plugin's nav fragment into one tree, apply the central
// override, then permission-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 `permission`, or `roles` includes that permission token; 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/permission). // 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,7 +15,7 @@ export interface NavNode {
icon?: string; icon?: string;
label: string; label: string;
open?: boolean; open?: boolean;
permission?: 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 permission", stated outright; consumed by the filter, never rendered. Mutually exclusive with permission (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).
} }
@@ -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,12 +103,12 @@ 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.permission != null && !roles.has(n.permission)) 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 });
} }
+1 -1
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { test } from "node:test"; import { test } from "node:test";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import * as ejs from "ejs"; import ejs from "ejs";
const pagination = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "pagination.ejs"); const pagination = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "pagination.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(pagination, data); const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(pagination, data);
+2 -2
View File
@@ -5,7 +5,7 @@ import { buildShellContext, shellUser } from "./shell-context.ts";
test("shellUser derives the profile from the real user; anonymous → Guest", () => { test("shellUser derives the profile from the real user; anonymous → Guest", () => {
assert.deepEqual(shellUser(null), { email: "", initials: "G", name: "Guest" }); assert.deepEqual(shellUser(null), { email: "", initials: "G", name: "Guest" });
// Real user: name = email local part, email kept, initials = first two letters of the local part. // Real user: name = email local part, email kept, initials = first two letters of the local part.
assert.deepEqual(shellUser({ email: "ada@example.com", id: "u1", roles: [] }), { email: "ada@example.com", initials: "AD", name: "ada" }); assert.deepEqual(shellUser({ email: "ada@example.com", id: "u1", permissions: [] }), { email: "ada@example.com", initials: "AD", name: "ada" });
}); });
test("buildShellContext maps branding + breadcrumbs, omitting unset optional fields", () => { test("buildShellContext maps branding + breadcrumbs, omitting unset optional fields", () => {
@@ -22,7 +22,7 @@ test("buildShellContext maps branding + breadcrumbs, omitting unset optional fie
menu: { branding: { logo: "/l.svg", name: "Acme", sub: "Ops", theme: "dark" }, override: {} }, menu: { branding: { logo: "/l.svg", name: "Acme", sub: "Ops", theme: "dark" }, override: {} },
signInHref: "/login?return_to=%2Fx", signInHref: "/login?return_to=%2Fx",
title: "Users", title: "Users",
user: { email: "a@b.c", id: "u1", roles: ["admin"] }, user: { email: "a@b.c", id: "u1", permissions: ["admin"] },
}); });
assert.deepEqual(full.brand, { logo: "/l.svg", name: "Acme", sub: "Ops" }); assert.deepEqual(full.brand, { logo: "/l.svg", name: "Acme", sub: "Ops" });
assert.equal(full.theme, "dark"); assert.equal(full.theme, "dark");
+1 -1
View File
@@ -1,7 +1,7 @@
// Shell view-model builder: the brand/theme/user/title block every app-shell page // Shell view-model builder: the brand/theme/user/title block every app-shell page
// (the home dashboard, the built-in admin screens) hands to shell.ejs. Pure. Extracted so the // (the home dashboard, the built-in admin screens) hands to shell.ejs. Pure. Extracted so the
// shell user is the *real* signed-in identity — no hardcoded demo profile — and branding is // shell user is the *real* signed-in identity — no hardcoded demo profile — and branding is
// read from one place. The User carries no display name (the JWT holds only id/email/roles), so // read from one place. The User carries no display name (the JWT holds only id/email/permissions), so
// the profile shows the email's local part as the name with the full email beneath, initials from // the profile shows the email's local part as the name with the full email beneath, initials from
// the local part; anonymous ⇒ "Guest". // the local part; anonymous ⇒ "Guest".
+1 -1
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { test } from "node:test"; import { test } from "node:test";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import * as ejs from "ejs"; import ejs from "ejs";
const shell = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "shell.ejs"); const shell = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "shell.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(shell, data); const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(shell, data);
+1 -1
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { test } from "node:test"; import { test } from "node:test";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import * as ejs from "ejs"; import ejs from "ejs";
const themeSwitch = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "theme-switch.ejs"); const themeSwitch = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "theme-switch.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(themeSwitch, data); const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(themeSwitch, data);
+9 -6
View File
@@ -11,12 +11,15 @@
- [x] CI/CD - Sync docker images to docker hub after each re-tag to git tags. (`release.yml` pushes the same `X.Y.Z`/`X.Y`/`X`/`latest` tags to `docker.io/larvit/plainpages` after the Gitea re-tag — releases only, no hash tags; auth via the `DOCKERHUB_USER` variable + `DOCKERHUB_TOKEN` secret; documented in README → CI/CD.) - [x] CI/CD - Sync docker images to docker hub after each re-tag to git tags. (`release.yml` pushes the same `X.Y.Z`/`X.Y`/`X`/`latest` tags to `docker.io/larvit/plainpages` after the Gitea re-tag — releases only, no hash tags; auth via the `DOCKERHUB_USER` variable + `DOCKERHUB_TOKEN` secret; documented in README → CI/CD.)
- [x] Write a short text on how to use this docker image to publish on docker hub and save it to README-dockerhub.md (tagline, tags, clone-free quick start — the image ships the Ory config, extracted via `docker run … tar` + a self-contained compose.yml — env table, first plugin; pasted into the Docker Hub overview by hand — noted in README → CI/CD.) - [x] Write a short text on how to use this docker image to publish on docker hub and save it to README-dockerhub.md (tagline, tags, clone-free quick start — the image ships the Ory config, extracted via `docker run … tar` + a self-contained compose.yml — env table, first plugin; pasted into the Docker Hub overview by hand — noted in README → CI/CD.)
- [x] CI/CD - Setup renovate bot. Check how other repos on this Gitea is setup you can get access to, there should be a number of renovate bot activated ones. (`renovate.yml` runs the self-hosted `renovate/renovate` image nightly against `renovate.json` — this repo only, via the shared `renovate@larvit.se` bot + `RENOVATE_TOKEN` secret, mirroring the `pwrpln/core` pattern; standard managers cover npm/Dockerfiles/compose/gitea-action pins, two custom regex managers cover the image tags embedded in workflow `run:` steps, the Ory + Playwright lockstep sets are grouped, every bump stays an exact pin, and each PR automerges once the gate is green; documented in README → CI/CD.) - [x] CI/CD - Setup renovate bot. Check how other repos on this Gitea is setup you can get access to, there should be a number of renovate bot activated ones. (`renovate.yml` runs the self-hosted `renovate/renovate` image nightly against `renovate.json` — this repo only, via the shared `renovate@larvit.se` bot + `RENOVATE_TOKEN` secret, mirroring the `pwrpln/core` pattern; standard managers cover npm/Dockerfiles/compose/gitea-action pins, two custom regex managers cover the image tags embedded in workflow `run:` steps, the Ory + Playwright lockstep sets are grouped, every bump stays an exact pin, and each PR automerges once the gate is green; documented in README → CI/CD.)
- [ ] CI/CD - Renovate: set a read-only `GITHUB_COM_TOKEN` env in `renovate.yml` so Renovate stops hitting github.com rate limits when resolving github-hosted deps (Playwright, lucide, `actions/checkout`) and can fetch changelogs. Non-blocking refinement; needs a read-only GitHub PAT stored as an Actions secret. - [x] CI/CD - Renovate: set a read-only `GITHUB_COM_TOKEN` env in `renovate.yml` so Renovate stops hitting github.com rate limits when resolving github-hosted deps (Playwright, lucide, `actions/checkout`) and can fetch changelogs. Non-blocking refinement; needs a read-only GitHub PAT stored as an Actions secret. (The renovate job forwards the `RENOVATE_GITHUB_TOKEN` secret — a scopeless read-only github.com PAT; Gitea rejects `GITHUB_`-prefixed secret names — into the container as `GITHUB_COM_TOKEN`; documented in README → CI/CD.)
- [x] CI/CD - When renovate updates a dependency - also release a new version of plainpages based on what got updated with Renovate. Major typescript? New apiVersion + new major. A tiny patch to ejs? Only patch release etc. Before implementing, explain in detail how you will solve this. (`renovate.yml` gains an `auto-release` job (`needs: renovate`) that cuts one `vX.Y.Z` tag per run for what Renovate merged; level = highest `Release-Bump:` trailer Renovate stamps via `commitBody`, any dep's major/minor/patch mapped straight through (default patch). Decoupled from `apiVersion` (tag-only, `HOST_API_VERSION` untouched — a "major" is just a bigger image tag, never a plugin break); pre-1.0 shifts down so nothing auto-crosses into 1.0.0. Pure `auto-release/next-version.ts` + unit tests; tag pushed with renovate-bot's PAT so `release.yml` fires; documented in README → CI/CD.) - [x] CI/CD - When renovate updates a dependency - also release a new version of plainpages based on what got updated with Renovate. Major typescript? New apiVersion + new major. A tiny patch to ejs? Only patch release etc. Before implementing, explain in detail how you will solve this. (`renovate.yml` gains an `auto-release` job (`needs: renovate`) that cuts one `vX.Y.Z` tag per run for what Renovate merged; level = highest `Release-Bump:` trailer Renovate stamps via `commitBody`, any dep's major/minor/patch mapped straight through (default patch). Decoupled from `apiVersion` (tag-only, `HOST_API_VERSION` untouched — a "major" is just a bigger image tag, never a plugin break); pre-1.0 shifts down so nothing auto-crosses into 1.0.0. Pure `auto-release/next-version.ts` + unit tests; tag pushed with renovate-bot's PAT so `release.yml` fires; documented in README → CI/CD.)
- [ ] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen. The full-flow e2e suite runs without Hydra (compose.full.yml), so /admin/clients register/detail/delete is only unit-covered (src/http/app.test.ts); wire Hydra into an e2e stack and drive the screen in the browser. - [x] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen. The full-flow e2e suite runs without Hydra (compose.full.yml), so /admin/clients register/detail/delete is only unit-covered (src/http/app.test.ts); wire Hydra into an e2e stack and drive the screen in the browser. (compose.full.yml now includes Hydra (`serve all --dev`) and full-flow.spec.ts drives /admin/clients register → one-time secret → list → detail → delete in the browser; documented in README → Testing.)
- [ ] Build and publish docker image as CI/CD. - [x] Build and publish docker image as CI/CD. (Duplicate of the CI/CD items above: `ci.yml` builds and pushes `gitea.larvit.se/larvit/plainpages:<commit hash>` behind the green gate, `release.yml` re-tags it to semver and syncs those tags to Docker Hub.)
- [ ] The human developer understands the security model in the auth in this project. - [x] The human developer understands the security model in the auth in this project. (Two README sections. [Users, groups & permissions](README.md#users-groups--permissions) carries the weight: the entity model, a worked graph, a per-route can/cannot walkthrough, and the trap that a per-row grant never widens a coarse gate — placed before Building plugins because a manifest's `permission:` gate is unreadable without it. [Security model](README.md#security-model) is deliberately short, only the facts a deployment gets wrong without them: the private network as the *only* guard on the Ory APIs, signed-not-encrypted claims, the 30-day Kratos session behind the ~10m JWT, and non-instant offboarding. The first attempt answered the *threat* model instead — a 12-row attack/defense table — which was the wrong question and mostly restated code readable at its source; cut. Also corrected the hardening checklist: `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`, so the committed Kratos/Hydra/Postgres/demo-admin secrets are now listed in "What you must supply". The mandatory-`exp` guard gained a test in `src/auth/jwt-middleware.test.ts`.)
- [ ] Add i18n support. - [ ] Add i18n support.
- [x] Settle the identity-vs-user vocabulary. (Plainpages says **user** everywhere — Keto namespace `User`, subjects `user:<kratos-id>`, `ctx.user`. Ory calls the record an "identity", but its own docs say it uses that term interchangeably with "users"/"accounts", so this is house style rather than a renamed concept, and "user" is the word readers know (Nielsen heuristic #2). README → Auth carries one note recording the mapping; the only place Ory's spelling survives is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors the Kratos wire shape. Recorded in AGENTS.md.)
- [ ] Decide whether the single generic Keto `Resource` namespace should become per-domain namespaces (`Shift`, `Document`, …), as Ory's own examples model it. One global `Resource` bucket is the project's own "no catch-all names" rule (`utils`, `helpers`, `misc`) applied to namespaces. Raised 2026-08-03; a design question, not a naming one.
- [ ] Decide (once) whether the CSRF token staying unbound to `sub`/session is accepted. `src/auth/csrf.ts` signs `<nonce>.<HMAC(secret, nonce)>` with no session binding, so any validly-signed token passes for any user — an attacker who can write cookies on the origin (a sibling subdomain, or a plaintext hop with `SECURE_COOKIES=false`) can fix a token they know. Standard for unbound signed double-submit and plausibly fine behind `SameSite=Lax` + HSTS. Accepted ⇒ record it in AGENTS.md → "Deliberate architectural deviations" and in README → Security model under "Not guaranteed"; not accepted ⇒ bind the nonce to `sub` (small change). Raised by review 2026-08-02; left undecided because it is a maintainer call, and an undocumented exception reads as a bug to the next reviewer.
## Architectural review findings (2026-07-02) ## Architectural review findings (2026-07-02)
@@ -24,8 +27,8 @@ Prioritized. Overall verdict: architecture is sound (contract-first plugin API,
- [x] **HIGH — Split `handleRequest` in `src/http/app.ts` (~380 lines).** It mixes the request pipeline with inline implementations of ~10 built-in endpoints (Kratos flows, /oauth2/*, /auth/complete, /logout, /, /dashboard, 404/405). Extract each endpoint into a named handler (auth/OAuth2 group → `src/auth/` route module) with the same `(req, res, ctx)` shape plugin routes use; reduce `handleRequest` to pipeline → internal route table → `sendResult`. - [x] **HIGH — Split `handleRequest` in `src/http/app.ts` (~380 lines).** It mixes the request pipeline with inline implementations of ~10 built-in endpoints (Kratos flows, /oauth2/*, /auth/complete, /logout, /, /dashboard, 404/405). Extract each endpoint into a named handler (auth/OAuth2 group → `src/auth/` route module) with the same `(req, res, ctx)` shape plugin routes use; reduce `handleRequest` to pipeline → internal route table → `sendResult`.
- [ ] **MEDIUM — Add complexity/method-size static analysis to the CI gate.** Only `tsc --strict` today; a size/complexity rule would have caught the `app.ts` growth. Also when wiring CI/CD: keep the merge gate fast (typecheck + units + Ory-free `visual` suite; heavy e2e suites required-but-separate) and make the pipeline the only path to a published image (build once at tag, promote). - [ ] **MEDIUM — Add complexity/method-size static analysis to the CI gate.** Only `tsc --strict` today; a size/complexity rule would have caught the `app.ts` growth. Also when wiring CI/CD: keep the merge gate fast (typecheck + units + Ory-free `visual` suite; heavy e2e suites required-but-separate) and make the pipeline the only path to a published image (build once at tag, promote).
- [ ] **MEDIUM — De-duplicate `examples/plugins/admin/admin-groups.ts` and `admin-roles.ts` (~80% identical).** Same "Keto membership object admin" concept twice; extract a parameterized helper keyed on `{ namespace, base, labels, columns }`, leave roles' effective-access view as the only delta. Matters extra because this is the reference plugin people copy. - [ ] **MEDIUM — De-duplicate `examples/plugins/admin/admin-groups.ts` and `admin-permissions.ts` (~80% identical).** Same "Keto membership object admin" concept twice; extract a parameterized helper keyed on `{ namespace, base, labels, columns }`, leave permissions' effective-access view as the only delta. Matters extra because this is the reference plugin people copy.
- [ ] **MEDIUM→LOW — Add a list-page view-model helper in `src/ui/`.** Every list screen (users, groups, roles, shifts) hand-rewrites the same ~40 lines bridging `parseListQuery`/`paginate` to the EJS partials; at minimum a `buildPaginationModel(page, hrefFor)` block. - [ ] **MEDIUM→LOW — Add a list-page view-model helper in `src/ui/`.** Every list screen (users, groups, permissions, shifts) hand-rewrites the same ~40 lines bridging `parseListQuery`/`paginate` to the EJS partials; at minimum a `buildPaginationModel(page, hrefFor)` block.
- [ ] **LOW→MEDIUM — Retire `src/ui/shell-context.ts`.** `ShellModel`/`buildShellContext` has one consumer left (dashboard) and duplicates `PageChrome` on almost every field, incl. identical brand-assembly in `chrome.ts` and `shell-context.ts`. Fold the dashboard onto `ctx.chrome` + title/breadcrumbs; keep `shellUser` as the shared primitive. - [ ] **LOW→MEDIUM — Retire `src/ui/shell-context.ts`.** `ShellModel`/`buildShellContext` has one consumer left (dashboard) and duplicates `PageChrome` on almost every field, incl. identical brand-assembly in `chrome.ts` and `shell-context.ts`. Fold the dashboard onto `ctx.chrome` + title/breadcrumbs; keep `shellUser` as the shared primitive.
- [ ] **LOW — Fix stale doc references to removed `docs/plugin-contract.md`** in `views/index.ejs` (user-visible dashboard text; also links /scheduling as if pre-installed) and `examples/plugins/scheduling/views/shifts.ejs`. - [ ] **LOW — Fix stale doc references to removed `docs/plugin-contract.md`** in `views/index.ejs` (user-visible dashboard text; also links /scheduling as if pre-installed) and `examples/plugins/scheduling/views/shifts.ejs`.
- [ ] **LOW — Decide (once) on a `ctx.system` facade.** `#plugin-api` exposes raw Ory client shapes, so an Ory client refactor is a major `apiVersion` bump. AGENTS.md accepts this; revisit only if external plugin authors appear. Record the decision. - [ ] **LOW — Decide (once) on a `ctx.system` facade.** `#plugin-api` exposes raw Ory client shapes, so an Ory client refactor is a major `apiVersion` bump. AGENTS.md accepts this; revisit only if external plugin authors appear. Record the decision.
+1 -1
View File
@@ -2,7 +2,7 @@
Themed Kratos self-service page inside the unified app shell: sign-in / register / Themed Kratos self-service page inside the unified app shell: sign-in / register /
reset / verify / settings. Renders a FlowView (src/auth/flow-view.ts) into the shell content, reusing the reset / verify / settings. Renders a FlowView (src/auth/flow-view.ts) into the shell content, reusing the
auth-card + field partials. The form posts straight to flow.ui.action — Kratos owns its CSRF. The auth-card + field partials. The form posts straight to flow.ui.action — Kratos owns its CSRF. The
shell's menu is role-filtered (anonymous ⇒ public items + Sign in); the topbar carries no heading, shell's menu is permission-filtered (anonymous ⇒ public items + Sign in); the topbar carries no heading,
so the card's own <h1> is the page's single heading. Data: chrome (PageChrome), flow (FlowView). so the card's own <h1> is the page's single heading. Data: chrome (PageChrome), flow (FlowView).
%><% %><%
const nav = include("partials/nav-tree", { nodes: chrome.nav }); const nav = include("partials/nav-tree", { nodes: chrome.nav });
+1 -1
View File
@@ -1,6 +1,6 @@
<%# <%#
Public landing page: the ungated "/", rendered inside the unified app shell so the menu Public landing page: the ungated "/", rendered inside the unified app shell so the menu
shows (role-filtered — anonymous ⇒ public items + Sign in). A brief intro + a prominent way in, or a shows (permission-filtered — anonymous ⇒ public items + Sign in). A brief intro + a prominent way in, or a
dashboard link when already signed in. A plugin may replace this via its `home` handler. dashboard link when already signed in. A plugin may replace this via its `home` handler.
Data: chrome (PageChrome), user (or null). Data: chrome (PageChrome), user (or null).
%><% %><%
+1 -1
View File
@@ -4,7 +4,7 @@
<symbol id="i-arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7" /><path d="M19 12H5" /></symbol> <symbol id="i-arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7" /><path d="M19 12H5" /></symbol>
<symbol id="i-bell" viewBox="0 0 24 24"><path d="M10.268 21a2 2 0 0 0 3.464 0" /><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol> <symbol id="i-bell" viewBox="0 0 24 24"><path d="M10.268 21a2 2 0 0 0 3.464 0" /><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol>
<symbol id="i-box" viewBox="0 0 24 24"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" /><path d="m3.3 7 8.7 5 8.7-5" /><path d="M12 22V12" /></symbol> <symbol id="i-box" viewBox="0 0 24 24"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" /><path d="m3.3 7 8.7 5 8.7-5" /><path d="M12 22V12" /></symbol>
<symbol id="i-cal" viewBox="0 0 24 24"><path d="M8 2v4" /><path d="M16 2v4" /><rect width="18" height="18" x="3" y="4" rx="2" /><path d="M3 10h18" /></symbol> <symbol id="i-cal" viewBox="0 0 24 24"><path d="M8 2v3" /><path d="M16 2v3" /><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18" /></symbol>
<symbol id="i-chart" viewBox="0 0 24 24"><path d="M5 21v-6" /><path d="M12 21V3" /><path d="M19 21V9" /></symbol> <symbol id="i-chart" viewBox="0 0 24 24"><path d="M5 21v-6" /><path d="M12 21V3" /><path d="M19 21V9" /></symbol>
<symbol id="i-check-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="m9 12 2 2 4-4" /></symbol> <symbol id="i-check-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="m9 12 2 2 4-4" /></symbol>
<symbol id="i-chev" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6" /></symbol> <symbol id="i-chev" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6" /></symbol>
+1 -1
View File
@@ -1,7 +1,7 @@
<%# <%#
App shell: sidebar (brand + nav slot + footer) · topbar · content slot. The one chrome every page App shell: sidebar (brand + nav slot + footer) · topbar · content slot. The one chrome every page
renders — dashboard, admin, plugin, login/registration/front — so the menu is identical renders — dashboard, admin, plugin, login/registration/front — so the menu is identical
everywhere, role-filtered to the visitor (anonymous ⇒ public items + Sign in). everywhere, permission-filtered to the visitor (anonymous ⇒ public items + Sign in).
Slots are pre-rendered HTML locals — `nav` (sidebar tree, see nav-tree partial), Slots are pre-rendered HTML locals — `nav` (sidebar tree, see nav-tree partial),
`actions` (topbar buttons), `body` (page content); `styles` is an optional array of `actions` (topbar buttons), `body` (page content); `styles` is an optional array of
extra stylesheet hrefs (e.g. a plugin's own /public/<id>/x.css). Text locals: `title` extra stylesheet hrefs (e.g. a plugin's own /public/<id>/x.css). Text locals: `title`