From eb5aafdfaa49ec15e09f1eefb998758f3f697721 Mon Sep 17 00:00:00 2001 From: lilleman Date: Sun, 2 Aug 2026 17:03:09 +0200 Subject: [PATCH 01/14] Document the auth security model in README --- README.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ todo.md | 4 ++-- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2827b8e..3e8c895 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ From here, render real pages against the app shell and fetch upstream data — s - [instant revoke](#instant-revoke-the-optional-denylist) - [three tiers](#three-tiers-of-may-i) - [OAuth2 (Hydra)](#oauth2-provider-hydra) + - [security model](#security-model) - [Email](#email) - [Architecture](#architecture) - [Stateless](#stateless) @@ -997,6 +998,60 @@ 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; writes go only to Hydra. +### Security model + +Everything above is *how* auth works. This is what to check a change against: who is trusted, +what defends what, and which guarantees are deliberately not offered. + +**Trust boundaries.** + +- **The browser is not trusted.** Cookies, form fields, URLs and headers are attacker-controlled + until verified or escaped. Nothing is believed because of where it arrived from. +- **The session JWT is trusted only after verification** — signature against the JWKS key its + `kid` names, then `exp`/`nbf` and the optional `iss`/`aud`. Before that it is bytes. +- **The private container network is the *only* thing guarding the Ory admin APIs.** Kratos + admin (`4434`), Hydra admin (`4445`) and Keto write (`4467`) authenticate no one — reaching + them *is* full identity and permission control. `compose.yml` publishes none of them (guarded + by `src/compose.test.ts`); dev publishes only the two ports a browser must reach. Never + expose an admin port, and never front one with a proxy that lacks its own auth. +- **Plugins are trusted code**, in-process and unsandboxed — a plugin can do anything the host + can. Vetting happens when you mount one, not at runtime (AGENTS.md: crash isolation is a + deliberate non-goal). +- **Row-level rules belong upstream**, in the service that owns the data — see + [three tiers](#three-tiers-of-may-i). + +**The JWT is signed, not encrypted.** Claims are base64: a signed-in user can read their own +`sub`, `email` and `roles`. `HttpOnly` keeps page JavaScript out of the cookie, not the user. +Never put anything in a claim you wouldn't show them. + +**What defends what.** + +| Threat | Defense | +| --- | --- | +| Forged or tampered token | signature verified against the `kid`'s JWKS key; the `alg` allowlist is `RS256`/`ES256` only — **never `HS*` or `none`**, either of which lets an attacker-supplied key verify (`src/auth/jwt.ts`) | +| `alg` confusion | a key's own `alg` must match the header's, and its type must match the family | +| Replayed expired token | `exp`/`nbf`, `JWT_CLOCK_SKEW_SEC` leeway | +| Token minted for another deployment | optional `JWT_ISSUER` / `JWT_AUDIENCE` pinning | +| Stolen session cookie | `HttpOnly`, `SameSite=Lax`, `Secure` (`SECURE_COOKIES`), ~10m TTL, plus the [denylist](#instant-revoke-the-optional-denylist) | +| CSRF on our own forms | signed double-submit token (`src/auth/csrf.ts`) + `SameSite=Lax`; Kratos' flows carry Kratos' own token | +| XSS | EJS `<%= %>` escapes; CSP `script-src 'self'` with no `'unsafe-inline'` (`src/http/security-headers.ts`) | +| Clickjacking | `frame-ancestors 'none'` + `X-Frame-Options: DENY` | +| Open redirect via `return_to` | validated host-relative (`localPath`, `src/http/safe-url.ts`) | +| Privilege escalation | roles are authored **only** in Keto; the identity projection is a derived read-only cache, re-read from Keto at every mint | +| Downgrade / MIME sniffing | HSTS when `SECURE_COOKIES=true`, `X-Content-Type-Options: nosniff` | +| A hung Ory parking requests | `ORY_TIMEOUT_SEC` per outbound call | + +**Fail closed.** Every rejection converges on two outcomes: a missing, malformed, expired or +revoked token yields *anonymous* — never a partly-trusted user — and an anonymous or +under-privileged request is denied (`requireSession` bounces to `/login`; `can`/`check` return +`false`). No verification failure degrades into access. + +**Not guaranteed** — accepted, and stated where each mechanism is: role 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` and `SECURE_COOKIES=true` over the +production secrets — see [what you must supply](#what-you-must-supply-the-only-manual-prep). + ## Email The only emails are the **recovery** and **verification** codes from Kratos' self-service diff --git a/todo.md b/todo.md index 1627ef4..970ba94 100644 --- a/todo.md +++ b/todo.md @@ -14,8 +14,8 @@ - [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] 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. -- [ ] The human developer understands the security model in the auth in this project. +- [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:` behind the green gate, `release.yml` re-tags it to semver and syncs those tags to Docker Hub.) +- [x] The human developer understands the security model in the auth in this project. (README → Auth → [Security model](README.md#security-model): trust boundaries — browser untrusted, JWT untrusted until verified, the private network as the *only* guard on the unauthenticated Ory admin APIs, plugins trusted and unsandboxed, row rules upstream — plus a threat→defense table, the fail-closed rule, and pointers to the limits that are deliberately not guaranteed. Signed-not-encrypted is called out so nothing secret lands in a claim. Every claim it makes is already enforced by an existing test.) - [ ] Add i18n support. ## Architectural review findings (2026-07-02) -- 2.52.0 From a2204782fa4fff470dc4f499e3da05dae9f15bf1 Mon Sep 17 00:00:00 2001 From: lilleman Date: Sun, 2 Aug 2026 17:14:20 +0200 Subject: [PATCH 02/14] Review fixes: re-mint path, real session lifetime, full secret checklist --- README.md | 80 +++++++++++++++++++++++++++++++++++-------------------- todo.md | 2 +- 2 files changed, 52 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 3e8c895..f86f241 100644 --- a/README.md +++ b/README.md @@ -837,14 +837,23 @@ 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 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 -are production-only** — neither blocks a clean clone: +[Quick start](#quick-start)). What can't be auto-generated is **production-only** — none of it +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`) | -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. Supplying a provider's creds via env activates it; no creds ⇒ no SSO button (see [Social sign-in (SSO)](#social-sign-in-sso)). @@ -1008,17 +1017,19 @@ what defends what, and which guarantees are deliberately not offered. - **The browser is not trusted.** Cookies, form fields, URLs and headers are attacker-controlled until verified or escaped. Nothing is believed because of where it arrived from. - **The session JWT is trusted only after verification** — signature against the JWKS key its - `kid` names, then `exp`/`nbf` and the optional `iss`/`aud`. Before that it is bytes. -- **The private container network is the *only* thing guarding the Ory admin APIs.** Kratos - admin (`4434`), Hydra admin (`4445`) and Keto write (`4467`) authenticate no one — reaching - them *is* full identity and permission control. `compose.yml` publishes none of them (guarded - by `src/compose.test.ts`); dev publishes only the two ports a browser must reach. Never - expose an admin port, and never front one with a proxy that lacks its own auth. + `kid` names (or the sole key, when the set has one), then a **mandatory** `exp`, plus `nbf` + and the optional `iss`/`aud`. Before that it is bytes. +- **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 permission control. Keto **read** (`4466`) cannot write, but discloses + the entire authorization graph, so treat it the same. `compose.yml` publishes none of the six + (guarded by `src/compose.test.ts`); dev publishes only the two Ory ports a browser must reach. + Never expose one, and never front one with a proxy that lacks its own auth. - **Plugins are trusted code**, in-process and unsandboxed — a plugin can do anything the host can. Vetting happens when you mount one, not at runtime (AGENTS.md: crash isolation is a deliberate non-goal). -- **Row-level rules belong upstream**, in the service that owns the data — see - [three tiers](#three-tiers-of-may-i). +- **Attribute-based row rules belong upstream**, in the service that owns the data; + relationship-based ones go to Keto — see [three tiers](#three-tiers-of-may-i). **The JWT is signed, not encrypted.** Claims are base64: a signed-in user can read their own `sub`, `email` and `roles`. `HttpOnly` keeps page JavaScript out of the cookie, not the user. @@ -1028,29 +1039,40 @@ Never put anything in a claim you wouldn't show them. | Threat | Defense | | --- | --- | -| Forged or tampered token | signature verified against the `kid`'s JWKS key; the `alg` allowlist is `RS256`/`ES256` only — **never `HS*` or `none`**, either of which lets an attacker-supplied key verify (`src/auth/jwt.ts`) | -| `alg` confusion | a key's own `alg` must match the header's, and its type must match the family | -| Replayed expired token | `exp`/`nbf`, `JWT_CLOCK_SKEW_SEC` leeway | +| Forged or tampered token | signature verified by `kid`; `alg` allowlist is `RS256`/`ES256` only — **never `HS*` or `none`**, either of which lets a forged token verify (`src/auth/jwt.ts`) | +| `alg` confusion | a key that pins an `alg` must match the header's; the key type must always match the family | +| Replayed expired token | `exp` is mandatory — a token without one is rejected, never treated as eternal; `JWT_CLOCK_SKEW_SEC` leeway | | Token minted for another deployment | optional `JWT_ISSUER` / `JWT_AUDIENCE` pinning | -| Stolen session cookie | `HttpOnly`, `SameSite=Lax`, `Secure` (`SECURE_COOKIES`), ~10m TTL, plus the [denylist](#instant-revoke-the-optional-denylist) | -| CSRF on our own forms | signed double-submit token (`src/auth/csrf.ts`) + `SameSite=Lax`; Kratos' flows carry Kratos' own token | -| XSS | EJS `<%= %>` escapes; CSP `script-src 'self'` with no `'unsafe-inline'` (`src/http/security-headers.ts`) | +| Stolen session cookie | `HttpOnly`, `SameSite=Lax`, `Secure` (`SECURE_COOKIES`) — but see the real session lifetime below | +| CSRF on our own forms | signed double-submit token, **opt-in per handler** via `ctx.verifyCsrf` (`src/auth/csrf.ts`) + `SameSite=Lax`; Kratos' flows carry Kratos' own token | +| XSS | EJS `<%= %>` escapes; CSP `script-src 'self'` with no `'unsafe-inline'` (`src/http/security-headers.ts`) — the `*.html` slots stay [raw by contract](#routes--handlers) | | Clickjacking | `frame-ancestors 'none'` + `X-Frame-Options: DENY` | | Open redirect via `return_to` | validated host-relative (`localPath`, `src/http/safe-url.ts`) | -| Privilege escalation | roles are authored **only** in Keto; the identity projection is a derived read-only cache, re-read from Keto at every mint | +| Privilege escalation | roles authored only in Keto, re-read at every mint — see [login & the session JWT](#login-and-the-session-jwt) | | Downgrade / MIME sniffing | HSTS when `SECURE_COOKIES=true`, `X-Content-Type-Options: nosniff` | | A hung Ory parking requests | `ORY_TIMEOUT_SEC` per outbound call | -**Fail closed.** Every rejection converges on two outcomes: a missing, malformed, expired or -revoked token yields *anonymous* — never a partly-trusted user — and an anonymous or -under-privileged request is denied (`requireSession` bounces to `/login`; `can`/`check` return -`false`). No verification failure degrades into access. +**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. + +**Fail closed — with one deliberate exception.** A token that cannot be verified (missing, +malformed, bad signature, wrong `iss`/`aud`) yields *anonymous*, never a partly-trusted user, +and anonymous or under-privileged is denied (`requireSession` bounces to `/login`; `can`/`check` +return `false`). An **expired or revoked** token instead triggers a re-mint against the live +Kratos session — full re-authentication with roles re-read from Keto, or a cleared cookie if +that session is dead; Ory unreachable ⇒ anonymous. Revoking a role therefore *downgrades* a +user promptly without signing them out; **ending a session outright means deactivating or +deleting the identity.** **Not guaranteed** — accepted, and stated where each mechanism is: role 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` and `SECURE_COOKIES=true` over the -production secrets — see [what you must supply](#what-you-must-supply-the-only-manual-prep). +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 or Postgres' throwaways. ## Email @@ -1452,7 +1474,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`. (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.) -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 header, or watch web's logs for a `jwks reload on kid miss` debug line as old clients present the new key. 4. **Wait ~12 min**, then **prune** the superseded key: diff --git a/todo.md b/todo.md index 970ba94..6a5640a 100644 --- a/todo.md +++ b/todo.md @@ -15,7 +15,7 @@ - [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] 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.) - [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:` behind the green gate, `release.yml` re-tags it to semver and syncs those tags to Docker Hub.) -- [x] The human developer understands the security model in the auth in this project. (README → Auth → [Security model](README.md#security-model): trust boundaries — browser untrusted, JWT untrusted until verified, the private network as the *only* guard on the unauthenticated Ory admin APIs, plugins trusted and unsandboxed, row rules upstream — plus a threat→defense table, the fail-closed rule, and pointers to the limits that are deliberately not guaranteed. Signed-not-encrypted is called out so nothing secret lands in a claim. Every claim it makes is already enforced by an existing test.) +- [x] The human developer understands the security model in the auth in this project. (README → Auth → [Security model](README.md#security-model): trust boundaries — browser untrusted, JWT untrusted until verified, the private network as the *only* guard on the unauthenticated Ory admin APIs, plugins trusted and unsandboxed, row rules upstream — plus a threat→defense table, the fail-closed rule, and pointers to the limits that are deliberately not guaranteed. Signed-not-encrypted is called out so nothing secret lands in a claim, and the JWT's ~10m TTL is separated from the 30-day Kratos session that re-mints it. Every row of the threat table is enforced by an existing test; the trust-boundary bullets are not testable claims. Review also corrected the hardening checklist — `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`, so the committed Kratos/Hydra/Postgres dev secrets are now listed in "What you must supply".) - [ ] Add i18n support. ## Architectural review findings (2026-07-02) -- 2.52.0 From 5a5803b26585d16259c06300772b65e4d3a5edd9 Mon Sep 17 00:00:00 2001 From: lilleman Date: Sun, 2 Aug 2026 17:25:07 +0200 Subject: [PATCH 03/14] Review fixes: denylist-conditional revoke, Ory secret wiring, exp guard test --- README.md | 25 +++++++++++++++---------- src/auth/jwt-middleware.test.ts | 4 +++- todo.md | 2 +- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f86f241..cdcf40a 100644 --- a/README.md +++ b/README.md @@ -854,6 +854,10 @@ blocks a clean clone: | `SECRETS_SYSTEM` | hydra env | encrypts OAuth2 tokens + consent at rest | | `POSTGRES_USER` / `POSTGRES_PASSWORD` | compose env | the Ory databases (default `ory`/`ory`) | + `CSRF_SECRET` and the Postgres 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. + 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 [Social sign-in (SSO)](#social-sign-in-sso)). @@ -1017,12 +1021,12 @@ what defends what, and which guarantees are deliberately not offered. - **The browser is not trusted.** Cookies, form fields, URLs and headers are attacker-controlled until verified or escaped. Nothing is believed because of where it arrived from. - **The session JWT is trusted only after verification** — signature against the JWKS key its - `kid` names (or the sole key, when the set has one), then a **mandatory** `exp`, plus `nbf` + `kid` names (or the sole key, when the token carries no `kid`), then a **mandatory** `exp`, plus `nbf` and the optional `iss`/`aud`. Before that it is bytes. - **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 permission control. Keto **read** (`4466`) cannot write, but discloses - the entire authorization graph, so treat it the same. `compose.yml` publishes none of the six + 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 Ory ports a browser must reach. Never expose one, and never front one with a proxy that lacks its own auth. - **Plugins are trusted code**, in-process and unsandboxed — a plugin can do anything the host @@ -1045,7 +1049,7 @@ Never put anything in a claim you wouldn't show them. | Token minted for another deployment | optional `JWT_ISSUER` / `JWT_AUDIENCE` pinning | | Stolen session cookie | `HttpOnly`, `SameSite=Lax`, `Secure` (`SECURE_COOKIES`) — but see the real session lifetime below | | CSRF on our own forms | signed double-submit token, **opt-in per handler** via `ctx.verifyCsrf` (`src/auth/csrf.ts`) + `SameSite=Lax`; Kratos' flows carry Kratos' own token | -| XSS | EJS `<%= %>` escapes; CSP `script-src 'self'` with no `'unsafe-inline'` (`src/http/security-headers.ts`) — the `*.html` slots stay [raw by contract](#routes--handlers) | +| XSS | EJS `<%= %>` escapes; the CSP blocks inline script ([headers](#production--deployment)) — the `*.html` slots stay [raw by contract](#escaping--the-trust-boundary) | | Clickjacking | `frame-ancestors 'none'` + `X-Frame-Options: DENY` | | Open redirect via `return_to` | validated host-relative (`localPath`, `src/http/safe-url.ts`) | | Privilege escalation | roles authored only in Keto, re-read at every mint — see [login & the session JWT](#login-and-the-session-jwt) | @@ -1060,11 +1064,13 @@ two cookies obey `SECURE_COOKIES`; the Kratos one takes its flags from Kratos' o **Fail closed — with one deliberate exception.** A token that cannot be verified (missing, malformed, bad signature, wrong `iss`/`aud`) yields *anonymous*, never a partly-trusted user, and anonymous or under-privileged is denied (`requireSession` bounces to `/login`; `can`/`check` -return `false`). An **expired or revoked** token instead triggers a re-mint against the live -Kratos session — full re-authentication with roles re-read from Keto, or a cleared cookie if -that session is dead; Ory unreachable ⇒ anonymous. Revoking a role therefore *downgrades* a -user promptly without signing them out; **ending a session outright means deactivating or -deleting the identity.** +return `false`). An **expired** token instead triggers a re-mint: re-validation against the live +Kratos session, roles re-read from Keto, or a cleared cookie if that session is dead; Ory +unreachable ⇒ anonymous. None of this is a session kill — a *revoked* state exists only with the +[denylist](#instant-revoke-the-optional-denylist) on (off by default), and it resolves through +that same re-mint. **Offboarding:** with the denylist on, revoking a role downgrades the user at +once and deactivating or deleting the identity ends the session; with it off, both land within +one token TTL. **Not guaranteed** — accepted, and stated where each mechanism is: role changes [lag up to one token TTL and sign-in needs Ory up](#two-trade-offs--both-deliberate), and the @@ -1474,8 +1480,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`. (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.) -3. **Verify** new logins mint the new `kid` — decode the `plainpages_jwt` cookie - header, or watch web's logs for a `jwks reload on kid miss` debug line as old clients +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 present the new key. 4. **Wait ~12 min**, then **prune** the superseded key: ```bash diff --git a/src/auth/jwt-middleware.test.ts b/src/auth/jwt-middleware.test.ts index 94a20a1..cb01c5a 100644 --- a/src/auth/jwt-middleware.test.ts +++ b/src/auth/jwt-middleware.test.ts @@ -29,8 +29,10 @@ test("verifyToken: a valid token → User, selecting the verify key by kid acros assert.deepEqual(user, { email: "a@b.c", id: "u1", roles: ["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 }; + // 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/); // exp 30s in the past but inside the 60s skew → still accepted. await verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: NOW - 30 }), jwks, opts); diff --git a/todo.md b/todo.md index 6a5640a..7a3e01c 100644 --- a/todo.md +++ b/todo.md @@ -15,7 +15,7 @@ - [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] 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.) - [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:` behind the green gate, `release.yml` re-tags it to semver and syncs those tags to Docker Hub.) -- [x] The human developer understands the security model in the auth in this project. (README → Auth → [Security model](README.md#security-model): trust boundaries — browser untrusted, JWT untrusted until verified, the private network as the *only* guard on the unauthenticated Ory admin APIs, plugins trusted and unsandboxed, row rules upstream — plus a threat→defense table, the fail-closed rule, and pointers to the limits that are deliberately not guaranteed. Signed-not-encrypted is called out so nothing secret lands in a claim, and the JWT's ~10m TTL is separated from the 30-day Kratos session that re-mints it. Every row of the threat table is enforced by an existing test; the trust-boundary bullets are not testable claims. Review also corrected the hardening checklist — `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`, so the committed Kratos/Hydra/Postgres dev secrets are now listed in "What you must supply".) +- [x] The human developer understands the security model in the auth in this project. (README → Auth → [Security model](README.md#security-model): trust boundaries — browser untrusted, JWT untrusted until verified, the private network as the *only* guard on the unauthenticated Ory admin APIs, plugins trusted and unsandboxed, row rules upstream — plus a threat→defense table, the fail-closed rule, and pointers to the limits that are deliberately not guaranteed. Signed-not-encrypted is called out so nothing secret lands in a claim, and the JWT's ~10m TTL is separated from the 30-day Kratos session that re-mints it. Every row of the threat table is enforced by a test — the mandatory-`exp` guard was the one gap, now asserted in `src/auth/jwt-middleware.test.ts`; the trust-boundary bullets are not testable claims. Review also corrected the hardening checklist — `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`, so the committed Kratos/Hydra/Postgres dev secrets are now listed in "What you must supply".) - [ ] Add i18n support. ## Architectural review findings (2026-07-02) -- 2.52.0 From 73a78d4404d810a499f5d664c7e68c5fcf563320 Mon Sep 17 00:00:00 2001 From: lilleman Date: Sun, 2 Aug 2026 17:33:04 +0200 Subject: [PATCH 04/14] Add the seeded admin login to the production secrets checklist --- README.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index cdcf40a..aa9a3d9 100644 --- a/README.md +++ b/README.md @@ -853,10 +853,12 @@ blocks a clean clone: | `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` and the Postgres 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. + `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. 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 @@ -1069,8 +1071,8 @@ Kratos session, roles re-read from Keto, or a cleared cookie if that session is unreachable ⇒ anonymous. None of this is a session kill — a *revoked* state exists only with the [denylist](#instant-revoke-the-optional-denylist) on (off by default), and it resolves through that same re-mint. **Offboarding:** with the denylist on, revoking a role downgrades the user at -once and deactivating or deleting the identity ends the session; with it off, both land within -one token TTL. +once (on the instance that handled it) and deactivating or deleting the identity ends the +session; with it off, both land within one token TTL. **Not guaranteed** — accepted, and stated where each mechanism is: role changes [lag up to one token TTL and sign-in needs Ory up](#two-trade-offs--both-deliberate), and the @@ -1078,7 +1080,7 @@ denylist is [single-instance and skips group changes](#instant-revoke-the-option 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 or Postgres' throwaways. +guards only `CSRF_SECRET`; nothing fails loud if you ship Ory's, Postgres' or the demo admin's throwaways. ## Email @@ -1480,7 +1482,8 @@ 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`. (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.) -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 +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 present the new key. 4. **Wait ~12 min**, then **prune** the superseded key: ```bash -- 2.52.0 From 4b4ac178abd1da97eae0425fdcc3d0128d3f5cc2 Mon Sep 17 00:00:00 2001 From: lilleman Date: Sun, 2 Aug 2026 17:33:44 +0200 Subject: [PATCH 05/14] Record the open CSRF token-binding decision as a todo item --- todo.md | 1 + 1 file changed, 1 insertion(+) diff --git a/todo.md b/todo.md index 7a3e01c..e24a18f 100644 --- a/todo.md +++ b/todo.md @@ -17,6 +17,7 @@ - [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:` behind the green gate, `release.yml` re-tags it to semver and syncs those tags to Docker Hub.) - [x] The human developer understands the security model in the auth in this project. (README → Auth → [Security model](README.md#security-model): trust boundaries — browser untrusted, JWT untrusted until verified, the private network as the *only* guard on the unauthenticated Ory admin APIs, plugins trusted and unsandboxed, row rules upstream — plus a threat→defense table, the fail-closed rule, and pointers to the limits that are deliberately not guaranteed. Signed-not-encrypted is called out so nothing secret lands in a claim, and the JWT's ~10m TTL is separated from the 30-day Kratos session that re-mints it. Every row of the threat table is enforced by a test — the mandatory-`exp` guard was the one gap, now asserted in `src/auth/jwt-middleware.test.ts`; the trust-boundary bullets are not testable claims. Review also corrected the hardening checklist — `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`, so the committed Kratos/Hydra/Postgres dev secrets are now listed in "What you must supply".) - [ ] Add i18n support. +- [ ] Decide (once) whether the CSRF token staying unbound to `sub`/session is accepted. `src/auth/csrf.ts` signs `.` 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) -- 2.52.0 From f0662cbd0f54b71c0cbe3a87b70ec545ec7b31e0 Mon Sep 17 00:00:00 2001 From: lilleman Date: Sun, 2 Aug 2026 17:34:57 +0200 Subject: [PATCH 06/14] Reflow the security-model prose to the file's wrap width --- README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index aa9a3d9..a095c5f 100644 --- a/README.md +++ b/README.md @@ -1023,13 +1023,13 @@ what defends what, and which guarantees are deliberately not offered. - **The browser is not trusted.** Cookies, form fields, URLs and headers are attacker-controlled until verified or escaped. Nothing is believed because of where it arrived from. - **The session JWT is trusted only after verification** — signature against the JWKS key its - `kid` names (or the sole key, when the token carries no `kid`), then a **mandatory** `exp`, plus `nbf` - and the optional `iss`/`aud`. Before that it is bytes. + `kid` names (or the sole key, when the token carries no `kid`), then a **mandatory** `exp`, + plus `nbf` and the optional `iss`/`aud`. Before that it is bytes. - **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 permission 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 Ory ports a browser must reach. + 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. - **Plugins are trusted code**, in-process and unsandboxed — a plugin can do anything the host can. Vetting happens when you mount one, not at runtime (AGENTS.md: crash isolation is a @@ -1080,7 +1080,8 @@ denylist is [single-instance and skips group changes](#instant-revoke-the-option 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. +guards only `CSRF_SECRET`; nothing fails loud if you ship Ory's, Postgres' or the demo admin's +throwaways. ## Email -- 2.52.0 From b580f7d06e0c90215651f0d95ecf9ebbfba0e278 Mon Sep 17 00:00:00 2001 From: lilleman Date: Mon, 3 Aug 2026 11:31:04 +0200 Subject: [PATCH 07/14] Rename the plugin-API permission gate to role --- README-dockerhub.md | 2 +- compose.yml | 2 +- e2e-tests/compose.full.yml | 2 +- e2e-tests/visual.spec.ts | 2 +- examples/README.md | 2 +- examples/config/menu.ts | 2 +- examples/plugins/admin/README.md | 6 ++--- examples/plugins/admin/admin-roles.test.ts | 2 +- examples/plugins/admin/admin-roles.ts | 10 +++---- examples/plugins/admin/admin-shared.test.ts | 6 ++--- examples/plugins/admin/admin-shared.ts | 10 +++---- examples/plugins/admin/admin-users.ts | 2 +- examples/plugins/admin/plugin.ts | 8 +++--- examples/plugins/scheduling/README.md | 2 +- examples/plugins/scheduling/plugin.ts | 18 ++++++------- examples/plugins/scheduling/shifts.ts | 4 +-- src/auth/bootstrap.ts | 10 +++---- src/auth/guards.ts | 2 +- src/auth/jwt-middleware.ts | 2 +- src/http/app.test.ts | 12 ++++----- src/http/app.ts | 4 +-- src/plugin-host/discovery.test.ts | 12 ++++----- src/plugin-host/discovery.ts | 12 ++++----- src/plugin-host/plugin-api.ts | 2 +- src/plugin-host/plugin.test.ts | 20 +++++++------- src/plugin-host/plugin.ts | 30 ++++++++++----------- src/plugin-host/router.test.ts | 4 +-- src/plugin-host/router.ts | 6 ++--- src/ui/chrome.test.ts | 8 +++--- src/ui/menu-config.ts | 2 +- src/ui/nav.test.ts | 22 +++++++-------- src/ui/nav.ts | 16 +++++------ 32 files changed, 122 insertions(+), 122 deletions(-) diff --git a/README-dockerhub.md b/README-dockerhub.md index 6847430..001873c 100644 --- a/README-dockerhub.md +++ b/README-dockerhub.md @@ -190,4 +190,4 @@ export default definePlugin({ ``` Restart (`docker compose restart web`) and visit . Views, -forms, permissions, and the runnable reference plugin: repo README → Building plugins. +forms, roles, and the runnable reference plugin: repo README → Building plugins. diff --git a/compose.yml b/compose.yml index e38a7fe..488ef0d 100644 --- a/compose.yml +++ b/compose.yml @@ -131,7 +131,7 @@ services: ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin} # Base roles for the demo admin; bootstrap also grants every discovered plugin's declared - # permission tokens (so the reference plugin — and any drop-in — works out of the box). + # role names (so the reference plugin — and any drop-in — works out of the box). ADMIN_ROLES: ${ADMIN_ROLES:-admin} APP_URL: ${APP_URL:-http://localhost:3000} # printed in the first-run login banner JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json diff --git a/e2e-tests/compose.full.yml b/e2e-tests/compose.full.yml index 9a3585b..bcd125f 100644 --- a/e2e-tests/compose.full.yml +++ b/e2e-tests/compose.full.yml @@ -30,7 +30,7 @@ services: - ./examples/plugins/scheduling:/app/plugins/scheduling:ro - ./examples/plugins/admin:/app/plugins/admin:ro - # bootstrap grants the demo admin every discovered plugin's permission tokens, so it needs the + # bootstrap grants the demo admin every discovered plugin's role names, so it needs the # example plugins present too — else the admin lacks scheduling:read/write and the gated pages 403. bootstrap: volumes: diff --git a/e2e-tests/visual.spec.ts b/e2e-tests/visual.spec.ts index 38dbd34..9e03271 100644 --- a/e2e-tests/visual.spec.ts +++ b/e2e-tests/visual.spec.ts @@ -114,7 +114,7 @@ test("unknown routes serve the 404 page (a real user-facing flow, covered end-to }); // The reference plugin (plugins/scheduling) ships discovered in the image. Its public Overview is -// reachable by anyone and its menu header shows for everyone; the shifts list stays permission-gated, +// reachable by anyone and its menu header shows for everyone; the shifts list stays role-gated, // so an anonymous visitor is bounced to sign in. The authenticated list/form flow is the full // E2E (full-flow.spec). Side-effect-free. test("the reference plugin: public Overview is open to all, the gated Shifts redirects to /login", async ({ page, request }) => { diff --git a/examples/README.md b/examples/README.md index 5b7cda1..75f3165 100644 --- a/examples/README.md +++ b/examples/README.md @@ -5,7 +5,7 @@ across (or bind-mount your own) and restart. | Path | Copy into | Example of | | --- | --- | --- | -| [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and 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 role-gated nav — built from the core building blocks, holding no state. Imports the host surface as `#plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). | | [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Roles / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). | | [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). | | [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `SCHEDULING_UPSTREAM` at the real thing instead. | diff --git a/examples/config/menu.ts b/examples/config/menu.ts index ea6e4d9..4cc7955 100644 --- a/examples/config/menu.ts +++ b/examples/config/menu.ts @@ -3,7 +3,7 @@ // config/ ships empty; mount your own or copy this in. Absent config = built-in defaults. // // Brand the app and reorder/rename/group/hide nav nodes (by their `id`) across all plugins — -// the override always wins, applied before the per-user permission filter. Every field is +// the override always wins, applied before the per-user role filter. Every field is // optional; delete one to fall back to the default. // See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README.md (The menu system). diff --git a/examples/plugins/admin/README.md b/examples/plugins/admin/README.md index 51d29e5..5d693e8 100644 --- a/examples/plugins/admin/README.md +++ b/examples/plugins/admin/README.md @@ -28,12 +28,12 @@ stack**, so they use the privileged **`ctx.system`** surface the host exposes to `ctx.system` is populated only when the host wired those services (the dev stack wires Kratos + Keto, and Hydra when configured). Where a capability is absent the screen degrades to a themed 503 rather than crashing — see `admin-shared.ts`. Everything else is an ordinary plugin: folder-discovered, -gated per route by `permission: "admin"`, rendering the core building blocks in `views/`. +gated per route by `role: "admin"`, rendering the core building blocks in `views/`. ## Layout -- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` permission token, and the - route table — one thin handler per method+path, all gated by `permission: "admin"`. +- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` role, and the + route table — one thin handler per method+path, all gated by `role: "admin"`. - `admin-users.ts` · `admin-groups.ts` · `admin-roles.ts` · `admin-clients.ts` — each a set of pure 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 diff --git a/examples/plugins/admin/admin-roles.test.ts b/examples/plugins/admin/admin-roles.test.ts index 3369ea4..727f257 100644 --- a/examples/plugins/admin/admin-roles.test.ts +++ b/examples/plugins/admin/admin-roles.test.ts @@ -1,4 +1,4 @@ -// Built-in Roles & permissions admin screen: the pure view-model + Keto builders. A role is a +// Built-in Roles admin screen: the pure view-model + Keto builders. A role is a // Keto subject set (Role:#members); members are users (subject_id) or groups (subject_set) — // "assign roles to users/groups". The "effective access" view flattens a Keto `expand` tree into the // distinct set of users who hold the role directly or transitively via a group. The HTTP diff --git a/examples/plugins/admin/admin-roles.ts b/examples/plugins/admin/admin-roles.ts index 0544c78..bda6b6b 100644 --- a/examples/plugins/admin/admin-roles.ts +++ b/examples/plugins/admin/admin-roles.ts @@ -1,4 +1,4 @@ -// Roles & permissions admin screen: list / create / delete Keto roles and assign +// Roles admin screen: list / create / delete Keto roles and assign // them to users and groups. A role is a Keto subject set `Role:#members` (OPL: members are users // or groups, resolved transitively) — the source of truth for the JWT `roles` claim. It shares the // Groups screen's membership model, so the pure helpers (parseSubject, member pickers, tuple paging) @@ -9,7 +9,7 @@ // 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 { ADMIN_PERMISSION, ADMIN_ROLES_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts"; +import { ADMIN_ROLE, ADMIN_ROLES_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts"; import { type GroupView, groupsFromTuples, @@ -333,7 +333,7 @@ export const rolesAddMember = withRoleName(async (deps, name) => { // GET /admin/roles/:name/delete — confirm, except the admin role can't be deleted. 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_ROLE) return roleDetailResult(deps, name, "The admin role can't be deleted — it would remove all admin access."); const base = detailHref(name); return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({ breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { href: base, label: name }, { label: "Delete" }], @@ -347,7 +347,7 @@ export const rolesDeleteConfirm = withRoleName((deps, name) => { export const rolesDelete = withRoleName(async (deps, name) => { const { ctx, keto, user } = deps; 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_ROLE) return roleDetailResult(deps, name, "The admin role can't be deleted — it would remove all admin access."); await keto.deleteTuple({ namespace: ROLE_NS, object: name, relation: MEMBERS }); ctx.log.info("admin: role deleted", { actor: user.id, role: name }); return { redirect: ADMIN_ROLES_BASE }; @@ -360,7 +360,7 @@ export const rolesRemoveMember = withRoleName(async (deps, name) => { const { ctx, keto, revoke, user } = deps; const form = (await guardedForm(ctx))!; const member = (form.get("member") ?? "").trim(); - if (name === ADMIN_PERMISSION && member === `user:${user.id}`) return roleDetailResult(deps, name, "You can't revoke your own admin access."); + if (name === ADMIN_ROLE && member === `user:${user.id}`) return roleDetailResult(deps, name, "You can't revoke your own admin access."); const tuple = roleMemberTuple(name, member); if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: role unassigned", { actor: user.id, member, role: name }); } return { redirect: detailHref(name) }; diff --git a/examples/plugins/admin/admin-shared.test.ts b/examples/plugins/admin/admin-shared.test.ts index 6d49f2f..a7acf04 100644 --- a/examples/plugins/admin/admin-shared.test.ts +++ b/examples/plugins/admin/admin-shared.test.ts @@ -7,7 +7,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { Readable } from "node:stream"; import { test } from "node:test"; import { GuardError, type Log, type PageChrome, type RequestContext, type User } from "#plugin-api"; -import { ADMIN_NAV, ADMIN_PERMISSION, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts"; +import { ADMIN_NAV, ADMIN_ROLE, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts"; const admin: User = { email: "ada@x.io", id: "u1", roles: ["admin"] }; const member: User = { email: "bo@x.io", id: "u2", roles: ["scheduling:read"] }; @@ -27,11 +27,11 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver test("ADMIN_NAV: a gated Admin header over the four screens; no per-request current/open state", () => { assert.equal(ADMIN_NAV.id, "admin"); - assert.equal(ADMIN_NAV.permission, ADMIN_PERMISSION); // gate on the header ⇒ composeNav drops the whole subtree for a non-admin + assert.equal(ADMIN_NAV.role, ADMIN_ROLE); // gate on the header ⇒ composeNav drops the whole subtree for a non-admin assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/roles", "/admin/clients"]); assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["Users", "Groups", "Roles", "OAuth2 clients"]); - assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined && c.permission === undefined)); // the header's gate covers the subtree + assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined && c.role === undefined)); // the header's gate covers the subtree }); // ---- auth gates ---- diff --git a/examples/plugins/admin/admin-shared.ts b/examples/plugins/admin/admin-shared.ts index 497d802..bf94909 100644 --- a/examples/plugins/admin/admin-shared.ts +++ b/examples/plugins/admin/admin-shared.ts @@ -5,7 +5,7 @@ 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_ROLE = "admin"; // role token gating the whole admin section export const ADMIN_USERS_BASE = "/admin/users"; export const ADMIN_GROUPS_BASE = "/admin/groups"; export const ADMIN_ROLES_BASE = "/admin/roles"; @@ -14,7 +14,7 @@ export const ADMIN_CLIENTS_BASE = "/admin/clients"; export type AdminScreen = "clients" | "groups" | "roles" | "users"; // The plugin's nav fragment: the gated "Admin" header + its four screens. The host composes it into -// the one global menu, filters per user (the header's `permission` drops the whole subtree for a +// the one global menu, filters per user (the header's `role` drops the whole subtree for a // non-admin), and current-marks the active item — so there is no `current`/`open` state here. export const ADMIN_NAV: NavNode = { children: [ @@ -26,15 +26,15 @@ export const ADMIN_NAV: NavNode = { icon: "i-shield", id: "admin", label: "Admin", - permission: ADMIN_PERMISSION, + role: ADMIN_ROLE, }; -// The admin gate: a signed-in admin only. Each route already declares `permission: "admin"`, so the +// The admin gate: a signed-in admin only. Each route already declares `role: "admin"`, so the // host enforces this before the handler runs; this is defence-in-depth and what a direct unit test // relies on. Returns the (non-null) user for the handler to thread on. GuardError → /login or 403. export function requireAdmin(ctx: RequestContext): User { 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_ROLE)) throw new GuardError(403, "admin role required"); return user; } diff --git a/examples/plugins/admin/admin-users.ts b/examples/plugins/admin/admin-users.ts index 1f3dae9..8d3dc04 100644 --- a/examples/plugins/admin/admin-users.ts +++ b/examples/plugins/admin/admin-users.ts @@ -268,7 +268,7 @@ function readUserInput(form: URLSearchParams): UserInput { // the Kratos capability (else a themed 503). Each route below is a thin handler over these. interface UsersDeps { ctx: RequestContext; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; } -// Resolve the shared deps, then run `inner`. The route's `permission: "admin"` already gated at the +// Resolve the shared deps, then run `inner`. The route's `role: "admin"` already gated at the // host; `requireAdmin` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it. function withUser(inner: (deps: UsersDeps) => Promise): RouteHandler { return async (ctx) => { diff --git a/examples/plugins/admin/plugin.ts b/examples/plugins/admin/plugin.ts index cc5b3d5..1ec59bb 100644 --- a/examples/plugins/admin/plugin.ts +++ b/examples/plugins/admin/plugin.ts @@ -11,19 +11,19 @@ import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clie 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 { 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_ROLE } from "./admin-shared.ts"; -// Every admin route is gated by the one `admin` permission — the host redirects an anonymous visitor +// Every admin route is gated by the one `admin` role — the host redirects an anonymous visitor // to /login, gives a signed-in non-admin the 403 page, and filters the nav the same way. Handlers are // thin and keyed on ctx.params (the host extracts :id / :name), the idiomatic per-route style. -const r = (method: HttpMethod, path: string, handler: RouteHandler): Route => ({ handler, method, path, permission: ADMIN_PERMISSION }); +const r = (method: HttpMethod, path: string, handler: RouteHandler): Route => ({ handler, method, path, role: ADMIN_ROLE }); export default definePlugin({ apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION nav: [ADMIN_NAV], - permissions: [{ description: "Administer users, groups, roles, and OAuth2 clients", token: ADMIN_PERMISSION }], + roles: [{ description: "Administer users, groups, roles, and OAuth2 clients", name: ADMIN_ROLE }], routes: [ // Users diff --git a/examples/plugins/scheduling/README.md b/examples/plugins/scheduling/README.md index 810599a..e5f56cb 100644 --- a/examples/plugins/scheduling/README.md +++ b/examples/plugins/scheduling/README.md @@ -15,7 +15,7 @@ What it demonstrates: `POST /scheduling/shifts` CSRF-verifies it (`ctx.verifyCsrf`) and forwards the create upstream, then POST-redirect-GET. The form body lives in the plugin's own `views/partials/shift-form.ejs`, reusing the core `field` partial. -- **Permission-gated nav** — the "Shifts" nav leaf and routes are gated on `scheduling:read` / +- **Role-gated nav** — the "Shifts" nav leaf and routes are gated on `scheduling:read` / `scheduling:write`; the whole "Scheduling" section is invisible to anyone without the grant. The plugin holds **no state** — data lives upstream (README → *Stateless*). Handlers are thin and diff --git a/examples/plugins/scheduling/plugin.ts b/examples/plugins/scheduling/plugin.ts index 8efea4a..ec27807 100644 --- a/examples/plugins/scheduling/plugin.ts +++ b/examples/plugins/scheduling/plugin.ts @@ -1,5 +1,5 @@ // Reference plugin: a worked example of the contract — a list page that fetches upstream -// data, a CSRF-guarded form that forwards a write upstream, and permission-gated nav. Copy this +// data, a CSRF-guarded form that forwards a write upstream, and role-gated nav. Copy this // folder, rename it, point it at your own backend. Full contract: README.md → Building plugins. import { definePlugin } from "#plugin-api"; @@ -23,7 +23,7 @@ export default definePlugin({ nav: [{ children: [ { href: SCHEDULING_PATH, id: "scheduling:overview", label: "Overview", public: true }, - { href: SHIFTS_PATH, id: "scheduling:shifts", label: "Shifts", permission: READ }, + { href: SHIFTS_PATH, id: "scheduling:shifts", label: "Shifts", role: READ }, ], icon: "i-cal", id: "scheduling", @@ -31,17 +31,17 @@ export default definePlugin({ }], // Tokens this plugin introduces (docs + Keto seeding). Namespaced `:`. - permissions: [ - { description: "View shifts", token: READ }, - { description: "Create and edit shifts", token: WRITE }, + roles: [ + { description: "View shifts", name: READ }, + { description: "Create and edit shifts", name: WRITE }, ], - // Mounted under /scheduling; `permission` gates before the handler runs. The overview is `public` + // Mounted under /scheduling; `role` gates before the handler runs. The overview is `public` // (anyone may reach /scheduling, signed in or not); the rest need a role. routes: [ { handler: overview(), method: "GET", path: "/", public: true }, - { handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ }, - { handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE }, - { handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE }, + { handler: listShifts(upstream), method: "GET", path: "/shifts", role: READ }, + { handler: newShiftForm(), method: "GET", path: "/shifts/new", role: WRITE }, + { handler: createShift(upstream), method: "POST", path: "/shifts", role: WRITE }, ], }); diff --git a/examples/plugins/scheduling/shifts.ts b/examples/plugins/scheduling/shifts.ts index 49c7505..7cdcdab 100644 --- a/examples/plugins/scheduling/shifts.ts +++ b/examples/plugins/scheduling/shifts.ts @@ -10,8 +10,8 @@ import { can, CSRF_FIELD, GuardError, type PageChrome, parseListQuery, readFormB export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page export const SHIFTS_PATH = "/scheduling/shifts"; -export const READ = "scheduling:read"; // permission token gating the list + nav -export const WRITE = "scheduling:write"; // permission token gating create +export const READ = "scheduling:read"; // role name gating the list + nav +export const WRITE = "scheduling:write"; // role name gating create export interface Shift { id: string; diff --git a/src/auth/bootstrap.ts b/src/auth/bootstrap.ts index 71114e2..ac864f4 100644 --- a/src/auth/bootstrap.ts +++ b/src/auth/bootstrap.ts @@ -2,8 +2,8 @@ // kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`: // 1. generate the JWKS signing key if absent (committed dev key makes this a safety net); // 2. seed a demo admin (admin@plainpages.local / admin) in Kratos; -// 3. grant it its roles in Keto so menu/permission checks resolve out of the box — `admin` plus -// every discovered plugin's declared permission tokens, so a dropped-in plugin is usable by +// 3. grant it its roles in Keto so menu/role checks resolve out of the box — `admin` plus +// every discovered plugin's declared role names, so a dropped-in plugin is usable by // the demo admin with no host config edit (the host stays plugin-agnostic). // Then prints a first-run banner; fails loud on any unexpected upstream error. import { existsSync, writeFileSync } from "node:fs"; @@ -29,7 +29,7 @@ export function roleTuple(identityId: string, role: string) { } // The roles to grant the demo admin = the configured base (ADMIN_ROLES, 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 role names (a route/nav `role` is a // coarse role — granted as a Keto `Role:#members` tuple). So the host names no plugin, yet a // 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[] { @@ -143,9 +143,9 @@ async function main() { await runWithLog(log, async () => { if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key"); - // Seed `admin` (or ADMIN_ROLES) + every discovered plugin's declared permission tokens, so the + // Seed `admin` (or ADMIN_ROLES) + every discovered plugin's declared role names, so the // shipped example — and any dropped-in plugin — works for the demo admin without a host edit. - const declared = (await discoverPlugins()).flatMap((p) => (p.permissions ?? []).map((d) => d.token)); + const declared = (await discoverPlugins()).flatMap((p) => (p.roles ?? []).map((d) => d.name)); const roles = seedRoles(env["ADMIN_ROLES"], declared); const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local"; const password = env["ADMIN_PASSWORD"] ?? "admin"; diff --git a/src/auth/guards.ts b/src/auth/guards.ts index 4a7c3bc..5405f42 100644 --- a/src/auth/guards.ts +++ b/src/auth/guards.ts @@ -1,5 +1,5 @@ // Auth guards: in-handler authorization, the imperative counterpart to the -// declarative route `permission` gate. The middleware already verified the session JWT and put +// declarative route `role` gate. The middleware already verified the session JWT and put // the User on ctx; these read it. `requireSession` asserts (throws GuardError, which app.ts maps // to a response); `can`/`check` are predicates a handler branches on. `check` is the one live // Keto call — the fine-grained "may I?" tier (README), reserved for relationship rules. diff --git a/src/auth/jwt-middleware.ts b/src/auth/jwt-middleware.ts index df110f0..68479bb 100644 --- a/src/auth/jwt-middleware.ts +++ b/src/auth/jwt-middleware.ts @@ -2,7 +2,7 @@ // the hot path that never calls Ory. Select the verify key by `kid` from the cached JWKS, // check the signature (src/auth/jwt.ts), validate the time/issuer/audience claims, project the // User onto the request context. `authenticate` fails closed: any bad/expired token ⇒ null -// (anonymous), so the route renders signed-out and the permission gate denies. +// (anonymous), so the route renders signed-out and the role gate denies. import type { User } from "../http/context.ts"; import { parseCookies } from "../http/cookie.ts"; import type { Denylist } from "./denylist.ts"; diff --git a/src/http/app.test.ts b/src/http/app.test.ts index f55ec21..fcb43e3 100644 --- a/src/http/app.test.ts +++ b/src/http/app.test.ts @@ -385,7 +385,7 @@ test("renders the 500 HTML page when a handler throws", async () => { } }); -// A test plugin exercising each RouteResult shape, a path param, and the permission gate. +// A test plugin exercising each RouteResult shape, a path param, and the role gate. const demoPlugin: Plugin = { apiVersion: "1.0.0", id: "demo", @@ -393,7 +393,7 @@ const demoPlugin: Plugin = { { handler: (ctx) => ({ html: `

Hi ${ctx.params.name}

` }), method: "GET", path: "/hello/:name" }, { handler: () => ({ json: { ok: true } }), method: "GET", path: "/data" }, { handler: () => ({ redirect: "/demo/hello/world" }), method: "POST", path: "/go" }, - { handler: () => ({ html: "secret" }), method: "GET", path: "/secret", permission: "demo:read" }, + { handler: () => ({ html: "secret" }), method: "GET", path: "/secret", role: "demo:read" }, { handler: () => ({ html: "open to all" }), method: "GET", path: "/public-page", public: true }, // blessed public { handler: () => ({ data: { who: "Plainpages" }, view: "page" }), method: "GET", path: "/page" }, ], @@ -406,7 +406,7 @@ async function startApp(t: TestContext, plugins: Plugin[], pluginsDir?: string): return `http://localhost:${(app.address() as AddressInfo).port}`; } -test("mounts plugin routes: params, html/json/redirect/view results, and the permission gate", async (t) => { +test("mounts plugin routes: params, html/json/redirect/view results, and the role gate", async (t) => { const dir = mkdtempSync(join(tmpdir(), "pp-plugins-")); mkdirSync(join(dir, "demo", "views"), { recursive: true }); mkdirSync(join(dir, "demo", "public"), { recursive: true }); @@ -610,7 +610,7 @@ test("guards map to responses: requireSession → /login, a failed can/check → { handler: (ctx) => ({ html: `hi ${requireSession(ctx).email}` }), method: "GET", path: "/me" }, { handler: (ctx) => { if (!can(ctx, "admin")) throw new GuardError(403, "no"); return { html: "ok" }; }, method: "GET", path: "/admin-only" }, { handler: async (ctx) => { if (!(await check(keto, ctx, { namespace: "Resource", object: ctx.params.id ?? "", relation: "view" }))) throw new GuardError(403, "no"); return { html: "seen" }; }, method: "GET", path: "/doc/:id" }, - { handler: () => ({ html: "gated" }), method: "GET", path: "/gated", permission: "secret:read" }, // declarative route gate + { handler: () => ({ html: "gated" }), method: "GET", path: "/gated", role: "secret:read" }, // declarative route gate ], }; const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [guarded] }); @@ -636,7 +636,7 @@ test("guards map to responses: requireSession → /login, a failed can/check → assert.equal((await fetch(url + "/guarded/doc/open", auth([]))).status, 200); assert.equal((await fetch(url + "/guarded/doc/shut", auth([]))).status, 403); - // declarative route `permission` gate: anonymous → sign in, signed-in-without-role → the 403 page, with → 200. + // declarative route `role` gate: anonymous → sign in, signed-in-without-role → the 403 page, with → 200. const gAnon = await fetch(url + "/guarded/gated", { redirect: "manual" }); assert.equal(gAnon.status, 303); assert.equal(gAnon.headers.get("location"), "/login?return_to=%2Fguarded%2Fgated"); @@ -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); }); -// 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 // 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) => { diff --git a/src/http/app.ts b/src/http/app.ts index 81bf0e2..0f4c01a 100644 --- a/src/http/app.ts +++ b/src/http/app.ts @@ -240,7 +240,7 @@ export function createApp(options: AppOptions = {}): Server { } } - // Plugin routes (any method): gate on the route's permission, then run the handler. The + // Plugin routes (any method): gate on the route's role, then run the handler. The // handler gets ctx.chrome (native app shell) + ctx.verifyCsrf (guard its own forms); a fresh // CSRF cookie is set so those forms have a valid double-submit token. const match = matchRoute(plugins, method, pathname); @@ -250,7 +250,7 @@ export function createApp(options: AppOptions = {}): Server { // 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. 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 role", { path: pathname, required: match.route.role ?? "", sub: routeCtx.user.id }); sendHtml(res, 403, await render("403", { title: "Forbidden" })); return; } diff --git a/src/plugin-host/discovery.test.ts b/src/plugin-host/discovery.test.ts index e9df7eb..853e78d 100644 --- a/src/plugin-host/discovery.test.ts +++ b/src/plugin-host/discovery.test.ts @@ -50,8 +50,8 @@ const badCases: Array<{ name: string; files: Record; match: RegE { name: "non-function dashboard", files: { "weirddash/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: "nope" };` }, match: /weirddash.*dashboard.*function/s }, { name: "reserved dashboard id shadows the gated dashboard", files: { "dashboard/plugin.ts": full("dashboard") }, match: /dashboard.*reserved/s }, { name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ }, - { name: "a route marked public AND permission is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", public: true, permission: "x", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*permission/s }, - { name: "a nav node marked public AND permission is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", public: true, permission: "x" }] };` }, match: /contranav.*public.*permission/s }, + { name: "a route marked public AND role is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", public: true, role: "x", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*role/s }, + { name: "a nav node marked public AND role is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", public: true, role: "x" }] };` }, match: /contranav.*public.*role/s }, { name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "b" }) };` }, match: /home/ }, { name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ }, ]; @@ -85,12 +85,12 @@ test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard) assert.equal(typeof plugins[0]?.dashboard, "function"); }); -test("a shared permission token only warns — both plugins still load", async (t) => { - const perm = `export default { apiVersion: "1.0.0", permissions: [{ token: "shared:read" }] };`; - const dir = scaffold(t, { "x/plugin.ts": perm, "y/plugin.ts": perm }); +test("a shared role name only warns — both plugins still load", async (t) => { + const shared = `export default { apiVersion: "1.0.0", roles: [{ name: "shared:read" }] };`; + const dir = scaffold(t, { "x/plugin.ts": shared, "y/plugin.ts": shared }); const warnings: string[] = []; const plugins = await discoverPlugins({ dir, logger: { warn: (m) => warnings.push(String(m)) } }); assert.equal(plugins.length, 2); - assert.ok(warnings.some((w) => /shared:read/.test(w)), "expected a permission-conflict warning"); + assert.ok(warnings.some((w) => /shared:read/.test(w)), "expected a role-conflict warning"); }); diff --git a/src/plugin-host/discovery.ts b/src/plugin-host/discovery.ts index 25bea8b..e3f978e 100644 --- a/src/plugin-host/discovery.ts +++ b/src/plugin-host/discovery.ts @@ -2,7 +2,7 @@ // validate it, assemble the loaded Plugin[]. The imperative shell over plugin.ts's pure rules // (isValidPluginId, checkApiVersion, findConflicts). Fails loud: every per-plugin problem and // error-level conflict is collected into one boot-stopping Error; warn-level diagnostics -// (older-minor apiVersion, shared permission token) log and load continues. Folder name = id. +// (older-minor apiVersion, shared role name) log and load continues. Folder name = id. import { existsSync, readdirSync } from "node:fs"; import { dirname, join } from "node:path"; @@ -85,7 +85,7 @@ function asManifest(value: unknown): PluginManifest | null { // The collection fields feed findConflicts, which iterates them — a non-array crashes it opaquely. function shapeError(manifest: PluginManifest): string | null { - for (const field of ["nav", "permissions", "routes"] as const) { + for (const field of ["nav", "roles", "routes"] as const) { if (manifest[field] !== undefined && !Array.isArray(manifest[field])) return `"${field}" must be an array`; } // `home` / `dashboard` (the landing-page overrides) are route handlers; the host calls them, so @@ -93,20 +93,20 @@ function shapeError(manifest: PluginManifest): string | null { for (const slot of ["home", "dashboard"] as const) { if (manifest[slot] !== undefined && typeof manifest[slot] !== "function") return `"${slot}" must be a function (a route handler)`; } - // `public` and `permission` are contradictory on the same route/nav node — "open to all" vs + // `public` and `role` are contradictory on the same route/nav node — "open to all" vs // "needs this role". Refuse rather than silently pick one, so the author's intent is unambiguous. 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.role != null) return `route "${route.method} ${route.path}" sets both public and role — they are mutually exclusive`; } const navContradiction = findPublicNavContradiction(manifest.nav); if (navContradiction) return navContradiction; return null; } -// Recurse the nav fragment: a node that is both `public` and `permission`-gated is contradictory. +// Recurse the nav fragment: a node that is both `public` and `role`-gated is contradictory. function findPublicNavContradiction(nodes: PluginManifest["nav"]): string | null { for (const node of Array.isArray(nodes) ? nodes : []) { - if (node?.public === true && node.permission != null) return `nav node "${node.label ?? node.id ?? "?"}" sets both public and permission — they are mutually exclusive`; + if (node?.public === true && node.role != null) return `nav node "${node.label ?? node.id ?? "?"}" sets both public and role — they are mutually exclusive`; const inChild = findPublicNavContradiction(node?.children); if (inChild) return inChild; } diff --git a/src/plugin-host/plugin-api.ts b/src/plugin-host/plugin-api.ts index 270214b..933c8f5 100644 --- a/src/plugin-host/plugin-api.ts +++ b/src/plugin-host/plugin-api.ts @@ -5,7 +5,7 @@ // a plugin should import from here, never reach into deeper modules. See README.md → Building plugins. export { definePlugin } from "./plugin.ts"; -export type { HttpMethod, Plugin, PluginHooks, PluginManifest, PermissionDecl, Route, RouteHandler, RouteResult } from "./plugin.ts"; +export type { HttpMethod, Plugin, PluginHooks, PluginManifest, RoleDecl, Route, RouteHandler, RouteResult } from "./plugin.ts"; export type { RequestContext, User } from "../http/context.ts"; export type { PageChrome } from "../ui/chrome.ts"; export type { NavNode } from "../ui/nav.ts"; diff --git a/src/plugin-host/plugin.test.ts b/src/plugin-host/plugin.test.ts index 11da110..1b77b60 100644 --- a/src/plugin-host/plugin.test.ts +++ b/src/plugin-host/plugin.test.ts @@ -21,13 +21,13 @@ const scheduling: PluginManifest = definePlugin({ apiVersion: "1.0.0", hooks: { onBoot: () => {} }, nav: [{ - children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }], + children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", role: "scheduling:read" }], icon: "i-cal", id: "scheduling:root", label: "Scheduling", }], - permissions: [{ description: "View shifts", token: "scheduling:read" }], + roles: [{ description: "View shifts", name: "scheduling:read" }], routes: [ - { handler: () => ({ data: { rows: [] }, view: "shifts" }), method: "GET", path: "/shifts", permission: "scheduling:read" }, - { handler: () => ({ redirect: "/scheduling/shifts" }), method: "POST", path: "/shifts", permission: "scheduling:write" }, + { handler: () => ({ data: { rows: [] }, view: "shifts" }), method: "GET", path: "/shifts", role: "scheduling:read" }, + { handler: () => ({ redirect: "/scheduling/shifts" }), method: "POST", path: "/shifts", role: "scheduling:write" }, { handler: (ctx) => void ctx.res.end("raw"), method: "GET", path: "/raw" }, // void = handler wrote res itself ], }); @@ -87,19 +87,19 @@ test("findConflicts: a duplicate id and a colliding route are loud errors", () = assert.ok(dupRoute.some((c) => c.kind === "route" && c.level === "error" && c.message.includes("/a/t"))); }); -test("findConflicts: duplicate nav id is an error, a shared permission token only warns", () => { +test("findConflicts: duplicate nav id is an error, a shared role name only warns", () => { const navDup = findConflicts([ p({ id: "a", nav: [{ id: "dup", label: "A" }] }), p({ id: "b", nav: [{ id: "dup", label: "B" }] }), ]); assert.ok(navDup.some((c) => c.kind === "nav-id" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b"))); - // Sharing a permission across plugins is legitimate (shared role) → warn, not error. - const permDup = findConflicts([ - p({ id: "a", permissions: [{ token: "shared:read" }] }), - p({ id: "b", permissions: [{ token: "shared:read" }] }), + // Sharing a role across plugins is legitimate → warn, not error. + const roleDup = findConflicts([ + p({ id: "a", roles: [{ name: "shared:read" }] }), + p({ id: "b", roles: [{ name: "shared:read" }] }), ]); - assert.ok(permDup.some((c) => c.kind === "permission" && c.level === "warn")); + assert.ok(roleDup.some((c) => c.kind === "role" && c.level === "warn")); }); test("findConflicts: each single slot (`home`/`dashboard`) may have one owner — two is a loud error", () => { diff --git a/src/plugin-host/plugin.ts b/src/plugin-host/plugin.ts index 660ab02..38b230a 100644 --- a/src/plugin-host/plugin.ts +++ b/src/plugin-host/plugin.ts @@ -29,18 +29,18 @@ export interface Route { handler: RouteHandler; method: HttpMethod; path: string; // relative to the plugin's mount path `/`; ":name" segments → ctx.params.name - permission?: string; // coarse gate (a role token); checked before the handler runs - // 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 - // choice, not an accident. Mutually exclusive with `permission` (discovery refuses both). + role?: string; // coarse gate — the Keto Role the caller must hold; checked before the handler runs + // Mark the page reachable by anyone, signed in or not. The same as omitting `role` + // — an ungated route is already open — but stated outright, so "public" is a deliberate + // choice, not an accident. Mutually exclusive with `role` (discovery refuses both). public?: boolean; } -// A permission token this plugin introduces — declared for docs/seeding. Tokens are a shared -// global namespace (so an operator grants them in Keto); namespace as `:`. -export interface PermissionDecl { +// A Keto Role this plugin gates on — declared for docs/seeding. Role names are a shared +// global namespace (so an operator grants them once in Keto); namespace as `:`. +export interface RoleDecl { description?: string; - token: string; + name: string; } // Optional hooks on system actions. Crash-isolation is a non-goal — a throwing hook fails loud. @@ -63,7 +63,7 @@ export interface PluginManifest { home?: RouteHandler; hooks?: PluginHooks; nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/ui/icons.ts), node ids must be globally unique - permissions?: PermissionDecl[]; + roles?: RoleDecl[]; routes?: Route[]; } @@ -147,7 +147,7 @@ export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HO } export interface PluginConflict { - kind: "dashboard" | "home" | "id" | "nav-id" | "permission" | "route"; + kind: "dashboard" | "home" | "id" | "nav-id" | "role" | "route"; level: "error" | "warn"; message: string; plugins: string[]; // unique ids involved @@ -155,8 +155,8 @@ export interface PluginConflict { // The conflict rules: defined, loud resolution — never last-write-wins. Pure over the discovered // plugins; discovery throws on any "error" and logs every "warn". Mount-path (`/`) uniqueness -// 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. +// is structural — it follows from the id check, so it needs no rule of its own. Shared role +// names are the one intentional overlap, so they warn rather than error. export function findConflicts(plugins: Plugin[]): PluginConflict[] { const out: PluginConflict[] = []; @@ -184,9 +184,9 @@ export function findConflicts(plugins: Plugin[]): PluginConflict[] { }); collect(plugins, (plugin, push) => { - for (const decl of plugin.permissions ?? []) push(decl.token); - }).forEach((owners, token) => { - if (owners.length > 1) out.push({ kind: "permission", level: "warn", message: `permission "${token}" declared by ${uniq(owners).length} plugins; namespace as ":" unless shared on purpose`, plugins: uniq(owners) }); + for (const decl of plugin.roles ?? []) push(decl.name); + }).forEach((owners, name) => { + if (owners.length > 1) out.push({ kind: "role", level: "warn", message: `role "${name}" declared by ${uniq(owners).length} plugins; namespace as ":" unless shared on purpose`, plugins: uniq(owners) }); }); return out; diff --git a/src/plugin-host/router.test.ts b/src/plugin-host/router.test.ts index 70ef699..25785d2 100644 --- a/src/plugin-host/router.test.ts +++ b/src/plugin-host/router.test.ts @@ -57,11 +57,11 @@ test("allowedMethods lists methods at a path (GET implies HEAD); empty when the test("isAuthorized: open routes pass; gated routes require the role token; public is explicitly open", () => { 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: "/", role: "x:read" }; const pub: Route = { handler: noop, method: "GET", path: "/", public: true }; // blessed public alias assert.equal(isAuthorized(open, []), true); assert.equal(isAuthorized(gated, []), false); assert.equal(isAuthorized(gated, ["x:read"]), true); assert.equal(isAuthorized(gated, ["other"]), false); - assert.equal(isAuthorized(pub, []), true); // open to anonymous, like omitting permission — but stated outright + assert.equal(isAuthorized(pub, []), true); // open to anonymous, like omitting role — but stated outright }); diff --git a/src/plugin-host/router.ts b/src/plugin-host/router.ts index 3eaeec6..f6ce394 100644 --- a/src/plugin-host/router.ts +++ b/src/plugin-host/router.ts @@ -74,9 +74,9 @@ export function allowedMethods(plugins: Plugin[], pathname: string): string[] { return [...methods].sort(); } -// Coarse permission gate: a route marked `public` (or one with no `permission`) is open; otherwise +// Coarse role gate: a route marked `public` (or one with no `role`) is open; otherwise // the user's roles (from the session JWT) must include the token. The same rule composeNav uses -// for the menu. `public` and `permission` are mutually exclusive (discovery refuses both). +// for the menu. `public` and `role` are mutually exclusive (discovery refuses both). export function isAuthorized(route: Route, roles: string[]): boolean { - return route.public === true || route.permission == null || roles.includes(route.permission); + return route.public === true || route.role == null || roles.includes(route.role); } diff --git a/src/ui/chrome.test.ts b/src/ui/chrome.test.ts index 0afdbad..e643314 100644 --- a/src/ui/chrome.test.ts +++ b/src/ui/chrome.test.ts @@ -9,13 +9,13 @@ const scheduling: Plugin = { apiVersion: "1.0.0", id: "scheduling", nav: [{ - children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }], + children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", role: "scheduling:read" }], icon: "i-cal", id: "scheduling", label: "Scheduling", }], }; // A plugin with a public nav node (reachable by anyone, signed in or not). const portal: Plugin = { apiVersion: "1.0.0", id: "portal", nav: [{ href: "/portal", id: "portal", label: "Portal", public: true }] }; -// A gated section fragment like the admin plugin's nav: the header carries the permission, so +// A gated section fragment like the admin plugin's nav: the header carries the role, so // composeNav drops the whole subtree for a non-holder (the admin screens ship as a drop-in plugin). const adminLike: Plugin = { apiVersion: "1.0.0", id: "admin", @@ -24,7 +24,7 @@ const adminLike: Plugin = { { href: "/admin/users", id: "users", label: "Users" }, { href: "/admin/groups", id: "groups", label: "Groups" }, ], - icon: "i-shield", id: "admin", label: "Admin", permission: "admin", + icon: "i-shield", id: "admin", label: "Admin", role: "admin", }], }; @@ -45,7 +45,7 @@ test("anonymous shell Sign-in link carries the current page as return_to", () => assert.equal(buildPluginChrome({ currentPath: "/portal", menu: DEFAULT_MENU }).signInHref, "/login?return_to=%2Fportal"); }); -test("a permission holder sees the Dashboard link + plugin nav; current path opens the active leaf", () => { +test("a role holder sees the Dashboard link + plugin nav; current path opens the active leaf", () => { const chrome = buildPluginChrome({ currentPath: "/scheduling/shifts", menu: DEFAULT_MENU, plugins: [scheduling], user: { email: "ada@x.io", id: "u1", roles: ["scheduling:read"] }, diff --git a/src/ui/menu-config.ts b/src/ui/menu-config.ts index 9e568af..3f194f6 100644 --- a/src/ui/menu-config.ts +++ b/src/ui/menu-config.ts @@ -1,7 +1,7 @@ // Central menu config: config/menu.ts lets an operator set branding (app name, logo, // default theme) and reorder/rename/group/hide nav nodes across all plugins. The reorder/rename/ // group/hide part is the NavOverride composeNav already applies (the override always wins, before -// the per-user permission filter). Authored as TypeScript (defineMenu types it); loaded once at +// the per-user role filter). Authored as TypeScript (defineMenu types it); loaded once at // boot — fail-loud on a malformed file, defaults when absent (clean clone needs no config). import { existsSync } from "node:fs"; diff --git a/src/ui/nav.test.ts b/src/ui/nav.test.ts index 0e0cdb9..34d8136 100644 --- a/src/ui/nav.test.ts +++ b/src/ui/nav.test.ts @@ -2,23 +2,23 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { composeNav, type NavNode } from "./nav.ts"; -// Two plugin fragments; ids let the override target nodes, `permission` gates per role. +// Two plugin fragments; ids let the override target nodes, `role` gates per role. const fragments: NavNode[][] = [ [{ icon: "i-cal", id: "sched", label: "Scheduling", children: [ - { href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" }, - { href: "/scheduling/manage", id: "manage", label: "Manage", permission: "scheduling:admin" }, + { href: "/scheduling/shifts", id: "shifts", label: "Shifts", role: "scheduling:read" }, + { href: "/scheduling/manage", id: "manage", label: "Manage", role: "scheduling:admin" }, ], }], - [{ href: "/reports", id: "reports", label: "Reports", permission: "reports:read" }], + [{ href: "/reports", id: "reports", label: "Reports", role: "reports:read" }], ]; test("composeNav merges fragments, filters by role, and emits clean render nodes", () => { const tree = composeNav(fragments, {}, ["scheduling:read"]); // Reports gone (no reports:read), Manage gone (no scheduling:admin), header kept with Shifts. - // Output carries no `id`/`permission` and omits absent fields — ready for nav-tree.ejs. + // Output carries no `id`/`role` and omits absent fields — ready for nav-tree.ejs. assert.deepEqual(tree, [ { icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling/shifts", label: "Shifts" }] }, ]); @@ -27,7 +27,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", () => { // A header the user can't reach takes its whole subtree, even visible children. const gatedHeader: NavNode[][] = [[ - { id: "admin", label: "Admin", permission: "admin", children: [{ href: "/u", id: "u", label: "Users" }] }, + { id: "admin", label: "Admin", role: "admin", children: [{ href: "/u", id: "u", label: "Users" }] }, { id: "free", label: "Free", children: [{ href: "/d", id: "d", label: "Docs" }] }, ]]; assert.deepEqual(composeNav(gatedHeader, {}, []), [ @@ -36,8 +36,8 @@ test("composeNav drops gated subtrees, empty headers, and (with no roles) all ga // A pure header whose children are all filtered is dropped; a header with an href survives as a leaf. const emptyHeader: NavNode[][] = [[ - { id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", permission: "x" }] }, - { href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", permission: "y" }] }, + { id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", role: "x" }] }, + { href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", role: "y" }] }, ]]; assert.deepEqual(composeNav(emptyHeader, {}, []), [{ href: "/hub", label: "Hub" }]); @@ -52,10 +52,10 @@ test("composeNav keeps a node marked public for everyone — the blessed public icon: "i-cal", id: "sched", label: "Scheduling", children: [ { href: "/scheduling", id: "overview", label: "Overview", public: true }, - { href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" }, + { href: "/scheduling/shifts", id: "shifts", label: "Shifts", role: "scheduling:read" }, ], }]]; - // `public` is filter-only (like id/permission) — never rendered into the output node. + // `public` is filter-only (like id/role) — never rendered into the output node. assert.deepEqual(composeNav(frag, {}, []), [ { icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling", label: "Overview" }] }, ]); @@ -66,7 +66,7 @@ test("composeNav applies the override: rename, group, order, hide (then filters) { href: "/a", id: "a", label: "Alpha" }, { href: "/b", id: "b", label: "Beta" }, { href: "/c", id: "c", label: "Gamma" }, - { href: "/secret", id: "secret", label: "Secret", permission: "root" }, + { href: "/secret", id: "secret", label: "Secret", role: "root" }, ]]; const tree = composeNav(base, { diff --git a/src/ui/nav.ts b/src/ui/nav.ts index c8757ef..48db8ec 100644 --- a/src/ui/nav.ts +++ b/src/ui/nav.ts @@ -1,10 +1,10 @@ // 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 role-filter per user. Pure and I/O-free — menu gating reads the JWT // `roles` claim (README "The menu system"), never Keto. A node is visible iff it is `public`, or -// declares no `permission`, or `roles` includes that permission token; a gated header hides its whole +// declares no `role`, or `roles` includes that role name; a gated header hides its whole // subtree, and a pure header left with no children is dropped. The config/menu.ts supplies // the override (+ branding); this helper only transforms data, so its result is per-deployment -// up to the final role filter and emits clean nodes ready for nav-tree.ejs (no id/permission). +// up to the final role filter and emits clean nodes ready for nav-tree.ejs (no id/role). export interface NavNode { id?: string; // stable key for override targeting; stripped from the rendered tree @@ -15,12 +15,12 @@ export interface NavNode { icon?: string; label: string; open?: boolean; - permission?: string; // required role 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). + role?: string; // required role token; consumed by the filter, never rendered + public?: boolean; // show to everyone, signed in or not — the blessed alias for "no role", stated outright; consumed by the filter, never rendered. Mutually exclusive with role (discovery refuses both). } // Central override (config/menu.ts). Targets nodes by `id`; applied rename → group → -// order → hide, then the per-user permission filter runs last. +// order → hide, then the per-user role filter runs last. export interface NavOverride { groups?: NavGroupSpec[]; // wrap top-level nodes (by id) under a new header hide?: string[]; // remove nodes by id, at any depth (incl. a group's id) @@ -106,7 +106,7 @@ function hideTree(nodes: NavNode[], hide: Set): NavNode[] { function filterByRoles(nodes: NavNode[], roles: Set): NavNode[] { const out: NavNode[] = []; 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.role != null && !roles.has(n.role)) continue; // gated → drop node + subtree (public always shows) if (!n.children) { out.push(n); continue; } const children = filterByRoles(n.children, roles); if (children.length === 0 && n.href == null) continue; // empty pure header → drop @@ -115,7 +115,7 @@ function filterByRoles(nodes: NavNode[], roles: Set): NavNode[] { return out; } -// Strip the helper-only fields (id/permission) and drop absent ones, so the tree is exactly +// Strip the helper-only fields (id/role) and drop absent ones, so the tree is exactly // what nav-tree.ejs reads. function toRenderNode(n: NavNode): NavNode { const out: NavNode = { label: n.label }; -- 2.52.0 From 8f9f79ac301e16e86bcf0830585bea22f5fd14ea Mon Sep 17 00:00:00 2001 From: lilleman Date: Mon, 3 Aug 2026 11:39:26 +0200 Subject: [PATCH 08/14] Document the users, groups and roles model in README --- AGENTS.md | 8 +- README.md | 241 ++++++++++++++++++------- examples/plugins/admin/admin-shared.ts | 2 +- examples/plugins/scheduling/plugin.ts | 2 +- examples/plugins/scheduling/shifts.ts | 4 +- src/auth/bootstrap.test.ts | 2 +- src/auth/bootstrap.ts | 4 +- todo.md | 4 +- 8 files changed, 195 insertions(+), 72 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3ac871b..9667fdd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,11 +120,15 @@ docker compose -f compose.yml up --build -d # production running **building plugins** comes first, then **configuring and securing** the system (Configuration, Auth); the **inner workings** (Architecture) and ops/runbooks are deliberately deferred — they're not top of mind when starting out. Concretely: Overview → - Building plugins → menu/blocks/interactivity → Configuration → Auth → Email → - Architecture → Testing → Production → Observability → the JWT-rotation runbook → the + Users, groups & roles → Building plugins → menu/blocks/interactivity → Configuration → Auth → + Email → Architecture → Testing → Production → Observability → the JWT-rotation runbook → the Project-layout file map → Extending. When adding a section, place it by this value (how early an adopter needs it), not by where it sits in the stack. + **Users, groups & roles precedes Building plugins** because a manifest's `role:` gate is + unreadable without the model, and operators need it as much as plugin authors. It is the one + home for that model — the plugin and auth sections link to it rather than restating it. + 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 one home, linking to it rather than restating (credentials, env vars, rotation steps). diff --git a/README.md b/README.md index a095c5f..dd97b29 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,10 @@ From here, render real pages against the app shell and fetch upstream data — s - [Overview](#overview) - [how it compares](#how-it-compares) +- [Users, groups & roles](#users-groups--roles) + - [a worked example](#a-worked-example) + - [granting a role](#granting-a-role) + - [fine-grained, per-row access](#fine-grained-per-row-access) - [Building plugins](#building-plugins) - [anatomy](#anatomy-of-a-plugin) - [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) - [RequestContext](#requestcontext) - [system capabilities (ctx.system)](#system-capabilities-the-ctxsystem-surface) - - [nav & permissions](#nav--permissions) + - [nav & role gates](#nav--role-gates) - [versioning](#contract-versioning) - [conflict rules](#conflict-rules) - [hooks](#hooks) @@ -89,7 +93,7 @@ From here, render real pages against the app shell and fetch upstream data — s - [canonical host](#canonical-host-one-public-url) - [what you must supply](#what-you-must-supply-the-only-manual-prep) - [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) - [instant revoke](#instant-revoke-the-optional-denylist) - [three tiers](#three-tiers-of-may-i) @@ -116,7 +120,7 @@ or gated**, so the same foundation serves a purely public site, a fully locked-d tool, or the common middle: a public front with an authenticated area behind it. Its **sweet spot** is the **back-office and operational tooling** you'd otherwise hand-roll for the tenth time, but nothing ties it to internal-only use. The core itself ships **no domain screens at -all** — even the screens for running the system (**users, groups, permissions**) are a **drop-in +all** — even the screens for running the system (**users, groups, roles**) are a **drop-in plugin** you opt into ([`examples/plugins/admin/`](examples/plugins/admin/)). Everything is a plugin. **Who it's for.** Experienced developers building server-rendered web products — back-office @@ -124,7 +128,7 @@ and operational tools, dashboards, portals, or public sites with a gated area 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 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. **Included vs. what you add.** @@ -142,7 +146,7 @@ obvious rather than surprising, you're the audience. **Priorities (unchanged from day one):** **simplicity, few dependencies, strict TypeScript, no build step, Docker-only, environment-agnostic** (no `NODE_ENV` — every behaviour is an explicit config toggle). Heavy lifting that *isn't* simple to do well — -identity, sessions, SSO, OAuth2, permission checks — is delegated to **Ory** sidecar +identity, sessions, SSO, OAuth2, role checks — is delegated to **Ory** sidecar services rather than reinvented. "Simple" is about the *whole architecture* staying simple — not just at the start, but after you've dropped in 240 plugins and run it hard in production. The shape doesn't change as it grows: every plugin is the same self-contained @@ -190,10 +194,126 @@ 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. | 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 step. Each neighbour shares one trait and trades away the rest — Plainpages is the intersection. +## Users, groups & roles + +Authorization here is **two hops, not three**: a user — directly, or through a group — is a +member of a **role**, and that role's *name* is exactly the string a plugin gates on. There is no +separate "permission" object to define, register, or wire up. + +- **Group** answers *who* — a reusable set of people. Optional: a role can be granted straight to a user. +- **Role** answers *what* — its **name is the string** you write in a manifest's `role:` gate. +- **A relation tuple** is the grant: `Role:#members@user:`, or `@Group:#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 | the identity behind `user:0198f2c1-…` | +| **Group** | Keto | who — a reusable set | `Group:support` | +| **Role** | Keto | what you may do | `Role:scheduling:read` | +| **Resource** | Keto | which specific row | `Resource:shift-4471` | + +Identities live in Kratos; every authorization edge is a Keto relation tuple. The app itself +stores none of it — it is [stateless](#stateless). The model is `ory/keto/namespaces.keto.ts`. + +> **On the word "permission".** Ory uses it for the fine-grained `Resource` tier — the `permits` +> block (`view`/`edit`/`delete`). Plainpages therefore never uses it for the coarse tier: what a +> route or a menu item gates on is a **role**, always. + +### A worked example + +Alice works support and leads scheduling; Bob works support; Carol administers the system. + +``` + people groups roles + ────── ────── ───── + + alice ──┬─────────> Group:support ────┐ + │ ├──> Group:staff ──> Role:scheduling:read + bob ────┘ │ + │ + alice ────────────> Group:sched-leads ┴──> Role:scheduling:write + + carol ───────────────────────────────────────────────> Role:admin +``` + +At login the host asks Keto which roles the user holds, walking those arrows transitively, and +bakes the answer into the session JWT (see [Login and the session +JWT](#login-and-the-session-jwt)): + +``` +alice → roles: ["scheduling:read", "scheduling:write"] +bob → roles: ["scheduling:read"] +carol → roles: ["admin"] +``` + +Note what Carol does *not* have. **There is no role hierarchy and no superuser** — `admin` is +just another name, granting nothing except where a route gates on `admin` itself. + +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 role gets the 403 page, because +there is nothing to sign in *as* that would help. The menu is filtered by the same roles, so +nobody is shown a door they cannot open. + +### Granting a role + +Write the tuple. The admin plugin's **Groups** and **Roles** screens do exactly this, or use +Keto's write API directly: + +```bash +# everyone in sched-leads may write shifts +curl -X PUT http://keto:4467/admin/relation-tuples -H 'content-type: application/json' -d '{ + "namespace": "Role", "object": "scheduling:write", "relation": "members", + "subject_set": { "namespace": "Group", "object": "sched-leads", "relation": "members" } +}' +``` + +Roles are authored **only in Keto** — nothing else writes them. Role names are a shared global +namespace on purpose, so an operator grants `scheduling:read` once and every plugin referencing it +is gated consistently; namespace yours as `:`. + +A change takes effect on the user's **next login or JWT re-mint** (~10 min) — see [Instant +revoke](#instant-revoke-the-optional-denylist) when you need it sooner. + +### Fine-grained, per-row access + +The `Resource` namespace covers what a role cannot express: *this* row, shared with *this* person. +Its permissions nest — `owner` ⊇ `editor` ⊇ `viewer`. + +**A per-row grant never widens a coarse gate.** The route's `role` is checked *before* the handler +runs, so a user rejected there never reaches the check. Gate the route on something they hold, +then narrow inside the handler: + +```ts +{ method: "POST", path: "/shifts/:id", role: 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 A plugin is a self-contained folder under `plugins/` that the host discovers at boot — no @@ -204,7 +324,7 @@ contract is **TypeScript** (`src/plugin-host/plugin.ts`), so the types there are source of truth; the sections below explain them, the guarantees around them, and the rules the host enforces. A complete, runnable example lives in **[`examples/plugins/scheduling/`](examples/plugins/scheduling/)** — a public overview page, a -permission-gated list page fetching upstream data (it points `SCHEDULING_UPSTREAM` at its backend; +role-gated list page fetching upstream data (it points `SCHEDULING_UPSTREAM` at its backend; the dev compose ships a tiny mock, `examples/shifts-upstream/`), a CSRF-guarded form forwarding writes upstream, and a mix of public + role-gated nav. It is **not** pre-installed — `plugins/` ships empty so you mount your own. To run it in dev, copy it in @@ -237,7 +357,7 @@ single `plugin.ts`. must be **URL/path-safe** (`isValidPluginId`: lowercase `a–z`, digits, and dashes — dashes anywhere; no uppercase, underscores, dots, or slashes); the host rejects a malformed folder name at discovery. The id also namespaces the plugin's `views/`, its `/public//` assets, and (by -convention) its nav/permission tokens. +convention) its nav/role names. A handful of ids are **reserved** for the host's own first-party mounts — the gated `dashboard`, the Kratos auth flows (`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`), @@ -269,20 +389,20 @@ import { listThings, createThings } from "./handlers.ts"; export default definePlugin({ apiVersion: "1.0.0", // semver string of the host contract this plugin was built against (see Versioning) - // Nav fragment, merged into the global menu and permission-filtered per user. + // Nav fragment, merged into the global menu and role-filtered per user. // `icon` is a Lucide icon by its sprite id (src/ui/icons.ts). - nav: [{ href: "/things", icon: "i-cal", id: "things:list", label: "Things", permission: "things:read" }], + nav: [{ href: "/things", icon: "i-cal", id: "things:list", label: "Things", role: "things:read" }], - // Permission tokens this plugin introduces. Optional — see Nav & permissions. - permissions: [ - { token: "things:read", description: "View things" }, - { token: "things:write", description: "Create and edit things" }, + // Roles this plugin gates on. Optional — see Nav & role gates. + roles: [ + { description: "View things", name: "things:read" }, + { 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). `role` gates first. routes: [ - { method: "GET", path: "/", permission: "things:read", handler: listThings }, - { method: "POST", path: "/", permission: "things:write", handler: createThings }, + { method: "GET", path: "/", role: "things:read", handler: listThings }, + { method: "POST", path: "/", role: "things:write", handler: createThings }, ], }); ``` @@ -299,7 +419,7 @@ there is **no `id` or `basePath`** in the manifest — both come from the folder | `home` | no | A `RouteHandler` that owns the **public** landing `/`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). | | `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). | | `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. | -| `permissions` | no | Tokens this plugin introduces. See [Nav & permissions](#nav--permissions). | +| `roles` | no | Roles this plugin gates on. See [Nav & role gates](#nav--role-gates). | | `routes` | no | See [Routes & handlers](#routes--handlers). | | `hooks` | no | See [Hooks](#hooks). | @@ -307,10 +427,10 @@ A plugin may be routes-only, nav-only, or hooks-only — every collection field ### Routes & handlers -A route is `{ method, path, permission?, public?, handler }`. `path` is **relative to the plugin's +A route is `{ method, path, role?, public?, handler }`. `path` is **relative to the plugin's mount path `/`** (so `path: "/:id"` in the `things` plugin serves `/things/:id`); the host matches `method` + the resolved full path, extracts `:name` segments into `ctx.params.name`, -runs the `permission` gate (a coarse JWT-claim check — see [Nav & permissions](#nav--permissions)), +runs the `role` gate (a coarse JWT-claim check — see [Nav & role gates](#nav--role-gates)), and only then calls the handler with the [request context](#requestcontext). When the gate fails, an **anonymous** visitor is redirected to `/login` to sign in; the requested page is preserved as `return_to`, so after signing in they land **back on the page they @@ -355,7 +475,7 @@ export async function listThings(ctx: RequestContext) { partials/subfolders to render a full page — exactly as the admin plugin's screens do. To load the plugin's own CSS, pass its `/public//x.css` href in the shell's `styles` slot (an array of extra stylesheet hrefs) — see the reference's `views/shifts.ejs`. -- **Finer authorization than the route `permission`** uses the guards from `#plugin-api`: +- **Finer authorization than the route `role`** uses the guards from `#plugin-api`: `requireSession(ctx)` (assert a session — throws a `GuardError` the host turns into a redirect to sign in), `can(ctx, role)` (a coarse JWT-claim check, zero I/O), and `check(keto, ctx, {namespace, object, relation})` (a live Keto check for relationship rules — the subject is the @@ -414,7 +534,7 @@ points there. 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 -single permission — there's no second dashboard to fall back to, so a user lacking it would land on a +single role — there's no second dashboard to fall back to, so a user lacking it would land on a 403. (Both slots answer `GET` and `HEAD`.) Only **one** plugin may own each slot: two declaring `home` (or two declaring `dashboard`) is a @@ -505,15 +625,15 @@ OAuth2 clients use `ctx.system.hydra`, and a deactivate/delete or user role-chan `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. -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 doesn't sandbox — [crash-isolation is a non-goal](#overview)). An ordinary domain plugin ignores it. -### Nav & permissions +### Nav & role gates A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/ui/nav.ts`), which applies the central override and then **filters per user** by the roles in the session JWT — a -node shows iff it is `public`, declares no `permission`, or the user's roles include that token. Use +node shows iff it is `public`, declares no `role`, or the user's roles include that name. Use arbitrary depth, counts, and icons; see `composeNav` for the node shape. A node's `icon` is a **Lucide icon**, referenced by its sprite id (e.g. `i-cal` → lucide `calendar`); the available ids are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name there. @@ -521,9 +641,9 @@ are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its luci #### Public pages & menu items A route or nav node may be marked **`public: true`** — reachable by **anyone, signed in or not**, -and the menu item shows for everyone. This is the same as omitting `permission` (a no-permission +and the menu item shows for everyone. This is the same as omitting `role` (an ungated route/node is already open) but stated outright, so "public" is a **deliberate choice, not the -accident of a forgotten gate**. `public` and `permission` are **mutually exclusive** — declaring +accident of a forgotten gate**. `public` and `role` are **mutually exclusive** — declaring both is contradictory and discovery refuses the plugin at boot. A public page still renders in the native shell via `ctx.chrome`; for an anonymous visitor @@ -533,18 +653,14 @@ empty (read a role with `can(ctx, …)` to branch). The reference plugin's `/sch **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`. -**A `permission` token is a coarse role.** The route/nav gate passes iff the user's JWT `roles` -include the token; those roles come from Keto at login, so an operator grants a token by writing the -Keto tuple `Role:#members@user:` (or to a group) — the admin **Roles** screen does this. -(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.) +The gate passes iff the user's JWT `roles` include that name. How roles are granted, why their +names are a shared global namespace, and the fine-grained per-row tier are all covered in +[Users, groups & roles](#users-groups--roles). -Permission tokens are a **shared global namespace** — that's deliberate, so an operator grants -`scheduling:read` once in Keto and every plugin referencing it is gated consistently. Namespace -your tokens as `:` to avoid accidental clashes. Declaring them in `permissions` is -optional but recommended: it documents them, feeds conflict detection, and lets the one-command -bootstrap seed them — the demo admin is granted every discovered plugin's declared tokens, so -a dropped-in plugin works out of the box without editing host config. +Declaring the ones you gate on in `roles` is **optional but recommended**: it documents them, +feeds conflict detection, and lets the one-command bootstrap seed them — the demo admin is +granted every discovered plugin's declared roles, so a dropped-in plugin works out of the box +without editing host config. ### Contract versioning @@ -579,15 +695,15 @@ with `findConflicts` and resolves them **loudly — never last-write-wins**. `er | `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/` prefix is unique), so this catches a plugin duplicating one of its own. | | `nav-id` | error | A nav node `id` is used more than once — the central override targets ids, so they must be unique. | | `home` / `dashboard` | error | More than one plugin declares `home` (or `dashboard`). Each landing page is a single slot, so only one may own it ([The landing pages](#the-landing-pages-home--dashboard)). | -| `permission` | warn | A permission token is declared by more than one plugin. Sharing is legitimate (shared role); namespace as `:` if unintended. | +| `role` | warn | A role name is declared by more than one plugin. Sharing is legitimate; namespace as `:` if unintended. | There is **no separate `basePath` rule**: the mount path is the derived `/`, so its -uniqueness follows from the id check. `permission` is the one intentional overlap, so it warns +uniqueness follows from the id check. `role` is the one intentional overlap, so it warns rather than aborts; everything else is an error an author fixes before the host will start. Beyond cross-plugin conflicts, discovery also rejects **per-manifest shape errors** at boot: a -non-array `nav`/`routes`/`permissions`, a non-function `home`/`dashboard`, or a route/nav node that -sets both `public` and `permission` (mutually exclusive — [Public pages](#public-pages--menu-items)). +non-array `nav`/`routes`/`roles`, a non-function `home`/`dashboard`, or a route/nav node that +sets both `public` and `role` (mutually exclusive — [Public pages](#public-pages--menu-items)). ### Hooks @@ -654,7 +770,7 @@ can't escape its own package scope, so it can't point at the host's file directl > Discovery — scanning `plugins/`, importing each `plugin.ts` default export, and > validating it (id, `apiVersion`, conflicts) — runs at boot (`src/plugin-host/discovery.ts`); a bad > plugin stops startup with a precise message. The router (`src/plugin-host/router.ts`) then mounts -> each route at `/`, resolves `:name` params, runs the permission gate, and turns the +> each route at `/`, resolves `:name` params, runs the role gate, and turns the > handler's `RouteResult` into the response; a `view` result renders > `plugins//views/.ejs` (`src/plugin-host/view-resolver.ts`), which may `include()` the core > building-block partials. A plugin's `public/` assets are served at `/public//` @@ -685,7 +801,7 @@ worked example: thin handlers bound to an injectable upstream client, unit-teste 3. **E2E the user-facing flow.** Per AGENTS.md §6, ship a side-effect-free Playwright test in `e2e-tests/` for each plugin page/form so the suite stays `fullyParallel`, run against the live `web` - service with the plugin mounted. The reference's permission-gating is covered in `visual.spec.ts`; + service with the plugin mounted. The reference's role-gating is covered in `visual.spec.ts`; its authenticated list/form happy-path is the full-E2E item (needs cross-host login infra). The validation an author hits is the same the host runs: bad `apiVersion` or a conflict @@ -713,13 +829,13 @@ The menu is **driven entirely by config** and assembled from two sources: export default defineMenu({ branding: { name: "Acme Ops" }, override: { hide: ["teams"] } }); ``` -Every nav item may carry a `permission`; the rendered tree is **filtered per user** by +Every nav item may carry a `role`; the rendered tree is **filtered per user** by reading the roles in the session JWT (no per-request authz call — see -[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: 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 -says so on purpose, and is mutually exclusive with `permission`). The markup is the +a public page and its menu entry (an ungated item is already public; `public` just +says so on purpose, and is mutually exclusive with `role`). The markup is the recursive, zero-JS nav tree from the design foundation (header/leaf × clickable/static, counts, arbitrary depth). Branding (name, logo, default theme) renders in the app shell — the sidebar brand shows the configured logo (else a default mark), and the theme sets the @@ -786,7 +902,7 @@ 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_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) | -| `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) | | `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) | @@ -880,7 +996,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 (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 in a **locally-validated JWT**, and **Keto** is reserved for the rare fine-grained, @@ -969,6 +1085,9 @@ deactivate the user, or use a direct user-role change, for an instant effect. ### Three tiers of "may I?" +[Users, groups & roles](#users-groups--roles) covers *what* the entities are; this is where each +**kind** of rule belongs. + ``` coarse (menu / route / feature) → JWT claim · in-process, zero I/O fine + attribute (owner / tenant / …) → upstream service that owns the row @@ -983,10 +1102,8 @@ deactivate the user, or use a direct user-role change, for an instant effect. is for. Reserve it for those; don't pay its tuple-sync cost for rules a service can already answer from its own data. -The built-in users / groups / permissions screens write authorization **only to Keto** — -coarse roles and fine-grained relationships alike. Roles reach the JWT by being read from -Keto at login and projected through the tokenizer (above); nothing authors them anywhere -else. +The admin plugin's users / groups / roles screens write authorization **only to Keto** — coarse +roles and fine-grained relationships alike. ### OAuth2 provider (Hydra) @@ -1027,7 +1144,7 @@ what defends what, and which guarantees are deliberately not offered. plus `nbf` and the optional `iss`/`aud`. Before that it is bytes. - **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 permission control. Keto **read** (`4466`) cannot write, but discloses + *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. @@ -1126,7 +1243,7 @@ pages by **verifying the JWT in-process, with no per-request call to Ory**. Keto 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 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 POSTs self-service flows) and Hydra public `4444`; **prod** (`docker compose -f compose.yml @@ -1170,7 +1287,7 @@ service — no Node/browsers on the host. There are five suites: **Visual + design system** (`visual.spec.ts`) — Ory-free, so it stays fast. It screenshots the live pages and asserts the rendered design system — the app shell, theme switch, mobile off-canvas layout, icon sprite, CSRF-guarded sign-out, the public landing, the 404 page, and -plugin permission-gating — the last exercised by bind-mounting the reference example +plugin role-gating — the last exercised by bind-mounting the reference example (`examples/plugins/scheduling/`) onto `/app/plugins/scheduling`. ```bash @@ -1206,7 +1323,7 @@ docker compose -f compose.yml -f e2e-tests/compose.oauth.yml down -v the themed **password login** and a **mocked-SSO** login (an in-network mock OIDC provider, `e2e-tests/mock-oidc.ts`), **menu filtering by role**, the **users/groups/roles** admin CRUD, the **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 +for it), a role-gated **plugin page**, and **logout**. Because the themed form posts straight to Kratos and cookies are host-scoped, a tiny same-origin gateway (`e2e-tests/proxy.ts`) fronts web + Kratos on one host (`ory/kratos/e2e-proxy.yml` points Kratos at it) — exactly as a production reverse proxy would. @@ -1401,7 +1518,7 @@ mid-response, so container restarts are clean. The first-boot **bootstrap** is idempotent and runs on every `up` — it generates the JWT signing key if absent, creates the demo admin in Kratos, and grants it the `admin` role plus -every discovered plugin's declared permission tokens in Keto, so permission checks (and any +every discovered plugin's declared role names in Keto, so role checks (and any dropped-in plugin) resolve out of the box. The web app waits for Kratos + Keto to be healthy *and* the bootstrap to finish before starting. **Change the demo admin before production.** @@ -1538,7 +1655,7 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *. jwks.ts JwksProvider — resolve the verify key by kid; createJwksProvider() picks by scheme: staticJwks (base64) or cachingJwks (file/http: TTL cache + rotation-on-miss reload) gen-jwks.ts generateJwks()/rotateJwks() + CLI (mint · --prepend · --prune): the ES256 session-tokenizer signing JWKS; see JWT signing key & rotation login.ts completeLogin()/remintSession(): login completion + TTL re-mint — roles from Keto → metadata_public projection → tokenize → session JWT cookie - guards.ts requireSession()/can()/check(): in-handler authorization — the imperative counterpart to the route 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 role gate; GuardError → 303 /login or 403; check() is the one live Keto "may I?" call csrf.ts CSRF for our own POST forms: signed double-submit token — issue/verify, cookie, request gate denylist.ts Optional instant-revoke denylist: in-memory, auto-evicting; hot path rejects a revoked subject's pre-revoke tokens (REVOCATION_DENYLIST) flow-view.ts buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs @@ -1557,7 +1674,7 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *. plugin-api.ts Stable plugin author barrel — the one module a plugin imports, as `#plugin-api` (definePlugin, ctx/result types, guards, body/CSRF/list-query/paginate helpers, and the ctx.system Ory client types) system.ts SystemCapabilities: the privileged ctx.system surface (Ory admin clients + instant-revoke) a system plugin uses; the host populates it from the wired clients, the admin plugin consumes it discovery.ts discoverPlugins(): scan plugins/, import + validate each plugin.ts default export, fail loud at boot - router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate + router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, role gate hooks.ts runBootHooks()/runRequestHooks()/runResponseHooks(): invoke a plugin's optional lifecycle hooks in discovery order; no sandbox (a throwing hook fails loud), skipped when no plugin declares one view-resolver.ts renderPluginView(): render plugins//views/.ejs; plugin views can include() core partials @@ -1576,7 +1693,7 @@ public/ Static assets under /public/ (css/styles.css + auth.css, fa 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) 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 + role-gated nav), plugins/admin/ (the system-admin plugin — Users/Groups/Roles/OAuth2-clients over Ory via ctx.system), both copied into plugins/; and config/menu.ts (the menu/branding template copied into config/); shifts-upstream/ is the dev mock backend the scheduling plugin reads/writes (stand-in for your real service) e2e-tests/ Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint) + oauth-login.spec (OAuth2 login + consent) + full-flow.spec (browser UI: password/SSO login, menu-by-role, admin CRUD, plugin page, logout) + devstack-login.spec (regression: login works from the banner's localhost URL and 127.0.0.1 is canonicalised, on the plain `docker compose up` topology); proxy.ts (same-origin gateway) + mock-oidc.ts (mock SSO provider) back full-flow. e2e-tests/Dockerfile + e2e-tests/compose.{visual,auth,oauth,full,devstack}.yml run them 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; diff --git a/examples/plugins/admin/admin-shared.ts b/examples/plugins/admin/admin-shared.ts index bf94909..ced9a18 100644 --- a/examples/plugins/admin/admin-shared.ts +++ b/examples/plugins/admin/admin-shared.ts @@ -5,7 +5,7 @@ import { can, CSRF_FIELD, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type User } from "#plugin-api"; -export const ADMIN_ROLE = "admin"; // role token gating the whole admin section +export const ADMIN_ROLE = "admin"; // the role gating the whole admin section export const ADMIN_USERS_BASE = "/admin/users"; export const ADMIN_GROUPS_BASE = "/admin/groups"; export const ADMIN_ROLES_BASE = "/admin/roles"; diff --git a/examples/plugins/scheduling/plugin.ts b/examples/plugins/scheduling/plugin.ts index ec27807..c80522f 100644 --- a/examples/plugins/scheduling/plugin.ts +++ b/examples/plugins/scheduling/plugin.ts @@ -30,7 +30,7 @@ export default definePlugin({ label: "Scheduling", }], - // Tokens this plugin introduces (docs + Keto seeding). Namespaced `:`. + // Roles this plugin introduces (docs + Keto seeding). Namespaced `:`. roles: [ { description: "View shifts", name: READ }, { description: "Create and edit shifts", name: WRITE }, diff --git a/examples/plugins/scheduling/shifts.ts b/examples/plugins/scheduling/shifts.ts index 7cdcdab..0d41aec 100644 --- a/examples/plugins/scheduling/shifts.ts +++ b/examples/plugins/scheduling/shifts.ts @@ -10,8 +10,8 @@ import { can, CSRF_FIELD, GuardError, type PageChrome, parseListQuery, readFormB export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page export const SHIFTS_PATH = "/scheduling/shifts"; -export const READ = "scheduling:read"; // role name gating the list + nav -export const WRITE = "scheduling:write"; // role name gating create +export const READ = "scheduling:read"; // the role gating the list + nav +export const WRITE = "scheduling:write"; // the role gating create export interface Shift { id: string; diff --git a/src/auth/bootstrap.test.ts b/src/auth/bootstrap.test.ts index 87fdc11..91ce657 100644 --- a/src/auth/bootstrap.test.ts +++ b/src/auth/bootstrap.test.ts @@ -30,7 +30,7 @@ test("roleTuple grants a role to user: in the Role namespace", () => { }); }); -test("seedRoles unions ADMIN_ROLES (default 'admin') with the discovered plugins' declared tokens", () => { +test("seedRoles unions ADMIN_ROLES (default 'admin') with the discovered plugins' declared roles", () => { // Clean clone: no ADMIN_ROLES, the scheduling plugin declares its two tokens → the demo admin // 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"]); diff --git a/src/auth/bootstrap.ts b/src/auth/bootstrap.ts index ac864f4..b0e42a5 100644 --- a/src/auth/bootstrap.ts +++ b/src/auth/bootstrap.ts @@ -32,9 +32,9 @@ export function roleTuple(identityId: string, role: string) { // unioned with every discovered plugin's declared role names (a route/nav `role` is a // coarse role — granted as a Keto `Role:#members` tuple). So the host names no plugin, yet a // 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 seedRoles(adminRolesEnv: string | undefined, declaredRoles: string[]): string[] { 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(declaredRoles)])]; } // --- JWKS safety net ----------------------------------------------------------------- diff --git a/todo.md b/todo.md index e24a18f..7a42f03 100644 --- a/todo.md +++ b/todo.md @@ -15,8 +15,10 @@ - [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] 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.) - [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:` behind the green gate, `release.yml` re-tags it to semver and syncs those tags to Docker Hub.) -- [x] The human developer understands the security model in the auth in this project. (README → Auth → [Security model](README.md#security-model): trust boundaries — browser untrusted, JWT untrusted until verified, the private network as the *only* guard on the unauthenticated Ory admin APIs, plugins trusted and unsandboxed, row rules upstream — plus a threat→defense table, the fail-closed rule, and pointers to the limits that are deliberately not guaranteed. Signed-not-encrypted is called out so nothing secret lands in a claim, and the JWT's ~10m TTL is separated from the 30-day Kratos session that re-mints it. Every row of the threat table is enforced by a test — the mandatory-`exp` guard was the one gap, now asserted in `src/auth/jwt-middleware.test.ts`; the trust-boundary bullets are not testable claims. Review also corrected the hardening checklist — `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`, so the committed Kratos/Hydra/Postgres dev secrets are now listed in "What you must supply".) +- [x] The human developer understands the security model in the auth in this project. (README → Auth → [Security model](README.md#security-model): trust boundaries — browser untrusted, JWT untrusted until verified, the private network as the *only* guard on the unauthenticated Ory admin APIs, plugins trusted and unsandboxed, row rules upstream — plus a threat→defense table, the fail-closed rule, and pointers to the limits that are deliberately not guaranteed. Signed-not-encrypted is called out so nothing secret lands in a claim, and the JWT's ~10m TTL is separated from the 30-day Kratos session that re-mints it. Every row of the threat table is enforced by a test — the mandatory-`exp` guard was the one gap, now asserted in `src/auth/jwt-middleware.test.ts`; the trust-boundary bullets are not testable claims. Review also corrected the hardening checklist — `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`, so the committed Kratos/Hydra/Postgres dev secrets are now listed in "What you must supply". Follow-up: the *threat* model turned out not to be the part that was unclear — the **authorization** model was. README gained a top-level [Users, groups & roles](README.md#users-groups--roles) section (entity table, worked graph, per-route can/cannot walkthrough, the "a per-row grant never widens a coarse gate" trap), placed before Building plugins because a manifest's `role:` gate is unreadable without it.) - [ ] Add i18n support. +- [ ] Decide whether the Keto `User` namespace should follow Kratos and become `Identity`. Kratos never says "user" — it is `/admin/identities`, `identity.traits`, `session.identity` — but our OPL declares `class User` with subjects `user:`, and the code already mixes both (`login.ts` passes `identityId`, `context.ts` exports `User`). Aligning means renaming the namespace *and* the `user:` subject prefix, which rewrites every relation tuple in Keto's Postgres — a data migration, not a code change. Raised 2026-08-03 while renaming the coarse gate to `role`; deliberately left out of that change because it is a different and much heavier class of edit. +- [ ] 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 `.` 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) -- 2.52.0 From 3486e0ad0053a77498e329c39918f6d473f3309a Mon Sep 17 00:00:00 2001 From: lilleman Date: Mon, 3 Aug 2026 12:19:12 +0200 Subject: [PATCH 09/14] Rename the Keto User namespace to Identity, matching Kratos --- README.md | 27 +++++---- examples/plugins/admin/admin-clients.ts | 4 +- examples/plugins/admin/admin-groups.test.ts | 20 +++---- examples/plugins/admin/admin-groups.ts | 18 +++--- examples/plugins/admin/admin-roles.test.ts | 16 +++--- examples/plugins/admin/admin-roles.ts | 14 ++--- examples/plugins/admin/admin-shared.test.ts | 12 ++-- examples/plugins/admin/admin-shared.ts | 4 +- examples/plugins/admin/admin-users.ts | 4 +- .../views/partials/group-detail-body.ejs | 2 +- .../admin/views/partials/role-detail-body.ejs | 2 +- examples/plugins/scheduling/shifts.test.ts | 4 +- examples/plugins/scheduling/shifts.ts | 2 +- ory/keto/namespaces.keto.ts | 16 +++--- src/auth/bootstrap.test.ts | 10 ++-- src/auth/bootstrap.ts | 4 +- src/auth/guards.test.ts | 10 ++-- src/auth/guards.ts | 12 ++-- src/auth/jwt-middleware.test.ts | 36 ++++++------ src/auth/jwt-middleware.ts | 20 +++---- src/auth/keto-client.test.ts | 4 +- src/auth/login.test.ts | 12 ++-- src/auth/login.ts | 12 ++-- src/auth/routes.ts | 6 +- src/http/app.test.ts | 56 +++++++++---------- src/http/app.ts | 28 +++++----- src/http/context.test.ts | 10 ++-- src/http/context.ts | 19 ++++--- src/keto.test.ts | 6 +- src/plugin-host/plugin-api.ts | 2 +- src/ui/chrome.test.ts | 4 +- src/ui/chrome.ts | 10 ++-- src/ui/dashboard.test.ts | 2 +- src/ui/dashboard.ts | 6 +- src/ui/shell-context.test.ts | 2 +- src/ui/shell-context.ts | 14 ++--- todo.md | 2 +- 37 files changed, 220 insertions(+), 212 deletions(-) diff --git a/README.md b/README.md index dd97b29..fa41009 100644 --- a/README.md +++ b/README.md @@ -206,18 +206,25 @@ separate "permission" object to define, register, or wire up. - **Group** answers *who* — a reusable set of people. Optional: a role can be granted straight to a user. - **Role** answers *what* — its **name is the string** you write in a manifest's `role:` gate. -- **A relation tuple** is the grant: `Role:#members@user:`, or `@Group:#members`. +- **A relation tuple** is the grant: `Role:#members@identity:`, or `@Group:#members`. - **Resource** answers *which row* — a live check, run only where a plugin explicitly asks for it. | Entity | Lives in | Answers | Example | | --- | --- | --- | --- | -| **User** | Kratos | who you are | the identity behind `user:0198f2c1-…` | +| **Identity** | Kratos | who you are | `identity:0198f2c1-…` | | **Group** | Keto | who — a reusable set | `Group:support` | | **Role** | Keto | what you may do | `Role:scheduling:read` | | **Resource** | Keto | which specific row | `Resource:shift-4471` | Identities live in Kratos; every authorization edge is a Keto relation tuple. The app itself -stores none of it — it is [stateless](#stateless). The model is `ory/keto/namespaces.keto.ts`. +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). `Identity` is named to match Kratos, which owns that +record. `Group`, `Role` and `Resource` have no upstream counterpart to match, so they use the +ordinary words. > **On the word "permission".** Ory uses it for the fine-grained `Resource` tier — the `permits` > block (`view`/`edit`/`delete`). Plainpages therefore never uses it for the coarse tier: what a @@ -527,12 +534,12 @@ export default definePlugin({ Each is a `RouteHandler` like any route's — it receives the [`RequestContext`](#requestcontext) and returns a `RouteResult`, typically a `view` from the plugin's own `views/`. A `dashboard` handler renders against the native app shell via `ctx.chrome` exactly as a route handler does; a `home` -handler is a **public** page, so `ctx.user` may be `null` (use it to show a "go to dashboard" link to +handler is a **public** page, so `ctx.identity` may be `null` (use it to show a "go to dashboard" link to a signed-in visitor, or sign-in / register to an anonymous one). After login the user lands on `/dashboard` (or the `return_to` they were headed to), and the global menu's **Dashboard** link 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.identity` is non-null; branch on `ctx.roles` *inside* to tailor the page per role. Don't gate `dashboard` itself behind a single role — there's no second dashboard to fall back to, so a user lacking it would land on a 403. (Both slots answer `GET` and `HEAD`.) @@ -550,15 +557,15 @@ request: ```ts interface RequestContext { chrome: PageChrome; // brand/global-nav/user/theme/csrf for the native app shell + identity: SessionIdentity | null; // { id, email, roles } from the verified session JWT, or null log: Log; // request-scoped logger, in this request's trace params: Record; // path params from the route match, e.g. /things/:id → { id } query: URLSearchParams; // alias of url.searchParams req: IncomingMessage; res: ServerResponse; - roles: string[]; // user?.roles ?? [] — coarse gate without a null-check + roles: string[]; // identity?.roles ?? [] — coarse gate without a null-check system?: SystemCapabilities; // privileged Ory clients + instant-revoke, for a system plugin (see below); undefined unless the host wired them url: URL; - 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 } ``` @@ -647,7 +654,7 @@ accident of a forgotten gate**. `public` and `role` are **mutually exclusive** both is contradictory and discovery refuses the plugin at boot. A public page still renders in the native shell via `ctx.chrome`; for an anonymous visitor -`ctx.user` is `null`, the shell shows a **Sign in** link (`chrome.signInHref`, returning to this page) +`ctx.identity` is `null`, the shell shows a **Sign in** link (`chrome.signInHref`, returning to this page) in place of the profile/sign-out block, the gated **Dashboard** link is hidden, and `ctx.roles` is empty (read a role 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, @@ -1028,7 +1035,7 @@ the session for a signed JWT once** via the Kratos **session tokenizer** (`whoam ``` **Keto is the single source of truth for roles.** Coarse roles are Keto relations (e.g. -`role:admin#members@user:alice`); the admin screens write them *only* to Keto. But the +`Role:admin#members@identity:alice`); the admin screens write them *only* to Keto. But the tokenizer's claims mapper can read only the **identity**, not call Keto — so at login the app reads the roles from Keto and refreshes a **derived projection**: a read-only copy written onto the identity's `metadata_public` for the tokenizer to see, which the template @@ -1651,7 +1658,7 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *. auth/ Identity, the session-JWT hot path, guards, and the Ory REST clients jwt.ts JWS signature verify via node:crypto, no jose (decode + verify a compact JWS against one JWK) - jwt-middleware.ts resolveSession()/authenticate(): per-request session-JWT verify — key by kid → signature → exp/nbf/iss/aud (clock skew) → ctx.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.identity/roles; flags a lapsed token for re-mint jwks.ts JwksProvider — resolve the verify key by kid; createJwksProvider() picks by scheme: staticJwks (base64) or cachingJwks (file/http: TTL cache + rotation-on-miss reload) gen-jwks.ts generateJwks()/rotateJwks() + CLI (mint · --prepend · --prune): the ES256 session-tokenizer signing JWKS; see JWT signing key & rotation login.ts completeLogin()/remintSession(): login completion + TTL re-mint — roles from Keto → metadata_public projection → tokenize → session JWT cookie diff --git a/examples/plugins/admin/admin-clients.ts b/examples/plugins/admin/admin-clients.ts index 1069e14..977a00d 100644 --- a/examples/plugins/admin/admin-clients.ts +++ b/examples/plugins/admin/admin-clients.ts @@ -5,7 +5,7 @@ // PRG redirect (mirrors the Users "trigger recovery" one-time code). Below the builders are thin // per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded. -import { type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type User } from "#plugin-api"; +import { type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type SessionIdentity } from "#plugin-api"; import { ADMIN_CLIENTS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts"; import type { FieldConfig } from "./admin-users.ts"; @@ -235,7 +235,7 @@ function readClientInput(form: URLSearchParams): ClientInput { // Shared per-request deps for the OAuth2-clients screen, resolved by `withClients`: the gate + the // Hydra capability (else a themed 503). Each route below is a thin handler over these. -interface ClientsDeps { ctx: RequestContext; hydra: HydraAdmin; user: User; } +interface ClientsDeps { ctx: RequestContext; hydra: HydraAdmin; user: SessionIdentity; } function withClients(inner: (deps: ClientsDeps) => Promise): RouteHandler { return async (ctx) => { diff --git a/examples/plugins/admin/admin-groups.test.ts b/examples/plugins/admin/admin-groups.test.ts index f1962ca..79d34c0 100644 --- a/examples/plugins/admin/admin-groups.test.ts +++ b/examples/plugins/admin/admin-groups.test.ts @@ -18,7 +18,7 @@ import type { RelationTuple } from "#plugin-api"; const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`; const userTuple = (group: string, n: number): RelationTuple => - ({ namespace: "Group", object: group, relation: "members", subject_id: `user:${uid(n)}` }); + ({ namespace: "Group", object: group, relation: "members", subject_id: `identity:${uid(n)}` }); const groupTuple = (group: string, child: string): RelationTuple => ({ namespace: "Group", object: group, relation: "members", subject_set: { namespace: "Group", object: child, relation: "members" } }); @@ -28,12 +28,12 @@ test("isValidGroupName accepts URL-safe names, rejects empties/spaces/uppercase/ }); test("parseSubject + memberTuple map the form value to the user/nested-group subject (else null)", () => { - assert.deepEqual(parseSubject(`user:${uid(1)}`), { subject_id: `user:${uid(1)}` }); + assert.deepEqual(parseSubject(`identity:${uid(1)}`), { subject_id: `identity:${uid(1)}` }); assert.deepEqual(parseSubject("group:eng"), { subject_set: { namespace: "Group", object: "eng", relation: "members" } }); // Both forms are validated: a non-UUID user / invalid group name is rejected, not written blindly. - for (const bad of ["", "user:", "user:not-a-uuid", "group:", "group:Bad Name", "nope:x", "plain"]) assert.equal(parseSubject(bad), null, bad); + for (const bad of ["", "identity:", "identity:not-a-uuid", "group:", "group:Bad Name", "nope:x", "plain"]) assert.equal(parseSubject(bad), null, bad); - assert.deepEqual(memberTuple("design", `user:${uid(2)}`), { namespace: "Group", object: "design", relation: "members", subject_id: `user:${uid(2)}` }); + assert.deepEqual(memberTuple("design", `identity:${uid(2)}`), { namespace: "Group", object: "design", relation: "members", subject_id: `identity:${uid(2)}` }); assert.deepEqual(memberTuple("design", "group:eng"), { namespace: "Group", object: "design", relation: "members", subject_set: { namespace: "Group", object: "eng", relation: "members" } }); assert.equal(memberTuple("design", "bad"), null); }); @@ -48,8 +48,8 @@ test("groupsFromTuples collapses membership tuples → distinct groups + member test("memberView resolves a user subject to its email (else the raw id) and a subject_set to the group", () => { const emails = new Map([[uid(1), "ada@example.com"]]); - assert.deepEqual(memberView(userTuple("eng", 1), emails), { kind: "user", label: "ada@example.com", subject: `user:${uid(1)}` }); - assert.deepEqual(memberView(userTuple("eng", 9), emails), { kind: "user", label: `user:${uid(9)}`, subject: `user:${uid(9)}` }); + assert.deepEqual(memberView(userTuple("eng", 1), emails), { kind: "identity", label: "ada@example.com", subject: `identity:${uid(1)}` }); + assert.deepEqual(memberView(userTuple("eng", 9), emails), { kind: "identity", label: `identity:${uid(9)}`, subject: `identity:${uid(9)}` }); assert.deepEqual(memberView(groupTuple("eng", "design"), emails), { kind: "group", label: "design", subject: "group:design" }); }); @@ -76,7 +76,7 @@ test("buildGroupsListModel filters by search, sorts, paginates; the name links t }); test("buildGroupFormModel: a create form with a required name field + member options, no group of its own", () => { - const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }]; + const options = [{ label: "ada@example.com", value: `identity:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }]; const m = buildGroupFormModel({ csrfToken: "tok.sig", memberOptions: options }); assert.equal(m.title, "New group"); assert.equal(m.form.action, "/admin/groups"); @@ -96,8 +96,8 @@ test("buildGroupFormModel: a create form with a required name field + member opt test("buildGroupDetailModel: members → rows, add-options exclude current members + the group itself, delete/remove wired", () => { const members = [memberView(userTuple("eng", 1), new Map([[uid(1), "ada@example.com"]])), memberView(groupTuple("eng", "design"), 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: "ada@example.com", value: `identity:${uid(1)}` }, // already a member → excluded + { label: "grace@example.com", value: `identity:${uid(2)}` }, { label: "design (group)", value: "group:design" }, // already a member → excluded { label: "eng (group)", value: "group:eng" }, // the group itself → excluded { label: "ops (group)", value: "group:ops" }, @@ -107,6 +107,6 @@ test("buildGroupDetailModel: members → rows, add-options exclude current membe assert.equal(m.members.rows.length, 2); assert.equal(m.members.action, "/admin/groups/eng/members/delete"); assert.equal(m.add.action, "/admin/groups/eng/members"); - assert.deepEqual(m.add.options.map((o) => o.value), [`user:${uid(2)}`, "group:ops"]); + assert.deepEqual(m.add.options.map((o) => o.value), [`identity:${uid(2)}`, "group:ops"]); assert.equal(m.delete.action, "/admin/groups/eng/delete"); }); diff --git a/examples/plugins/admin/admin-groups.ts b/examples/plugins/admin/admin-groups.ts index 2f5e00b..cd8c881 100644 --- a/examples/plugins/admin/admin-groups.ts +++ b/examples/plugins/admin/admin-groups.ts @@ -6,7 +6,7 @@ // per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded, // each returning a RouteResult. -import { type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type User } from "#plugin-api"; +import { type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type SessionIdentity } from "#plugin-api"; import { ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts"; import type { FieldConfig } from "./admin-users.ts"; @@ -25,9 +25,9 @@ export interface GroupView { } // A member's view model: a user (label = email) or a nested group (label = group name). `subject` -// is the form value that round-trips it — `user:` or `group:` (see parseSubject). +// is the form value that round-trips it — `identity:` or `group:` (see parseSubject). export interface MemberView { - kind: "group" | "user"; + kind: "group" | "identity"; label: string; subject: string; } @@ -35,7 +35,7 @@ export interface MemberView { // One option in a member <% }) -%> @@ -32,7 +32,7 @@

Effective access

-

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

+

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

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

No users hold this role yet.

+

No users hold this permission yet.

<% } -%>
-

Assign the role

+

Assign the permission

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

All users and groups already have this role.

+

All users and groups already have this permission.

<% } -%>
-
- Delete role +
+ Delete permission
diff --git a/examples/plugins/admin/views/partials/role-form-body.ejs b/examples/plugins/admin/views/partials/permission-form-body.ejs similarity index 85% rename from examples/plugins/admin/views/partials/role-form-body.ejs rename to examples/plugins/admin/views/partials/permission-form-body.ejs index 9582cd9..63cab01 100644 --- a/examples/plugins/admin/views/partials/role-form-body.ejs +++ b/examples/plugins/admin/views/partials/permission-form-body.ejs @@ -1,5 +1,5 @@ <%# - Admin role create form body, captured into the shell content slot. Config: + Admin permission create form body, captured into the shell content slot. Config: form { action, csrfToken, submitLabel, cancelHref, nameField: field.ejs config, memberOptions: {label,value}[], selectedMember } error? string shown when a write was rejected @@ -16,7 +16,7 @@
- A role exists once assigned; add more users or groups after creating it. + A permission exists once assigned; add more users or groups after creating it.
Cancel diff --git a/examples/plugins/admin/views/permission-detail.ejs b/examples/plugins/admin/views/permission-detail.ejs new file mode 100644 index 0000000..3ceed2d --- /dev/null +++ b/examples/plugins/admin/views/permission-detail.ejs @@ -0,0 +1,16 @@ +<%# + Permission admin detail page: the permission-detail body (members · effective access) in the shell. +%><% + const nav = include("partials/nav-tree", { nodes: chrome.nav }); + const body = include("partials/permission-detail-body", { add: model.add, csrfToken: model.csrfToken, del: model.delete, effective: model.effective, error: model.error, members: model.members, permission: model.permission }); +-%> +<%- include("partials/shell", { + body, + brand: chrome.brand, + breadcrumbs: model.breadcrumbs, + csrfToken: chrome.csrfToken, + nav, + theme: chrome.theme, + title: model.title, + user: chrome.user, +}) %> diff --git a/examples/plugins/admin/views/role-form.ejs b/examples/plugins/admin/views/permission-form.ejs similarity index 60% rename from examples/plugins/admin/views/role-form.ejs rename to examples/plugins/admin/views/permission-form.ejs index 56b1a58..89fb8cf 100644 --- a/examples/plugins/admin/views/role-form.ejs +++ b/examples/plugins/admin/views/permission-form.ejs @@ -1,8 +1,8 @@ <%# - Role admin create page: the role-form body captured into the app shell. + Permission admin create page: the permission-form body captured into the app shell. %><% const nav = include("partials/nav-tree", { nodes: chrome.nav }); - const body = include("partials/role-form-body", { error: model.error, form: model.form }); + const body = include("partials/permission-form-body", { error: model.error, form: model.form }); -%> <%- include("partials/shell", { body, diff --git a/examples/plugins/admin/views/roles.ejs b/examples/plugins/admin/views/permissions.ejs similarity index 58% rename from examples/plugins/admin/views/roles.ejs rename to examples/plugins/admin/views/permissions.ejs index 091f1a0..8eff683 100644 --- a/examples/plugins/admin/views/roles.ejs +++ b/examples/plugins/admin/views/permissions.ejs @@ -1,12 +1,12 @@ <%# - Roles admin list: the same building blocks as the Groups screen, around the shell, backed - by live Keto Role subject sets (admin-roles.ts). Filter/sort/page round-trip the URL. + Permissions admin list: the same building blocks as the Groups screen, around the shell, backed + by live Keto Permission subject sets (admin-permissions.ts). Filter/sort/page round-trip the URL. %><% const nav = include("partials/nav-tree", { nodes: chrome.nav }); const filters = include("partials/filter-bar", model.filterBar); const table = include("partials/data-table", model.table); const pager = include("partials/pagination", model.pagination); - const actions = 'Add role'; + const actions = 'Add permission'; -%> <%- include("partials/shell", { actions, diff --git a/examples/plugins/admin/views/role-detail.ejs b/examples/plugins/admin/views/role-detail.ejs deleted file mode 100644 index 2534908..0000000 --- a/examples/plugins/admin/views/role-detail.ejs +++ /dev/null @@ -1,16 +0,0 @@ -<%# - Role admin detail page: the role-detail body (members · effective access) in the shell. -%><% - const nav = include("partials/nav-tree", { nodes: chrome.nav }); - const body = include("partials/role-detail-body", { add: model.add, csrfToken: model.csrfToken, del: model.delete, effective: model.effective, error: model.error, members: model.members, role: model.role }); --%> -<%- include("partials/shell", { - body, - brand: chrome.brand, - breadcrumbs: model.breadcrumbs, - csrfToken: chrome.csrfToken, - nav, - theme: chrome.theme, - title: model.title, - user: chrome.user, -}) %> diff --git a/examples/plugins/scheduling/README.md b/examples/plugins/scheduling/README.md index e5f56cb..8586401 100644 --- a/examples/plugins/scheduling/README.md +++ b/examples/plugins/scheduling/README.md @@ -15,7 +15,7 @@ What it demonstrates: `POST /scheduling/shifts` CSRF-verifies it (`ctx.verifyCsrf`) and forwards the create upstream, then POST-redirect-GET. The form body lives in the plugin's own `views/partials/shift-form.ejs`, reusing the core `field` partial. -- **Role-gated nav** — the "Shifts" nav leaf and routes are gated on `scheduling:read` / +- **Permission-gated nav** — the "Shifts" nav leaf and routes are gated on `scheduling:read` / `scheduling:write`; the whole "Scheduling" section is invisible to anyone without the grant. The plugin holds **no state** — data lives upstream (README → *Stateless*). Handlers are thin and @@ -46,6 +46,6 @@ cosmetically) — normalise to your backend's format there if it matters. ## Granting access -A user sees Scheduling once they hold the `scheduling:read` role in Keto (and `scheduling:write` +A user sees Scheduling once they hold the `scheduling:read` permission in Keto (and `scheduling:write` to create). The one-command bootstrap grants both to the demo admin, so the seeded `admin@plainpages.local` can use it immediately. diff --git a/examples/plugins/scheduling/plugin.ts b/examples/plugins/scheduling/plugin.ts index c80522f..a7282ff 100644 --- a/examples/plugins/scheduling/plugin.ts +++ b/examples/plugins/scheduling/plugin.ts @@ -1,5 +1,5 @@ // Reference plugin: a worked example of the contract — a list page that fetches upstream -// data, a CSRF-guarded form that forwards a write upstream, and role-gated nav. Copy this +// data, a CSRF-guarded form that forwards a write upstream, and permission-gated nav. Copy this // folder, rename it, point it at your own backend. Full contract: README.md → Building plugins. import { definePlugin } from "#plugin-api"; @@ -23,7 +23,7 @@ export default definePlugin({ nav: [{ children: [ { href: SCHEDULING_PATH, id: "scheduling:overview", label: "Overview", public: true }, - { href: SHIFTS_PATH, id: "scheduling:shifts", label: "Shifts", role: READ }, + { href: SHIFTS_PATH, id: "scheduling:shifts", label: "Shifts", permission: READ }, ], icon: "i-cal", id: "scheduling", @@ -31,17 +31,17 @@ export default definePlugin({ }], // Roles this plugin introduces (docs + Keto seeding). Namespaced `:`. - roles: [ + permissions: [ { description: "View shifts", name: READ }, { description: "Create and edit shifts", name: WRITE }, ], - // Mounted under /scheduling; `role` gates before the handler runs. The overview is `public` - // (anyone may reach /scheduling, signed in or not); the rest need a role. + // Mounted under /scheduling; `permission` gates before the handler runs. The overview is `public` + // (anyone may reach /scheduling, signed in or not); the rest need a permission. routes: [ { handler: overview(), method: "GET", path: "/", public: true }, - { handler: listShifts(upstream), method: "GET", path: "/shifts", role: READ }, - { handler: newShiftForm(), method: "GET", path: "/shifts/new", role: WRITE }, - { handler: createShift(upstream), method: "POST", path: "/shifts", role: WRITE }, + { handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ }, + { handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE }, + { handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE }, ], }); diff --git a/examples/plugins/scheduling/shifts.test.ts b/examples/plugins/scheduling/shifts.test.ts index b22abcd..966c9eb 100644 --- a/examples/plugins/scheduling/shifts.test.ts +++ b/examples/plugins/scheduling/shifts.test.ts @@ -12,12 +12,12 @@ import { const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } }; -function fakeCtx(opts: { body?: string; roles?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext { +function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext { const url = new URL(opts.url ?? "http://localhost/scheduling/shifts"); const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage; return { chrome: CHROME, identity: null, log: new Log("none"), params: {}, query: url.searchParams, req, res: {} as ServerResponse, - roles: opts.roles ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true), + permissions: opts.permissions ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true), }; } @@ -93,8 +93,8 @@ test("readInput trims; validate requires title + assignee", () => { // ---- list handler ---- -test("listShifts renders the upstream rows; q filters; canWrite reflects the role", async () => { - const r = asView(await listShifts(fakeUpstream())(fakeCtx({ roles: ["scheduling:write"] }))); +test("listShifts renders the upstream rows; q filters; canWrite reflects the permission", async () => { + const r = asView(await listShifts(fakeUpstream())(fakeCtx({ permissions: ["scheduling:write"] }))); assert.equal(r.view, "shifts"); const table = r.data["table"] as { rows: { name: string }[] }; assert.deepEqual(table.rows.map((x) => x.name), ["Morning desk", "Afternoon support"]); @@ -112,15 +112,15 @@ test("listShifts degrades to a recoverable error page when the upstream is down assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []); }); -// ---- public overview handler (a page anyone can reach, gated data stays behind the role) ---- +// ---- public overview handler (a page anyone can reach, gated data stays behind the permission) ---- test("overview renders a public page for anyone; it links straight to Shifts only for a reader", async () => { - const anon = asView(await overview()(fakeCtx())); // user null, no roles + const anon = asView(await overview()(fakeCtx())); // user null, no permissions assert.equal(anon.view, "overview"); assert.equal(anon.data["chrome"], CHROME); assert.equal(anon.data["canRead"], false); // anonymous → prompt to sign in, no shifts link - const reader = asView(await overview()(fakeCtx({ roles: ["scheduling:read"] }))); + const reader = asView(await overview()(fakeCtx({ permissions: ["scheduling:read"] }))); assert.equal(reader.data["canRead"], true); // a reader gets a link straight to the shifts list }); diff --git a/examples/plugins/scheduling/shifts.ts b/examples/plugins/scheduling/shifts.ts index 85c1882..4b78741 100644 --- a/examples/plugins/scheduling/shifts.ts +++ b/examples/plugins/scheduling/shifts.ts @@ -10,8 +10,8 @@ import { can, CSRF_FIELD, GuardError, type PageChrome, parseListQuery, readFormB export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page export const SHIFTS_PATH = "/scheduling/shifts"; -export const READ = "scheduling:read"; // the role gating the list + nav -export const WRITE = "scheduling:write"; // the role gating create +export const READ = "scheduling:read"; // the permission gating the list + nav +export const WRITE = "scheduling:write"; // the permission gating create export interface Shift { id: string; @@ -188,7 +188,7 @@ export function newShiftForm(): RouteHandler { // Public overview: a page anyone may reach — its route + nav node are marked `public`, so the // gate lets an anonymous visitor through and the menu option shows for everyone. The real data // (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone -// else a prompt to sign in. ctx.identity may be null here, so read the role via can() (zero I/O). +// else a prompt to sign in. ctx.identity may be null here, so read the permission via can() (zero I/O). export function overview(): RouteHandler { return (ctx) => ({ data: { breadcrumbs: [{ label: "Overview" }], canRead: can(ctx, READ), chrome: ctx.chrome, shiftsHref: SHIFTS_PATH, title: "Scheduling" }, diff --git a/examples/plugins/scheduling/views/overview.ejs b/examples/plugins/scheduling/views/overview.ejs index 8858c17..444d21e 100644 --- a/examples/plugins/scheduling/views/overview.ejs +++ b/examples/plugins/scheduling/views/overview.ejs @@ -12,7 +12,7 @@ -%> <%- include("partials/shell", { actions: "", - body: '

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

' + cta + '
', + body: '

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

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