Rename the admin example plugin to auth-admin, and let config/menu.ts hide or rename a profile-menu entry
CI / full-gate (push) Successful in 3m32s

This commit is contained in:
2026-11-08 21:25:11 +01:00
parent 34dfbea156
commit 05da1d7897
57 changed files with 317 additions and 270 deletions
+1 -1
View File
@@ -218,7 +218,7 @@ one-time setup. A file-map or table row gets a clause, not a paragraph.
`{method, path}` in the manifest (the host extracts `:id`/`:name` and 404s malformed `%`-encoding). `{method, path}` in the manifest (the host extracts `:id`/`:name` and 404s malformed `%`-encoding).
Don't funnel many routes into one dispatcher that re-parses `ctx.url.pathname`: it duplicates the Don't funnel many routes into one dispatcher that re-parses `ctx.url.pathname`: it duplicates the
URL shape, ignores the router's params, and has to re-handle HEAD. Factor shared per-request setup URL shape, ignores the router's params, and has to re-handle HEAD. Factor shared per-request setup
into a small `withX` wrapper — see `examples/plugins/admin/`. into a small `withX` wrapper — see `examples/plugins/auth-admin/`.
- **A function stays within 50 lines and complexity 20** (ESLint's defaults, measured by - **A function stays within 50 lines and complexity 20** (ESLint's defaults, measured by
`src/function-limits.test.ts`). Split one that outgrows them; never add it to the test's `src/function-limits.test.ts`). Split one that outgrows them; never add it to the test's
`grandfathered` list or raise a ceiling there. `grandfathered` list or raise a ceiling there.
+14
View File
@@ -12,6 +12,20 @@ The host's contract version is now 0.5.0, so discovery refuses a plugin whose `a
`0.4.x`. Change the literal in each plugin's manifest when you upgrade, and add `assetHref` to any `0.4.x`. Change the literal in each plugin's manifest when you upgrade, and add `assetHref` to any
`RequestContext` your tests build by hand: `assetHref: (href) => href`. `RequestContext` your tests build by hand: `assetHref: (href) => href`.
### An operator can hide or rename a plugin's profile-menu entry
A `profileMenu` entry takes an optional `id`, and `config/menu.ts`'s `override.hide` and
`override.rename` apply to it as they do to a nav node. Nav nodes and profile-menu entries share one
set of ids, so the boot refuses an entry whose `id` another entry or nav node already uses.
### The admin example plugin is now `auth-admin`, and its screens live at `/auth-admin/*`
The Users, Groups, OAuth2-clients and Plugin-settings screens move from `/admin/*` to
`/auth-admin/*`. An installed `plugins/admin/` keeps working until you replace it: delete it, copy
`examples/plugins/auth-admin/` into `plugins/`, and move any `locales/plugins/admin/` to
`locales/plugins/auth-admin/`. The boot refuses the two installed together, since both declare the
nav section `admin`.
### The `request` log line renames `method`, `path`, `status` and `ms`, and gains the caller, site and route ### The `request` log line renames `method`, `path`, `status` and `ms`, and gains the caller, site and route
Update dashboards and log queries: the `request` line and the request span now use OpenTelemetry's Update dashboards and log queries: the `request` line and the request span now use OpenTelemetry's
+23 -23
View File
@@ -28,17 +28,17 @@ docker compose up -d # http://localhost:3000, live-reloads on source chan
**`admin@plainpages.local` / `admin`**. **`admin@plainpages.local` / `admin`**.
**3. Enable user & group admin (optional).** The core ships **no admin GUI** — the Users / Groups **3. Enable user & group admin (optional).** The core ships **no admin GUI** — the Users / Groups
/ OAuth2-clients screens are a drop-in plugin. Copy it in to mount them at `/admin/*`: / OAuth2-clients screens are a drop-in plugin. Copy it in to mount them at `/auth-admin/*`:
```bash ```bash
cp -r examples/plugins/admin plugins/admin cp -r examples/plugins/auth-admin plugins/auth-admin
docker compose up -d docker compose up -d
``` ```
The bootstrap grants the seeded admin every permission the installed plugins declare, so the The bootstrap grants the seeded admin every permission the installed plugins declare, so the
**Admin** section now shows in the menu. Use `up -d`, not `restart web` — the seed runs in the **Admin** section now shows in the menu. Use `up -d`, not `restart web` — the seed runs in the
one-shot `bootstrap` service, and only `up` re-runs it. See one-shot `bootstrap` service, and only `up` re-runs it. See
[`examples/plugins/admin/`](examples/plugins/admin/). [`examples/plugins/auth-admin/`](examples/plugins/auth-admin/).
**4. Add your first plugin.** The clone is bind-mounted into the container, so a new **4. Add your first plugin.** The clone is bind-mounted into the container, so a new
folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.ts`: folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.ts`:
@@ -200,11 +200,11 @@ public front with an authenticated area behind it.
- **Included in the core:** themed sign-in / register / reset (Kratos-backed), the design - **Included in the core:** themed sign-in / register / reset (Kratos-backed), the design
system + app shell, the config-driven menu, sessions, and access control. No domain screens. system + app shell, the config-driven menu, sessions, and access control. No domain screens.
- **Opt-in admin plugin:** the **users, groups, and OAuth2-clients** screens ship as - **Opt-in auth-admin plugin:** the **users, groups, and OAuth2-clients** screens ship as
[`examples/plugins/admin/`](examples/plugins/admin/) — an ordinary plugin, reaching Ory through [`examples/plugins/auth-admin/`](examples/plugins/auth-admin/) — an ordinary plugin, reaching Ory through
the privileged [`ctx.system`](#system-capabilities-the-ctxsystem-surface) surface. the privileged [`ctx.system`](#system-capabilities-the-ctxsystem-surface) surface.
- **You add:** everything else domain-specific, as **plugins** — built from the same building - **You add:** everything else domain-specific, as **plugins** — built from the same building
blocks the admin plugin uses. blocks the auth-admin plugin uses.
Identity, sessions, SSO, OAuth2 and permission checks are delegated to **Ory** sidecar Identity, sessions, SSO, OAuth2 and permission checks are delegated to **Ory** sidecar
services. The shape doesn't change as it grows: every plugin is the same self-contained folder, services. The shape doesn't change as it grows: every plugin is the same self-contained folder,
@@ -321,9 +321,9 @@ Against the reference plugins' actual routes:
| `GET /scheduling/shifts` | `scheduling:read` | ✅ | ✅ | 403 | → `/login` | | `GET /scheduling/shifts` | `scheduling:read` | ✅ | ✅ | 403 | → `/login` |
| `GET /scheduling/shifts/new` | `scheduling:write` | ✅ | 403 | 403 | → `/login` | | `GET /scheduling/shifts/new` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
| `POST /scheduling/shifts` | `scheduling:write` | ✅ | 403 | 403 | → `/login` | | `POST /scheduling/shifts` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
| `GET /admin/users` | `users:read` | 403 | 403 | ✅ | → `/login` | | `GET /auth-admin/users` | `users:read` | 403 | 403 | ✅ | → `/login` |
| `POST /admin/users` | `users:write` | 403 | 403 | ✅ | → `/login` | | `POST /auth-admin/users` | `users:write` | 403 | 403 | ✅ | → `/login` |
| `GET /admin/groups` | `groups:read` | 403 | 403 | 403 | → `/login` | | `GET /auth-admin/groups` | `groups:read` | 403 | 403 | 403 | → `/login` |
Bob reaches the shifts list with no direct grant — `support` → `staff` → `scheduling:read`, two hops Bob reaches the shifts list with no direct grant — `support` → `staff` → `scheduling:read`, two hops
resolved by Keto at his login. An anonymous visitor gets a **redirect** carrying `return_to`, not a resolved by Keto at his login. An anonymous visitor gets a **redirect** carrying `return_to`, not a
@@ -333,7 +333,7 @@ door they cannot open.
### Granting a permission ### Granting a permission
Write the tuple. The admin plugin's **Users** and **Groups** screens do exactly this — each offers Write the tuple. The auth-admin plugin's **Users** and **Groups** screens do exactly this — each offers
the declared permissions as a checkbox list — or use Keto's write API directly: the declared permissions as a checkbox list — or use Keto's write API directly:
```bash ```bash
@@ -418,7 +418,7 @@ URL/path-safe (`isValidPluginId`: lowercase `a–z`, digits, dashes) and also na
`RESERVED_PLUGIN_IDS` are refused at discovery — the gated `dashboard`, the Kratos auth flows `RESERVED_PLUGIN_IDS` are refused at discovery — the gated `dashboard`, the Kratos auth flows
(`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`) and their `error` page, the `oauth2` (`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`) and their `error` page, the `oauth2`
provider routes, and `public` — since plugin routes resolve first and a folder claiming one would provider routes, and `public` — since plugin routes resolve first and a folder claiming one would
silently shadow a built-in. `admin` is **not** reserved: the admin screens are themselves a plugin. silently shadow a built-in. `auth-admin` is **not** reserved: the admin screens are themselves a plugin.
Installing a plugin is "drop the folder, restart"; removing one is "delete the folder, restart". Installing a plugin is "drop the folder, restart"; removing one is "delete the folder, restart".
@@ -467,7 +467,7 @@ folder-derived `id` to produce the loaded `Plugin`.
| `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). | | `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). Every node names [exactly one gate](#public-pages--menu-items). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. A `label` that names a catalog key is [translated](#languages-i18n); anything else renders as written. | | `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). Every node names [exactly one gate](#public-pages--menu-items). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. A `label` that names a catalog key is [translated](#languages-i18n); anything else renders as written. |
| `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). | | `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). |
| `profileMenu` | no | `ProfileMenuEntry[]`: `{ href, label, icon?, …gate }` links added above Sign out in the profile dropdown, shown only to a signed-in visitor. Each names [exactly one gate](#public-pages--menu-items); `icon` and `label` follow the same rules as a `nav` node. | | `profileMenu` | no | `ProfileMenuEntry[]`: `{ href, label, icon?, id?, …gate }` links added above Sign out in the profile dropdown, shown only to a signed-in visitor. Each names [exactly one gate](#public-pages--menu-items); `icon`, `id` and `label` follow the same rules as a `nav` node. |
| `routes` | no | See [Routes & handlers](#routes--handlers). | | `routes` | no | See [Routes & handlers](#routes--handlers). |
| `hooks` | no | See [Hooks](#hooks). | | `hooks` | no | See [Hooks](#hooks). |
| `settings` | no | Configuration this plugin accepts, one `PLUGIN_SETTING_<ID>_<KEY>` variable per key, resolved and validated at boot and handed to `onBoot`. See [Plugin settings](#plugin-settings). | | `settings` | no | Configuration this plugin accepts, one `PLUGIN_SETTING_<ID>_<KEY>` variable per key, resolved and validated at boot and handed to `onBoot`. See [Plugin settings](#plugin-settings). |
@@ -651,7 +651,7 @@ interface SystemCapabilities { // every field optional — present only
`ctx.system` is **`undefined` unless the host wired at least one** of these. A system plugin treats `ctx.system` is **`undefined` unless the host wired at least one** of these. A system plugin treats
every field as optional and **degrades when absent** — the host never fails a request over it. The every field as optional and **degrades when absent** — the host never fails a request over it. The
**admin plugin** ([`examples/plugins/admin/`](examples/plugins/admin/)) is the reference consumer: **auth-admin plugin** ([`examples/plugins/auth-admin/`](examples/plugins/auth-admin/)) is the reference consumer:
Users uses `kratosAdmin`, Groups and the permission pickers `keto`, OAuth2 clients `hydra`, and a Users uses `kratosAdmin`, Groups and the permission pickers `keto`, OAuth2 clients `hydra`, and a
deactivate/delete or user permission-change calls `revoke` so the change lands before the JWT TTL; deactivate/delete or user permission-change calls `revoke` so the change lands before the JWT TTL;
a missing capability renders a themed 503. a missing capability renders a themed 503.
@@ -731,7 +731,7 @@ The host detects collisions across all discovered plugins with `findConflicts` a
| --- | --- | --- | | --- | --- | --- |
| `id` | error | Two plugins share an `id` (folder name). Ids must be globally unique — they namespace the mount path, views/static, and the override target. | | `id` | error | Two plugins share an `id` (folder name). Ids must be globally unique — they namespace the mount path, views/static, and the override target. |
| `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/<id>` prefix is unique), so this catches a plugin duplicating one of its own. | | `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/<id>` prefix is unique), so this catches a plugin duplicating one of its own. |
| `nav-id` | error | A nav node `id` is used more than once — the central override targets ids, so they must be unique. | | `nav-id` | error | A nav node or profile-menu entry `id` is used more than once — the central override targets ids, so they must be unique across both. |
| `setting` | error | Two settings resolve to the same `PLUGIN_SETTING_` variable name; rename a key or a plugin folder ([Plugin settings](#plugin-settings)). | | `setting` | error | Two settings resolve to the same `PLUGIN_SETTING_` variable name; rename a key or a plugin folder ([Plugin settings](#plugin-settings)). |
| `home` / `dashboard` | error | More than one plugin declares `home` (or `dashboard`). Each landing page is a single slot, so only one may own it ([The landing pages](#the-landing-pages-home--dashboard)). | | `home` / `dashboard` | error | More than one plugin declares `home` (or `dashboard`). Each landing page is a single slot, so only one may own it ([The landing pages](#the-landing-pages-home--dashboard)). |
| `permission` | warn | A permission name is declared by more than one plugin. Sharing is legitimate; pick a more specific [`<resource>`](#naming-a-permission) if unintended. Also warned: a route, nav node or profile-menu entry gates on a permission no installed plugin declares, so the admin screens can't grant it; declare it in that plugin's `permissions`. | | `permission` | warn | A permission name is declared by more than one plugin. Sharing is legitimate; pick a more specific [`<resource>`](#naming-a-permission) if unintended. Also warned: a route, nav node or profile-menu entry gates on a permission no installed plugin declares, so the admin screens can't grant it; declare it in that plugin's `permissions`. |
@@ -891,7 +891,7 @@ installed plugin declares is only *reported* — it is usually a typo in the one
set, and naming it turns two unrelated-looking errors into one. Declaring settings without an set, and naming it turns two unrelated-looking errors into one. Declaring settings without an
`onBoot` warns too: they resolve, but nothing receives them. `onBoot` warns too: they resolve, but nothing receives them.
**Reading what a deployment is configured with.** The admin plugin's **Plugin settings** screen **Reading what a deployment is configured with.** The auth-admin plugin's **Plugin settings** screen
(`plugin-settings:read`) lists every installed plugin, its declared keys, the variable that sets (`plugin-settings:read`) lists every installed plugin, its declared keys, the variable that sets
each, and whether the value came from the environment or the declared default. each, and whether the value came from the environment or the declared default.
@@ -1003,8 +1003,8 @@ The menu is **driven entirely by config** and assembled from two sources:
1. **Plugin fragments** — each plugin contributes its own `nav` (above). 1. **Plugin fragments** — each plugin contributes its own `nav` (above).
2. **A central override** — `config/menu.ts` (loaded by `src/ui/menu-config.ts`, validated at 2. **A central override** — `config/menu.ts` (loaded by `src/ui/menu-config.ts`, validated at
boot) — where the operator reorders, renames, groups, or hides items (by node `id`), and boot) — where the operator reorders, renames, groups, or hides items (by node `id`), hides or
sets branding (app name, logo, default theme). The override always wins, applied before renames a profile-menu entry (by its `id`), and sets branding (app name, logo, default theme). The override always wins, applied before
the per-user filter. A clean clone needs no `config/menu.ts`; defaults apply. the per-user filter. A clean clone needs no `config/menu.ts`; defaults apply.
`config/` is an **empty drop-in mount point** (like `plugins/`): it ships empty, and you `config/` is an **empty drop-in mount point** (like `plugins/`): it ships empty, and you
@@ -1048,7 +1048,7 @@ fully styled and zero-JS:
auth guards (`src/auth/guards.ts`): `requireSession`, `can(permission)` (coarse JWT-claim check, auth guards (`src/auth/guards.ts`): `requireSession`, `can(permission)` (coarse JWT-claim check,
zero I/O), `check(relation, object)` (the one live Keto call). zero I/O), `check(relation, object)` (the one live Keto call).
A list page's pagination, from URL to partial (`examples/plugins/admin/admin-users.ts` is the full A list page's pagination, from URL to partial (`examples/plugins/auth-admin/admin-users.ts` is the full
page): page):
```ts ```ts
@@ -1137,7 +1137,7 @@ catalog for it. There is **no locale cookie**: the URL is the only place a choic
link is shareable and a page is what its address says it is. When the URL asked for a language, the link is shareable and a page is what its address says it is. When the URL asked for a language, the
host carries `?locale=` onto every link *it* renders and `ctx.localeHref(href)` does the same for a host carries `?locale=` onto every link *it* renders and `ctx.localeHref(href)` does the same for a
plugin's. The picker lists every installed locale and renders **on every page**. After a POST the plugin's. The picker lists every installed locale and renders **on every page**. After a POST the
current URL may answer no GET (`POST /admin/users/:id/recovery` has no GET sibling), so the host current URL may answer no GET (`POST /auth-admin/users/:id/recovery` has no GET sibling), so the host
points the picker at this path when it answers GET, else the page the form was submitted from, else points the picker at this path when it answers GET, else the page the form was submitted from, else
`/` — switching language there leaves that POST's own result behind. `/` — switching language there leaves that POST's own result behind.
@@ -1425,7 +1425,7 @@ Nothing in the menu or first-party pages needs Hydra.
post-logout redirect. The first-party `POST /logout` still owns ending the Kratos session and our post-logout redirect. The first-party `POST /logout` still owns ending the Kratos session and our
JWT cookie. JWT cookie.
Clients are registered from the admin plugin's **OAuth2 clients** screen (`/admin/clients`): Hydra Clients are registered from the auth-admin plugin's **OAuth2 clients** screen (`/auth-admin/clients`): Hydra
shows the generated `client_secret` **once**, on the confirmation page. Confidential vs public shows the generated `client_secret` **once**, on the confirmation page. Confidential vs public
(PKCE) and the first-party auto-consent flag are set at registration; writes go only to Hydra. (PKCE) and the first-party auto-consent flag are set at registration; writes go only to Hydra.
@@ -1635,7 +1635,7 @@ tightened a manifest rule since. Discovery fails loud at boot rather than runnin
honour, naming the plugin and the rule: honour, naming the plugin and the rule:
```bash ```bash
rm -rf plugins/admin && cp -r examples/plugins/admin plugins/admin rm -rf plugins/auth-admin && cp -r examples/plugins/auth-admin plugins/auth-admin
docker compose up -d --build docker compose up -d --build
``` ```
@@ -1668,7 +1668,7 @@ OpenTelemetry's HTTP conventions:
- `http.request.method`, `url.path` (the query is dropped, it can carry tokens), - `http.request.method`, `url.path` (the query is dropped, it can carry tokens),
`http.response.status_code`, `server.address` (the `Host`), `user_agent.original`, `http.response.status_code`, `server.address` (the `Host`), `user_agent.original`,
`client.address` and `requestId`. `client.address` and `requestId`.
- `http.route`: the matched pattern, such as `/admin/users/:id`. It is absent when no route - `http.route`: the matched pattern, such as `/auth-admin/users/:id`. It is absent when no route
matched (a 404, a 405, a `/public/` file, a redirect) and on a plugin's `onRequest` short-circuit. matched (a 404, a 405, a `/public/` file, a redirect) and on a plugin's `onRequest` short-circuit.
- Latency is the span's duration, so it reaches you only with `OTLP_ENDPOINT` set. OTLP carries every - Latency is the span's duration, so it reaches you only with `OTLP_ENDPOINT` set. OTLP carries every
attribute as a string. attribute as a string.
@@ -1799,7 +1799,7 @@ locales/ Drop-in mount: extra or replacement catalogs. Ships empty.
plugins/ Drop-in mount: plugin folders, scanned at /app/plugins. Ships empty. plugins/ Drop-in mount: plugin folders, scanned at /app/plugins. Ships empty.
ory/ Ory config — kratos/ (identity schema, oidc claims mapper, tokenizer + dev JWKS), ory/ Ory config — kratos/ (identity schema, oidc claims mapper, tokenizer + dev JWKS),
keto/ (namespaces.keto.ts OPL), hydra/, postgres/init/ keto/ (namespaces.keto.ts OPL), hydra/, postgres/init/
examples/ Copy-in reference mirroring the mount dirs: plugins/scheduling/, plugins/admin/, examples/ Copy-in reference mirroring the mount dirs: plugins/scheduling/, plugins/auth-admin/,
config/menu.ts, and shifts-upstream/ (the dev mock backend) config/menu.ts, and shifts-upstream/ (the dev mock backend)
e2e-tests/ Playwright specs + their Dockerfile and compose.{visual,auth,oauth,full,devstack}.yml; e2e-tests/ Playwright specs + their Dockerfile and compose.{visual,auth,oauth,full,devstack}.yml;
proxy.ts (same-origin gateway) and mock-oidc.ts back full-flow proxy.ts (same-origin gateway) and mock-oidc.ts back full-flow
+3 -3
View File
@@ -11,7 +11,7 @@ reader can name.
The folders are `http/`, `auth/`, `i18n/`, `plugin-host/`, `ui/`, and `test-tooling/` (modules only The folders are `http/`, `auth/`, `i18n/`, `plugin-host/`, `ui/`, and `test-tooling/` (modules only
tests import), with `server.ts`, `config.ts`, `logger.ts` and the topology-guard `*.test.ts` at the tests import), with `server.ts`, `config.ts`, `logger.ts` and the topology-guard `*.test.ts` at the
root. Tests are co-located. Add a new module to the folder owning its concern. Even the admin GUI root. Tests are co-located. Add a new module to the folder owning its concern. Even the admin GUI
is a drop-in plugin (`examples/plugins/admin/`). is a drop-in plugin (`examples/plugins/auth-admin/`).
## Plugins and config import the host only through the two barrels ## Plugins and config import the host only through the two barrels
@@ -233,7 +233,7 @@ read-only parent is EROFS, and the container never starts.
2026-08-05, the maintainer. Code health. Valid while route tables name their permission per route. 2026-08-05, the maintainer. Code health. Valid while route tables name their permission per route.
Inside the admin example it keeps the route table and the in-handler guard deriving from one Inside the auth-admin example it keeps the route table and the in-handler guard deriving from one
function, so 29 routes × 2 gate sites cannot drift. Generalised, it would make authorization a function, so 29 routes × 2 gate sites cannot drift. Generalised, it would make authorization a
function of the transport verb, and a route table must answer "what does this need?" on its own. function of the transport verb, and a route table must answer "what does this need?" on its own.
@@ -360,7 +360,7 @@ keys.
2026-08-04, the maintainer. Goal 2. Valid while the picker is expected on literally every page; if 2026-08-04, the maintainer. Goal 2. Valid while the picker is expected on literally every page; if
that softens, hiding it after a POST is simpler. that softens, hiding it after a POST is simpler.
A POST-rendered URL often answers no GET (`POST /admin/users/:id/recovery`), so the host resolves A POST-rendered URL often answers no GET (`POST /auth-admin/users/:id/recovery`), so the host resolves
the picker's target (`app.ts` → `switchBase`): this path when it answers GET, else the same-origin the picker's target (`app.ts` → `switchBase`): this path when it answers GET, else the same-origin
Referer, else `/`. The accepted cost: switching language there leaves that POST's own result behind. Referer, else `/`. The accepted cost: switching language there leaves that POST's own result behind.
+17
View File
@@ -440,3 +440,20 @@ Read most (opens and searches):
- `src/ci-gate.test.ts` 1 - `src/ci-gate.test.ts` 1
- `src/function-limits.test.ts` 1 - `src/function-limits.test.ts` 1
- `src/plugin-host/discovery.ts` 1 - `src/plugin-host/discovery.ts` 1
## Reading cost, chunk 7100f96a1d8d, finished 2026-11-08T20:19:54Z
96357 new input tokens before the first commit.
Read most (opens and searches):
- `AGENTS.md` 11
- `README.md` 4
- `docs/decisions.md` 4
- `.gitea/workflows/release.yml` 2
- `examples/plugins/admin/admin-shared.ts` 2
- `src/postgres.test.ts` 2
- `todo.md` 2
- `src/auth/gate.ts` 1
- `src/auth/jwt.ts` 1
- `src/server.ts` 1
+5 -5
View File
@@ -6,7 +6,7 @@
# docker compose -f compose.yml -f e2e-tests/compose.full.yml down -v # tear down after # docker compose -f compose.yml -f e2e-tests/compose.full.yml down -v # tear down after
services: services:
web: web:
# The base's full depends_on applies (Hydra included — the admin plugin's OAuth2-clients # The base's full depends_on applies (Hydra included — the auth-admin plugin's OAuth2-clients
# screen needs it); only the reference plugin's upstream is added. SSO is enabled here only # screen needs it); only the reference plugin's upstream is added. SSO is enabled here only
# (clean clone stays password-only): the mock provider's whole array is the env-settable form # (clean clone stays password-only): the mock provider's whole array is the env-settable form
# Kratos offers, mapped through the committed claims jsonnet. # Kratos offers, mapped through the committed claims jsonnet.
@@ -24,18 +24,18 @@ services:
timeout: 4s timeout: 4s
retries: 30 retries: 30
# plugins/ is empty in the image; bind the example plugins in so the browser flow can open the # plugins/ is empty in the image; bind the example plugins in so the browser flow can open the
# gated /scheduling/shifts page and the /admin/* screens (the admin screens ship as a drop-in # gated /scheduling/shifts page and the /auth-admin/* screens (the admin screens ship as a drop-in
# plugin, mounted at /app/plugins/admin, reaching the host's Ory clients via ctx.system). # plugin, mounted at /app/plugins/auth-admin, reaching the host's Ory clients via ctx.system).
volumes: volumes:
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro - ./examples/plugins/scheduling:/app/plugins/scheduling:ro
- ./examples/plugins/admin:/app/plugins/admin:ro - ./examples/plugins/auth-admin:/app/plugins/auth-admin:ro
# bootstrap grants the demo admin every discovered plugin's permission names, so it needs the # bootstrap grants the demo admin every discovered plugin's permission names, so it needs the
# example plugins present too — else the admin lacks scheduling:read/write and the gated pages 403. # example plugins present too — else the admin lacks scheduling:read/write and the gated pages 403.
bootstrap: bootstrap:
volumes: volumes:
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro - ./examples/plugins/scheduling:/app/plugins/scheduling:ro
- ./examples/plugins/admin:/app/plugins/admin:ro - ./examples/plugins/auth-admin:/app/plugins/auth-admin:ro
# Browser-facing URLs (base_url, every ui_url, the after-login redirect) move to the gateway host. # Browser-facing URLs (base_url, every ui_url, the after-login redirect) move to the gateway host.
# `--dev`: the browser hits the gateway over http, but Kratos marks cookies Secure for a # `--dev`: the browser hits the gateway over http, but Kratos marks cookies Secure for a
+26 -26
View File
@@ -55,7 +55,7 @@ test.describe.serial("authenticated admin journey", () => {
// The list screens rebuild their query from the list state (sort/page/filter), so they are where a // The list screens rebuild their query from the list state (sort/page/filter), so they are where a
// chosen language is most easily dropped; the core building blocks carry it through. // chosen language is most easily dropped; the core building blocks carry it through.
test("a sorted, paged admin list keeps the visitor's language", async () => { test("a sorted, paged admin list keeps the visitor's language", async () => {
await page.goto("/admin/users?locale=sv-SE"); await page.goto("/auth-admin/users?locale=sv-SE");
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE"); await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
await expect(page.getByRole("heading", { name: "Användare" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Användare" })).toBeVisible();
@@ -80,7 +80,7 @@ test.describe.serial("authenticated admin journey", () => {
// A POST that re-renders a page: the write must keep the language, and the picker — which is on // A POST that re-renders a page: the write must keep the language, and the picker — which is on
// every page — must point somewhere that answers GET rather than at the POST-only URL. // every page — must point somewhere that answers GET rather than at the POST-only URL.
test("a write keeps the visitor's language, and the picker still works on the POST-rendered page", async () => { test("a write keeps the visitor's language, and the picker still works on the POST-rendered page", async () => {
await page.goto("/admin/users?locale=sv-SE"); await page.goto("/auth-admin/users?locale=sv-SE");
await page.getByRole("link", { name: "Ny användare" }).click(); await page.getByRole("link", { name: "Ny användare" }).click();
await page.fill('input[name="email"]', `lang-${suffix}@plainpages.local`); await page.fill('input[name="email"]', `lang-${suffix}@plainpages.local`);
await page.getByRole("button", { name: "Skapa användare" }).click(); await page.getByRole("button", { name: "Skapa användare" }).click();
@@ -89,7 +89,7 @@ test.describe.serial("authenticated admin journey", () => {
// Open the new user's edit page the way the CRUD test does — the row's Edit link carries the id. // Open the new user's edit page the way the CRUD test does — the row's Edit link carries the id.
const row = page.locator("tr", { hasText: `lang-${suffix}@plainpages.local` }); const row = page.locator("tr", { hasText: `lang-${suffix}@plainpages.local` });
const editHref = await row.locator('a[href^="/admin/users/"]').first().getAttribute("href"); const editHref = await row.locator('a[href^="/auth-admin/users/"]').first().getAttribute("href");
await page.goto(`${editHref}`); await page.goto(`${editHref}`);
await expect(page.locator('button[aria-label="Språk"]')).toHaveCount(1); await expect(page.locator('button[aria-label="Språk"]')).toHaveCount(1);
await page.getByRole("button", { name: "Skapa återställningskod" }).click(); // POST-only route await page.getByRole("button", { name: "Skapa återställningskod" }).click(); // POST-only route
@@ -108,33 +108,33 @@ test.describe.serial("authenticated admin journey", () => {
// seeds exactly those), so both gated sections are present in the menu (collapsed by default → // seeds exactly those), so both gated sections are present in the menu (collapsed by default →
// assert they're in the DOM, not necessarily visible). // assert they're in the DOM, not necessarily visible).
await page.goto("/dashboard"); await page.goto("/dashboard");
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(1); await expect(page.locator('.sidebar a[href="/auth-admin/users"]')).toHaveCount(1);
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(1); await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(1);
}); });
test("users CRUD: create a user, see it listed, then delete it via the confirm step", async () => { test("users CRUD: create a user, see it listed, then delete it via the confirm step", async () => {
const email = `e2e-${suffix}@plainpages.local`; const email = `e2e-${suffix}@plainpages.local`;
await page.goto("/admin/users/new"); await page.goto("/auth-admin/users/new");
await page.fill('input[name="email"]', email); await page.fill('input[name="email"]', email);
await page.fill('input[name="first"]', "E2E"); await page.fill('input[name="first"]', "E2E");
await page.fill('input[name="last"]', "User"); await page.fill('input[name="last"]', "User");
await page.locator('.form-card button[type="submit"]').click(); await page.locator('.form-card button[type="submit"]').click();
await expect(page).toHaveURL(/\/admin\/users(\?|$)/); // PRG back to the list await expect(page).toHaveURL(/\/auth-admin\/users(\?|$)/); // PRG back to the list
const row = page.locator("tr", { hasText: email }); const row = page.locator("tr", { hasText: email });
await expect(row).toBeVisible(); await expect(row).toBeVisible();
// Row actions sit behind the kebab popover: opening it reveals them, in the top layer, so the // Row actions sit behind the kebab popover: opening it reveals them, in the top layer, so the
// scrolling table around the row cannot clip the panel. // scrolling table around the row cannot clip the panel.
await row.locator("button.kebab").click(); await row.locator("button.kebab").click();
await expect(row.locator('a[href^="/admin/users/"]').first()).toBeVisible(); await expect(row.locator('a[href^="/auth-admin/users/"]').first()).toBeVisible();
// Delete through the confirm interstitial (the row's Edit link carries the id). // Delete through the confirm interstitial (the row's Edit link carries the id).
const editHref = await row.locator('a[href^="/admin/users/"]').first().getAttribute("href"); const editHref = await row.locator('a[href^="/auth-admin/users/"]').first().getAttribute("href");
await page.goto(`${editHref}/delete`); await page.goto(`${editHref}/delete`);
await page.getByRole("button", { name: "Delete user" }).click(); // the confirm form's danger button await page.getByRole("button", { name: "Delete user" }).click(); // the confirm form's danger button
await expect(page).toHaveURL(/\/admin\/users(\?|$)/); await expect(page).toHaveURL(/\/auth-admin\/users(\?|$)/);
await expect(page.locator("tr", { hasText: email })).toHaveCount(0); await expect(page.locator("tr", { hasText: email })).toHaveCount(0);
}); });
@@ -143,8 +143,8 @@ test.describe.serial("authenticated admin journey", () => {
test.setTimeout(90_000); test.setTimeout(90_000);
const email = `admin-recovery-${suffix}@plainpages.local`; const email = `admin-recovery-${suffix}@plainpages.local`;
await createIdentity(email); await createIdentity(email);
await page.goto("/admin/users"); await page.goto("/auth-admin/users");
const editHref = await page.locator("tr", { hasText: email }).locator('a[href^="/admin/users/"]').first().getAttribute("href"); const editHref = await page.locator("tr", { hasText: email }).locator('a[href^="/auth-admin/users/"]').first().getAttribute("href");
await page.goto(editHref!); await page.goto(editHref!);
await page.getByRole("button", { name: "Generate recovery code" }).click(); await page.getByRole("button", { name: "Generate recovery code" }).click();
const code = await page.locator(".recovery-code code").innerText(); const code = await page.locator(".recovery-code code").innerText();
@@ -162,27 +162,27 @@ test.describe.serial("authenticated admin journey", () => {
// A Keto set exists only while it has ≥1 member, so create needs a first member (the form // A Keto set exists only while it has ≥1 member, so create needs a first member (the form
// enforces it); pick the first option (a user) from the required picker. // enforces it); pick the first option (a user) from the required picker.
const group = `e2e-grp-${suffix}`; const group = `e2e-grp-${suffix}`;
await page.goto("/admin/groups/new"); await page.goto("/auth-admin/groups/new");
await page.fill('input[name="name"]', group); await page.fill('input[name="name"]', group);
await page.locator('select[name="member"]').selectOption({ index: 1 }); await page.locator('select[name="member"]').selectOption({ index: 1 });
await page.locator('.form-card button[type="submit"]').click(); await page.locator('.form-card button[type="submit"]').click();
await expect(page).toHaveURL(/\/admin\/groups(\?|\/|$)/); await expect(page).toHaveURL(/\/auth-admin\/groups(\?|\/|$)/);
await expect(page.locator("main")).toContainText(group); await expect(page.locator("main")).toContainText(group);
// Permissions are declared in plugin code, so the group's detail page offers them as a fixed // Permissions are declared in plugin code, so the group's detail page offers them as a fixed
// checkbox list rather than a create form — there is no Permissions screen to visit. // checkbox list rather than a create form — there is no Permissions screen to visit.
await page.goto(`/admin/groups/${group}`); await page.goto(`/auth-admin/groups/${group}`);
allowConsole(/status of 409 .*\/admin\/groups\/[^/]+\/permissions/); // the stale save below, refused on purpose allowConsole(/status of 409 .*\/auth-admin\/groups\/[^/]+\/permissions/); // the stale save below, refused on purpose
// A second browser signed in as the same admin, opened before the save below, so its form carries the old grants. // A second browser signed in as the same admin, opened before the save below, so its form carries the old grants.
const staleTab = await watchedPage(await browser.newContext({ storageState: await page.context().storageState() })); const staleTab = await watchedPage(await browser.newContext({ storageState: await page.context().storageState() }));
await staleTab.goto(`/admin/groups/${group}`); await staleTab.goto(`/auth-admin/groups/${group}`);
const scheduling = page.locator('input[name="permission"][value="scheduling:read"]'); const scheduling = page.locator('input[name="permission"][value="scheduling:read"]');
await expect(scheduling).toHaveCount(1); // declared by the reference plugin, so it's on offer await expect(scheduling).toHaveCount(1); // declared by the reference plugin, so it's on offer
await expect(scheduling).not.toBeChecked(); await expect(scheduling).not.toBeChecked();
await scheduling.check(); await scheduling.check();
await page.locator('form:has(input[name="permission"]) button[type="submit"]').click(); await page.locator('form:has(input[name="permission"]) button[type="submit"]').click();
await expect(page).toHaveURL(new RegExp(`/admin/groups/${group}`)); await expect(page).toHaveURL(new RegExp(`/auth-admin/groups/${group}`));
await expect(page.getByText("Permissions saved.")).toBeVisible(); await expect(page.getByText("Permissions saved.")).toBeVisible();
await expect(page.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked(); await expect(page.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked();
@@ -197,7 +197,7 @@ test.describe.serial("authenticated admin journey", () => {
test("OAuth2 clients CRUD: register a client (writes go to Hydra), see the one-time secret once, then delete it via the confirm step", async () => { test("OAuth2 clients CRUD: register a client (writes go to Hydra), see the one-time secret once, then delete it via the confirm step", async () => {
const name = `e2e-client-${suffix}`; const name = `e2e-client-${suffix}`;
await page.goto("/admin/clients"); await page.goto("/auth-admin/clients");
await page.getByRole("link", { name: "Register client" }).click(); await page.getByRole("link", { name: "Register client" }).click();
await page.fill('input[name="name"]', name); await page.fill('input[name="name"]', name);
await page.fill('textarea[name="redirectUris"]', "https://app.example.com/callback"); await page.fill('textarea[name="redirectUris"]', "https://app.example.com/callback");
@@ -210,17 +210,17 @@ test.describe.serial("authenticated admin journey", () => {
await expect(page.locator("#csecret")).toHaveValue(/.+/); await expect(page.locator("#csecret")).toHaveValue(/.+/);
// Listed; the row header links to the plain detail, which never shows the secret again. // Listed; the row header links to the plain detail, which never shows the secret again.
await page.goto("/admin/clients"); await page.goto("/auth-admin/clients");
const row = page.locator("tr", { hasText: name }); const row = page.locator("tr", { hasText: name });
await expect(row).toBeVisible(); await expect(row).toBeVisible();
await row.getByRole("link", { name }).click(); await row.getByRole("link", { name }).click();
await expect(page).toHaveURL(new RegExp(`/admin/clients/${clientId}`)); await expect(page).toHaveURL(new RegExp(`/auth-admin/clients/${clientId}`));
await expect(page.locator("#csecret")).toHaveCount(0); await expect(page.locator("#csecret")).toHaveCount(0);
// Delete through the confirm interstitial (danger link on the detail → confirm form's button). // Delete through the confirm interstitial (danger link on the detail → confirm form's button).
await page.getByRole("link", { name: "Delete client" }).click(); await page.getByRole("link", { name: "Delete client" }).click();
await page.getByRole("button", { name: "Delete client" }).click(); await page.getByRole("button", { name: "Delete client" }).click();
await expect(page).toHaveURL(/\/admin\/clients(\?|$)/); await expect(page).toHaveURL(/\/auth-admin\/clients(\?|$)/);
await expect(page.locator("tr", { hasText: name })).toHaveCount(0); await expect(page.locator("tr", { hasText: name })).toHaveCount(0);
}); });
@@ -237,7 +237,7 @@ test.describe.serial("authenticated admin journey", () => {
}); });
test("plugin settings: the screen names the variable that sets each declared key", async () => { test("plugin settings: the screen names the variable that sets each declared key", async () => {
await page.goto("/admin/plugin-settings"); await page.goto("/auth-admin/plugin-settings");
await expect(page.locator("h1")).toHaveText("Plugin settings"); await expect(page.locator("h1")).toHaveText("Plugin settings");
// The reference plugin's one declared setting, and the variable an operator would set for it. // The reference plugin's one declared setting, and the variable an operator would set for it.
const scheduling = page.locator("table").filter({ hasText: "PLUGIN_SETTING_SCHEDULING_UPSTREAM" }); const scheduling = page.locator("table").filter({ hasText: "PLUGIN_SETTING_SCHEDULING_UPSTREAM" });
@@ -257,7 +257,7 @@ test.describe.serial("authenticated admin journey", () => {
// The session is gone: /dashboard is gated, so it bounces back to the login page (no admin nav). // The session is gone: /dashboard is gated, so it bounces back to the login page (no admin nav).
await page.goto("/dashboard"); await page.goto("/dashboard");
await expect(page).toHaveURL(/\/login(\?|$)/); await expect(page).toHaveURL(/\/login(\?|$)/);
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0); await expect(page.locator('.sidebar a[href="/auth-admin/users"]')).toHaveCount(0);
}); });
}); });
@@ -265,13 +265,13 @@ test("return_to: a deep link while logged out returns to that page after login",
test.setTimeout(90_000); test.setTimeout(90_000);
// A gated deep link, logged out → bounced to the themed login (return_to is baked into the Kratos // A gated deep link, logged out → bounced to the themed login (return_to is baked into the Kratos
// flow server-side, so it's consumed, not shown in the settled URL). // flow server-side, so it's consumed, not shown in the settled URL).
await page.goto("/admin/users"); await page.goto("/auth-admin/users");
await expect(page).toHaveURL(/\/login(\?|$)/); await expect(page).toHaveURL(/\/login(\?|$)/);
await page.fill('input[name="identifier"]', ADMIN_EMAIL); await page.fill('input[name="identifier"]', ADMIN_EMAIL);
await page.fill('input[name="password"]', ADMIN_PASSWORD); await page.fill('input[name="password"]', ADMIN_PASSWORD);
await page.locator('.auth-form button[type="submit"]').click(); await page.locator('.auth-form button[type="submit"]').click();
// Completion routes through /auth/complete (mints the JWT) and on to the requested page, not the dashboard. // Completion routes through /auth/complete (mints the JWT) and on to the requested page, not the dashboard.
await expect(page).toHaveURL(/\/admin\/users(\?|$)/); await expect(page).toHaveURL(/\/auth-admin\/users(\?|$)/);
await expect(page.locator("h1")).toHaveText("Users"); await expect(page.locator("h1")).toHaveText("Users");
}); });
@@ -294,7 +294,7 @@ test("mocked SSO login: the provider button signs a user in via OIDC", async ({
// Mock OIDC auto-approves → Kratos creates the identity → /auth/complete → dashboard, signed in. // Mock OIDC auto-approves → Kratos creates the identity → /auth/complete → dashboard, signed in.
await expect(page.locator(".profile-mail")).toHaveText(SSO_EMAIL); await expect(page.locator(".profile-mail")).toHaveText(SSO_EMAIL);
// A fresh SSO identity holds no permissions, so the gated Admin section stays hidden. // A fresh SSO identity holds no permissions, so the gated Admin section stays hidden.
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0); await expect(page.locator('.sidebar a[href="/auth-admin/users"]')).toHaveCount(0);
}); });
// The newest one-time code Kratos' courier mailed to `to`, read from the mail catcher (compose.full.yml). // The newest one-time code Kratos' courier mailed to `to`, read from the mail catcher (compose.full.yml).
+1 -1
View File
@@ -6,6 +6,6 @@ across (or bind-mount your own) and restart.
| Path | Copy into | Example of | | Path | Copy into | Example of |
| --- | --- | --- | | --- | --- | --- |
| [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and permission-gated nav — built from the core building blocks, holding no state. Imports the host surface as `@plainpages/plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). | | [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and permission-gated nav — built from the core building blocks, holding no state. Imports the host surface as `@plainpages/plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). |
| [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). | | [`plugins/auth-admin/`](plugins/auth-admin/) | `plugins/auth-admin/` | The system-admin plugin: the Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/auth-admin/README.md). |
| [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). | | [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). |
| [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `PLUGIN_SETTING_SCHEDULING_UPSTREAM` at the real thing instead. | | [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `PLUGIN_SETTING_SCHEDULING_UPSTREAM` at the real thing instead. |
+2 -2
View File
@@ -2,8 +2,8 @@
// cp examples/config/menu.ts config/menu.ts // cp examples/config/menu.ts config/menu.ts
// Absent config = built-in defaults. // Absent config = built-in defaults.
// //
// Brand the app and reorder/rename/group/hide nav nodes (by their `id`) across all plugins — the // Brand the app, reorder/rename/group/hide nav nodes and rename/hide profile-menu entries (by their
// override always wins, applied before the per-user permission filter. Every field is optional. // `id`) across all plugins — the override always wins, applied before the per-user permission filter. Every field is optional.
// See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README → The menu system. // See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README → The menu system.
import { defineMenu } from "#menu-config"; import { defineMenu } from "#menu-config";
@@ -1,11 +1,11 @@
# Admin — the system-administration plugin # auth-admin — the plugin that administers users, groups, permissions and OAuth2 clients
The Users / Groups / OAuth2-clients screens for running Plainpages itself, shipped as a **drop-in The Users / Groups / OAuth2-clients screens for running Plainpages itself, shipped as a **drop-in
example plugin** so a fresh clone has no admin GUI until you opt in. Copy this folder into `plugins/` example plugin** so a fresh clone has no admin GUI until you opt in. Copy this folder into `plugins/`
(it keeps the id and mount path `admin`, so the screens live at `/admin/*`) and restart: (it keeps the id and mount path `auth-admin`, so the screens live at `/auth-admin/*`) and restart:
```bash ```bash
cp -r examples/plugins/admin plugins/admin cp -r examples/plugins/auth-admin plugins/auth-admin
docker compose up -d docker compose up -d
``` ```
@@ -59,15 +59,15 @@ test("validateClientInput requires a name, ≥1 redirect URI, and absolute redir
test("buildClientsListModel filters by search, paginates; the name links to the detail page", () => { test("buildClientsListModel filters by search, paginates; the name links to the detail page", () => {
const clients = Array.from({ length: 30 }, (_, i) => ({ client_id: `id-${String(i).padStart(2, "0")}`, client_name: `app-${String(i).padStart(2, "0")}`, token_endpoint_auth_method: "client_secret_basic" })); const clients = Array.from({ length: 30 }, (_, i) => ({ client_id: `id-${String(i).padStart(2, "0")}`, client_name: `app-${String(i).padStart(2, "0")}`, token_endpoint_auth_method: "client_secret_basic" }));
const all = buildClientsListModel({ clients, url: "http://x/admin/clients" }); const all = buildClientsListModel({ clients, url: "http://x/auth-admin/clients" });
assert.equal(all.pagination.summary.total, 30); assert.equal(all.pagination.summary.total, 30);
assert.equal(all.table.rows.length, 25); // default page size assert.equal(all.table.rows.length, 25); // default page size
assert.equal(all.title, "OAuth2 clients"); assert.equal(all.title, "OAuth2 clients");
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } }; const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
assert.equal(first.rowHeader.text, "app-00"); assert.equal(first.rowHeader.text, "app-00");
assert.equal(first.rowHeader.href, "/admin/clients/id-00"); assert.equal(first.rowHeader.href, "/auth-admin/clients/id-00");
const one = buildClientsListModel({ clients, url: "http://x/admin/clients?q=app-07" }); const one = buildClientsListModel({ clients, url: "http://x/auth-admin/clients?q=app-07" });
assert.equal(one.pagination.summary.total, 1); assert.equal(one.pagination.summary.total, 1);
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]); assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
}); });
@@ -75,7 +75,7 @@ test("buildClientsListModel filters by search, paginates; the name links to the
test("buildClientFormModel: a register form with name + scope fields; values reflected on error", () => { test("buildClientFormModel: a register form with name + scope fields; values reflected on error", () => {
const m = buildClientFormModel({ csrfToken: "tok.sig" }); const m = buildClientFormModel({ csrfToken: "tok.sig" });
assert.equal(m.title, "Register client"); assert.equal(m.title, "Register client");
assert.equal(m.form.action, "/admin/clients"); assert.equal(m.form.action, "/auth-admin/clients");
assert.equal(m.form.submitLabel, "Register client"); assert.equal(m.form.submitLabel, "Register client");
assert.equal(m.form.csrfToken, "tok.sig"); assert.equal(m.form.csrfToken, "tok.sig");
assert.equal(m.form.nameField.required, true); assert.equal(m.form.nameField.required, true);
@@ -94,7 +94,7 @@ test("buildClientDetailModel: client info + delete action; the one-time secret +
const plain = buildClientDetailModel({ client }); const plain = buildClientDetailModel({ client });
assert.equal(plain.title, "Acme"); assert.equal(plain.title, "Acme");
assert.equal(plain.delete.action, "/admin/clients/c1/delete"); assert.equal(plain.delete.action, "/auth-admin/clients/c1/delete");
assert.equal(plain.created, false); assert.equal(plain.created, false);
assert.equal(plain.secret, undefined); assert.equal(plain.secret, undefined);
@@ -257,13 +257,13 @@ const canWriteClients = (ctx: RequestContext): boolean => can(ctx, permissionNam
const clientDetailResult = (ctx: RequestContext, client: OAuth2Client, extra: { created?: boolean; secret?: string } = {}): RouteResult => const clientDetailResult = (ctx: RequestContext, client: OAuth2Client, extra: { created?: boolean; secret?: string } = {}): RouteResult =>
({ data: { chrome: ctx.chrome, model: buildClientDetailModel({ canWrite: canWriteClients(ctx), client: toClientView(client), csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "client-detail" }); ({ data: { chrome: ctx.chrome, model: buildClientDetailModel({ canWrite: canWriteClients(ctx), client: toClientView(client), csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "client-detail" });
// GET /admin/clients — the list. // GET /auth-admin/clients — the list.
export const clientsList = withClients(async ({ ctx, hydra }) => { export const clientsList = withClients(async ({ ctx, hydra }) => {
const { clients } = await hydra.listClients({ pageSize: LIST_FETCH_SIZE }); const { clients } = await hydra.listClients({ pageSize: LIST_FETCH_SIZE });
return { data: { chrome: ctx.chrome, model: buildClientsListModel({ canWrite: canWriteClients(ctx), clients, csrfToken: ctx.chrome.csrfToken, t: ctx.t, url: ctx.url }) }, view: "clients" }; return { data: { chrome: ctx.chrome, model: buildClientsListModel({ canWrite: canWriteClients(ctx), clients, csrfToken: ctx.chrome.csrfToken, t: ctx.t, url: ctx.url }) }, view: "clients" };
}); });
// POST /admin/clients — register; on success show the one-time secret directly (no PRG, Hydra never // POST /auth-admin/clients — register; on success show the one-time secret directly (no PRG, Hydra never
// returns it again). A Hydra 4xx (bad redirect/scope) re-renders the form (400); a 5xx rethrows → 500. // returns it again). A Hydra 4xx (bad redirect/scope) re-renders the form (400); a 5xx rethrows → 500.
export const clientsCreate = withClients(async ({ ctx, hydra, user }) => { export const clientsCreate = withClients(async ({ ctx, hydra, user }) => {
const input = readClientInput((await guardedForm(ctx))!); const input = readClientInput((await guardedForm(ctx))!);
@@ -280,13 +280,13 @@ export const clientsCreate = withClients(async ({ ctx, hydra, user }) => {
return clientDetailResult(ctx, created, { created: true, ...(created.client_secret ? { secret: created.client_secret } : {}) }); return clientDetailResult(ctx, created, { created: true, ...(created.client_secret ? { secret: created.client_secret } : {}) });
}); });
// GET /admin/clients/new — the register form. // GET /auth-admin/clients/new — the register form.
export const clientsNewForm = withClients(({ ctx }) => Promise.resolve(clientFormResult(ctx, {})), "write"); export const clientsNewForm = withClients(({ ctx }) => Promise.resolve(clientFormResult(ctx, {})), "write");
// GET /admin/clients/:id — the detail (read-only; the secret is shown only once, at creation). // GET /auth-admin/clients/:id — the detail (read-only; the secret is shown only once, at creation).
export const clientsDetail = withClient((deps, client) => Promise.resolve(clientDetailResult(deps.ctx, client))); export const clientsDetail = withClient((deps, client) => Promise.resolve(clientDetailResult(deps.ctx, client)));
// GET /admin/clients/:id/delete — the deliberate confirm step. // GET /auth-admin/clients/:id/delete — the deliberate confirm step.
export const clientsDeleteConfirm = withClient((deps, client, id) => { export const clientsDeleteConfirm = withClient((deps, client, id) => {
const base = detailHref(id); const base = detailHref(id);
const name = toClientView(client).name; const name = toClientView(client).name;
@@ -298,7 +298,7 @@ export const clientsDeleteConfirm = withClient((deps, client, id) => {
}) }, view: "confirm" }); }) }, view: "confirm" });
}, "write"); }, "write");
// POST /admin/clients/:id/delete — perform it. // POST /auth-admin/clients/:id/delete — perform it.
export const clientsDelete = withClient(async ({ ctx, hydra, user }, _client, id) => { export const clientsDelete = withClient(async ({ ctx, hydra, user }, _client, id) => {
await guardedForm(ctx); // CSRF-verify the POST await guardedForm(ctx); // CSRF-verify the POST
await hydra.deleteClient(id); await hydra.deleteClient(id);
@@ -36,8 +36,8 @@ test("grantDiff ignores anything the plugins don't declare, in both directions",
}); });
test("buildPermissionPicker ticks what is held and carries each declaration's description", () => { test("buildPermissionPicker ticks what is held and carries each declaration's description", () => {
const picker = buildPermissionPicker({ action: "/admin/users/u1/permissions", declared, direct: ["users:write"] }); const picker = buildPermissionPicker({ action: "/auth-admin/users/u1/permissions", declared, direct: ["users:write"] });
assert.equal(picker.action, "/admin/users/u1/permissions"); assert.equal(picker.action, "/auth-admin/users/u1/permissions");
assert.deepEqual(picker.choices.map((c) => c.name), ["users:read", "users:write", "groups:read"]); assert.deepEqual(picker.choices.map((c) => c.name), ["users:read", "users:write", "groups:read"]);
assert.deepEqual(picker.choices.map((c) => c.checked), [false, true, false]); assert.deepEqual(picker.choices.map((c) => c.checked), [false, true, false]);
assert.equal(picker.choices[0]?.description, "View users"); assert.equal(picker.choices[0]?.description, "View users");
@@ -56,22 +56,22 @@ test("memberView resolves a user subject to its email (else the raw id) and a su
test("buildGroupsListModel filters by search, sorts, paginates; the name links to the detail page", () => { test("buildGroupsListModel filters by search, sorts, paginates; the name links to the detail page", () => {
const groups = Array.from({ length: 30 }, (_, i) => ({ memberCount: i + 1, name: `team-${String(i).padStart(2, "0")}` })); const groups = Array.from({ length: 30 }, (_, i) => ({ memberCount: i + 1, name: `team-${String(i).padStart(2, "0")}` }));
const all = buildGroupsListModel({ groups, url: "http://x/admin/groups" }); const all = buildGroupsListModel({ groups, url: "http://x/auth-admin/groups" });
assert.equal(all.pagination.summary.total, 30); assert.equal(all.pagination.summary.total, 30);
assert.equal(all.table.rows.length, 25); // default page size assert.equal(all.table.rows.length, 25); // default page size
assert.equal(all.title, "Groups"); assert.equal(all.title, "Groups");
// The group name is the row header, linking to its detail page. // The group name is the row header, linking to its detail page.
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } }; const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
assert.equal(first.rowHeader.text, "team-00"); assert.equal(first.rowHeader.text, "team-00");
assert.equal(first.rowHeader.href, "/admin/groups/team-00"); assert.equal(first.rowHeader.href, "/auth-admin/groups/team-00");
// Search narrows + shows a pill. // Search narrows + shows a pill.
const one = buildGroupsListModel({ groups, url: "http://x/admin/groups?q=team-07" }); const one = buildGroupsListModel({ groups, url: "http://x/auth-admin/groups?q=team-07" });
assert.equal(one.pagination.summary.total, 1); assert.equal(one.pagination.summary.total, 1);
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]); assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
// Sort by members descending puts the biggest group first. // Sort by members descending puts the biggest group first.
const desc = buildGroupsListModel({ groups, url: "http://x/admin/groups?sort=-members" }); const desc = buildGroupsListModel({ groups, url: "http://x/auth-admin/groups?sort=-members" });
assert.equal((desc.table.rows[0]!.cells[0] as { rowHeader: { text: string } }).rowHeader.text, "team-29"); assert.equal((desc.table.rows[0]!.cells[0] as { rowHeader: { text: string } }).rowHeader.text, "team-29");
}); });
@@ -79,7 +79,7 @@ test("buildGroupFormModel: a create form with a required name field + member opt
const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }]; const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
const m = buildGroupFormModel({ csrfToken: "tok.sig", memberOptions: options }); const m = buildGroupFormModel({ csrfToken: "tok.sig", memberOptions: options });
assert.equal(m.title, "New group"); assert.equal(m.title, "New group");
assert.equal(m.form.action, "/admin/groups"); assert.equal(m.form.action, "/auth-admin/groups");
assert.equal(m.form.submitLabel, "Create group"); assert.equal(m.form.submitLabel, "Create group");
assert.equal(m.form.csrfToken, "tok.sig"); assert.equal(m.form.csrfToken, "tok.sig");
assert.equal(m.form.nameField.name, "name"); assert.equal(m.form.nameField.name, "name");
@@ -105,8 +105,8 @@ test("buildGroupDetailModel: members → rows, add-options exclude current membe
const m = buildGroupDetailModel({ candidates, group: { name: "eng" }, members }); const m = buildGroupDetailModel({ candidates, group: { name: "eng" }, members });
assert.equal(m.title, "eng"); assert.equal(m.title, "eng");
assert.equal(m.members.rows.length, 2); assert.equal(m.members.rows.length, 2);
assert.equal(m.members.action, "/admin/groups/eng/members/delete"); assert.equal(m.members.action, "/auth-admin/groups/eng/members/delete");
assert.equal(m.add.action, "/admin/groups/eng/members"); assert.equal(m.add.action, "/auth-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), [`user:${uid(2)}`, "group:ops"]);
assert.equal(m.delete.action, "/admin/groups/eng/delete"); assert.equal(m.delete.action, "/auth-admin/groups/eng/delete");
}); });
@@ -331,13 +331,13 @@ const groupFormResult = async (deps: GroupsDeps, extra: { error?: string; values
return { data: { chrome: deps.ctx.chrome, model: buildGroupFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, t: deps.ctx.t, ...extra }) }, view: "group-form" }; return { data: { chrome: deps.ctx.chrome, model: buildGroupFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, t: deps.ctx.t, ...extra }) }, view: "group-form" };
}; };
// GET /admin/groups — the list. // GET /auth-admin/groups — the list.
export const groupsList = withGroups(async ({ ctx, keto }) => { export const groupsList = withGroups(async ({ ctx, keto }) => {
const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS })); const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS }));
return { data: { chrome: ctx.chrome, model: buildGroupsListModel({ canWrite: can(ctx, permissionName("groups", "write")), csrfToken: ctx.chrome.csrfToken, groups, t: ctx.t, url: ctx.url }) }, view: "groups" }; return { data: { chrome: ctx.chrome, model: buildGroupsListModel({ canWrite: can(ctx, permissionName("groups", "write")), csrfToken: ctx.chrome.csrfToken, groups, t: ctx.t, url: ctx.url }) }, view: "groups" };
}); });
// POST /admin/groups — create (a group exists once it has ≥1 member, so this writes the first tuple). // POST /auth-admin/groups — create (a group exists once it has ≥1 member, so this writes the first tuple).
export const groupsCreate = withGroups(async (deps) => { export const groupsCreate = withGroups(async (deps) => {
const { ctx, keto, user } = deps; const { ctx, keto, user } = deps;
const form = (await guardedForm(ctx))!; const form = (await guardedForm(ctx))!;
@@ -353,7 +353,7 @@ export const groupsCreate = withGroups(async (deps) => {
return { redirect: detailHref(name) }; return { redirect: detailHref(name) };
}); });
// GET /admin/groups/new — the create form. // GET /auth-admin/groups/new — the create form.
export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {}), "write"); export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {}), "write");
// The detail + membership page. `notice` and `error` ride the permission picker; `pageError` tops the // The detail + membership page. `notice` and `error` ride the permission picker; `pageError` tops the
@@ -379,11 +379,11 @@ async function groupDetailPage({ ctx, keto, kratosAdmin }: GroupsDeps, name: str
return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ canWrite: !permissions.readOnly, candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members, permissions, t: ctx.t, ...(say.pageError ? { error: say.pageError } : {}) }) }, view: "group-detail" }; return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ canWrite: !permissions.readOnly, candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members, permissions, t: ctx.t, ...(say.pageError ? { error: say.pageError } : {}) }) }, view: "group-detail" };
} }
// GET /admin/groups/:name — the detail + membership page. // GET /auth-admin/groups/:name — the detail + membership page.
export const groupsDetail = withGroupName((deps, name) => export const groupsDetail = withGroupName((deps, name) =>
groupDetailPage(deps, name, isSavedNotice(deps.ctx.url) ? { notice: deps.ctx.t("admin.grants.saved") } : {})); groupDetailPage(deps, name, isSavedNotice(deps.ctx.url) ? { notice: deps.ctx.t("admin.grants.saved") } : {}));
// POST /admin/groups/:name/permissions — the submitted checkboxes are the desired set. Members hold // POST /auth-admin/groups/:name/permissions — the submitted checkboxes are the desired set. Members hold
// a group's permissions transitively, so the change reaches them at their next login or re-mint — // a group's permissions transitively, so the change reaches them at their next login or re-mint —
// the documented instant-revoke tradeoff for anything held through a group. // the documented instant-revoke tradeoff for anything held through a group.
export const groupsPermissions = withGroupName(async (deps, name) => { export const groupsPermissions = withGroupName(async (deps, name) => {
@@ -406,7 +406,7 @@ export const groupsPermissions = withGroupName(async (deps, name) => {
return { redirect: `${detailHref(name)}?info-msg=${SAVED_NOTICE}` }; return { redirect: `${detailHref(name)}?info-msg=${SAVED_NOTICE}` };
}); });
// POST /admin/groups/:name/members — add a member (skip an invalid member or a self-nest). // POST /auth-admin/groups/:name/members — add a member (skip an invalid member or a self-nest).
export const groupsAddMember = withGroupName(async ({ ctx, keto }, name) => { export const groupsAddMember = withGroupName(async ({ ctx, keto }, name) => {
const form = (await guardedForm(ctx))!; const form = (await guardedForm(ctx))!;
const tuple = memberTuple(name, (form.get("member") ?? "").trim()); const tuple = memberTuple(name, (form.get("member") ?? "").trim());
@@ -414,7 +414,7 @@ export const groupsAddMember = withGroupName(async ({ ctx, keto }, name) => {
return { redirect: detailHref(name) }; return { redirect: detailHref(name) };
}); });
// GET /admin/groups/:name/delete — the deliberate confirm step. // GET /auth-admin/groups/:name/delete — the deliberate confirm step.
export const groupsDeleteConfirm = withGroupName(async (deps, name) => { export const groupsDeleteConfirm = withGroupName(async (deps, name) => {
const lost = await lostOnDelete(deps, name); const lost = await lostOnDelete(deps, name);
if (lost.length > 0) return refuseSelfLockout(deps, name, "delete", lost); if (lost.length > 0) return refuseSelfLockout(deps, name, "delete", lost);
@@ -427,7 +427,7 @@ export const groupsDeleteConfirm = withGroupName(async (deps, name) => {
}) }, view: "confirm" }; }) }, view: "confirm" };
}, "write"); }, "write");
// POST /admin/groups/:name/delete — remove every member tuple (the group ceases to exist). // POST /auth-admin/groups/:name/delete — remove every member tuple (the group ceases to exist).
export const groupsDelete = withGroupName(async (deps, name) => { export const groupsDelete = withGroupName(async (deps, name) => {
const { ctx, keto, user } = deps; const { ctx, keto, user } = deps;
await guardedForm(ctx); // CSRF-verify the POST await guardedForm(ctx); // CSRF-verify the POST
@@ -444,7 +444,7 @@ export const groupsDelete = withGroupName(async (deps, name) => {
return { redirect: ADMIN_GROUPS_BASE }; return { redirect: ADMIN_GROUPS_BASE };
}); });
// POST /admin/groups/:name/members/delete — remove one member. // POST /auth-admin/groups/:name/members/delete — remove one member.
export const groupsRemoveMember = withGroupName(async (deps, name) => { export const groupsRemoveMember = withGroupName(async (deps, name) => {
const { ctx, keto, user } = deps; const { ctx, keto, user } = deps;
const form = (await guardedForm(ctx))!; const form = (await guardedForm(ctx))!;
@@ -68,7 +68,7 @@ function valueLabel(setting: SettingSummary, t: Translate): string {
return setting.value ?? t("admin.pluginSettings.unset"); return setting.value ?? t("admin.pluginSettings.unset");
} }
// GET /admin/plugin-settings // GET /auth-admin/plugin-settings
export const pluginSettingsList: RouteHandler = (ctx) => { export const pluginSettingsList: RouteHandler = (ctx) => {
requirePermission(ctx, "plugin-settings"); requirePermission(ctx, "plugin-settings");
return { data: { chrome: ctx.chrome, model: buildPluginSettingsModel({ chrome: ctx.chrome, settings: ctx.declaredSettings, t: ctx.t }) }, view: "plugin-settings" }; return { data: { chrome: ctx.chrome, model: buildPluginSettingsModel({ chrome: ctx.chrome, settings: ctx.declaredSettings, t: ctx.t }) }, view: "plugin-settings" };
@@ -15,7 +15,7 @@ const member: User = { email: "bo@x.io", id: "u2", permissions: ["scheduling:rea
const CHROME = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } } as PageChrome; const CHROME = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } } as PageChrome;
function fakeCtx(opts: { body?: string; method?: string; user?: User | null; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext { function fakeCtx(opts: { body?: string; method?: string; user?: User | null; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
const url = new URL("http://localhost/admin/users"); const url = new URL("http://localhost/auth-admin/users");
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage; const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
req.method = opts.method ?? "GET"; req.method = opts.method ?? "GET";
return { return {
@@ -36,7 +36,7 @@ test("ADMIN_NAV: an ungated Admin header whose screens each gate on their own re
assert.equal(ADMIN_NAV.permission, undefined); assert.equal(ADMIN_NAV.permission, undefined);
assert.equal(ADMIN_NAV.href, undefined); assert.equal(ADMIN_NAV.href, undefined);
assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/clients", "/admin/plugin-settings"]); assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/auth-admin/users", "/auth-admin/groups", "/auth-admin/clients", "/auth-admin/plugin-settings"]);
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.permission), ["users:read", "groups:read", "oauth2-clients:read", "plugin-settings:read"]); assert.deepEqual(ADMIN_NAV.children?.map((c) => c.permission), ["users:read", "groups:read", "oauth2-clients:read", "plugin-settings:read"]);
// Labels are catalog keys; the host translates them with this plugin's catalog when it composes // Labels are catalog keys; the host translates them with this plugin's catalog when it composes
// the menu, so what a visitor sees is the en-US (or sv-SE …) wording behind these keys. // the menu, so what a visitor sees is the en-US (or sv-SE …) wording behind these keys.
@@ -64,7 +64,7 @@ test("actionForMethod: read for GET/HEAD, write for every mutation", () => {
// ---- auth gates ---- // ---- auth gates ----
test("requirePermission: anonymous → 401→/login, wrong permission → 403, and read never grants write", () => { test("requirePermission: anonymous → 401→/login, wrong permission → 403, and read never grants write", () => {
assert.throws(() => requirePermission(fakeCtx({ user: null }), "users"), (e: unknown) => e instanceof GuardError && e.status === 401 && e.location === "/login?return_to=%2Fadmin%2Fusers"); // bounce remembers the page assert.throws(() => requirePermission(fakeCtx({ user: null }), "users"), (e: unknown) => e instanceof GuardError && e.status === 401 && e.location === "/login?return_to=%2Fauth-admin%2Fusers"); // bounce remembers the page
assert.throws(() => requirePermission(fakeCtx({ user: member }), "users"), (e: unknown) => e instanceof GuardError && e.status === 403); assert.throws(() => requirePermission(fakeCtx({ user: member }), "users"), (e: unknown) => e instanceof GuardError && e.status === 403);
assert.equal(requirePermission(fakeCtx({ user: reader }), "users"), reader); assert.equal(requirePermission(fakeCtx({ user: reader }), "users"), reader);
// The whole point of the split: users:read opens the list but not the create/delete POSTs. // The whole point of the split: users:read opens the list but not the create/delete POSTs.
@@ -9,10 +9,10 @@ import enUS from "./i18n/en-US.ts";
// i.e. its unit tests. At runtime the handlers pass ctx.t instead. // i.e. its unit tests. At runtime the handlers pass ctx.t instead.
export const ADMIN_EN: Translate = englishTranslator(enUS); export const ADMIN_EN: Translate = englishTranslator(enUS);
export const ADMIN_USERS_BASE = "/admin/users"; export const ADMIN_USERS_BASE = "/auth-admin/users";
export const ADMIN_GROUPS_BASE = "/admin/groups"; export const ADMIN_GROUPS_BASE = "/auth-admin/groups";
export const ADMIN_CLIENTS_BASE = "/admin/clients"; export const ADMIN_CLIENTS_BASE = "/auth-admin/clients";
export const ADMIN_PLUGIN_SETTINGS_BASE = "/admin/plugin-settings"; export const ADMIN_PLUGIN_SETTINGS_BASE = "/auth-admin/plugin-settings";
// One resource per screen — the `<resource>` half of every permission this plugin gates on. // One resource per screen — the `<resource>` half of every permission this plugin gates on.
// `oauth2-clients` rather than `clients` because permission names are one global namespace. // `oauth2-clients` rather than `clients` because permission names are one global namespace.
@@ -38,42 +38,42 @@ test("toUserView maps traits → name/initials/email/state; falls back to the em
test("buildUsersListModel filters by search + status, sorts, and paginates", () => { test("buildUsersListModel filters by search + status, sorts, and paginates", () => {
const people = Array.from({ length: 30 }, (_, i) => identity(i + 1, { state: i % 2 ? "inactive" : "active" })); const people = Array.from({ length: 30 }, (_, i) => identity(i + 1, { state: i % 2 ? "inactive" : "active" }));
const all = buildUsersListModel({ identities: people, url: "http://x/admin/users" }); const all = buildUsersListModel({ identities: people, url: "http://x/auth-admin/users" });
assert.equal(all.pagination.summary.total, 30); assert.equal(all.pagination.summary.total, 30);
assert.equal(all.table.rows.length, 25); // default page size assert.equal(all.table.rows.length, 25); // default page size
assert.equal(all.title, "Users"); assert.equal(all.title, "Users");
// Search narrows to one and shows a pill. // Search narrows to one and shows a pill.
const one = buildUsersListModel({ identities: people, url: "http://x/admin/users?q=user7%40example.com" }); const one = buildUsersListModel({ identities: people, url: "http://x/auth-admin/users?q=user7%40example.com" });
assert.equal(one.pagination.summary.total, 1); assert.equal(one.pagination.summary.total, 1);
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]); assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
// Status filter keeps only inactive identities. // Status filter keeps only inactive identities.
const inactive = buildUsersListModel({ identities: people, url: "http://x/admin/users?status=inactive" }); const inactive = buildUsersListModel({ identities: people, url: "http://x/auth-admin/users?status=inactive" });
assert.equal(inactive.pagination.summary.total, 15); assert.equal(inactive.pagination.summary.total, 15);
assert.deepEqual(inactive.filterBar.pills.map((p) => p.label), ["Status"]); assert.deepEqual(inactive.filterBar.pills.map((p) => p.label), ["Status"]);
// Sort by email descending reverses the order. // Sort by email descending reverses the order.
const emailOf = (m: ReturnType<typeof buildUsersListModel>, i: number) => (m.table.rows[i]!.cells[1]) as string; const emailOf = (m: ReturnType<typeof buildUsersListModel>, i: number) => (m.table.rows[i]!.cells[1]) as string;
const desc = buildUsersListModel({ identities: people, url: "http://x/admin/users?sort=-email" }); const desc = buildUsersListModel({ identities: people, url: "http://x/auth-admin/users?sort=-email" });
assert.ok(emailOf(desc, 0) > emailOf(desc, 1)); assert.ok(emailOf(desc, 0) > emailOf(desc, 1));
// Edit link points at the per-identity route. // Edit link points at the per-identity route.
assert.match(all.table.rows[0]!.actions![0]!.href!, new RegExp(`/admin/users/${people[0]!.id}$`)); assert.match(all.table.rows[0]!.actions![0]!.href!, new RegExp(`/auth-admin/users/${people[0]!.id}$`));
assert.deepEqual([all.table.rows[0]!.actions![0]!.icon, all.table.rows[0]!.actions![0]!.label], ["i-edit", "Edit"]); assert.deepEqual([all.table.rows[0]!.actions![0]!.icon, all.table.rows[0]!.actions![0]!.label], ["i-edit", "Edit"]);
}); });
// The detail page of a `users:read` holder is read-only, so the row action must not promise an edit. // The detail page of a `users:read` holder is read-only, so the row action must not promise an edit.
test("buildUsersListModel: a read-only holder's row action says View, with the same target", () => { test("buildUsersListModel: a read-only holder's row action says View, with the same target", () => {
const people: Identity[] = [{ id: "u1", traits: { email: "a@x.io" } }]; const people: Identity[] = [{ id: "u1", traits: { email: "a@x.io" } }];
const action = buildUsersListModel({ canWrite: false, identities: people, url: "http://x/admin/users" }).table.rows[0]!.actions![0]!; const action = buildUsersListModel({ canWrite: false, identities: people, url: "http://x/auth-admin/users" }).table.rows[0]!.actions![0]!;
assert.deepEqual([action.icon, action.label, action.href], ["i-eye", "View", "/admin/users/u1"]); assert.deepEqual([action.icon, action.label, action.href], ["i-eye", "View", "/auth-admin/users/u1"]);
}); });
test("buildUserFormModel: create mode has an editable email + password, no edit actions", () => { test("buildUserFormModel: create mode has an editable email + password, no edit actions", () => {
const m = buildUserFormModel({ csrfToken: "tok.sig" }); const m = buildUserFormModel({ csrfToken: "tok.sig" });
assert.equal(m.title, "New user"); assert.equal(m.title, "New user");
assert.equal(m.form.action, "/admin/users"); assert.equal(m.form.action, "/auth-admin/users");
assert.equal(m.form.submitLabel, "Create user"); assert.equal(m.form.submitLabel, "Create user");
assert.equal(m.form.csrfToken, "tok.sig"); assert.equal(m.form.csrfToken, "tok.sig");
const names = m.form.fields.map((f) => f.name); const names = m.form.fields.map((f) => f.name);
@@ -85,7 +85,7 @@ test("buildUserFormModel: create mode has an editable email + password, no edit
test("buildUserFormModel: edit mode prefills, locks email, and exposes state/delete/recovery actions", () => { test("buildUserFormModel: edit mode prefills, locks email, and exposes state/delete/recovery actions", () => {
const m = buildUserFormModel({ identity: identity(3) }); const m = buildUserFormModel({ identity: identity(3) });
assert.equal(m.title, "Edit user"); assert.equal(m.title, "Edit user");
assert.equal(m.form.action, `/admin/users/${id(3)}`); assert.equal(m.form.action, `/auth-admin/users/${id(3)}`);
assert.equal(m.form.submitLabel, "Save changes"); assert.equal(m.form.submitLabel, "Save changes");
const email = m.form.fields.find((f) => f.name === "email")!; const email = m.form.fields.find((f) => f.name === "email")!;
assert.equal(email.value, "user3@example.com"); assert.equal(email.value, "user3@example.com");
@@ -109,7 +109,7 @@ test("buildUserFormModel: the title and breadcrumb say New, Edit, or View for a
for (const [opts, title, crumb] of cases) { for (const [opts, title, crumb] of cases) {
const m = buildUserFormModel(opts); const m = buildUserFormModel(opts);
assert.equal(m.title, title); assert.equal(m.title, title);
assert.deepEqual(m.breadcrumbs, [{ href: "/admin/users", label: "Users" }, { label: crumb }]); assert.deepEqual(m.breadcrumbs, [{ href: "/auth-admin/users", label: "Users" }, { label: crumb }]);
} }
}); });
@@ -288,13 +288,13 @@ function withTarget(inner: (deps: UsersDeps, identity: Identity, id: string) =>
const formResult = (ctx: RequestContext, extra: Parameters<typeof buildUserFormModel>[0]): RouteResult => const formResult = (ctx: RequestContext, extra: Parameters<typeof buildUserFormModel>[0]): RouteResult =>
({ data: { chrome: ctx.chrome, model: buildUserFormModel({ csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "user-form" }); ({ data: { chrome: ctx.chrome, model: buildUserFormModel({ csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "user-form" });
// GET /admin/users — the filtered/sorted/paged list. // GET /auth-admin/users — the filtered/sorted/paged list.
export const usersList = withUser(async ({ ctx, kratosAdmin }) => { export const usersList = withUser(async ({ ctx, kratosAdmin }) => {
const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE }); const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE });
return { data: { chrome: ctx.chrome, model: buildUsersListModel({ canWrite: canWriteUsers(ctx), csrfToken: ctx.chrome.csrfToken, identities, t: ctx.t, url: ctx.url }) }, view: "users" }; return { data: { chrome: ctx.chrome, model: buildUsersListModel({ canWrite: canWriteUsers(ctx), csrfToken: ctx.chrome.csrfToken, identities, t: ctx.t, url: ctx.url }) }, view: "users" };
}); });
// POST /admin/users — create; a Kratos 4xx re-renders the form (400), keeping the input. // POST /auth-admin/users — create; a Kratos 4xx re-renders the form (400), keeping the input.
export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => { export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => {
const input = readUserInput((await guardedForm(ctx))!); const input = readUserInput((await guardedForm(ctx))!);
try { try {
@@ -307,10 +307,10 @@ export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => {
return { redirect: ADMIN_USERS_BASE }; return { redirect: ADMIN_USERS_BASE };
}); });
// GET /admin/users/new — the empty create form. // GET /auth-admin/users/new — the empty create form.
export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx, {})), "write"); export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx, {})), "write");
// GET /admin/users/:id — the edit form, prefilled. // GET /auth-admin/users/:id — the edit form, prefilled.
export const usersEditForm = withTarget(async (deps, identity, id) => { export const usersEditForm = withTarget(async (deps, identity, id) => {
const permissions = await userPermissionPicker(deps, id, undefined, isSavedNotice(deps.ctx.url) ? deps.ctx.t("admin.grants.saved") : undefined); const permissions = await userPermissionPicker(deps, id, undefined, isSavedNotice(deps.ctx.url) ? deps.ctx.t("admin.grants.saved") : undefined);
return formResult(deps.ctx, { canWrite: canWriteUsers(deps.ctx), identity, ...(permissions ? { permissions } : {}) }); return formResult(deps.ctx, { canWrite: canWriteUsers(deps.ctx), identity, ...(permissions ? { permissions } : {}) });
@@ -342,7 +342,7 @@ async function userPermissionPicker(deps: UsersDeps, id: string, error?: string,
}; };
} }
// POST /admin/users/:id/permissions — the submitted checkboxes are the desired set of *direct* // POST /auth-admin/users/:id/permissions — the submitted checkboxes are the desired set of *direct*
// grants; grant what's newly ticked, revoke what's newly unticked. A change to a user's own grants // grants; grant what's newly ticked, revoke what's newly unticked. A change to a user's own grants
// revokes their live tokens so it lands now rather than at the next re-mint. // revokes their live tokens so it lands now rather than at the next re-mint.
export const usersPermissions = withTarget(async (deps, identity, id) => { export const usersPermissions = withTarget(async (deps, identity, id) => {
@@ -376,7 +376,7 @@ export const usersPermissions = withTarget(async (deps, identity, id) => {
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}?info-msg=${SAVED_NOTICE}` }; return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}?info-msg=${SAVED_NOTICE}` };
}); });
// POST /admin/users/:id — save edits; a Kratos 4xx re-renders the form (400). // POST /auth-admin/users/:id — save edits; a Kratos 4xx re-renders the form (400).
export const usersUpdate = withTarget(async (deps, identity, id) => { export const usersUpdate = withTarget(async (deps, identity, id) => {
const { ctx, kratosAdmin } = deps; const { ctx, kratosAdmin } = deps;
const input = readUserInput((await guardedForm(ctx))!); const input = readUserInput((await guardedForm(ctx))!);
@@ -390,7 +390,7 @@ export const usersUpdate = withTarget(async (deps, identity, id) => {
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` }; return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
}); });
// POST /admin/users/:id/state — toggle active/inactive; a deactivation revokes the target's live // POST /auth-admin/users/:id/state — toggle active/inactive; a deactivation revokes the target's live
// tokens now (not after the JWT TTL). Self-protection: an admin can't deactivate their own account. // tokens now (not after the JWT TTL). Self-protection: an admin can't deactivate their own account.
export const usersState = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => { export const usersState = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => {
await guardedForm(ctx); // CSRF-verify the POST (no fields read) await guardedForm(ctx); // CSRF-verify the POST (no fields read)
@@ -402,7 +402,7 @@ export const usersState = withTarget(async ({ ctx, kratosAdmin, revoke, user },
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` }; return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
}); });
// GET /admin/users/:id/delete — the deliberate confirm step (zero-JS). Refuses self-delete. // GET /auth-admin/users/:id/delete — the deliberate confirm step (zero-JS). Refuses self-delete.
export const usersDeleteConfirm = withTarget((deps, identity, id) => { export const usersDeleteConfirm = withTarget((deps, identity, id) => {
if (id === deps.user.id) return Promise.resolve({ ...formResult(deps.ctx, { error: deps.ctx.t("admin.users.error.selfDelete"), identity }), status: 400 }); if (id === deps.user.id) return Promise.resolve({ ...formResult(deps.ctx, { error: deps.ctx.t("admin.users.error.selfDelete"), identity }), status: 400 });
const back = `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}`; const back = `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}`;
@@ -415,7 +415,7 @@ export const usersDeleteConfirm = withTarget((deps, identity, id) => {
}) }, view: "confirm" }); }) }, view: "confirm" });
}, "write"); }, "write");
// POST /admin/users/:id/delete — perform it; revoke the gone account's live tokens. Refuses self-delete. // POST /auth-admin/users/:id/delete — perform it; revoke the gone account's live tokens. Refuses self-delete.
export const usersDelete = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => { export const usersDelete = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => {
await guardedForm(ctx); // CSRF-verify the POST await guardedForm(ctx); // CSRF-verify the POST
if (id === user.id) return { ...formResult(ctx, { error: ctx.t("admin.users.error.selfDelete"), identity }), status: 400 }; if (id === user.id) return { ...formResult(ctx, { error: ctx.t("admin.users.error.selfDelete"), identity }), status: 400 };
@@ -425,7 +425,7 @@ export const usersDelete = withTarget(async ({ ctx, kratosAdmin, revoke, user },
return { redirect: ADMIN_USERS_BASE }; return { redirect: ADMIN_USERS_BASE };
}); });
// POST /admin/users/:id/recovery — mint a one-time recovery code, shown on the edit page. // POST /auth-admin/users/:id/recovery — mint a one-time recovery code, shown on the edit page.
export const usersRecovery = withTarget(async (deps, identity, id) => { export const usersRecovery = withTarget(async (deps, identity, id) => {
const { ctx, kratosAdmin } = deps; const { ctx, kratosAdmin } = deps;
await guardedForm(ctx); // CSRF-verify the POST await guardedForm(ctx); // CSRF-verify the POST
@@ -1,5 +1,5 @@
// Admin example plugin: the Users / Groups / OAuth2-clients screens for running the system. Copy // Admin example plugin: the Users / Groups / OAuth2-clients screens for running the system. Copy
// this folder to plugins/admin (then restart) to enable it — see README → Quick start. // this folder to plugins/auth-admin (then restart) to enable it — see README → Quick start.
// //
// It is a *system* plugin: its handlers reach the host's Ory admin clients and the instant-revoke // It is a *system* plugin: its handlers reach the host's Ory admin clients and the instant-revoke
// hook via ctx.system. Where a capability is absent the screen degrades to a themed 503. // hook via ctx.system. Where a capability is absent the screen degrades to a themed 503.
@@ -7,7 +7,7 @@
const table = include("partials/data-table", model.table); const table = include("partials/data-table", model.table);
const pager = include("partials/pagination", model.pagination); const pager = include("partials/pagination", model.pagination);
// Only offer "Register client" to an oauth2-clients:write holder — a :read one would get the 403 page. // Only offer "Register client" to an oauth2-clients:write holder — a :read one would get the 403 page.
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/admin/clients/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.clients.registerClient") + '</a>'; const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/auth-admin/clients/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.clients.registerClient") + '</a>';
-%> -%>
<%- include("partials/shell", { <%- include("partials/shell", {
actions, actions,
@@ -7,7 +7,7 @@
const table = include("partials/data-table", model.table); const table = include("partials/data-table", model.table);
const pager = include("partials/pagination", model.pagination); const pager = include("partials/pagination", model.pagination);
// Only offer "New group" to a groups:write holder — a groups:read one would get the 403 page. // Only offer "New group" to a groups:write holder — a groups:read one would get the 403 page.
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/admin/groups/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.groups.new") + '</a>'; const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/auth-admin/groups/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.groups.new") + '</a>';
-%> -%>
<%- include("partials/shell", { <%- include("partials/shell", {
actions, actions,
@@ -7,7 +7,7 @@
const table = include("partials/data-table", model.table); const table = include("partials/data-table", model.table);
const pager = include("partials/pagination", model.pagination); const pager = include("partials/pagination", model.pagination);
// Only offer "New user" to a users:write holder — a users:read one would get the 403 page. // Only offer "New user" to a users:write holder — a users:read one would get the 403 page.
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/admin/users/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.users.new") + '</a>'; const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/auth-admin/users/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.users.new") + '</a>';
-%> -%>
<%- include("partials/shell", { <%- include("partials/shell", {
actions, actions,
+1 -1
View File
@@ -41,7 +41,7 @@ export default definePlugin({
], ],
// Also offered in the profile dropdown above Sign out, since the page is the visitor's own. // Also offered in the profile dropdown above Sign out, since the page is the visitor's own.
profileMenu: [{ href: MINE_PATH, icon: "i-cal", label: "scheduling.nav.mine", session: true }], profileMenu: [{ href: MINE_PATH, icon: "i-cal", id: "scheduling:profile-mine", label: "scheduling.nav.mine", session: true }],
routes: [ routes: [
{ handler: overview(), method: "GET", path: "/", public: true }, { handler: overview(), method: "GET", path: "/", public: true },
+1 -1
View File
@@ -23,7 +23,7 @@ test("every author-facing apiVersion sample matches the shipped contract", () =>
const [major, minor] = host.split("."); const [major, minor] = host.split(".");
for (const file of [ for (const file of [
"README.md", "README.md",
"examples/plugins/admin/plugin.ts", "examples/plugins/auth-admin/plugin.ts",
"examples/plugins/scheduling/plugin.ts", "examples/plugins/scheduling/plugin.ts",
"release-tooling/dockerhub-overview.md.tmpl", "release-tooling/dockerhub-overview.md.tmpl",
"views/index.ejs", "views/index.ejs",
+1 -1
View File
@@ -21,7 +21,7 @@ const grandfathered: Record<string, { lines?: number; complexity?: number }> = {
"src/plugin-host/discovery.ts discoverPlugins": { lines: 74, complexity: 22 }, "src/plugin-host/discovery.ts discoverPlugins": { lines: 74, complexity: 22 },
"src/config.ts loadConfig": { lines: 52 }, "src/config.ts loadConfig": { lines: 52 },
"release-tooling/dockerhub-overview.ts main": { lines: 57 }, "release-tooling/dockerhub-overview.ts main": { lines: 57 },
"examples/plugins/admin/admin-users.ts buildUserFormModel": { complexity: 21 }, "examples/plugins/auth-admin/admin-users.ts buildUserFormModel": { complexity: 21 },
}; };
interface AstNode { interface AstNode {
+110 -110
View File
@@ -13,7 +13,7 @@ import type { RelationTuple } from "../auth/keto-client.ts";
import type { Identity } from "../auth/kratos-admin.ts"; import type { Identity } from "../auth/kratos-admin.ts";
import { SESSION_COOKIE } from "../auth/login.ts"; import { SESSION_COOKIE } from "../auth/login.ts";
import type { Plugin } from "../plugin-host/plugin.ts"; import type { Plugin } from "../plugin-host/plugin.ts";
import adminManifest from "../../examples/plugins/admin/plugin.ts"; import adminManifest from "../../examples/plugins/auth-admin/plugin.ts";
import { createI18n } from "../i18n/runtime.ts"; import { createI18n } from "../i18n/runtime.ts";
import { loadI18n } from "../i18n/load.ts"; import { loadI18n } from "../i18n/load.ts";
import { ecJwk, fakeKeto, matchesTuple, mintJwt, stubAdmin, stubHydra } from "../test-tooling/app-harness.ts"; import { ecJwk, fakeKeto, matchesTuple, mintJwt, stubAdmin, stubHydra } from "../test-tooling/app-harness.ts";
@@ -21,7 +21,7 @@ import { ecJwk, fakeKeto, matchesTuple, mintJwt, stubAdmin, stubHydra } from "..
// The HTTP-level admin tests mount the example plugin via createApp — stub Ory clients on // The HTTP-level admin tests mount the example plugin via createApp — stub Ory clients on
// ctx.system, views from examples/plugins — exactly as an operator would after copying it in. // ctx.system, views from examples/plugins — exactly as an operator would after copying it in.
const examplesPluginsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "examples", "plugins"); const examplesPluginsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "examples", "plugins");
const adminPlugin: Plugin = { ...adminManifest, id: "admin" }; const adminPlugin: Plugin = { ...adminManifest, id: "auth-admin" };
// Shared harness for the admin-screen HTTP tests: an app on a random port with an admin JWT + // Shared harness for the admin-screen HTTP tests: an app on a random port with an admin JWT +
// CSRF cookie. get(path, permissions)/post(path, body) carry them; `token` is the matching CSRF field. // CSRF cookie. get(path, permissions)/post(path, body) carry them; `token` is the matching CSRF field.
@@ -72,22 +72,22 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
const denylist = createDenylist(); // a deactivate/delete should revoke the target's live tokens instantly const denylist = createDenylist(); // a deactivate/delete should revoke the target's live tokens instantly
const { get, post, token, url } = await adminHarness(t, { denylist, kratosAdmin }); const { get, post, token, url } = await adminHarness(t, { denylist, kratosAdmin });
await assertAdminGate(url, get, "/admin/users"); await assertAdminGate(url, get, "/auth-admin/users");
// Nav: the admin plugin's section composes into the one global menu, and each screen is filtered // Nav: the admin plugin's section composes into the one global menu, and each screen is filtered
// by its own read permission — proving the drop-in nav fragment. A user holding only users:read // by its own read permission — proving the drop-in nav fragment. A user holding only users:read
// sees Users and nothing else; holding none of the three, composeNav drops the emptied header. // sees Users and nothing else; holding none of the three, composeNav drops the emptied header.
assert.match(await (await get("/dashboard")).text(), /href="\/admin\/users"/); assert.match(await (await get("/dashboard")).text(), /href="\/auth-admin\/users"/);
const usersOnlyNav = await (await get("/dashboard", ["users:read"])).text(); const usersOnlyNav = await (await get("/dashboard", ["users:read"])).text();
assert.match(usersOnlyNav, /href="\/admin\/users"/); assert.match(usersOnlyNav, /href="\/auth-admin\/users"/);
assert.doesNotMatch(usersOnlyNav, /href="\/admin\/groups"/); assert.doesNotMatch(usersOnlyNav, /href="\/auth-admin\/groups"/);
assert.doesNotMatch(await (await get("/dashboard", ["scheduling:read"])).text(), /href="\/admin\/users"/); assert.doesNotMatch(await (await get("/dashboard", ["scheduling:read"])).text(), /href="\/auth-admin\/users"/);
// The read/write split: users:read opens the list but is refused on every mutation, and the // The read/write split: users:read opens the list but is refused on every mutation, and the
// resources don't leak — a users holder is not a groups holder. // resources don't leak — a users holder is not a groups holder.
assert.equal((await get("/admin/users", ["users:read"])).status, 200); assert.equal((await get("/auth-admin/users", ["users:read"])).status, 200);
assert.equal((await get("/admin/groups", ["users:read", "users:write"])).status, 403); assert.equal((await get("/auth-admin/groups", ["users:read", "users:write"])).status, 403);
const readOnlyPost = await fetch(url + "/admin/users", { const readOnlyPost = await fetch(url + "/auth-admin/users", {
body: `_csrf=${token}&email=nope@example.com`, body: `_csrf=${token}&email=nope@example.com`,
headers: { "content-type": "application/x-www-form-urlencoded", cookie: `${SESSION_COOKIE}=${mintJwt({ email: "r@x", exp: Math.floor(Date.now() / 1000) + 600, permissions: ["users:read"], sub: "reader1" })}; ${CSRF_COOKIE}=${token}` }, headers: { "content-type": "application/x-www-form-urlencoded", cookie: `${SESSION_COOKIE}=${mintJwt({ email: "r@x", exp: Math.floor(Date.now() / 1000) + 600, permissions: ["users:read"], sub: "reader1" })}; ${CSRF_COOKIE}=${token}` },
method: "POST", method: "POST",
@@ -97,44 +97,44 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
assert.equal(store.some((i) => i.traits?.email === "nope@example.com"), false); assert.equal(store.some((i) => i.traits?.email === "nope@example.com"), false);
// List: the admin sees the rows + the "add" link; the status filter narrows server-side. // List: the admin sees the rows + the "add" link; the status filter narrows server-side.
const listHtml = await (await get("/admin/users")).text(); const listHtml = await (await get("/auth-admin/users")).text();
assert.match(listHtml, /ada@example\.com/); assert.match(listHtml, /ada@example\.com/);
assert.match(listHtml, /href="\/admin\/users\/new"/); assert.match(listHtml, /href="\/auth-admin\/users\/new"/);
assert.doesNotMatch(await (await get("/admin/users?status=inactive")).text(), /ada@example\.com/); assert.doesNotMatch(await (await get("/auth-admin/users?status=inactive")).text(), /ada@example\.com/);
// Create: the form renders; a valid post creates the identity and redirects to the list. // Create: the form renders; a valid post creates the identity and redirects to the list.
assert.match(await (await get("/admin/users/new")).text(), /Create user/); assert.match(await (await get("/auth-admin/users/new")).text(), /Create user/);
const created = await post("/admin/users", `_csrf=${token}&email=grace%40example.com&first=Grace&last=Hopper&password=`); const created = await post("/auth-admin/users", `_csrf=${token}&email=grace%40example.com&first=Grace&last=Hopper&password=`);
assert.equal(created.status, 303); assert.equal(created.status, 303);
assert.equal(created.headers.get("location"), "/admin/users"); assert.equal(created.headers.get("location"), "/auth-admin/users");
assert.deepEqual(lastCreate?.traits, { email: "grace@example.com", name: { first: "Grace", last: "Hopper" } }); assert.deepEqual(lastCreate?.traits, { email: "grace@example.com", name: { first: "Grace", last: "Hopper" } });
// A create with no CSRF token is refused and creates nothing. // A create with no CSRF token is refused and creates nothing.
const before = store.length; const before = store.length;
assert.equal((await post("/admin/users", "email=x%40y.z")).status, 403); assert.equal((await post("/auth-admin/users", "email=x%40y.z")).status, 403);
assert.equal(store.length, before); assert.equal(store.length, before);
// CSRF also guards a POST to an *existing* target (the per-route :id handlers), not just the // CSRF also guards a POST to an *existing* target (the per-route :id handlers), not just the
// collection: a delete with no token is refused (403) and removes nothing. // collection: a delete with no token is refused (403) and removes nothing.
assert.equal((await post(`/admin/users/${store[1]!.id}/delete`, "")).status, 403); assert.equal((await post(`/auth-admin/users/${store[1]!.id}/delete`, "")).status, 403);
assert.ok(store.some((x) => x.id === store[1]!.id)); assert.ok(store.some((x) => x.id === store[1]!.id));
// Edit: email is read-only + prefilled; a post rewrites the name. // Edit: email is read-only + prefilled; a post rewrites the name.
const target = store[0]!; const target = store[0]!;
const editHtml = await (await get(`/admin/users/${target.id}`)).text(); const editHtml = await (await get(`/auth-admin/users/${target.id}`)).text();
assert.match(editHtml, /name="email"[^>]*readonly/); assert.match(editHtml, /name="email"[^>]*readonly/);
assert.match(editHtml, /value="ada@example\.com"/); assert.match(editHtml, /value="ada@example\.com"/);
const updated = await post(`/admin/users/${target.id}`, `_csrf=${token}&first=Ada&last=King`); const updated = await post(`/auth-admin/users/${target.id}`, `_csrf=${token}&first=Ada&last=King`);
assert.equal(updated.status, 303); assert.equal(updated.status, 303);
assert.deepEqual((target.traits as { name: unknown }).name, { first: "Ada", last: "King" }); assert.deepEqual((target.traits as { name: unknown }).name, { first: "Ada", last: "King" });
// Deactivate (state toggle): active → inactive, and the target's live tokens are revoked at once. // Deactivate (state toggle): active → inactive, and the target's live tokens are revoked at once.
await post(`/admin/users/${target.id}/state`, `_csrf=${token}`); await post(`/auth-admin/users/${target.id}/state`, `_csrf=${token}`);
assert.equal(target.state, "inactive"); assert.equal(target.state, "inactive");
assert.equal(denylist.isRevoked(target.id, 0), true); assert.equal(denylist.isRevoked(target.id, 0), true);
// Recovery: renders the edit page (200) showing the generated code, linked to the flow it was minted on. // Recovery: renders the edit page (200) showing the generated code, linked to the flow it was minted on.
const rec = await post(`/admin/users/${target.id}/recovery`, `_csrf=${token}`); const rec = await post(`/auth-admin/users/${target.id}/recovery`, `_csrf=${token}`);
assert.equal(rec.status, 200); assert.equal(rec.status, 200);
const recHtml = await rec.text(); const recHtml = await rec.text();
assert.match(recHtml, /Recovery code generated/); assert.match(recHtml, /Recovery code generated/);
@@ -142,22 +142,22 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
assert.match(recHtml, new RegExp(`<a href="http://kratos/self-service/recovery\\?flow=flow-${target.id}">`)); assert.match(recHtml, new RegExp(`<a href="http://kratos/self-service/recovery\\?flow=flow-${target.id}">`));
// Delete needs a deliberate confirm step (zero-JS): GET renders the interstitial, POST performs it. // Delete needs a deliberate confirm step (zero-JS): GET renders the interstitial, POST performs it.
const confirm = await (await get(`/admin/users/${target.id}/delete`)).text(); const confirm = await (await get(`/auth-admin/users/${target.id}/delete`)).text();
assert.match(confirm, /Cancel/); assert.match(confirm, /Cancel/);
assert.match(confirm, new RegExp(`action="/admin/users/${target.id}/delete"`)); assert.match(confirm, new RegExp(`action="/auth-admin/users/${target.id}/delete"`));
const del = await post(`/admin/users/${target.id}/delete`, `_csrf=${token}`); const del = await post(`/auth-admin/users/${target.id}/delete`, `_csrf=${token}`);
assert.equal(del.status, 303); assert.equal(del.status, 303);
assert.ok(!store.some((x) => x.id === target.id)); assert.ok(!store.some((x) => x.id === target.id));
// Self-protection: an admin can't delete or deactivate their own account (JWT sub = admin1). // Self-protection: an admin can't delete or deactivate their own account (JWT sub = admin1).
assert.equal((await post(`/admin/users/admin1/delete`, `_csrf=${token}`)).status, 400); assert.equal((await post(`/auth-admin/users/admin1/delete`, `_csrf=${token}`)).status, 400);
assert.ok(store.some((x) => x.id === "admin1")); assert.ok(store.some((x) => x.id === "admin1"));
assert.equal((await post(`/admin/users/admin1/state`, `_csrf=${token}`)).status, 400); assert.equal((await post(`/auth-admin/users/admin1/state`, `_csrf=${token}`)).status, 400);
assert.equal(store.find((x) => x.id === "admin1")!.state, "active"); assert.equal(store.find((x) => x.id === "admin1")!.state, "active");
// Unknown id → 404; malformed %-encoding → 404 (not a 500), matching groups/permissions/clients. // Unknown id → 404; malformed %-encoding → 404 (not a 500), matching groups/permissions/clients.
assert.equal((await get(`/admin/users/${randomUUID()}`)).status, 404); assert.equal((await get(`/auth-admin/users/${randomUUID()}`)).status, 404);
assert.equal((await get("/admin/users/%ZZ")).status, 404); assert.equal((await get("/auth-admin/users/%ZZ")).status, 404);
}); });
// Built-in Groups admin screen: gate + list/create/membership/delete over HTTP against a // Built-in Groups admin screen: gate + list/create/membership/delete over HTTP against a
@@ -174,56 +174,56 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) }); const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
const { get, post, token, url } = await adminHarness(t, { keto, kratosAdmin }); const { get, post, token, url } = await adminHarness(t, { keto, kratosAdmin });
await assertAdminGate(url, get, "/admin/groups"); await assertAdminGate(url, get, "/auth-admin/groups");
// List: the existing group shows + the "add" link. // List: the existing group shows + the "add" link.
const listHtml = await (await get("/admin/groups")).text(); const listHtml = await (await get("/auth-admin/groups")).text();
assert.match(listHtml, /href="\/admin\/groups\/eng"/); assert.match(listHtml, /href="\/auth-admin\/groups\/eng"/);
assert.match(listHtml, /href="\/admin\/groups\/new"/); assert.match(listHtml, /href="\/auth-admin\/groups\/new"/);
// Create: the form renders; a valid post writes the first-member tuple and redirects to the detail. // Create: the form renders; a valid post writes the first-member tuple and redirects to the detail.
assert.match(await (await get("/admin/groups/new")).text(), /Create group/); assert.match(await (await get("/auth-admin/groups/new")).text(), /Create group/);
const created = await post("/admin/groups", `_csrf=${token}&name=design&member=user:${grace}`); const created = await post("/auth-admin/groups", `_csrf=${token}&name=design&member=user:${grace}`);
assert.equal(created.status, 303); assert.equal(created.status, 303);
assert.equal(created.headers.get("location"), "/admin/groups/design"); assert.equal(created.headers.get("location"), "/auth-admin/groups/design");
assert.ok(tuples.some((tp) => tp.object === "design" && tp.subject_id === `user:${grace}`)); assert.ok(tuples.some((tp) => tp.object === "design" && tp.subject_id === `user:${grace}`));
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written. // An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written.
const before = tuples.length; const before = tuples.length;
assert.equal((await post("/admin/groups", `_csrf=${token}&name=Bad Name&member=user:${grace}`)).status, 400); assert.equal((await post("/auth-admin/groups", `_csrf=${token}&name=Bad Name&member=user:${grace}`)).status, 400);
const duplicate = await post("/admin/groups?locale=sv-SE", `_csrf=${token}&name=eng&member=user:${grace}`); const duplicate = await post("/auth-admin/groups?locale=sv-SE", `_csrf=${token}&name=eng&member=user:${grace}`);
assert.equal(duplicate.status, 400); assert.equal(duplicate.status, 400);
assert.match(await duplicate.text(), /Det finns redan en grupp med det namnet\./); assert.match(await duplicate.text(), /Det finns redan en grupp med det namnet\./);
assert.equal((await post("/admin/groups", `name=x&member=user:${grace}`)).status, 403); assert.equal((await post("/auth-admin/groups", `name=x&member=user:${grace}`)).status, 403);
assert.equal(tuples.length, before); assert.equal(tuples.length, before);
// Detail: lists the current member by email. // Detail: lists the current member by email.
assert.match(await (await get("/admin/groups/eng")).text(), /ada@example\.com/); assert.match(await (await get("/auth-admin/groups/eng")).text(), /ada@example\.com/);
// Add a member, then remove it. // Add a member, then remove it.
await post("/admin/groups/eng/members", `_csrf=${token}&member=user:${grace}`); await post("/auth-admin/groups/eng/members", `_csrf=${token}&member=user:${grace}`);
assert.ok(tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`)); assert.ok(tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=user:${grace}`); await post("/auth-admin/groups/eng/members/delete", `_csrf=${token}&member=user:${grace}`);
assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`)); assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
// Give it a permission first, so the delete below has an orphan to avoid leaving behind. // Give it a permission first, so the delete below has an orphan to avoid leaving behind.
const permissionsBaseline = baselineOf(await (await get("/admin/groups/eng")).text()); const permissionsBaseline = baselineOf(await (await get("/auth-admin/groups/eng")).text());
await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${permissionsBaseline}&permission=users%3Aread`); await post("/auth-admin/groups/eng/permissions", `_csrf=${token}&baseline=${permissionsBaseline}&permission=users%3Aread`);
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "users:read" && tp.subject_set?.object === "eng")); assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "users:read" && tp.subject_set?.object === "eng"));
// Delete the group: a confirm step (GET) then the POST removes every member tuple, back to the list. // Delete the group: a confirm step (GET) then the POST removes every member tuple, back to the list.
assert.match(await (await get("/admin/groups/eng/delete")).text(), /Cancel/); assert.match(await (await get("/auth-admin/groups/eng/delete")).text(), /Cancel/);
const del = await post("/admin/groups/eng/delete", `_csrf=${token}`); const del = await post("/auth-admin/groups/eng/delete", `_csrf=${token}`);
assert.equal(del.status, 303); assert.equal(del.status, 303);
assert.equal(del.headers.get("location"), "/admin/groups"); assert.equal(del.headers.get("location"), "/auth-admin/groups");
assert.ok(!tuples.some((tp) => tp.object === "eng")); assert.ok(!tuples.some((tp) => tp.object === "eng"));
// …and the permissions it held go with it. A Keto set exists only through its tuples, so an // …and the permissions it held go with it. A Keto set exists only through its tuples, so an
// orphaned grant would resurrect the moment someone re-created a group with the same name. // orphaned grant would resurrect the moment someone re-created a group with the same name.
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.subject_set?.object === "eng")); assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.subject_set?.object === "eng"));
// An invalid group name in the path → 404; malformed %-encoding doesn't 500. // An invalid group name in the path → 404; malformed %-encoding doesn't 500.
assert.equal((await get("/admin/groups/Bad%20Name")).status, 404); assert.equal((await get("/auth-admin/groups/Bad%20Name")).status, 404);
assert.equal((await get("/admin/groups/%ZZ")).status, 404); assert.equal((await get("/auth-admin/groups/%ZZ")).status, 404);
}); });
// Granting permissions over HTTP. The offered set is the host's catalog (ctx.declaredPermissions), // Granting permissions over HTTP. The offered set is the host's catalog (ctx.declaredPermissions),
@@ -238,7 +238,7 @@ test("admin permission grants: the picker offers the declared catalog, and a sav
const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin }); const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin });
// The user edit page renders one checkbox per declared permission, ticked where already held. // The user edit page renders one checkbox per declared permission, ticked where already held.
const edit = await (await get(`/admin/users/${ada}`)).text(); const edit = await (await get(`/auth-admin/users/${ada}`)).text();
for (const name of ["users:read", "users:write", "groups:read", "groups:write", "oauth2-clients:read", "oauth2-clients:write"]) { for (const name of ["users:read", "users:write", "groups:read", "groups:write", "oauth2-clients:read", "oauth2-clients:write"]) {
assert.match(edit, new RegExp(`value="${name.replace(":", ":")}"`), name); assert.match(edit, new RegExp(`value="${name.replace(":", ":")}"`), name);
} }
@@ -248,7 +248,7 @@ test("admin permission grants: the picker offers the declared catalog, and a sav
// Save a new set: users:write is added, users:read is dropped — the POST is the whole truth. // Save a new set: users:write is added, users:read is dropped — the POST is the whole truth.
const baseline = baselineOf(edit); const baseline = baselineOf(edit);
const saved = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite&permission=groups%3Aread`); const saved = await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite&permission=groups%3Aread`);
assert.equal(saved.status, 303); assert.equal(saved.status, 303);
assert.deepEqual( assert.deepEqual(
tuples.filter((tp) => tp.subject_id === `user:${ada}`).map((tp) => tp.object).sort(), tuples.filter((tp) => tp.subject_id === `user:${ada}`).map((tp) => tp.object).sort(),
@@ -258,20 +258,20 @@ test("admin permission grants: the picker offers the declared catalog, and a sav
// The redirect lands on a page that confirms the save, and shows the new state. // The redirect lands on a page that confirms the save, and shows the new state.
const landing = saved.headers.get("location") ?? ""; const landing = saved.headers.get("location") ?? "";
assert.match(landing, new RegExp(`^/admin/users/${ada}\\?info-msg=permissions-saved$`)); assert.match(landing, new RegExp(`^/auth-admin/users/${ada}\\?info-msg=permissions-saved$`));
const confirmed = await (await get(landing)).text(); const confirmed = await (await get(landing)).text();
assert.match(confirmed, /Permissions saved/); assert.match(confirmed, /Permissions saved/);
assert.match(confirmed, /value="users:write"[^>]*checked/); assert.match(confirmed, /value="users:write"[^>]*checked/);
// A crafted POST can't grant something no plugin declares. // A crafted POST can't grant something no plugin declares.
await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baselineOf(confirmed)}&permission=users%3Awrite&permission=superuser%3Aall`); await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baselineOf(confirmed)}&permission=users%3Awrite&permission=superuser%3Aall`);
assert.ok(!tuples.some((tp) => tp.object === "superuser:all")); assert.ok(!tuples.some((tp) => tp.object === "superuser:all"));
// The same picker on a group writes the group's subject_set, which Keto resolves transitively. // The same picker on a group writes the group's subject_set, which Keto resolves transitively.
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` }); tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
const groupBaseline = baselineOf(await (await get("/admin/groups/eng")).text()); const groupBaseline = baselineOf(await (await get("/auth-admin/groups/eng")).text());
const groupSaved = await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupBaseline}&permission=groups%3Aread`); const groupSaved = await post("/auth-admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupBaseline}&permission=groups%3Aread`);
assert.equal(groupSaved.headers.get("location"), "/admin/groups/eng?info-msg=permissions-saved"); assert.equal(groupSaved.headers.get("location"), "/auth-admin/groups/eng?info-msg=permissions-saved");
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "groups:read" && tp.subject_set?.object === "eng")); assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "groups:read" && tp.subject_set?.object === "eng"));
}); });
@@ -283,12 +283,12 @@ test("admin permission grants: a save from a stale page is refused, and the page
const keto = fakeKeto(tuples); const keto = fakeKeto(tuples);
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) }); const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin }); const { get, post, token } = await adminHarness(t, { keto, kratosAdmin });
const stale = baselineOf(await (await get(`/admin/users/${ada}`)).text()); const stale = baselineOf(await (await get(`/auth-admin/users/${ada}`)).text());
// Another admin grants users:write while this page is open. // Another admin grants users:write while this page is open.
tuples.push({ namespace: "Permission", object: "users:write", relation: "granted", subject_id: `user:${ada}` }); tuples.push({ namespace: "Permission", object: "users:write", relation: "granted", subject_id: `user:${ada}` });
const before = JSON.stringify(tuples); const before = JSON.stringify(tuples);
const refused = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${stale}&permission=users%3Aread&permission=groups%3Aread`); const refused = await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${stale}&permission=users%3Aread&permission=groups%3Aread`);
assert.equal(refused.status, 409); assert.equal(refused.status, 409);
const page = await refused.text(); const page = await refused.text();
assert.match(page, /changed while you had the page open/); assert.match(page, /changed while you had the page open/);
@@ -297,18 +297,18 @@ test("admin permission grants: a save from a stale page is refused, and the page
assert.notEqual(baselineOf(page), stale); assert.notEqual(baselineOf(page), stale);
// Saving from the re-rendered page goes through. // Saving from the re-rendered page goes through.
const retried = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baselineOf(page)}&permission=users%3Aread&permission=users%3Awrite&permission=groups%3Aread`); const retried = await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baselineOf(page)}&permission=users%3Aread&permission=users%3Awrite&permission=groups%3Aread`);
assert.equal(retried.status, 303); assert.equal(retried.status, 303);
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === `user:${ada}`)); assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === `user:${ada}`));
// A POST with no token at all (a crafted request, or a form rendered before this check existed) is stale too. // A POST with no token at all (a crafted request, or a form rendered before this check existed) is stale too.
assert.equal((await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&permission=users%3Aread`)).status, 409); assert.equal((await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&permission=users%3Aread`)).status, 409);
// Groups are checked the same way. // Groups are checked the same way.
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` }); tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
const groupStale = baselineOf(await (await get("/admin/groups/eng")).text()); const groupStale = baselineOf(await (await get("/auth-admin/groups/eng")).text());
tuples.push({ namespace: "Permission", object: "groups:read", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } }); tuples.push({ namespace: "Permission", object: "groups:read", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
const groupRefused = await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupStale}&permission=users%3Aread`); const groupRefused = await post("/auth-admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupStale}&permission=users%3Aread`);
assert.equal(groupRefused.status, 409); assert.equal(groupRefused.status, 409);
assert.match(await groupRefused.text(), /changed while you had the page open/); assert.match(await groupRefused.text(), /changed while you had the page open/);
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_set?.object === "eng"), "the group keeps its grant"); assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_set?.object === "eng"), "the group keeps its grant");
@@ -329,9 +329,9 @@ test("admin permission grants: a Keto failure midway reports a partial save and
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) }); const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const denylist = createDenylist(); const denylist = createDenylist();
const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin }); const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin });
const baseline = baselineOf(await (await get(`/admin/users/${ada}`)).text()); const baseline = baselineOf(await (await get(`/auth-admin/users/${ada}`)).text());
const res = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=groups%3Aread&permission=users%3Aread&permission=users%3Awrite`); const res = await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=groups%3Aread&permission=users%3Aread&permission=users%3Awrite`);
assert.equal(res.status, 502); assert.equal(res.status, 502);
const page = await res.text(); const page = await res.text();
assert.match(page, /stopped part-way/); assert.match(page, /stopped part-way/);
@@ -350,11 +350,11 @@ test("admin permission grants: a save that changes nothing is not refused for a
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) }); const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const denylist = createDenylist(); const denylist = createDenylist();
const { get, post, token } = await adminHarness(t, { denylist, keto: fakeKeto(tuples), kratosAdmin }); const { get, post, token } = await adminHarness(t, { denylist, keto: fakeKeto(tuples), kratosAdmin });
const stale = baselineOf(await (await get(`/admin/users/${ada}`)).text()); const stale = baselineOf(await (await get(`/auth-admin/users/${ada}`)).text());
tuples.push({ namespace: "Permission", object: "users:write", relation: "granted", subject_id: `user:${ada}` }); tuples.push({ namespace: "Permission", object: "users:write", relation: "granted", subject_id: `user:${ada}` });
const before = JSON.stringify(tuples); const before = JSON.stringify(tuples);
const repeated = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${stale}&permission=users%3Aread&permission=users%3Awrite`); const repeated = await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${stale}&permission=users%3Aread&permission=users%3Awrite`);
assert.equal(repeated.status, 303); assert.equal(repeated.status, 303);
assert.equal(JSON.stringify(tuples), before); assert.equal(JSON.stringify(tuples), before);
assert.equal(denylist.isRevoked(ada, 0), false); assert.equal(denylist.isRevoked(ada, 0), false);
@@ -380,9 +380,9 @@ test("admin permission grants: a partial save is still reported when Keto cannot
}); });
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) }); const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin }); const { get, post, token } = await adminHarness(t, { keto, kratosAdmin });
const baseline = baselineOf(await (await get(`/admin/users/${ada}`)).text()); const baseline = baselineOf(await (await get(`/auth-admin/users/${ada}`)).text());
const res = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=groups%3Aread&permission=users%3Aread`); const res = await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=groups%3Aread&permission=users%3Aread`);
assert.equal(res.status, 502); assert.equal(res.status, 502);
assert.match(await res.text(), /stopped part-way/); assert.match(await res.text(), /stopped part-way/);
assert.deepEqual(tuples.map((tp) => tp.object), ["groups:read"]); assert.deepEqual(tuples.map((tp) => tp.object), ["groups:read"]);
@@ -392,8 +392,8 @@ test("admin permission grants: a partial save is still reported when Keto cannot
writes = 0; writes = 0;
tuples.length = 0; tuples.length = 0;
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` }); tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
const groupBaseline = baselineOf(await (await get("/admin/groups/eng")).text()); const groupBaseline = baselineOf(await (await get("/auth-admin/groups/eng")).text());
const groupRes = await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupBaseline}&permission=groups%3Aread&permission=users%3Aread`); const groupRes = await post("/auth-admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupBaseline}&permission=groups%3Aread&permission=users%3Aread`);
assert.equal(groupRes.status, 502); assert.equal(groupRes.status, 502);
assert.match(await groupRes.text(), /stopped part-way/); assert.match(await groupRes.text(), /stopped part-way/);
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_set?.object === "eng"), "the first write landed"); assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_set?.object === "eng"), "the first write landed");
@@ -403,7 +403,7 @@ test("admin permission grants: a partial save is still reported when Keto cannot
down = false; down = false;
writes = 0; writes = 0;
tuples.length = 1; tuples.length = 1;
const again = await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${baselineOf(await (await get("/admin/groups/eng")).text())}&permission=groups%3Aread&permission=users%3Aread`); const again = await post("/auth-admin/groups/eng/permissions", `_csrf=${token}&baseline=${baselineOf(await (await get("/auth-admin/groups/eng")).text())}&permission=groups%3Aread&permission=users%3Aread`);
assert.equal(again.status, 502); assert.equal(again.status, 502);
const page = await again.text(); const page = await again.text();
assert.match(page, /stopped part-way/); assert.match(page, /stopped part-way/);
@@ -419,15 +419,15 @@ test("admin permission grants: you can't revoke your own permissions, but you ca
const keto = fakeKeto(tuples); const keto = fakeKeto(tuples);
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) }); const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin }); const { get, post, token } = await adminHarness(t, { keto, kratosAdmin });
const baseline = baselineOf(await (await get("/admin/users/admin1")).text()); const baseline = baselineOf(await (await get("/auth-admin/users/admin1")).text());
const refused = await post("/admin/users/admin1/permissions", `_csrf=${token}&baseline=${baseline}`); // every box cleared const refused = await post("/auth-admin/users/admin1/permissions", `_csrf=${token}&baseline=${baseline}`); // every box cleared
assert.equal(refused.status, 400); assert.equal(refused.status, 400);
assert.match(await refused.text(), /lock yourself out/); assert.match(await refused.text(), /lock yourself out/);
assert.ok(tuples.some((tp) => tp.object === "users:write" && tp.subject_id === "user:admin1"), "nothing was revoked"); assert.ok(tuples.some((tp) => tp.object === "users:write" && tp.subject_id === "user:admin1"), "nothing was revoked");
// Granting yourself more is not a lockout, so it goes through. // Granting yourself more is not a lockout, so it goes through.
const granted = await post("/admin/users/admin1/permissions", `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite&permission=groups%3Aread`); const granted = await post("/auth-admin/users/admin1/permissions", `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite&permission=groups%3Aread`);
assert.equal(granted.status, 303); assert.equal(granted.status, 303);
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === "user:admin1")); assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === "user:admin1"));
}); });
@@ -464,32 +464,32 @@ test("admin groups: you can't take away permissions you hold only through a grou
const keto = fakeKeto(tuples, { check: resolvingCheck(tuples) }); const keto = fakeKeto(tuples, { check: resolvingCheck(tuples) });
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) }); const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin }, you); const { get, post, token } = await adminHarness(t, { keto, kratosAdmin }, you);
const baseline = baselineOf(await (await get("/admin/groups/eng")).text()); const baseline = baselineOf(await (await get("/auth-admin/groups/eng")).text());
// A refusal renders that group's own page and names what you would lose. // A refusal renders that group's own page and names what you would lose.
const refusedWithMessage = async (res: Response, group: string, lost: string) => { const refusedWithMessage = async (res: Response, group: string, lost: string) => {
assert.equal(res.status, 400); assert.equal(res.status, 400);
const html = await res.text(); const html = await res.text();
assert.match(html, new RegExp(`lock you out of ${lost}, which`)); assert.match(html, new RegExp(`lock you out of ${lost}, which`));
assert.match(html, new RegExp(`action="/admin/groups/${group}/members"`)); assert.match(html, new RegExp(`action="/auth-admin/groups/${group}/members"`));
}; };
// Unticking users:write on eng would take it from you: refused, nothing revoked. // Unticking users:write on eng would take it from you: refused, nothing revoked.
const before = JSON.stringify(tuples); const before = JSON.stringify(tuples);
await refusedWithMessage(await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${baseline}&permission=groups%3Awrite`), "eng", "users:write"); await refusedWithMessage(await post("/auth-admin/groups/eng/permissions", `_csrf=${token}&baseline=${baseline}&permission=groups%3Awrite`), "eng", "users:write");
// Leaving eng, removing eng from leads, or deleting eng (the confirm step too) are refused the same // Leaving eng, removing eng from leads, or deleting eng (the confirm step too) are refused the same
// way; leaving eng also loses what it gets from leads. // way; leaving eng also loses what it gets from leads.
await refusedWithMessage(await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=user:${you}`), "eng", "oauth2-clients:write, users:write"); await refusedWithMessage(await post("/auth-admin/groups/eng/members/delete", `_csrf=${token}&member=user:${you}`), "eng", "oauth2-clients:write, users:write");
await refusedWithMessage(await post("/admin/groups/leads/members/delete", `_csrf=${token}&member=group:eng`), "leads", "oauth2-clients:write"); await refusedWithMessage(await post("/auth-admin/groups/leads/members/delete", `_csrf=${token}&member=group:eng`), "leads", "oauth2-clients:write");
await refusedWithMessage(await get("/admin/groups/eng/delete"), "eng", "oauth2-clients:write, users:write"); await refusedWithMessage(await get("/auth-admin/groups/eng/delete"), "eng", "oauth2-clients:write, users:write");
await refusedWithMessage(await post("/admin/groups/eng/delete", `_csrf=${token}`), "eng", "oauth2-clients:write, users:write"); await refusedWithMessage(await post("/auth-admin/groups/eng/delete", `_csrf=${token}`), "eng", "oauth2-clients:write, users:write");
assert.equal(JSON.stringify(tuples), before, "nothing changed"); assert.equal(JSON.stringify(tuples), before, "nothing changed");
// You hold groups:write directly too, so unticking it on eng costs you nothing. // You hold groups:write directly too, so unticking it on eng costs you nothing.
assert.equal((await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite`)).status, 303); assert.equal((await post("/auth-admin/groups/eng/permissions", `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite`)).status, 303);
// Removing someone else, leaving a group that confers nothing, and deleting a group you are not in all go through. // Removing someone else, leaving a group that confers nothing, and deleting a group you are not in all go through.
assert.equal((await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=user:${ada}`)).status, 303); assert.equal((await post("/auth-admin/groups/eng/members/delete", `_csrf=${token}&member=user:${ada}`)).status, 303);
assert.equal((await post("/admin/groups/social/members/delete", `_csrf=${token}&member=user:${you}`)).status, 303); assert.equal((await post("/auth-admin/groups/social/members/delete", `_csrf=${token}&member=user:${you}`)).status, 303);
assert.equal((await post("/admin/groups/design/delete", `_csrf=${token}`)).status, 303); assert.equal((await post("/auth-admin/groups/design/delete", `_csrf=${token}`)).status, 303);
assert.ok(!tuples.some((tp) => tp.object === "design" || tp.subject_set?.object === "design")); assert.ok(!tuples.some((tp) => tp.object === "design" || tp.subject_set?.object === "design"));
}); });
@@ -507,43 +507,43 @@ test("admin screens render no write affordance for a read-only holder", async (t
const { get } = await adminHarness(t, { hydra, keto, kratosAdmin }); const { get } = await adminHarness(t, { hydra, keto, kratosAdmin });
const readOnly = ["users:read", "groups:read"]; const readOnly = ["users:read", "groups:read"];
const list = await (await get("/admin/users", readOnly)).text(); const list = await (await get("/auth-admin/users", readOnly)).text();
assert.doesNotMatch(list, /href="\/admin\/users\/new"/); // no "New user" assert.doesNotMatch(list, /href="\/auth-admin\/users\/new"/); // no "New user"
assert.match(list, /ada@example\.com/); // but the list itself is there — that's the point of :read assert.match(list, /ada@example\.com/); // but the list itself is there — that's the point of :read
// (The shell's own sign-out is a POST form, so assert on the affordances by name, not on <form>.) // (The shell's own sign-out is a POST form, so assert on the affordances by name, not on <form>.)
const detail = await (await get(`/admin/users/${ada}`, readOnly)).text(); const detail = await (await get(`/auth-admin/users/${ada}`, readOnly)).text();
assert.doesNotMatch(detail, /Save changes/); assert.doesNotMatch(detail, /Save changes/);
assert.doesNotMatch(detail, /Generate recovery code/); assert.doesNotMatch(detail, /Generate recovery code/);
assert.doesNotMatch(detail, /Delete user/); assert.doesNotMatch(detail, /Delete user/);
assert.doesNotMatch(detail, /Save permissions/); assert.doesNotMatch(detail, /Save permissions/);
assert.match(detail, /type="checkbox"[^>]*disabled/); // the permissions are shown, just not editable assert.match(detail, /type="checkbox"[^>]*disabled/); // the permissions are shown, just not editable
const group = await (await get("/admin/groups/eng", readOnly)).text(); const group = await (await get("/auth-admin/groups/eng", readOnly)).text();
assert.doesNotMatch(group, /Add a member/); assert.doesNotMatch(group, /Add a member/);
assert.doesNotMatch(group, /Delete group/); assert.doesNotMatch(group, /Delete group/);
assert.doesNotMatch(group, /Save permissions/); assert.doesNotMatch(group, /Save permissions/);
// The OAuth2-clients screen is held to the same rule. // The OAuth2-clients screen is held to the same rule.
const clientsRes = await get("/admin/clients", ["oauth2-clients:read"]); const clientsRes = await get("/auth-admin/clients", ["oauth2-clients:read"]);
assert.equal(clientsRes.status, 200); // a real render, not the capability-missing 503 assert.equal(clientsRes.status, 200); // a real render, not the capability-missing 503
const clients = await clientsRes.text(); const clients = await clientsRes.text();
assert.match(clients, /Reporting/); // the list is there — that's what :read buys assert.match(clients, /Reporting/); // the list is there — that's what :read buys
assert.doesNotMatch(clients, /href="\/admin\/clients\/new"/); assert.doesNotMatch(clients, /href="\/auth-admin\/clients\/new"/);
// The detail page is where Delete lives, so check it too and not just the list. // The detail page is where Delete lives, so check it too and not just the list.
const clientDetail = await (await get("/admin/clients/existing", ["oauth2-clients:read"])).text(); const clientDetail = await (await get("/auth-admin/clients/existing", ["oauth2-clients:read"])).text();
assert.match(clientDetail, /Reporting/); assert.match(clientDetail, /Reporting/);
assert.doesNotMatch(clientDetail, /clients\/existing\/delete/); assert.doesNotMatch(clientDetail, /clients\/existing\/delete/);
// A write-intent GET — a create form or a delete-confirm — refuses a reader outright rather than // A write-intent GET — a create form or a delete-confirm — refuses a reader outright rather than
// rendering a form whose submit would 403. // rendering a form whose submit would 403.
for (const path of ["/admin/users/new", "/admin/groups/new", `/admin/users/${ada}/delete`, "/admin/groups/eng/delete"]) { for (const path of ["/auth-admin/users/new", "/auth-admin/groups/new", `/auth-admin/users/${ada}/delete`, "/auth-admin/groups/eng/delete"]) {
assert.equal((await get(path, readOnly)).status, 403, path); assert.equal((await get(path, readOnly)).status, 403, path);
} }
assert.equal((await get("/admin/clients/new", ["oauth2-clients:read"])).status, 403); assert.equal((await get("/auth-admin/clients/new", ["oauth2-clients:read"])).status, 403);
// A writer sees the affordances the reader didn't. // A writer sees the affordances the reader didn't.
const writable = await (await get(`/admin/users/${ada}`, ["users:read", "users:write"])).text(); const writable = await (await get(`/auth-admin/users/${ada}`, ["users:read", "users:write"])).text();
assert.match(writable, /Save changes/); assert.match(writable, /Save changes/);
assert.match(writable, /Save permissions/); assert.match(writable, /Save permissions/);
}); });
@@ -563,20 +563,20 @@ test("admin OAuth2 clients screen: gate, list, register (one-time secret), detai
}); });
const { get, post, token, url } = await adminHarness(t, { hydra }); const { get, post, token, url } = await adminHarness(t, { hydra });
await assertAdminGate(url, get, "/admin/clients"); await assertAdminGate(url, get, "/auth-admin/clients");
// List: the existing client shows + the "register" link. // List: the existing client shows + the "register" link.
const listHtml = await (await get("/admin/clients")).text(); const listHtml = await (await get("/auth-admin/clients")).text();
assert.match(listHtml, /href="\/admin\/clients\/existing"/); assert.match(listHtml, /href="\/auth-admin\/clients\/existing"/);
assert.match(listHtml, /href="\/admin\/clients\/new"/); assert.match(listHtml, /href="\/auth-admin\/clients\/new"/);
assert.match(listHtml, /Reporting/); assert.match(listHtml, /Reporting/);
// Register: the form renders (with confidential-vs-public guidance); a valid post creates the // Register: the form renders (with confidential-vs-public guidance); a valid post creates the
// client and shows the one-time secret + id. // client and shows the one-time secret + id.
const formHtml = await (await get("/admin/clients/new")).text(); const formHtml = await (await get("/auth-admin/clients/new")).text();
assert.match(formHtml, /Register client/); assert.match(formHtml, /Register client/);
assert.match(formHtml, /keep a secret/i); // guidance on the public-vs-confidential choice (apostrophes arrive escaped: t() text goes through <%= %>) assert.match(formHtml, /keep a secret/i); // guidance on the public-vs-confidential choice (apostrophes arrive escaped: t() text goes through <%= %>)
const created = await post("/admin/clients", `_csrf=${token}&name=Grafana&redirectUris=${encodeURIComponent("https://graf/cb")}&scope=openid+offline_access`); const created = await post("/auth-admin/clients", `_csrf=${token}&name=Grafana&redirectUris=${encodeURIComponent("https://graf/cb")}&scope=openid+offline_access`);
assert.equal(created.status, 200); // not a redirect — the secret is shown once assert.equal(created.status, 200); // not a redirect — the secret is shown once
const createdHtml = await created.text(); const createdHtml = await created.text();
assert.match(createdHtml, /Client registered/); assert.match(createdHtml, /Client registered/);
@@ -586,26 +586,26 @@ test("admin OAuth2 clients screen: gate, list, register (one-time secret), detai
// Invalid input (missing redirect URI) and a missing CSRF token are both refused, nothing created. // Invalid input (missing redirect URI) and a missing CSRF token are both refused, nothing created.
const before = store.length; const before = store.length;
assert.equal((await post("/admin/clients", `_csrf=${token}&name=NoRedirect&redirectUris=`)).status, 400); assert.equal((await post("/auth-admin/clients", `_csrf=${token}&name=NoRedirect&redirectUris=`)).status, 400);
assert.equal((await post("/admin/clients", `name=x&redirectUris=${encodeURIComponent("https://x/cb")}`)).status, 403); assert.equal((await post("/auth-admin/clients", `name=x&redirectUris=${encodeURIComponent("https://x/cb")}`)).status, 403);
assert.equal(store.length, before); assert.equal(store.length, before);
// Detail: read-only info, never the secret again, a delete control. // Detail: read-only info, never the secret again, a delete control.
const detail = await (await get("/admin/clients/existing")).text(); const detail = await (await get("/auth-admin/clients/existing")).text();
assert.match(detail, /reporting\.example\/cb/); assert.match(detail, /reporting\.example\/cb/);
assert.doesNotMatch(detail, /Client secret/i); // the secret is shown only once, at creation assert.doesNotMatch(detail, /Client secret/i); // the secret is shown only once, at creation
assert.match(detail, /delete and re-register/i); // no edit: the lifecycle guidance is surfaced assert.match(detail, /delete and re-register/i); // no edit: the lifecycle guidance is surfaced
assert.match(detail, /href="\/admin\/clients\/existing\/delete"/); assert.match(detail, /href="\/auth-admin\/clients\/existing\/delete"/);
// Delete: a confirm step (GET) then the POST removes the client, back to the list. // Delete: a confirm step (GET) then the POST removes the client, back to the list.
assert.match(await (await get("/admin/clients/existing/delete")).text(), /Cancel/); assert.match(await (await get("/auth-admin/clients/existing/delete")).text(), /Cancel/);
const del = await post("/admin/clients/existing/delete", `_csrf=${token}`); const del = await post("/auth-admin/clients/existing/delete", `_csrf=${token}`);
assert.equal(del.status, 303); assert.equal(del.status, 303);
assert.equal(del.headers.get("location"), "/admin/clients"); assert.equal(del.headers.get("location"), "/auth-admin/clients");
assert.ok(!store.some((c) => c.client_id === "existing")); assert.ok(!store.some((c) => c.client_id === "existing"));
// Unknown id → 404; malformed %-encoding doesn't 500. // Unknown id → 404; malformed %-encoding doesn't 500.
assert.equal((await get("/admin/clients/does-not-exist")).status, 404); assert.equal((await get("/auth-admin/clients/does-not-exist")).status, 404);
assert.equal((await get("/admin/clients/%ZZ")).status, 404); assert.equal((await get("/auth-admin/clients/%ZZ")).status, 404);
}); });
+6
View File
@@ -29,6 +29,12 @@ test("findConflicts: duplicate nav id is an error, a shared permission name only
p({ id: "b", nav: [{ id: "dup", label: "B" }] }), p({ id: "b", nav: [{ id: "dup", label: "B" }] }),
]); ]);
assert.ok(navDup.some((c) => c.kind === "nav-id" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b"))); assert.ok(navDup.some((c) => c.kind === "nav-id" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
// The override targets profile-menu entries by the same ids, so one namespace covers both.
const profileDup = findConflicts([
p({ id: "a", nav: [{ id: "dup", label: "A" }] }),
p({ id: "b", profileMenu: [{ href: "/b", id: "dup", label: "B", session: true }] }),
]);
assert.ok(profileDup.some((c) => c.kind === "nav-id" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
// Sharing a permission across plugins is legitimate → warn, not error. // Sharing a permission across plugins is legitimate → warn, not error.
const permissionDup = findConflicts([ const permissionDup = findConflicts([
+5 -1
View File
@@ -34,7 +34,11 @@ export function findConflicts(plugins: Plugin[]): PluginConflict[] {
if (owners.length > 1) out.push({ kind: "route", level: "error", message: `${owners.length} routes resolve to "${key}"`, plugins: uniq(owners) }); if (owners.length > 1) out.push({ kind: "route", level: "error", message: `${owners.length} routes resolve to "${key}"`, plugins: uniq(owners) });
}); });
collect(plugins, (plugin, push) => collectNavIds(plugin.nav, push)).forEach((owners, id) => { const overrideTargets = (plugin: Plugin, push: (id: string) => void): void => {
collectNavIds(plugin.nav, push);
for (const entry of plugin.profileMenu ?? []) if (entry.id != null) push(entry.id);
};
collect(plugins, overrideTargets).forEach((owners, id) => {
if (owners.length > 1) out.push({ kind: "nav-id", level: "error", message: `nav id "${id}" used ${owners.length}×; override targets ids, so they must be unique`, plugins: uniq(owners) }); if (owners.length > 1) out.push({ kind: "nav-id", level: "error", message: `nav id "${id}" used ${owners.length}×; override targets ids, so they must be unique`, plugins: uniq(owners) });
}); });
+3 -3
View File
@@ -128,11 +128,11 @@ test("a route + nav node may be marked public, or session, and load fine", async
assert.equal(sess?.nav?.[0]?.session, true); assert.equal(sess?.nav?.[0]?.session, true);
}); });
test("`admin` is not reserved — the admin screens ship as a drop-in plugin mounted at /admin", async (t) => { test("`auth-admin` is not reserved — the admin screens ship as a drop-in plugin mounted at /auth-admin", async (t) => {
const dir = scaffold(t, { "admin/plugin.ts": full("admin") }); const dir = scaffold(t, { "auth-admin/plugin.ts": full("auth-admin") });
const plugins = await discoverPlugins({ dir }); const plugins = await discoverPlugins({ dir });
assert.equal(plugins.length, 1); assert.equal(plugins.length, 1);
assert.equal(plugins[0]?.id, "admin"); assert.equal(plugins[0]?.id, "auth-admin");
}); });
test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard) handlers", async (t) => { test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard) handlers", async (t) => {
+3 -3
View File
@@ -108,8 +108,8 @@ test("RESERVED_PLUGIN_IDS covers every built-in top-level mount; `home` (the / f
]); ]);
for (const id of builtins) assert.ok(RESERVED_PLUGIN_IDS.has(id), `built-in mount "${id}" must be a reserved plugin id`); for (const id of builtins) assert.ok(RESERVED_PLUGIN_IDS.has(id), `built-in mount "${id}" must be a reserved plugin id`);
// "/" is owned by the `home` manifest field (not a /<id> route), so it cannot be shadowed and is // "/" is owned by the `home` manifest field (not a /<id> route), so it cannot be shadowed and is
// deliberately not reserved — a plugin folder named "home" is legal. `admin` is likewise free: the // deliberately not reserved — a plugin folder named "home" is legal. `auth-admin` is likewise free:
// admin screens ship as a drop-in plugin mounted at /admin, not a built-in route. // the admin screens ship as a drop-in plugin mounted at /auth-admin, not a built-in route.
assert.equal(RESERVED_PLUGIN_IDS.has("home"), false); assert.equal(RESERVED_PLUGIN_IDS.has("home"), false);
assert.equal(RESERVED_PLUGIN_IDS.has("admin"), false); assert.equal(RESERVED_PLUGIN_IDS.has("auth-admin"), false);
}); });
+15
View File
@@ -113,3 +113,18 @@ test("profile menu entries are gated like nav nodes, translated by their plugin
assert.deepEqual(entries({ permissions: ["billing:read"] }).map((e) => e.label), ["Inställningar", "account.billing", "Help"]); assert.deepEqual(entries({ permissions: ["billing:read"] }).map((e) => e.label), ["Inställningar", "account.billing", "Help"]);
assert.deepEqual(entries({ signedIn: false }).map((e) => e.label), []); // an anonymous visitor has no profile menu assert.deepEqual(entries({ signedIn: false }).map((e) => e.label), []); // an anonymous visitor has no profile menu
}); });
test("the central override hides and renames a profile-menu entry by its id, which is not rendered", () => {
const links = buildPluginChrome({
menu: { ...DEFAULT_MENU, override: { hide: ["help"], rename: { settings: "Preferences" } } },
plugins: [{
apiVersion: "1.0.0", id: "account",
profileMenu: [
{ href: "/account/settings", id: "settings", label: "account.settings", session: true },
{ href: "/account/help", id: "help", label: "Help", public: true },
],
}],
user: { email: "ada@x.io", id: "u1", permissions: [] },
}).user.profileLinks;
assert.deepEqual(links, [{ href: "/account/settings", label: "Preferences" }]);
});
+11 -4
View File
@@ -20,7 +20,9 @@ export interface ProfileLink {
} }
// What a plugin's `profileMenu` declares: a link plus the one gate that decides who sees it. // What a plugin's `profileMenu` declares: a link plus the one gate that decides who sees it.
export interface ProfileMenuEntry extends ProfileLink, Gate {} export interface ProfileMenuEntry extends ProfileLink, Gate {
id?: string; // targets the entry from config/menu.ts's `hide` and `rename`, as on a nav node; not rendered
}
// `profileLinks` rides on the user, which every view already hands to the shell, so no view needs a new local. // `profileLinks` rides on the user, which every view already hands to the shell, so no view needs a new local.
export interface ShellUser { export interface ShellUser {
@@ -86,13 +88,18 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
function profileLinks(opts: ChromeOptions, t: Translate, carryLocale: (href: string) => string): ProfileLink[] { function profileLinks(opts: ChromeOptions, t: Translate, carryLocale: (href: string) => string): ProfileLink[] {
const user = opts.user ?? null; const user = opts.user ?? null;
if (!user) return []; if (!user) return [];
const { hide = [], rename = {} } = opts.menu.override;
return (opts.plugins ?? []).flatMap((plugin) => { return (opts.plugins ?? []).flatMap((plugin) => {
const pluginT = opts.translatorFor?.(plugin.id) ?? t; const pluginT = opts.translatorFor?.(plugin.id) ?? t;
return (plugin.profileMenu ?? []).filter((entry) => allows(entry, user)).map((entry) => ({ const shown = (plugin.profileMenu ?? []).filter((entry) => allows(entry, user) && !(entry.id != null && hide.includes(entry.id)));
return shown.map((entry) => {
const renamed = entry.id != null ? rename[entry.id] : undefined;
return {
href: carryLocale(entry.href), href: carryLocale(entry.href),
...(entry.icon != null ? { icon: entry.icon } : {}), ...(entry.icon != null ? { icon: entry.icon } : {}),
label: pluginT(entry.label), label: renamed != null ? t(renamed) : pluginT(entry.label),
})); };
});
}); });
} }
+2 -2
View File
@@ -23,9 +23,9 @@ export interface NavNode extends Gate {
// order → hide, then the per-user gate filter runs last. // order → hide, then the per-user gate filter runs last.
export interface NavOverride { export interface NavOverride {
groups?: NavGroupSpec[]; // wrap top-level nodes (by id) under a new header groups?: NavGroupSpec[]; // wrap top-level nodes (by id) under a new header
hide?: string[]; // remove nodes by id, at any depth (incl. a group's id) hide?: string[]; // remove nodes by id, at any depth (incl. a group's id), and profile-menu entries
order?: string[]; // reorder top-level nodes by id; unlisted keep their order, after order?: string[]; // reorder top-level nodes by id; unlisted keep their order, after
rename?: Record<string, string>; // id → replacement label rename?: Record<string, string>; // id → replacement label, for a node or a profile-menu entry
} }
export interface NavGroupSpec { export interface NavGroupSpec {
-16
View File
@@ -12,10 +12,8 @@
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score | | ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|---|---|---|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|---|---|---|
| 2 | 0.5.0 | | | **Rename the admin example plugin to a name that says what it administers.** | 3 | 1 | 2 | 4 | 6 | 9 by 3, code health by 2 | 2.6 |
| 5 | 0.5.0 | | | **List grants that no installed plugin declares, with a remove action.** | 2 | 2 | 2 | 4 | 4 | 2 by 3 | 2.1 | | 5 | 0.5.0 | | | **List grants that no installed plugin declares, with a remove action.** | 2 | 2 | 2 | 4 | 4 | 2 by 3 | 2.1 |
| 41 | 0.5.0 | | | **Apply a permission save as one Keto call, so it cannot stop part-way.** | 2 | 2 | 2 | 4 | 3 | 2 by 2 | 2.1 | | 41 | 0.5.0 | | | **Apply a permission save as one Keto call, so it cannot stop part-way.** | 2 | 2 | 2 | 4 | 3 | 2 by 2 | 2.1 |
| 58 | 0.5.0 | | owner | **Let an operator hide a plugin's profile-menu entry from `config/menu.ts`.** | 2 | 2 | 2 | 4 | 4 | 7 by 3 | 2.0 |
| 19 | 0.6.0 | | question | **Run the documented Docker commands on macOS and fix whatever misbehaves.** | 2 | 2 | 1 | 3 | 3 | 1 by 3 | 3.3 | | 19 | 0.6.0 | | question | **Run the documented Docker commands on macOS and fix whatever misbehaves.** | 2 | 2 | 1 | 3 | 3 | 1 by 3 | 3.3 |
| 16 | 0.6.0 | | question | **Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.** | 3 | 2 | 3 | 6 | 6 | 7 by 2 | 0.5 | | 16 | 0.6.0 | | question | **Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.** | 3 | 2 | 3 | 6 | 6 | 7 by 2 | 0.5 |
| 14 | 0.6.0 | | | **Drop a double-clicked duplicate submit, with no client-side JavaScript.** | 4 | 2 | 3 | 7 | 7 | 2 by 7, 1 by 4 | 1.4 | | 14 | 0.6.0 | | | **Drop a double-clicked duplicate submit, with no client-side JavaScript.** | 4 | 2 | 3 | 7 | 7 | 2 by 7, 1 by 4 | 1.4 |
@@ -27,11 +25,6 @@
## Details ## Details
### 2. Rename the admin example plugin to a name that says what it administers.
The new name is `auth-admin`, chosen by the maintainer. The id is also the mount path, so the compose files,
E2E stacks and README move with it.
### 5. List grants that no installed plugin declares, with a remove action. ### 5. List grants that no installed plugin declares, with a remove action.
`grantDiff` never revokes an undeclared name, and nothing shows one either, so it can't be audited or `grantDiff` never revokes an undeclared name, and nothing shows one either, so it can't be audited or
@@ -45,15 +38,6 @@ and deletes in one transaction; confirm that against the Keto version `ory/` pin
call to `KetoClient`, which is contract surface (`@plainpages/plugin-api`) and so needs 0.5.0, and the call to `KetoClient`, which is contract surface (`@plainpages/plugin-api`) and so needs 0.5.0, and the
partial-failure path in the admin example can go. partial-failure path in the admin example can go.
### 58. Let an operator hide a plugin's profile-menu entry from `config/menu.ts`.
A manifest's `profileMenu` entries have no `id`, and the central override (`NavOverride` in
`src/ui/nav.ts`) targets sidebar nav nodes only, so hiding or renaming one means forking the plugin,
though README → The menu system promises hide and rename for menu items. The fix gives an entry an optional
`id`, unique across plugins through a `findConflicts` kind like `nav-id`, and applies `hide` and
`rename` to it in `profileLinks` (`src/ui/chrome.ts`). The alternative is a decision recording
profile entries as plugin-only, with README saying so. The human chose to keep it in 0.5.0.
### 19. Run the documented Docker commands on macOS and fix whatever misbehaves. ### 19. Run the documented Docker commands on macOS and fix whatever misbehaves.
Needs a Mac. Two suspects, both from `--user "$(id -u):$(id -g)"`: a macOS `id -g` is `20`, which is Needs a Mac. Two suspects, both from `--user "$(id -u):$(id -g)"`: a macOS `id -g` is `20`, which is