Permission names are <resource>:<action>; permissions come from plugin code #58

Merged
lilleman merged 12 commits from permission-naming into main 2026-08-05 18:04:34 +02:00
53 changed files with 1255 additions and 978 deletions
+84
View File
@@ -93,6 +93,90 @@ them. Revisit only if the stated reason stops holding.
gates on one operation, so it gates on a permission, and a bundle is just a group with several gates on one operation, so it gates on a permission, and a bundle is just a group with several
grants (groups nest). Ory's own "permission" (the `Resource` `permits`: view/edit/delete) is the grants (groups nest). Ory's own "permission" (the `Resource` `permits`: view/edit/delete) is the
separate per-row tier. separate per-row tier.
- **A stricter manifest rule breaks already-copied plugins, and while `HOST_API_VERSION` is frozen the
failure names a symptom rather than the cause.** `plugins/` is an operator-owned drop-in mount that
ships empty, so no test ever sees a *stale* copy — an operator's is whatever version they took. On
2026-08-05 the `<resource>:<action>` rule stopped a pre-existing `plugins/admin` at boot with
"route gates on admin", which reads as the operator's bug rather than an out-of-date copy.
**Accepted during development** (maintainer, 2026-08-05): `checkApiVersion` is already the right
mechanism — a breaking manifest change bumps the major and a stale plugin is refused by *version*,
which says plainly what happened. That only starts working once the freeze lifts, so until then a
stricter rule ships with a README → Upgrading entry and the discovery error carries the re-copy
hint. **Valid while `HOST_API_VERSION` stays frozen at 1.0.0** — when the first external plugin
lifts it (see the Rules section), the version check takes over and this note can go.
Fail-loud stays right either way: the alternative is a route gating on a name nobody can be
granted, i.e. a permanent silent 403.
- **A permission name is always `<resource>:<action>`** — `scheduling:read`, `users:write`. A bare
word names *who someone is* — a role — and roles are groups here; the old catch-all `admin`
permission was exactly that mistake, split into `users:`/`groups:`/`permissions:`/`oauth2-clients:`
× `read`/`write` 2026-08-05. **Enforced at discovery** (`isValidPermissionName` in
`plugin-host/plugin.ts`, checked by `shapeError` over every route/nav `permission` and every
declared name), fail-loud like every other manifest rule — not only in the admin GUI, which an
operator removes by not copying the example in. Decisions around it:
- **Names are authored in plugin code; only grants live in Keto.** The host collects every
installed plugin's declarations into one catalog (`declaredPermissions``ctx.declaredPermissions`),
and that catalog *is* the fixed list the admin screens offer. So there is **no Permissions admin
screen**: nothing in a GUI invents a name, and holding one is a property of a user or a group,
edited as a checkbox list on those two screens. A tuple in Keto naming something no installed
plugin declares gates nothing and is not offered — and a save never revokes it, since the picker
only speaks for what it showed. Decided with the maintainer 2026-08-05, replacing the CRUD
Permissions screen.
- `<resource>` is **global, not plugin-scoped** (hence `oauth2-clients`, not `clients`). Deliberate
cross-plugin sharing is a goal, so the pre-2026-08-05 `<id>:<action>` guidance was wrong: users
are the *host's*, not the admin plugin's. Cost: collision-freedom became a convention rather than
structural. Accepted — the alternative penalizes the sharing case.
- **Declaring a permission stays optional.** Requiring every gated route to declare its permission
would make `findConflicts` see all overlaps, but would then warn on exactly the legitimate
sharing case above. Shape is enforced; declaration is not.
- **There is no name-minting path in the GUI at all**, which is what makes the discovery check the
whole story: the only way a name comes into being is a plugin declaring it, and discovery refuses
a badly-shaped declaration at boot. An earlier revision of this branch enforced the rule in the
Permissions screen's create form instead and needed a second guard for the assign form, which
could also mint one — deleting the screen removed both.
- `ADMIN_PERMISSIONS` **defaults to empty**: every permission is owned by the plugin that gates on
it, and a host-invented default would gate nothing. **An unusable value there is dropped with a
warning, never fatal** — fail-loud belongs at the manifest boundary, where a developer authored
the mistake; `bootstrap` gates `web`, so refusing operator env takes the whole stack down. This
is not hypothetical: `admin` was this setting's own default until 2026-08-05, so a boot-breaking
value is the *expected* leftover on upgrade, and a revision of this branch shipped exactly that
bug past a green CI. `e2e-tests/compose.auth.yml` now seeds `ADMIN_PERMISSIONS: admin,users:read`
so the container proves it survives one; verified by negative control (re-adding the throw fails
that suite at stack-up). This makes the seed a function of what
`bootstrap` discovers, and a plugin dropped in after first boot therefore needs
`docker compose up -d` (which re-runs the one-shot), not `restart web`. The base file gives
`bootstrap` and `web` the same baked `plugins/`; only `compose.override.yml`'s dev-only `.:/app`
makes `web` diverge onto the host tree, so the matching `./plugins` mount for `bootstrap` lives
**there and only there** — in the base file it would desynchronise prod and collide with the e2e
stacks, which bind individual plugins *inside* `/app/plugins` (a nested mount into a read-only
parent is EROFS and the container never starts). Valid while bootstrap is the only writer of
grants.
- **`actionForMethod` is plugin-local and must not migrate into `#plugin-api`.** Inside the admin
example it buys one thing: the route table and the in-handler guard derive from one function, so
29 routes × 2 gate sites cannot drift. As a general mechanism it would make authorization a
function of the transport verb, and a route table must answer "what does this need?" on its own.
- **A `:read`-only holder must never be shown a write affordance.** The split created a real read-only
operator (a helpdesk account with `users:read`), and the host's 403 is the backstop, not the UX: the
list/detail models carry `canWrite` and the views drop create/save/delete/add/remove, while the
permission picker still renders — disabled — because *seeing* who holds what is the point of `:read`.
A *write-intent GET* — a create form or a delete-confirm page — is the exception to
`actionForMethod`: it gates on `:write` (declared in the route table and passed to the handler's
guard, so the two still agree), because a page whose only purpose is to start a write should refuse
a reader rather than render a form whose submit 403s.
Two grant-specific guards go with it, both restoring behaviour the deleted Permissions screen had:
you cannot revoke your own **direct** grants on the Users screen (self-lockout would need a `curl`
against Keto to undo, which the operator persona can't do — same shape as the self-deactivate/
self-delete guards), and a permission held *through a group* renders ticked-but-disabled rather than
unticked, because showing it unticked stated the opposite of the truth and unticking it wrote
nothing while looking like a successful revoke. **Known gap, same scope the deleted screen had:**
the group paths are unguarded — unticking a permission on a group you belong to, removing yourself
from it, or deleting it can all still strip your own effective access. The robust "last effective
holder" check needs a reverse Keto query and is deferred. Raised by the architecture + product +
stability reviews 2026-08-05.
- **`users:write` and `groups:write` are equivalent to full administrative access**, and the split
does not change that: `groups:write` adds you to any group, including one holding every permission;
`users:write` mints a recovery code for any account. The containment the split buys is real on the
**read** half only (`users:read` is a safe helpdesk grant). Don't let the per-resource naming imply
otherwise in docs. Raised by the architecture review 2026-08-05.
- **Plainpages says "user" everywhere; Ory's word for it is "identity".** Kratos calls the record - **Plainpages says "user" everywhere; Ory's word for it is "identity".** Kratos calls the record
an identity, but Ory's own docs state it uses that term *interchangeably* with "users" and an identity, but Ory's own docs state it uses that term *interchangeably* with "users" and
"accounts" — so this is house style, not a renamed concept, and "user" is the word readers "accounts" — so this is house style, not a renamed concept, and "user" is the word readers
+105 -23
View File
@@ -28,14 +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
/ Permissions / 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 `/admin/*`:
```bash ```bash
cp -r examples/plugins/admin plugins/admin cp -r examples/plugins/admin plugins/admin
docker compose restart web docker compose up -d
``` ```
The seeded admin already holds the `admin` permission, so the **Admin** section now shows in the menu. 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
one-shot `bootstrap` service, and only `up` re-runs it to pick up the new plugin's permissions
(it is idempotent, so re-running costs nothing).
See [`examples/plugins/admin/`](examples/plugins/admin/). See [`examples/plugins/admin/`](examples/plugins/admin/).
**4. Add your first plugin.** The clone is bind-mounted into the container, so a new **4. Add your first plugin.** The clone is bind-mounted into the container, so a new
@@ -59,7 +62,8 @@ docker compose restart web
Visit <http://localhost:3000/hello> — the page is mounted at `/hello` (the folder name Visit <http://localhost:3000/hello> — the page is mounted at `/hello` (the folder name
is the plugin id *and* the mount path) and "Hello" is in the menu. That's the whole loop: is the plugin id *and* the mount path) and "Hello" is in the menu. That's the whole loop:
**drop a folder in `plugins/`, restart, it's live.** **drop a folder in `plugins/`, restart, it's live.** A plugin that declares `permissions` needs
`docker compose up -d` instead, so the seed re-runs and grants them (as in step 3).
From here, render real pages against the app shell and fetch upstream data — see From here, render real pages against the app shell and fetch upstream data — see
[Building plugins](#building-plugins) and the runnable reference in [Building plugins](#building-plugins) and the runnable reference in
@@ -70,6 +74,7 @@ From here, render real pages against the app shell and fetch upstream data — s
- [Overview](#overview) - [Overview](#overview)
- [how it compares](#how-it-compares) - [how it compares](#how-it-compares)
- [Users, groups & permissions](#users-groups--permissions) - [Users, groups & permissions](#users-groups--permissions)
- [naming a permission](#naming-a-permission)
- [a worked example](#a-worked-example) - [a worked example](#a-worked-example)
- [granting a permission](#granting-a-permission) - [granting a permission](#granting-a-permission)
- [fine-grained, per-row access](#fine-grained-per-row-access) - [fine-grained, per-row access](#fine-grained-per-row-access)
@@ -108,6 +113,7 @@ From here, render real pages against the app shell and fetch upstream data — s
- [the full gate](#the-full-gate-one-command) - [the full gate](#the-full-gate-one-command)
- [CI/CD](#cicd) - [CI/CD](#cicd)
- [Production & deployment](#production--deployment) - [Production & deployment](#production--deployment)
- [Upgrading](#upgrading)
- [Observability](#observability) - [Observability](#observability)
- [JWT signing key & rotation](#jwt-signing-key--rotation) - [JWT signing key & rotation](#jwt-signing-key--rotation)
- [Project layout](#project-layout) - [Project layout](#project-layout)
@@ -121,7 +127,7 @@ or gated**, so the same foundation serves a purely public site, a fully locked-d
tool, or the common middle: a public front with an authenticated area behind it. Its **sweet tool, or the common middle: a public front with an authenticated area behind it. Its **sweet
spot** is the **back-office and operational tooling** you'd otherwise hand-roll for the tenth spot** is the **back-office and operational tooling** you'd otherwise hand-roll for the tenth
time, but nothing ties it to internal-only use. The core itself ships **no domain screens at time, but nothing ties it to internal-only use. The core itself ships **no domain screens at
all** — even the screens for running the system (**users, groups, permissions**) are a **drop-in all** — even the screens for running the system (**users, groups, OAuth2 clients**) are a **drop-in
plugin** you opt into ([`examples/plugins/admin/`](examples/plugins/admin/)). Everything is a plugin. plugin** you opt into ([`examples/plugins/admin/`](examples/plugins/admin/)). Everything is a plugin.
**Who it's for.** Experienced developers building server-rendered web products — back-office **Who it's for.** Experienced developers building server-rendered web products — back-office
@@ -155,7 +161,7 @@ audience above, and three of them shape the design more than any feature request
- **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, permissions, and OAuth2-clients** screens (users via - **Opt-in admin plugin:** the **users, groups, and OAuth2-clients** screens (users via
Kratos, the relationship graph via Keto, OAuth2 clients via Hydra) ship as Kratos, the relationship graph via Keto, OAuth2 clients via Hydra) ship as
[`examples/plugins/admin/`](examples/plugins/admin/) — copy it into `plugins/` to get a GUI for [`examples/plugins/admin/`](examples/plugins/admin/) — copy it into `plugins/` to get a GUI for
user & group admin. It's an ordinary plugin, using the privileged user & group admin. It's an ordinary plugin, using the privileged
@@ -258,6 +264,38 @@ transitively, through nested groups).
> **permission**. When you want the bundle, make a group and grant it several — groups nest, so a > **permission**. When you want the bundle, make a group and grant it several — groups nest, so a
> group of groups works too. > group of groups works too.
### Naming a permission
**Every permission name is `<resource>:<action>`.** `scheduling:read`, `users:write`,
`oauth2-clients:read`. Both halves are lowercase letters, digits, dashes and underscores, and the
host refuses a plugin that breaks the rule at discovery — so it holds for every installed plugin,
not just the ones you wrote.
- **`<resource>`** names the thing acted on, not the plugin that happens to own it — permission
names are one **global namespace**, so an operator grants `scheduling:read` once and every plugin
referencing it is gated consistently. Pick a name no other plugin would claim for something else:
`oauth2-clients`, not `clients`.
- **`<action>`** names the operation. `read` and `write` cover most screens; use a more specific
verb when the operation really is distinct (`invoices:approve`).
A bare word is the mistake this rule exists to stop. `admin` says *who someone is*, not *what they
may do* — that is a role, and roles are **groups** here. Split it by resource and action, then
bundle it back up with a group if you want one grant to hand out several:
```
Group:it-support ──> Permission:users:read, Permission:users:write, Permission:groups:read, …
```
The host checks the shape at **discovery**: a plugin gating on — or declaring — a name that isn't
`<resource>:<action>` stops the boot, like any other bad manifest. Declaring is still optional, so
two plugins may deliberately share a name.
> **A `:write` is not a small grant.** Splitting by resource contains the **read** half — `users:read`
> alone is a safe helpdesk grant. It contains the write half much less than the naming suggests:
> `groups:write` lets someone add themselves to a group that holds every permission, and `users:write`
> lets them mint a recovery code for any account and sign in as it. Treat `users:write` and
> `groups:write` as full administrative access.
### A worked example ### A worked example
Alice works support and leads scheduling; Bob works support; Carol administers the system. Alice works support and leads scheduling; Bob works support; Carol administers the system.
@@ -272,7 +310,8 @@ Alice works support and leads scheduling; Bob works support; Carol administers t
alice ────────────> Group:sched-leads ┴──> Permission:scheduling:write alice ────────────> Group:sched-leads ┴──> Permission:scheduling:write
carol ───────────────────────────────────────────────> Permission:admin carol ────────────> Group:it-support ─┬──> Permission:users:read
└──> Permission:users:write
``` ```
At login the host asks Keto which permissions the user holds, walking those arrows At login the host asks Keto which permissions the user holds, walking those arrows
@@ -282,11 +321,12 @@ JWT](#login-and-the-session-jwt)):
``` ```
alice → permissions: ["scheduling:read", "scheduling:write"] alice → permissions: ["scheduling:read", "scheduling:write"]
bob → permissions: ["scheduling:read"] bob → permissions: ["scheduling:read"]
carol → permissions: ["admin"] carol → permissions: ["users:read", "users:write"]
``` ```
Note what Carol does *not* have. **Permissions do not nest, and there is no superuser**`admin` Note what Carol does *not* have. **Permissions do not nest, and there is no superuser**running
is just another name, granting nothing except where a route gates on `admin` itself. the Users screen grants nothing on Groups, and nothing at all on `/scheduling`. `it-support` is the
bundle; it is a **group**, not a permission.
Against the reference plugins' actual routes: Against the reference plugins' actual routes:
@@ -296,11 +336,15 @@ 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` | `admin` | 403 | 403 | ✅ | → `/login` | | `GET /admin/users` | `users:read` | 403 | 403 | ✅ | → `/login` |
| `POST /admin/users` | `users:write` | 403 | 403 | ✅ | → `/login` |
| `GET /admin/groups` | `groups:read` | 403 | 403 | 403 | → `/login` |
Bob reaches the shifts list with no direct grant: he is in `support`, support's members are Bob reaches the shifts list with no direct grant: he is in `support`, support's members are
`staff`, and staff holds `scheduling:read` — two hops, resolved by Keto at his login. He is `staff`, and staff holds `scheduling:read` — two hops, resolved by Keto at his login. He is
refused the new-shift form because `scheduling:write` hangs off `sched-leads`, which he is not in. refused the new-shift form because `scheduling:write` hangs off `sched-leads`, which he is not in.
Carol reads *and* writes users because `it-support` holds both halves, but the Groups screen is a
different resource and she was never granted it.
An anonymous visitor gets a **redirect**, not a 403, carrying `return_to` so signing in lands them An anonymous visitor gets a **redirect**, not a 403, carrying `return_to` so signing in lands them
on the page they asked for; a signed-in user who merely lacks the permission gets the 403 page, on the page they asked for; a signed-in user who merely lacks the permission gets the 403 page,
because there is nothing to sign in *as* that would help. The menu is filtered by the same because there is nothing to sign in *as* that would help. The menu is filtered by the same
@@ -308,8 +352,8 @@ permissions, so nobody is shown a door they cannot open.
### Granting a permission ### Granting a permission
Write the tuple. The admin plugin's **Groups** and **Permissions** screens do exactly this, or use Write the tuple. The admin plugin's **Users** and **Groups** screens do exactly this — each offers
Keto's write API directly: the declared permissions as a checkbox list — or use Keto's write API directly:
```bash ```bash
# everyone in sched-leads may write shifts # everyone in sched-leads may write shifts
@@ -319,9 +363,12 @@ curl -X PUT http://keto:4467/admin/relation-tuples -H 'content-type: application
}' }'
``` ```
Permissions are authored **only in Keto** — nothing else writes them. Their names are a shared **A permission's name is authored in plugin code; only its *grants* live in Keto.** A plugin
global namespace on purpose, so an operator grants `scheduling:read` once and every plugin declares the permissions it gates on (`permissions:` in the manifest), and the host collects them
referencing it is gated consistently; namespace yours as `<id>:<action>`. into one catalog — `ctx.declaredPermissions` — which is exactly the fixed list the admin screens
offer. Nothing in the GUI invents a name: granting is ticking a box against that list, and a tuple
in Keto naming something no installed plugin declares gates nothing. Name yours
[`<resource>:<action>`](#naming-a-permission).
A change takes effect on the user's **next login or JWT re-mint** (~10 min) — see [Instant A change takes effect on the user's **next login or JWT re-mint** (~10 min) — see [Instant
revoke](#instant-revoke-the-optional-denylist) when you need it sooner. revoke](#instant-revoke-the-optional-denylist) when you need it sooner.
@@ -599,6 +646,7 @@ interface RequestContext {
req: IncomingMessage; req: IncomingMessage;
res: ServerResponse; res: ServerResponse;
permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check
declaredPermissions: readonly PermissionDecl[]; // every permission the installed plugins declare, deduped + sorted — what *exists*, vs `permissions` = what this user *holds*
system?: SystemCapabilities; // privileged Ory clients + instant-revoke, for a system plugin (see below); undefined unless the host wired them system?: SystemCapabilities; // privileged Ory clients + instant-revoke, for a system plugin (see below); undefined unless the host wired them
url: URL; url: URL;
verifyCsrf(submitted): boolean; // gate a form POST against the request's signed CSRF cookie verifyCsrf(submitted): boolean; // gate a form POST against the request's signed CSRF cookie
@@ -666,7 +714,7 @@ interface SystemCapabilities { // every field optional — present only
Hydra configured, the [revocation denylist](#instant-revoke-the-optional-denylist) enabled). A system Hydra configured, the [revocation denylist](#instant-revoke-the-optional-denylist) enabled). A system
plugin treats every field as optional and **degrades when absent** — the host never fails a request plugin treats every field as optional and **degrades when absent** — the host never fails a request
over it. The built-in **admin plugin** ([`examples/plugins/admin/`](examples/plugins/admin/)) is the over it. The built-in **admin plugin** ([`examples/plugins/admin/`](examples/plugins/admin/)) is the
reference consumer: its Users screen uses `ctx.system.kratosAdmin`, Groups/Permissions use `ctx.system.keto`, reference consumer: its Users screen uses `ctx.system.kratosAdmin`, Groups and the permission pickers use `ctx.system.keto`,
OAuth2 clients use `ctx.system.hydra`, and a deactivate/delete or user permission-change calls OAuth2 clients use `ctx.system.hydra`, and a deactivate/delete or user permission-change calls
`ctx.system.revoke` so the change lands now instead of after the JWT TTL; where a capability is missing `ctx.system.revoke` so the change lands now instead of after the JWT TTL; where a capability is missing
the screen renders a themed 503. the screen renders a themed 503.
@@ -684,6 +732,12 @@ arbitrary depth, counts, and icons; see `composeNav` for the node shape. A node'
**Lucide icon**, referenced by its sprite id (e.g. `i-cal` → lucide `calendar`); the available ids **Lucide icon**, referenced by its sprite id (e.g. `i-cal` → lucide `calendar`); the available ids
are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name there. are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name there.
**Gating a section header.** Putting the `permission` on the header is the simple form — the whole
subtree disappears with it. When the children need *different* permissions, leave the header ungated
and gate each child: `composeNav` drops a header whose children all filtered out. That second form
only works while the header carries **no `href`** — give it one and it survives the filter as an
ungated leaf, visible to everyone. The admin example uses it (three screens, six permissions).
#### Public pages & menu items #### Public pages & menu items
A route or nav node may be marked **`public: true`** — reachable by **anyone, signed in or not**, A route or nav node may be marked **`public: true`** — reachable by **anyone, signed in or not**,
@@ -741,7 +795,7 @@ with `findConflicts` and resolves them **loudly — never last-write-wins**. `er
| `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/<id>` prefix is unique), so this catches a plugin duplicating one of its own. | | `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/<id>` prefix is unique), so this catches a plugin duplicating one of its own. |
| `nav-id` | error | A nav node `id` is used more than once — the central override targets ids, so they must be unique. | | `nav-id` | error | A nav node `id` is used more than once — the central override targets ids, so they must be unique. |
| `home` / `dashboard` | error | More than one plugin declares `home` (or `dashboard`). Each landing page is a single slot, so only one may own it ([The landing pages](#the-landing-pages-home--dashboard)). | | `home` / `dashboard` | error | More than one plugin declares `home` (or `dashboard`). Each landing page is a single slot, so only one may own it ([The landing pages](#the-landing-pages-home--dashboard)). |
| `permission` | warn | A permission name is declared by more than one plugin. Sharing is legitimate; namespace as `<id>:<action>` if unintended. | | `permission` | warn | A permission name is declared by more than one plugin. Sharing is legitimate; pick a more specific [`<resource>`](#naming-a-permission) if unintended. |
There is **no separate `basePath` rule**: the mount path is the derived `/<id>`, so its There is **no separate `basePath` rule**: the mount path is the derived `/<id>`, so its
uniqueness follows from the id check. `permission` is the one intentional overlap, so it warns uniqueness follows from the id check. `permission` is the one intentional overlap, so it warns
@@ -1279,7 +1333,7 @@ deactivate the user, or use a direct user-permission change, for an instant effe
is for. Reserve it for those; don't pay its tuple-sync cost for rules a service can already is for. Reserve it for those; don't pay its tuple-sync cost for rules a service can already
answer from its own data. answer from its own data.
The admin plugin's users / groups / permissions screens write authorization **only to Keto** — coarse The admin plugin's users / groups screens write authorization **only to Keto** — coarse
permissions and fine-grained relationships alike. permissions and fine-grained relationships alike.
### OAuth2 provider (Hydra) ### OAuth2 provider (Hydra)
@@ -1682,11 +1736,39 @@ The server drains in-flight requests on `SIGTERM`/`SIGINT` rather than cutting t
mid-response, so container restarts are clean. mid-response, so container restarts are clean.
The first-boot **bootstrap** is idempotent and runs on every `up` — it generates the JWT The first-boot **bootstrap** is idempotent and runs on every `up` — it generates the JWT
signing key if absent, creates the demo admin in Kratos, and grants it the `admin` permission plus signing key if absent, creates the demo admin in Kratos, and grants it every discovered plugin's
every discovered plugin's declared permission names in Keto, so permission checks (and any declared permission names in Keto (plus any `ADMIN_PERMISSIONS`), so permission checks (and any
dropped-in plugin) resolve out of the box. The web app waits for Kratos + Keto to be healthy dropped-in plugin) resolve out of the box. The web app waits for Kratos + Keto to be healthy
*and* the bootstrap to finish before starting. **Change the demo admin before production.** *and* the bootstrap to finish before starting. **Change the demo admin before production.**
## Upgrading
**Re-copy your drop-in plugins.** Anything under `plugins/` is *your* copy — the host never updates
it. When you pull a newer Plainpages, a plugin you copied from `examples/` is still the old one, and
the host may have tightened a manifest rule since. Discovery fails loud at boot rather than running a
plugin it can't honour, naming the plugin and the rule:
```bash
rm -rf plugins/admin && cp -r examples/plugins/admin plugins/admin
docker compose up -d --build
```
Do the same for any other folder you copied out of `examples/`. A plugin you wrote yourself needs the
manifest change the error names — the same rules the shipped examples follow.
Once [`HOST_API_VERSION`](#contract-versioning) starts moving, you won't have to read the rule to know
why: a breaking manifest change bumps the major, and a plugin built against an older host is refused
by **version**, naming both. The version is frozen at `1.0.0` until the first external plugin exists,
so for now the error names the rule it tripped instead.
### Breaking changes
- **Permission names must be `<resource>:<action>`** (2026-08-05). A manifest gating on — or
declaring — a bare word like `admin` now stops the boot. The bundled admin plugin was split into
`users:`/`groups:`/`oauth2-clients:` × `read`/`write`, so a copy taken before this needs re-copying.
`ADMIN_PERMISSIONS` is held to the same rule, but an unusable value there is dropped with a warning
rather than failing the boot. See [Naming a permission](#naming-a-permission).
## Observability ## Observability
Logging is **structured** and **OTLP-native**, on Logging is **structured** and **OTLP-native**, on
@@ -1863,13 +1945,13 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *.
list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize } list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize }
paginate.ts paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs paginate.ts paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs
views/ Core EJS templates, all in the one app shell: home (public "/" landing), index (instructional /dashboard), auth (themed Kratos flows), oauth-consent (OAuth2 consent), error (flow-error sink → /error), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, landing/flow/consent bodies, menu/popover, theme switch, language picker, icon sprite). Domain screens live in plugins, not here — the admin plugin ships its own views/ (incl. its Users/Groups/Permissions/Clients + confirm bodies) views/ Core EJS templates, all in the one app shell: home (public "/" landing), index (instructional /dashboard), auth (themed Kratos flows), oauth-consent (OAuth2 consent), error (flow-error sink → /error), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, landing/flow/consent bodies, menu/popover, theme switch, language picker, icon sprite). Domain screens live in plugins, not here — the admin plugin ships its own views/ (incl. its Users/Groups/Clients + permission-picker + confirm bodies)
public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt) public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
config/ Drop-in mount point for the central menu override + branding (config/menu.ts). Ships empty (.gitkeep, git-ignored otherwise) — mount your own or copy the template from examples/config/; defaults apply when absent config/ Drop-in mount point for the central menu override + branding (config/menu.ts). Ships empty (.gitkeep, git-ignored otherwise) — mount your own or copy the template from examples/config/; defaults apply when absent
locales/ Drop-in mount point for extra (or replacement) language catalogs — a <locale>.ts here adds a language for the core, or replaces the shipped catalog for that tag wholesale; plugins/<id>/<locale>.ts does the same for an installed plugin. Ships empty (.gitkeep, git-ignored otherwise); see Languages locales/ Drop-in mount point for extra (or replacement) language catalogs — a <locale>.ts here adds a language for the core, or replaces the shipped catalog for that tag wholesale; plugins/<id>/<locale>.ts does the same for an installed plugin. Ships empty (.gitkeep, git-ignored otherwise); see Languages
ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — permission/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs → /oauth2/*) + storage init (postgres/init/init.sql: one DB per service) ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — permission/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs → /oauth2/*) + storage init (postgres/init/init.sql: one DB per service)
plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in). Ships empty (.gitkeep, git-ignored otherwise) — mount your own; the E2E suites bind-mount the example plugins onto /app/plugins/scheduling and /app/plugins/admin plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in). Ships empty (.gitkeep, git-ignored otherwise) — mount your own; the E2E suites bind-mount the example plugins onto /app/plugins/scheduling and /app/plugins/admin
examples/ Copy-in reference material, mirroring the mount dirs: plugins/scheduling/ (the reference plugin — list/form over an upstream + permission-gated nav), plugins/admin/ (the system-admin plugin — Users/Groups/Permissions/OAuth2-clients over Ory via ctx.system), both copied into plugins/; and config/menu.ts (the menu/branding template copied into config/); shifts-upstream/ is the dev mock backend the scheduling plugin reads/writes (stand-in for your real service) examples/ Copy-in reference material, mirroring the mount dirs: plugins/scheduling/ (the reference plugin — list/form over an upstream + permission-gated nav), plugins/admin/ (the system-admin plugin — Users/Groups/OAuth2-clients over Ory via ctx.system, permissions granted from the host's declared catalog), both copied into plugins/; and config/menu.ts (the menu/branding template copied into config/); shifts-upstream/ is the dev mock backend the scheduling plugin reads/writes (stand-in for your real service)
e2e-tests/ Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint) + oauth-login.spec (OAuth2 login + consent) + full-flow.spec (browser UI: password/SSO login, menu-by-permission, admin CRUD, plugin page, logout) + devstack-login.spec (regression: login works from the banner's localhost URL and 127.0.0.1 is canonicalised, on the plain `docker compose up` topology); proxy.ts (same-origin gateway) + mock-oidc.ts (mock SSO provider) back full-flow. e2e-tests/Dockerfile + e2e-tests/compose.{visual,auth,oauth,full,devstack}.yml run them e2e-tests/ Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint) + oauth-login.spec (OAuth2 login + consent) + full-flow.spec (browser UI: password/SSO login, menu-by-permission, admin CRUD, plugin page, logout) + devstack-login.spec (regression: login works from the banner's localhost URL and 127.0.0.1 is canonicalised, on the plain `docker compose up` topology); proxy.ts (same-origin gateway) + mock-oidc.ts (mock SSO provider) back full-flow. e2e-tests/Dockerfile + e2e-tests/compose.{visual,auth,oauth,full,devstack}.yml run them
ci.sh The full CI gate: typecheck → unit tests → every E2E suite, each on a fresh, always-torn-down stack (`bash ci.sh`) ci.sh The full CI gate: typecheck → unit tests → every E2E suite, each on a fresh, always-torn-down stack (`bash ci.sh`)
.gitea/workflows/ Gitea Actions: ci.yml — the full gate (ci.sh) on every branch push except main; .gitea/workflows/ Gitea Actions: ci.yml — the full gate (ci.sh) on every branch push except main;
+12
View File
@@ -22,6 +22,18 @@ services:
# Mount your own menu/branding override into the empty config/ dir (defaults apply otherwise): # Mount your own menu/branding override into the empty config/ dir (defaults apply otherwise):
# - ./config:/app/config:ro # your config/menu.ts — see examples/config/menu.ts for a template # - ./config:/app/config:ro # your config/menu.ts — see examples/config/menu.ts for a template
# Mirror web's source mount so bootstrap discovers the same plugins *and* runs the same code. Only
# dev needs saying: the base file gives both services the image's baked copy, and it is the
# `.:/app` above — dev-only — that makes web diverge onto the host tree. Without the mirror,
# bootstrap silently runs whatever `src/` was baked at image-build time, so an edit to
# bootstrap.ts appears to do nothing until someone remembers `--build`.
# It belongs here and not in the base file, where it would desynchronise prod and collide with the
# e2e stacks, which bind individual plugins *inside* /app/plugins.
bootstrap:
volumes:
- .:/app
- /app/node_modules
# Mock backend ready for the reference plugin (examples/plugins/scheduling): plugins/ ships empty, so # Mock backend ready for the reference plugin (examples/plugins/scheduling): plugins/ ships empty, so
# the plugin is opt-in — `cp -r examples/plugins/scheduling plugins/scheduling`, restart, and this # the plugin is opt-in — `cp -r examples/plugins/scheduling plugins/scheduling`, restart, and this
# backs it (SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service — # backs it (SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
+1 -1
View File
@@ -132,7 +132,7 @@ services:
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
# Base permissions for the demo admin; bootstrap also grants every discovered plugin's declared # Base permissions for the demo admin; bootstrap also grants every discovered plugin's declared
# permission names (so the reference plugin — and any drop-in — works out of the box). # permission names (so the reference plugin — and any drop-in — works out of the box).
ADMIN_PERMISSIONS: ${ADMIN_PERMISSIONS:-admin} ADMIN_PERMISSIONS: ${ADMIN_PERMISSIONS:-}
APP_URL: ${APP_URL:-http://localhost:3000} # printed in the first-run login banner APP_URL: ${APP_URL:-http://localhost:3000} # printed in the first-run login banner
JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json
KETO_WRITE_URL: http://keto:4467 KETO_WRITE_URL: http://keto:4467
+2 -2
View File
@@ -83,7 +83,7 @@ test("an expired session JWT is silently re-minted while Kratos lives, then clea
const claims1 = jwtClaims(jwt1); const claims1 = jwtClaims(jwt1);
expect(claims1.email).toBe(ADMIN_EMAIL); expect(claims1.email).toBe(ADMIN_EMAIL);
expect(claims1.sub, "sub is the Kratos identity id").toBeTruthy(); expect(claims1.sub, "sub is the Kratos identity id").toBeTruthy();
expect(claims1.permissions, "permissions are projected from Keto").toContain("admin"); expect(claims1.permissions, "permissions are projected from Keto").toContain("users:read");
// 2. Token timeout → refresh: once the 8s TTL lapses, the next request re-mints a fresh JWT. // 2. Token timeout → refresh: once the 8s TTL lapses, the next request re-mints a fresh JWT.
const jwt2Line = await awaitJwtSetCookie(session, jwt1); const jwt2Line = await awaitJwtSetCookie(session, jwt1);
@@ -91,7 +91,7 @@ test("an expired session JWT is silently re-minted while Kratos lives, then clea
expect(jwt2, "a different token was minted").not.toBe(jwt1); expect(jwt2, "a different token was minted").not.toBe(jwt1);
const claims2 = jwtClaims(jwt2); const claims2 = jwtClaims(jwt2);
expect(claims2.exp, "the new token expires later").toBeGreaterThan(claims1.exp); expect(claims2.exp, "the new token expires later").toBeGreaterThan(claims1.exp);
expect(claims2.permissions, "re-mint re-reads permissions from Keto").toContain("admin"); expect(claims2.permissions, "re-mint re-reads permissions from Keto").toContain("users:read");
// 3. Kill the Kratos session: now the lapsed token cannot refresh — the cookie is cleared. // 3. Kill the Kratos session: now the lapsed token cannot refresh — the cookie is cleared.
const revoke = await fetch(`${KRATOS_ADMIN}/admin/identities/${claims1.sub}/sessions`, { method: "DELETE" }); const revoke = await fetch(`${KRATOS_ADMIN}/admin/identities/${claims1.sub}/sessions`, { method: "DELETE" });
+12
View File
@@ -30,6 +30,18 @@ services:
timeout: 4s timeout: 4s
retries: 30 retries: 30
# This stack mounts no plugins, so nothing declares a permission for the bootstrap to seed — and
# the suite asserts that Keto's grants reach the JWT claim. Name one explicitly so there is
# something to project.
#
# `admin` rides along on purpose: it was this setting's default until 2026-08-05 and is not a legal
# `<resource>:<action>` name, so it is exactly the leftover an upgrading deployment carries. An
# earlier revision made that fatal, and bootstrap gates `web` — so if the boot ever refuses operator
# env again, `web` never turns healthy and this suite fails instead of CI going green over it.
bootstrap:
environment:
ADMIN_PERMISSIONS: admin,users:read
# Shorten the session→JWT TTL and expose a network-resolvable base_url (ory/kratos/e2e.yml), # Shorten the session→JWT TTL and expose a network-resolvable base_url (ory/kratos/e2e.yml),
# merged after the base config. # merged after the base config.
kratos: kratos:
+1 -1
View File
@@ -1,5 +1,5 @@
# Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO # Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO
# login, menu filtering by permission, users/groups/permissions/OAuth2-clients CRUD, a plugin page, logout. A # login, menu filtering by permission, users/groups/OAuth2-clients CRUD + permission granting, a plugin page, logout. A
# tiny same-origin gateway (proxy, e2e-tests/proxy.ts) fronts web + Kratos on one host so the browser's cookies # tiny same-origin gateway (proxy, e2e-tests/proxy.ts) fronts web + Kratos on one host so the browser's cookies
# round-trip (ory/kratos/e2e-proxy.yml points Kratos at it); a mock OIDC provider backs the SSO test. # round-trip (ory/kratos/e2e-proxy.yml points Kratos at it); a mock OIDC provider backs the SSO test.
# docker compose -f compose.yml -f e2e-tests/compose.full.yml run --build --rm e2e # docker compose -f compose.yml -f e2e-tests/compose.full.yml run --build --rm e2e
+15 -10
View File
@@ -102,8 +102,9 @@ test.describe.serial("authenticated admin journey", () => {
}); });
test("menu filters by permission: an admin sees the gated Admin section + the plugin", async () => { test("menu filters by permission: an admin sees the gated Admin section + the plugin", async () => {
// The signed-in admin holds admin + scheduling:read/write, so both gated sections are present // The signed-in admin holds every permission the two mounted plugins declare (the bootstrap
// in the menu (collapsed by default → assert they're in the DOM, not necessarily visible). // seeds exactly those), so both gated sections are present in the menu (collapsed by default →
// assert they're in the DOM, not necessarily visible).
await page.goto("/dashboard"); await page.goto("/dashboard");
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(1); await expect(page.locator('.sidebar a[href="/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);
@@ -135,7 +136,7 @@ test.describe.serial("authenticated admin journey", () => {
await expect(page.locator("tr", { hasText: email })).toHaveCount(0); await expect(page.locator("tr", { hasText: email })).toHaveCount(0);
}); });
test("groups + permissions CRUD: create one of each (writes go to Keto) and see them listed", async () => { test("groups CRUD: create a group (writes go to Keto), see it listed, then grant it a permission", async () => {
// A Keto set exists only while it has ≥1 member, so create needs a first member (the form // A Keto set exists only while it has ≥1 member, so create needs a first member (the form
// enforces it); pick the first option (a user) from the required picker. // enforces it); pick the first option (a user) from the required picker.
const group = `e2e-grp-${suffix}`; const group = `e2e-grp-${suffix}`;
@@ -146,13 +147,17 @@ test.describe.serial("authenticated admin journey", () => {
await expect(page).toHaveURL(/\/admin\/groups(\?|\/|$)/); await expect(page).toHaveURL(/\/admin\/groups(\?|\/|$)/);
await expect(page.locator("main")).toContainText(group); await expect(page.locator("main")).toContainText(group);
const permission = `e2e-permission-${suffix}`; // Permissions are declared in plugin code, so the group's detail page offers them as a fixed
await page.goto("/admin/permissions/new"); // checkbox list rather than a create form — there is no Permissions screen to visit.
await page.fill('input[name="name"]', permission); await page.goto(`/admin/groups/${group}`);
await page.locator('select[name="member"]').selectOption({ index: 1 }); const scheduling = page.locator('input[name="permission"][value="scheduling:read"]');
await page.locator('.form-card button[type="submit"]').click(); await expect(scheduling).toHaveCount(1); // declared by the reference plugin, so it's on offer
await expect(page).toHaveURL(/\/admin\/permissions(\?|\/|$)/); await expect(scheduling).not.toBeChecked();
await expect(page.locator("main")).toContainText(permission); await scheduling.check();
await page.locator('form:has(input[name="permission"]) button[type="submit"]').click();
await expect(page).toHaveURL(new RegExp(`/admin/groups/${group}`));
await expect(page.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked();
}); });
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 () => {
+1 -1
View File
@@ -20,7 +20,7 @@ export default defineMenu({
// Operator override (rename → group → order → hide), keyed by node id. // Operator override (rename → group → order → hide), keyed by node id.
override: { override: {
// rename: { people: "Staff" }, // node id → new label (or a catalog key) // rename: { people: "Staff" }, // node id → new label (or a catalog key)
// groups: [{ id: "admin", label: "Admin", children: ["users", "permissions"] }], // groups: [{ id: "admin", label: "Admin", children: ["users", "groups"] }],
// order: ["people", "reports"], // top-level order by id // order: ["people", "reports"], // top-level order by id
// hide: ["teams"], // remove nodes (any depth) // hide: ["teams"], // remove nodes (any depth)
}, },
+30 -12
View File
@@ -1,17 +1,22 @@
# Admin — the system-administration plugin # Admin — the system-administration plugin
The Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. These used to be The Users / Groups / OAuth2-clients screens for running Plainpages itself. These used to be
built into the core; they now ship as a **drop-in example plugin** so a fresh clone has no admin GUI built into the core; they now ship as a **drop-in example plugin** so a fresh clone has no admin GUI
until you opt in. Copy this folder into `plugins/` (it keeps the id and mount path `admin`, so the until you opt in. Copy this folder into `plugins/` (it keeps the id and mount path `admin`, so the
screens live at `/admin/*`) and restart: screens live at `/admin/*`) and restart:
```bash ```bash
cp -r examples/plugins/admin plugins/admin cp -r examples/plugins/admin plugins/admin
docker compose restart web docker compose up -d
``` ```
The seeded `admin@plainpages.local` already holds the `admin` permission, so the section appears in the The bootstrap grants the seeded `admin@plainpages.local` every permission this plugin declares, so the
menu and the screens work immediately. section appears in the menu and the screens work immediately.
> **Already have `plugins/admin` from an earlier version?** Re-copy it. Your copy is yours — the host
> never updates it — and this plugin's permissions changed on 2026-08-05 (`admin` → `users:`/`groups:`/
> `oauth2-clients:` × `read`/`write`). A stale copy stops the boot with a message naming it; see
> [README → Upgrading](../../../README.md#upgrading).
Every string it renders comes from its own catalogs (`i18n/en-US.ts`, `i18n/sv-SE.ts`) — the nav Every string it renders comes from its own catalogs (`i18n/en-US.ts`, `i18n/sv-SE.ts`) — the nav
labels included, which are catalog keys in `admin-shared.ts`. Each pure view-model builder takes an labels included, which are catalog keys in `admin-shared.ts`. Each pure view-model builder takes an
@@ -25,7 +30,7 @@ reference](../scheduling/README.md)). The admin screens instead administer **Pla
stack**, so they use the privileged **`ctx.system`** surface the host exposes to a system plugin: stack**, so they use the privileged **`ctx.system`** surface the host exposes to a system plugin:
- **`ctx.system.kratosAdmin`** — create/edit/deactivate/delete Kratos identities (Users). - **`ctx.system.kratosAdmin`** — create/edit/deactivate/delete Kratos identities (Users).
- **`ctx.system.keto`** — read/write the Keto relationship graph (Groups, Permissions). - **`ctx.system.keto`** — read/write the Keto relationship graph (group membership, permission grants).
- **`ctx.system.hydra`** — register/list/delete Ory Hydra OAuth2 clients. - **`ctx.system.hydra`** — register/list/delete Ory Hydra OAuth2 clients.
- **`ctx.system.revoke(sub)`** — the optional instant-revoke hook: a deactivate/delete or a - **`ctx.system.revoke(sub)`** — the optional instant-revoke hook: a deactivate/delete or a
user's permission change kills that subject's live tokens at once instead of waiting out the JWT TTL. user's permission change kills that subject's live tokens at once instead of waiting out the JWT TTL.
@@ -33,21 +38,34 @@ stack**, so they use the privileged **`ctx.system`** surface the host exposes to
`ctx.system` is populated only when the host wired those services (the dev stack wires Kratos + Keto, `ctx.system` is populated only when the host wired those services (the dev stack wires Kratos + Keto,
and Hydra when configured). Where a capability is absent the screen degrades to a themed 503 rather and Hydra when configured). Where a capability is absent the screen degrades to a themed 503 rather
than crashing — see `admin-shared.ts`. Everything else is an ordinary plugin: folder-discovered, than crashing — see `admin-shared.ts`. Everything else is an ordinary plugin: folder-discovered,
gated per route by `permission: "admin"`, rendering the core building blocks in `views/`. gated per route by its screen's `<resource>:<action>` permission, rendering the core building blocks
in `views/`.
Each screen is its own resource — `users`, `groups`, `oauth2-clients` — and each splits into `:read`
and `:write`, so a helpdesk account can be given `users:read` alone. The nav is filtered by the same
permissions: holding none of the three hides the Admin section entirely.
There is **no Permissions screen**. Permission names are declared in plugin code, not created in a
GUI, so the host's catalog (`ctx.declaredPermissions`) is the fixed list — and holding one is a
property of a user or a group, edited as a checkbox list on those two screens (`admin-grants.ts`).
## Layout ## Layout
- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` permission, and the - `plugin.ts` — the manifest: the Admin nav fragment, the six permissions the plugin declares, and
route table — one thin handler per method+path, all gated by `permission: "admin"`. the route table — one thin handler per method+path, gated via `permissionName(resource, actionForMethod(method))`
- `admin-users.ts` · `admin-groups.ts` · `admin-permissions.ts` · `admin-clients.ts` — each a set of pure so a GET needs `:read` and a POST `:write`.
- `admin-grants.ts` — the permission picker and the grant diff, shared by the Users and Groups
screens: what a submitted checkbox set grants and revokes, against the host's declared catalog.
- `admin-users.ts` · `admin-groups.ts` · `admin-clients.ts` — each a set of pure
view-model builders (unit-tested in the matching `*.test.ts`) plus thin per-route handlers keyed on view-model builders (unit-tested in the matching `*.test.ts`) plus thin per-route handlers keyed on
`ctx.params` (the host extracts `:id`/`:name`), sharing a small `withX` wrapper that resolves the `ctx.params` (the host extracts `:id`/`:name`), sharing a small `withX` wrapper that resolves the
admin gate + the needed `ctx.system` clients once. screen's permission gate + the needed `ctx.system` clients once.
- `admin-shared.ts` — the shared gate (`requireAdmin`), CSRF form reader (`guardedForm`), confirm - `admin-shared.ts` — the permission naming (`permissionName` / `actionForMethod`), the shared gate
(`requirePermission`), CSRF form reader (`guardedForm`), confirm
model, nav fragment, and the not-found / unavailable helpers. model, nav fragment, and the not-found / unavailable helpers.
- `views/` — the screens' EJS, plus the admin-specific body partials under `views/partials/`. They - `views/` — the screens' EJS, plus the admin-specific body partials under `views/partials/`. They
`include()` the core building-block partials (shell, data-table, filter-bar, field, …). `include()` the core building-block partials (shell, data-table, filter-bar, field, …).
The four screens hold **no state** — everything lives in Ory. Handlers are thin, so their builders The three screens hold **no state** — everything lives in Ory. Handlers are thin, so their builders
unit-test as pure functions with no host; the HTTP routing/gate/CSRF is covered in unit-test as pure functions with no host; the HTTP routing/gate/CSRF is covered in
`src/http/app.test.ts` (which mounts this plugin) and end-to-end in `e2e-tests/full-flow.spec.ts`. `src/http/app.test.ts` (which mounts this plugin) and end-to-end in `e2e-tests/full-flow.spec.ts`.
+16 -10
View File
@@ -5,8 +5,8 @@
// PRG redirect (mirrors the Users "trigger recovery" one-time code). Below the builders are thin // PRG redirect (mirrors the Users "trigger recovery" one-time code). Below the builders are thin
// per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded. // per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded.
import { type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "#plugin-api"; import { can, type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "#plugin-api";
import { ADMIN_CLIENTS_BASE, ADMIN_EN, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts"; import { ADMIN_CLIENTS_BASE, ADMIN_EN, type AdminAction, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts"; import type { FieldConfig } from "./admin-users.ts";
const DEFAULT_PAGE_SIZE = 25; const DEFAULT_PAGE_SIZE = 25;
@@ -100,6 +100,7 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
} }
export function buildClientsListModel(opts: { export function buildClientsListModel(opts: {
canWrite?: boolean;
clients: OAuth2Client[]; clients: OAuth2Client[];
csrfToken?: string; csrfToken?: string;
t?: Translate; t?: Translate;
@@ -119,6 +120,7 @@ export function buildClientsListModel(opts: {
return { return {
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.nav.section") }, { label: t("admin.clients.title") }], breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.nav.section") }, { label: t("admin.clients.title") }],
canWrite: opts.canWrite !== false,
filterBar: listFilterBar(state, t), filterBar: listFilterBar(state, t),
pagination: listPagination(state, page, t), pagination: listPagination(state, page, t),
table: listTable(rows, t), table: listTable(rows, t),
@@ -208,6 +210,7 @@ export function buildClientFormModel(opts: {
} }
export function buildClientDetailModel(opts: { export function buildClientDetailModel(opts: {
canWrite?: boolean;
client: ClientView; client: ClientView;
created?: boolean; // just registered → success banner + the one-time secret (if any) created?: boolean; // just registered → success banner + the one-time secret (if any)
csrfToken?: string; csrfToken?: string;
@@ -218,6 +221,7 @@ export function buildClientDetailModel(opts: {
const base = detailHref(opts.client.id); const base = detailHref(opts.client.id);
return { return {
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.clients.title") }, { label: opts.client.name }], breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.clients.title") }, { label: opts.client.name }],
canWrite: opts.canWrite !== false,
client: opts.client, client: opts.client,
created: opts.created ?? false, created: opts.created ?? false,
csrfToken: opts.csrfToken ?? "", csrfToken: opts.csrfToken ?? "",
@@ -243,9 +247,9 @@ function readClientInput(form: URLSearchParams): ClientInput {
// Hydra capability (else a themed 503). Each route below is a thin handler over these. // Hydra capability (else a themed 503). Each route below is a thin handler over these.
interface ClientsDeps { ctx: RequestContext; hydra: HydraAdmin; user: User; } interface ClientsDeps { ctx: RequestContext; hydra: HydraAdmin; user: User; }
function withClients(inner: (deps: ClientsDeps) => Promise<RouteResult>): RouteHandler { function withClients(inner: (deps: ClientsDeps) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
return async (ctx) => { return async (ctx) => {
const user = requireAdmin(ctx); const user = requirePermission(ctx, "oauth2-clients", action);
const hydra = ctx.system?.hydra; const hydra = ctx.system?.hydra;
if (!hydra) return unavailable(ctx, ctx.t("admin.capability.hydra")); if (!hydra) return unavailable(ctx, ctx.t("admin.capability.hydra"));
return inner({ ctx, hydra, user }); return inner({ ctx, hydra, user });
@@ -253,24 +257,26 @@ function withClients(inner: (deps: ClientsDeps) => Promise<RouteResult>): RouteH
} }
// Same, plus the target client from ctx.params.id (unknown → themed 404). // Same, plus the target client from ctx.params.id (unknown → themed 404).
function withClient(inner: (deps: ClientsDeps, client: OAuth2Client, id: string) => Promise<RouteResult>): RouteHandler { function withClient(inner: (deps: ClientsDeps, client: OAuth2Client, id: string) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
return withClients(async (deps) => { return withClients(async (deps) => {
const id = deps.ctx.params["id"] ?? ""; const id = deps.ctx.params["id"] ?? "";
const client = await deps.hydra.getClient(id); const client = await deps.hydra.getClient(id);
if (!client) return notFound(deps.ctx); if (!client) return notFound(deps.ctx);
return inner(deps, client, id); return inner(deps, client, id);
}); }, action);
} }
const clientFormResult = (ctx: RequestContext, extra: { error?: string; values?: Partial<ClientInput> }): RouteResult => const clientFormResult = (ctx: RequestContext, extra: { error?: string; values?: Partial<ClientInput> }): RouteResult =>
({ data: { chrome: ctx.chrome, model: buildClientFormModel({ csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "client-form" }); ({ data: { chrome: ctx.chrome, model: buildClientFormModel({ csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "client-form" });
const canWriteClients = (ctx: RequestContext): boolean => can(ctx, permissionName("oauth2-clients", "write"));
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({ 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 /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({ 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 /admin/clients — register; on success show the one-time secret directly (no PRG, Hydra never
@@ -291,7 +297,7 @@ export const clientsCreate = withClients(async ({ ctx, hydra, user }) => {
}); });
// GET /admin/clients/new — the register form. // GET /admin/clients/new — the register form.
export const clientsNewForm = withClients(({ ctx }) => Promise.resolve(clientFormResult(ctx, {}))); 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 /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)));
@@ -306,7 +312,7 @@ export const clientsDeleteConfirm = withClient((deps, client, id) => {
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.clients.delete"), cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.clients.delete"),
message: tt("admin.clients.deleteMessage", { name }), title: tt("admin.clients.delete"), message: tt("admin.clients.deleteMessage", { name }), title: tt("admin.clients.delete"),
}) }, view: "confirm" }); }) }, view: "confirm" });
}); }, "write");
// POST /admin/clients/:id/delete — perform it. // POST /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) => {
@@ -0,0 +1,83 @@
// The pure half of permission granting: what a submitted checkbox set changes, and the picker the
// two screens render from it. The Keto writes and the HTTP round trip are covered in app.test.ts.
import assert from "node:assert/strict";
import { test } from "node:test";
import type { PermissionDecl } from "#plugin-api";
import { buildPermissionPicker, grantDiff, grantTuple, groupSubject, userSubject } from "./admin-grants.ts";
const declared: PermissionDecl[] = [
{ description: "View users", name: "users:read" },
{ description: "Edit users", name: "users:write" },
{ name: "groups:read" },
];
test("grantTuple targets a user by subject_id and a group by subject_set", () => {
assert.deepEqual(grantTuple("users:read", userSubject("u1")), { namespace: "Permission", object: "users:read", relation: "granted", subject_id: "user:u1" });
assert.deepEqual(grantTuple("users:read", groupSubject("eng")), {
namespace: "Permission", object: "users:read", relation: "granted",
subject_set: { namespace: "Group", object: "eng", relation: "members" },
});
});
test("grantDiff: the submitted set is the desired state — tick grants, untick revokes, unchanged is a no-op", () => {
assert.deepEqual(grantDiff(declared, ["users:read"], ["users:read", "users:write"]), { grant: ["users:write"], revoke: [] });
assert.deepEqual(grantDiff(declared, ["users:read", "users:write"], ["users:read"]), { grant: [], revoke: ["users:write"] });
assert.deepEqual(grantDiff(declared, ["users:read"], ["users:read"]), { grant: [], revoke: [] });
assert.deepEqual(grantDiff(declared, ["users:read"], []), { grant: [], revoke: ["users:read"] }); // every box cleared
});
test("grantDiff ignores anything the plugins don't declare, in both directions", () => {
// A crafted POST can't grant a name no plugin gates on…
assert.deepEqual(grantDiff(declared, [], ["superuser:all"]), { grant: [], revoke: [] });
// …and a held name that is no longer declared (its plugin was uninstalled) is left alone rather
// than silently revoked by an unrelated save — this screen only speaks for what it offered.
assert.deepEqual(grantDiff(declared, ["legacy:thing"], ["users:read"]), { grant: ["users:read"], revoke: [] });
});
test("buildPermissionPicker ticks what is held and carries each declaration's description", () => {
const picker = buildPermissionPicker({ action: "/admin/users/u1/permissions", declared, direct: ["users:write"] });
assert.equal(picker.action, "/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.checked), [false, true, false]);
assert.equal(picker.choices[0]?.description, "View users");
assert.equal(picker.choices[2]?.description, ""); // a declaration may omit one
assert.equal(picker.empty, undefined);
assert.equal(picker.readOnly, false);
assert.equal(picker.inheritedNote, undefined); // nothing is group-held here
});
// The failure this prevents: a permission held through a group used to render unticked, so the page
// said "not held" about a grant that reaches the JWT — and unticking it wrote nothing, which read as
// a successful revoke. Inherited rows are ticked, disabled, and never posted.
test("buildPermissionPicker distinguishes a direct grant from one inherited through a group", () => {
const picker = buildPermissionPicker({ action: "/x", declared, direct: ["users:write"], effective: ["users:read", "users:write"] });
assert.deepEqual(picker.choices.map((c) => [c.name, c.checked, c.inherited]), [
["users:read", true, true], // effective but not direct → shown as held, not editable here
["users:write", true, false], // direct → editable
["groups:read", false, false],
]);
assert.ok(picker.inheritedNote, "the disabled row needs an explanation");
});
test("buildPermissionPicker in read-only mode still shows the state, and marks itself unwritable", () => {
const picker = buildPermissionPicker({ action: "/x", declared, direct: ["users:read"], effective: ["users:read", "groups:read"], readOnly: true });
assert.equal(picker.readOnly, true);
assert.deepEqual(picker.choices.map((c) => c.checked), [true, false, true]); // a reader still sees who holds what
// Every row renders disabled for a reader, so the writable copy would be wrong twice over: "tick to
// grant" is false, and "greyed-out means group-held" would misattribute the direct grant.
assert.equal(picker.inheritedNote, undefined);
assert.notEqual(picker.hint, buildPermissionPicker({ action: "/x", declared, direct: [] }).hint);
});
test("buildPermissionPicker notes the transitive lag for a group, and stays quiet for a user", () => {
// A group's members inherit, so the change reaches them at their next re-mint; a user's own grant
// change revokes their live tokens, so there is nothing to warn about.
assert.ok(buildPermissionPicker({ action: "/x", declared, direct: [], transitive: true }).pending);
assert.equal(buildPermissionPicker({ action: "/x", declared, direct: [] }).pending, undefined);
});
test("buildPermissionPicker says so when no plugin declares a permission, rather than rendering an empty box", () => {
const picker = buildPermissionPicker({ action: "/x", declared: [], direct: [] });
assert.deepEqual(picker.choices, []);
assert.ok(picker.empty);
});
+123
View File
@@ -0,0 +1,123 @@
// Permission grants, shared by the Users and Groups screens. A permission is held by a user
// (`Permission:<name>#granted@user:<id>`) or by a whole group (`…@Group:<name>#members`), and Keto
// resolves a group's grant transitively at login.
//
// The set of permissions that *exist* is `ctx.declaredPermissions` — the host's catalog, built from
// what the installed plugins declare in code. Nothing here invents a name, which is why the old
// Permissions screen is gone: a grant is a property of a user or a group, edited where they are.
import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "#plugin-api";
const PERMISSION_NS = "Permission";
const GRANTED = "granted";
export const PERMISSIONS_FIELD = "permission"; // the checkbox name the two forms post
export type GrantSubject = { subject_id: string } | { subject_set: SubjectSet };
export const userSubject = (id: string): GrantSubject => ({ subject_id: `user:${id}` });
export const groupSubject = (name: string): GrantSubject => ({ subject_set: { namespace: "Group", object: name, relation: "members" } });
export function grantTuple(permission: string, subject: GrantSubject): RelationTuple {
return { namespace: PERMISSION_NS, object: permission, relation: GRANTED, ...subject };
}
// The permissions this subject holds *directly* — one Keto read filtered by the subject, not one per
// declared name. This is the edge the picker edits; `effectivePermissions` adds what a group confers.
export async function heldPermissions(keto: KetoClient, subject: GrantSubject): Promise<string[]> {
const held = new Set<string>();
let pageToken: string | undefined;
do {
const page = await keto.listRelations({ namespace: PERMISSION_NS, relation: GRANTED, ...subject, ...(pageToken ? { pageToken } : {}) });
for (const tuple of page.tuples) held.add(tuple.object);
pageToken = page.nextPageToken ?? undefined;
} while (pageToken);
return [...held].sort();
}
// Every declared permission the subject effectively holds — direct grants *plus* anything reached
// through a group, which is what actually lands in their JWT. One Keto check per declared name;
// the catalog is small and this is an admin screen (login does the same walk).
export async function effectivePermissions(keto: KetoClient, subject: GrantSubject, declared: readonly PermissionDecl[]): Promise<string[]> {
const held = await Promise.all(declared.map((decl) => keto.check({ namespace: PERMISSION_NS, object: decl.name, relation: GRANTED, ...subject })));
return declared.filter((_, i) => held[i]).map((decl) => decl.name);
}
export interface PermissionChoice {
checked: boolean; // held directly — the only state this form can change
description: string;
// Effective through a group, not granted directly. Rendered ticked but disabled: the grant is real
// (it reaches the JWT), and it is removed by editing the group, not this subject.
inherited: boolean;
name: string;
}
export interface PermissionPicker {
action: string;
choices: PermissionChoice[];
empty: string | undefined; // set when no plugin declares a permission — the picker has nothing to offer
error?: string; // a rejected save (e.g. the self-revoke guard), rendered above the list
field: string;
hint: string;
inheritedNote: string | undefined; // set when at least one choice is group-held, to explain the disabled row
legend: string;
// Set for a group: its members hold these transitively, so a change reaches them at their next
// re-mint rather than at once. The user picker revokes live tokens, so it says nothing.
pending: string | undefined;
readOnly: boolean; // the viewer holds :read but not :write — show the state, offer no save
submit: string;
}
// The checkbox list: every declared permission, ticked where this subject holds it. A fixed list
// means the form is the whole truth — what it posts back *is* the desired set of *direct* grants
// (grantDiff). An inherited row is disabled, so it never posts and can never be diffed into a revoke.
export function buildPermissionPicker(opts: {
action: string;
declared: readonly PermissionDecl[];
direct: string[];
effective?: string[]; // omit when the caller can't resolve group-held grants; then only direct shows
readOnly?: boolean;
t?: Translate;
transitive?: boolean; // a group: its members inherit, so the change lands at their next re-mint
}): PermissionPicker {
const t = opts.t ?? ((k: string) => k);
const directSet = new Set(opts.direct);
const effectiveSet = new Set(opts.effective ?? opts.direct);
const choices = opts.declared.map((decl) => ({
checked: directSet.has(decl.name) || effectiveSet.has(decl.name),
description: decl.description ?? "",
inherited: !directSet.has(decl.name) && effectiveSet.has(decl.name),
name: decl.name,
}));
return {
action: opts.action,
choices,
empty: opts.declared.length === 0 ? t("admin.grants.none") : undefined,
field: PERMISSIONS_FIELD,
// A reader sees every row disabled, so "tick to grant" is false and "greyed-out means group-held"
// is worse than false — it would misattribute a *direct* grant to a group that doesn't hold it.
hint: t(opts.readOnly === true ? "admin.grants.hintReadOnly" : "admin.grants.hint"),
inheritedNote: opts.readOnly !== true && choices.some((c) => c.inherited) ? t("admin.grants.inherited") : undefined,
legend: t("admin.grants.legend"),
pending: opts.transitive === true ? t("admin.grants.pending") : undefined,
readOnly: opts.readOnly === true,
submit: t("admin.grants.save"),
};
}
// What a submitted set changes. Pure so the diff is testable without Keto: only declared names are
// considered, so a crafted POST cannot grant something no plugin gates on, and a held-but-undeclared
// name (left over from an uninstalled plugin) is never silently revoked by an unrelated save.
export function grantDiff(declared: readonly PermissionDecl[], held: string[], wanted: string[]): { grant: string[]; revoke: string[] } {
const offered = new Set(declared.map((d) => d.name));
const heldSet = new Set(held);
const wantedSet = new Set(wanted.filter((name) => offered.has(name)));
return {
grant: [...wantedSet].filter((name) => !heldSet.has(name)).sort(),
revoke: [...heldSet].filter((name) => offered.has(name) && !wantedSet.has(name)).sort(),
};
}
export async function applyGrants(keto: KetoClient, subject: GrantSubject, diff: { grant: string[]; revoke: string[] }): Promise<void> {
for (const name of diff.grant) await keto.writeTuple(grantTuple(name, subject));
for (const name of diff.revoke) await keto.deleteTuple(grantTuple(name, subject));
}
+54 -14
View File
@@ -6,8 +6,9 @@
// per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded, // per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded,
// each returning a RouteResult. // each returning a RouteResult.
import { type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "#plugin-api"; import { can, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "#plugin-api";
import { ADMIN_EN, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts"; import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD } from "./admin-grants.ts";
import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts"; import type { FieldConfig } from "./admin-users.ts";
const GROUP_NS = "Group"; const GROUP_NS = "Group";
@@ -110,6 +111,7 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
} }
export function buildGroupsListModel(opts: { export function buildGroupsListModel(opts: {
canWrite?: boolean;
csrfToken?: string; csrfToken?: string;
groups: GroupView[]; groups: GroupView[];
t?: Translate; t?: Translate;
@@ -139,6 +141,7 @@ export function buildGroupsListModel(opts: {
return { return {
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.nav.section") }, { label: t("admin.groups.title") }], breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.nav.section") }, { label: t("admin.groups.title") }],
canWrite: opts.canWrite !== false,
filterBar: listFilterBar(state, t), filterBar: listFilterBar(state, t),
pagination: listPagination(state, page, t), pagination: listPagination(state, page, t),
table: listTable(rows, state, sort, t), table: listTable(rows, state, sort, t),
@@ -224,11 +227,13 @@ export function buildGroupFormModel(opts: {
} }
export function buildGroupDetailModel(opts: { export function buildGroupDetailModel(opts: {
canWrite?: boolean; // false ⇒ a `groups:read` holder: show the members, offer no edit
candidates: MemberOption[]; candidates: MemberOption[];
csrfToken?: string; csrfToken?: string;
error?: string; error?: string;
group: { name: string }; group: { name: string };
members: MemberView[]; members: MemberView[];
permissions?: PermissionPicker;
t?: Translate; t?: Translate;
}) { }) {
const t = opts.t ?? ADMIN_EN; const t = opts.t ?? ADMIN_EN;
@@ -237,21 +242,24 @@ export function buildGroupDetailModel(opts: {
const taken = new Set(opts.members.map((m) => m.subject)); const taken = new Set(opts.members.map((m) => m.subject));
const self = `group:${name}`; // a group can't be a member of itself const self = `group:${name}`; // a group can't be a member of itself
const options = opts.candidates.filter((c) => c.value !== self && !taken.has(c.value)); const options = opts.candidates.filter((c) => c.value !== self && !taken.has(c.value));
const canWrite = opts.canWrite !== false;
return { return {
add: { action: `${base}/members`, options }, add: { action: `${base}/members`, options },
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.groups.title") }, { label: name }], breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.groups.title") }, { label: name }],
canWrite, // the view drops add/remove/delete when false; the host already 403s those POSTs
csrfToken: opts.csrfToken ?? "", csrfToken: opts.csrfToken ?? "",
delete: { action: `${base}/delete` }, delete: { action: `${base}/delete` },
error: opts.error, error: opts.error,
group: { name }, group: { name },
members: { action: `${base}/members/delete`, rows: opts.members }, members: { action: `${base}/members/delete`, rows: opts.members },
permissions: opts.permissions,
title: name, title: name,
}; };
} }
// ---- request handler (imperative shell) ---- // ---- request handler (imperative shell) ----
// Drain every page of a relation-tuple query. (Reused by the Roles screen — same membership model.) // Drain every page of a relation-tuple query.
export async function pagedTuples(keto: KetoClient, query: RelationQuery): Promise<RelationTuple[]> { export async function pagedTuples(keto: KetoClient, query: RelationQuery): Promise<RelationTuple[]> {
const out: RelationTuple[] = []; const out: RelationTuple[] = [];
let pageToken: string | undefined; let pageToken: string | undefined;
@@ -285,13 +293,14 @@ async function groupExists(keto: KetoClient, name: string): Promise<boolean> {
return page.tuples.length > 0; return page.tuples.length > 0;
} }
// Shared per-request deps for the Groups screen, resolved by `withGroups`: the gate + the Keto and // Shared per-request deps for the Groups screen, resolved by `withGroups`: the gate (`groups:read` on
// Kratos capabilities (else a themed 503). Each route below is a thin handler over these. // a GET, `groups:write` on a POST) + the Keto and Kratos capabilities (else a themed 503). Each route
// below is a thin handler over these.
interface GroupsDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; user: User; } interface GroupsDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; user: User; }
function withGroups(inner: (deps: GroupsDeps) => Promise<RouteResult>): RouteHandler { function withGroups(inner: (deps: GroupsDeps) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
return async (ctx) => { return async (ctx) => {
const user = requireAdmin(ctx); const user = requirePermission(ctx, "groups", action);
const keto = ctx.system?.keto; const keto = ctx.system?.keto;
const kratosAdmin = ctx.system?.kratosAdmin; const kratosAdmin = ctx.system?.kratosAdmin;
if (!keto || !kratosAdmin) return unavailable(ctx, ctx.t("admin.capability.keto")); if (!keto || !kratosAdmin) return unavailable(ctx, ctx.t("admin.capability.keto"));
@@ -300,12 +309,12 @@ function withGroups(inner: (deps: GroupsDeps) => Promise<RouteResult>): RouteHan
} }
// Same, plus the validated :name from ctx.params (an invalid group name → themed 404). // Same, plus the validated :name from ctx.params (an invalid group name → themed 404).
function withGroupName(inner: (deps: GroupsDeps, name: string) => Promise<RouteResult>): RouteHandler { function withGroupName(inner: (deps: GroupsDeps, name: string) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
return withGroups((deps) => { return withGroups((deps) => {
const name = deps.ctx.params["name"] ?? ""; const name = deps.ctx.params["name"] ?? "";
if (!isValidGroupName(name)) return Promise.resolve(notFound(deps.ctx)); if (!isValidGroupName(name)) return Promise.resolve(notFound(deps.ctx));
return inner(deps, name); return inner(deps, name);
}); }, action);
} }
const groupFormResult = async (deps: GroupsDeps, extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => { const groupFormResult = async (deps: GroupsDeps, extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
@@ -316,7 +325,7 @@ const groupFormResult = async (deps: GroupsDeps, extra: { error?: string; values
// GET /admin/groups — the list. // GET /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({ 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 /admin/groups — create (a group exists once it has ≥1 member, so this writes the first tuple).
@@ -336,13 +345,38 @@ export const groupsCreate = withGroups(async (deps) => {
}); });
// GET /admin/groups/new — the create form. // GET /admin/groups/new — the create form.
export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {})); export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {}), "write");
// GET /admin/groups/:name — the detail + membership page. // GET /admin/groups/:name — the detail + membership page.
export const groupsDetail = withGroupName(async ({ ctx, keto, kratosAdmin }, name) => { export const groupsDetail = withGroupName(async ({ ctx, keto, kratosAdmin }, name) => {
const { emailById, options } = await memberCandidates(keto, kratosAdmin); const { emailById, options } = await memberCandidates(keto, kratosAdmin);
const members = (await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS })).map((t) => memberView(t, emailById)); const members = (await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS })).map((t) => memberView(t, emailById));
return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members, t: ctx.t }) }, view: "group-detail" }; const subject = groupSubject(name);
const [direct, effective] = await Promise.all([heldPermissions(keto, subject), effectivePermissions(keto, subject, ctx.declaredPermissions)]);
const permissions = buildPermissionPicker({
action: `${detailHref(name)}/permissions`,
declared: ctx.declaredPermissions,
direct,
effective, // a group nested in another group inherits its permissions too
readOnly: !can(ctx, permissionName("groups", "write")),
t: ctx.t,
transitive: true, // members inherit, so a change here lands at their next re-mint, not at once
});
return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ canWrite: !permissions.readOnly, candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members, permissions, t: ctx.t }) }, view: "group-detail" };
});
// POST /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 —
// the documented instant-revoke tradeoff for anything held through a group.
export const groupsPermissions = withGroupName(async ({ ctx, keto, user }, name) => {
const form = (await guardedForm(ctx))!;
const subject = groupSubject(name);
const diff = grantDiff(ctx.declaredPermissions, await heldPermissions(keto, subject), form.getAll(PERMISSIONS_FIELD));
await applyGrants(keto, subject, diff);
if (diff.grant.length > 0 || diff.revoke.length > 0) {
ctx.log.info("admin: group permissions changed", { actor: user.id, granted: diff.grant.join(","), group: name, revoked: diff.revoke.join(",") });
}
return { redirect: detailHref(name) };
}); });
// POST /admin/groups/:name/members — add a member (skip an invalid member or a self-nest). // POST /admin/groups/:name/members — add a member (skip an invalid member or a self-nest).
@@ -362,13 +396,19 @@ export const groupsDeleteConfirm = withGroupName((deps, name) => {
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.groups.delete"), cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.groups.delete"),
message: tt("admin.groups.deleteMessage", { name }), title: tt("admin.groups.delete"), message: tt("admin.groups.deleteMessage", { name }), title: tt("admin.groups.delete"),
}) }, view: "confirm" }); }) }, view: "confirm" });
}); }, "write");
// POST /admin/groups/:name/delete — remove every member tuple (the group ceases to exist). // POST /admin/groups/:name/delete — remove every member tuple (the group ceases to exist).
export const groupsDelete = withGroupName(async ({ ctx, keto, user }, name) => { export const groupsDelete = withGroupName(async ({ ctx, keto, user }, name) => {
await guardedForm(ctx); // CSRF-verify the POST await guardedForm(ctx); // CSRF-verify the POST
// Drop what the group *holds* before what it *contains*: a Keto set exists only through its
// tuples, so leaving the grants behind would resurrect every permission the moment someone
// re-created a group with the same name.
const subject = groupSubject(name);
const held = await heldPermissions(keto, subject);
for (const permission of held) await keto.deleteTuple(grantTuple(permission, subject));
await keto.deleteTuple({ namespace: GROUP_NS, object: name, relation: MEMBERS }); await keto.deleteTuple({ namespace: GROUP_NS, object: name, relation: MEMBERS });
ctx.log.info("admin: group deleted", { actor: user.id, group: name }); ctx.log.info("admin: group deleted", { actor: user.id, group: name, revoked: held.join(",") });
return { redirect: ADMIN_GROUPS_BASE }; return { redirect: ADMIN_GROUPS_BASE };
}); });
@@ -1,106 +0,0 @@
// Built-in Roles admin screen: the pure view-model + Keto builders. A permission is a
// Keto subject set (Permission:<name>#members); members are users (subject_id) or groups (subject_set) —
// "assign permissions to users/groups". The "effective access" view flattens a Keto `expand` tree into the
// distinct set of users who hold the permission directly or transitively via a group. The HTTP
// routing/gate/CSRF + live Keto/Kratos calls are exercised over HTTP in app.test.ts.
import assert from "node:assert/strict";
import { test } from "node:test";
import { memberView } from "./admin-groups.ts";
import {
buildPermissionDetailModel,
buildPermissionFormModel,
buildPermissionsListModel,
expandToEffectiveUsers,
isValidRoleName,
permissionGrantTuple,
} from "./admin-permissions.ts";
import type { ExpandTree, RelationTuple } from "#plugin-api";
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
const userTuple = (permission: string, n: number): RelationTuple =>
({ namespace: "Permission", object: permission, relation: "granted", subject_id: `user:${uid(n)}` });
const groupTuple = (permission: string, group: string): RelationTuple =>
({ namespace: "Permission", object: permission, relation: "granted", subject_set: { namespace: "Group", object: group, relation: "members" } });
test("isValidRoleName + permissionGrantTuple map the form value to a Permission tuple over a user/group (else null)", () => {
for (const ok of ["admin", "editor", "team-a", "a1_b9"]) assert.equal(isValidRoleName(ok), true, ok);
for (const bad of ["", "Admin", "a b", "-bad", "a".repeat(65)]) assert.equal(isValidRoleName(bad), false, bad);
assert.deepEqual(permissionGrantTuple("editor", `user:${uid(2)}`), { namespace: "Permission", object: "editor", relation: "granted", subject_id: `user:${uid(2)}` });
assert.deepEqual(permissionGrantTuple("editor", "group:eng"), { namespace: "Permission", object: "editor", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
for (const bad of ["", "user:not-a-uuid", "group:Bad Name", "nope:x"]) assert.equal(permissionGrantTuple("editor", bad), null, bad);
});
test("expandToEffectiveUsers flattens an expand tree → sorted distinct user ids, transitive through groups", () => {
// The subject rides on each node's `tuple` (Keto v26.2.0 shape, verified live).
const leaf = (n: number): ExpandTree => ({ tuple: { namespace: "", object: "", relation: "", subject_id: `user:${uid(n)}` }, type: "leaf" });
const tree: ExpandTree = {
children: [
leaf(1), // direct
{
children: [leaf(2), leaf(1)], // via group + dup
tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Group", object: "eng", relation: "members" } }, // a member group, not a user
type: "union",
},
],
tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Permission", object: "admin", relation: "granted" } },
type: "union",
};
assert.deepEqual(expandToEffectiveUsers(tree), [uid(1), uid(2)]);
assert.deepEqual(expandToEffectiveUsers(null), []);
assert.deepEqual(expandToEffectiveUsers({ type: "leaf" }), []); // an empty permission
});
test("buildPermissionsListModel filters by search, sorts, paginates; the name links to the detail page", () => {
const permissions = Array.from({ length: 30 }, (_, i) => ({ memberCount: i + 1, name: `permission-${String(i).padStart(2, "0")}` }));
const all = buildPermissionsListModel({ permissions, url: "http://x/admin/permissions" });
assert.equal(all.pagination.summary.total, 30);
assert.equal(all.table.rows.length, 25); // default page size
assert.equal(all.title, "Permissions");
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
assert.equal(first.rowHeader.text, "permission-00");
assert.equal(first.rowHeader.href, "/admin/permissions/permission-00");
const one = buildPermissionsListModel({ permissions, url: "http://x/admin/permissions?q=permission-07" });
assert.equal(one.pagination.summary.total, 1);
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
const desc = buildPermissionsListModel({ permissions, url: "http://x/admin/permissions?sort=-members" });
assert.equal((desc.table.rows[0]!.cells[0] as { rowHeader: { text: string } }).rowHeader.text, "permission-29");
});
test("buildPermissionFormModel: a create form with a required name field + member options (user or group)", () => {
const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
const m = buildPermissionFormModel({ csrfToken: "tok.sig", memberOptions: options });
assert.equal(m.title, "New permission");
assert.equal(m.form.action, "/admin/permissions");
assert.equal(m.form.submitLabel, "Create permission");
assert.equal(m.form.csrfToken, "tok.sig");
assert.equal(m.form.nameField.required, true);
assert.deepEqual(m.form.memberOptions, options);
const err = buildPermissionFormModel({ error: "That name is taken.", memberOptions: options, values: { member: "group:eng", name: "Admin" } });
assert.equal(err.error, "That name is taken.");
assert.equal(err.form.nameField.value, "Admin");
assert.equal(err.form.selectedMember, "group:eng");
});
test("buildPermissionDetailModel: members → rows, add-options exclude current members, effective access listed, actions wired", () => {
const members = [memberView(userTuple("admin", 1), new Map([[uid(1), "ada@example.com"]])), memberView(groupTuple("admin", "eng"), new Map())];
const candidates = [
{ label: "ada@example.com", value: `user:${uid(1)}` }, // already a member → excluded
{ label: "grace@example.com", value: `user:${uid(2)}` },
{ label: "eng (group)", value: "group:eng" }, // already a member → excluded
{ label: "ops (group)", value: "group:ops" },
];
const effective = [{ label: "ada@example.com" }, { label: "grace@example.com" }]; // ada direct, grace via eng
const m = buildPermissionDetailModel({ candidates, effective, members, permission: { name: "admin" } });
assert.equal(m.title, "admin");
assert.equal(m.members.rows.length, 2);
assert.equal(m.members.action, "/admin/permissions/admin/members/delete");
assert.equal(m.add.action, "/admin/permissions/admin/members");
assert.deepEqual(m.add.options.map((o) => o.value), [`user:${uid(2)}`, "group:ops"]);
assert.deepEqual(m.effective.map((e) => e.label), ["ada@example.com", "grace@example.com"]);
assert.equal(m.delete.action, "/admin/permissions/admin/delete");
});
-374
View File
@@ -1,374 +0,0 @@
// Permissions admin screen: list / create / delete Keto permissions and assign
// them to users and groups. A permission is a Keto subject set `Permission:<name>#members` (OPL: members are users
// or groups, resolved transitively) — the source of truth for the JWT `permissions` claim. It shares the
// Groups screen's membership model, so the pure helpers (parseSubject, member pickers, tuple paging)
// are reused from admin-groups. The permission-specific piece is the **effective access** view:
// `keto.expand(Permission:<name>#members)` flattened to the distinct users who hold the permission directly or via
// a group — matching what login projects into the JWT (login.ts readPermissions). Writes go only to Keto;
// Kratos is read only to label members. Below the builders are thin per-route handlers (keyed on
// ctx.params) over a shared `withRoles` gate — admin-only, CSRF-guarded.
import { type ExpandTree, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "#plugin-api";
import { ADMIN_EN, ADMIN_PERMISSION, ADMIN_PERMISSIONS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
import {
type GroupView,
groupsFromTuples,
isValidGroupName,
memberCandidates,
type MemberOption,
type MemberView,
memberView,
pagedTuples,
parseSubject,
} from "./admin-groups.ts";
import type { FieldConfig } from "./admin-users.ts";
const PERMISSION_NS = "Permission";
const GRANTED = "granted";
const DEFAULT_PAGE_SIZE = 25;
const PAGE_SIZES = [25, 50, 100];
// Expand far past any sane group-nesting depth so the effective-access view never silently
// under-reports the deepest members (Keto's own default is shallow).
const EXPAND_MAX_DEPTH = 50;
// A permission and a group share the URL-safe name rule and the user|group membership model.
export type PermissionView = GroupView;
export const isValidRoleName = isValidGroupName;
export const permissionsFromTuples = groupsFromTuples;
export interface EffectiveUser {
label: string; // email (or the raw id when unresolved)
}
// The full membership tuple for assigning/revoking `value` to/from `permission` (null if value is invalid).
export function permissionGrantTuple(permission: string, value: string): RelationTuple | null {
const subject = parseSubject(value);
return subject ? { namespace: PERMISSION_NS, object: permission, relation: GRANTED, ...subject } : null;
}
// Flatten a Keto `expand` tree → the sorted, distinct user ids that effectively hold the permission
// (direct leaves + users reached through member groups, any depth). The subject rides on each
// node's `tuple`; subject-set nodes (the groups) contribute nothing directly — their members
// surface as leaves under them.
export function expandToEffectiveUsers(tree: ExpandTree | null | undefined): string[] {
const ids = new Set<string>();
const walk = (node?: ExpandTree | null): void => {
if (!node) return;
const subjectId = node.tuple?.subject_id;
if (subjectId?.startsWith("user:")) ids.add(subjectId.slice("user:".length));
node.children?.forEach(walk);
};
walk(tree);
return [...ids].sort();
}
// ---- list view model ----
interface ListState {
page: number;
pageSize: number;
q: string;
sort: string | null;
}
const SORT: Record<string, (r: PermissionView) => number | string> = {
members: (r) => r.memberCount,
name: (r) => r.name,
};
const COLUMNS = [
{ key: "name", label: "admin.permissions.column.name" },
{ key: "members", label: "admin.permissions.column.members" },
];
function detailHref(name: string): string {
return `${ADMIN_PERMISSIONS_BASE}/${encodeURIComponent(name)}`;
}
function listHref(state: ListState, overrides: Partial<ListState> = {}): string {
const s = { ...state, ...overrides };
const p = new URLSearchParams();
if (s.q) p.set("q", s.q);
if (s.sort) p.set("sort", s.sort);
if (s.page > 1) p.set("page", String(s.page));
if (s.pageSize !== DEFAULT_PAGE_SIZE) p.set("pageSize", String(s.pageSize));
const qs = p.toString();
return qs ? `${ADMIN_PERMISSIONS_BASE}?${qs}` : ADMIN_PERMISSIONS_BASE;
}
export function buildPermissionsListModel(opts: {
csrfToken?: string;
permissions: PermissionView[];
t?: Translate;
url: URL | URLSearchParams | string;
}) {
const t = opts.t ?? ADMIN_EN;
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
const sort = query.sort && SORT[query.sort.field] ? query.sort : null;
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
const needle = query.q.toLowerCase();
let list = opts.permissions.filter((r) => !needle || r.name.toLowerCase().includes(needle));
if (sort) {
const get = SORT[sort.field]!;
const dir = sort.dir === "desc" ? -1 : 1;
list = [...list].sort((a, b) => {
const av = get(a), bv = get(b);
const cmp = typeof av === "number" && typeof bv === "number" ? av - bv : String(av).localeCompare(String(bv));
return cmp * dir;
});
}
const page = paginate(list.length, query.page, query.pageSize, { boundaries: 1, siblings: 1 });
const start = (page.page - 1) * page.pageSize;
const rows = list.slice(start, start + page.pageSize);
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken };
return {
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: t("admin.nav.section") }, { label: t("admin.permissions.title") }],
filterBar: listFilterBar(state, t),
pagination: listPagination(state, page, t),
table: listTable(rows, state, sort, t),
title: t("admin.permissions.title"),
};
}
function listTable(rows: PermissionView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null, t: Translate) {
return {
caption: t("admin.permissions.title"),
columns: COLUMNS.map((c) => {
const dir = sort && sort.field === c.key ? sort.dir : undefined;
const next = dir === "asc" ? `-${c.key}` : c.key;
return { href: listHref(state, { page: 1, sort: next }), label: t(c.label), sort: dir, sortable: true };
}),
rows: rows.map((r) => ({
cells: [{ rowHeader: { href: detailHref(r.name), text: r.name } }, String(r.memberCount)],
name: r.name,
})),
};
}
function listFilterBar(state: ListState, t: Translate) {
const pills: { label: string; remove: string; value: string }[] = [];
if (state.q) pills.push({ label: t("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q });
return {
applyLabel: t("filter.apply"),
clearHref: ADMIN_PERMISSIONS_BASE,
label: t("admin.permissions.filter"),
pills,
rows: [[
{ label: t("admin.permissions.searchLabel"), name: "q", placeholder: t("admin.permissions.searchPlaceholder"), type: "search", value: state.q },
{ type: "spacer" },
]],
};
}
function listPagination(state: ListState, page: ReturnType<typeof paginate>, t: Translate) {
const hidden: { name: string; value: string }[] = [];
if (state.q) hidden.push({ name: "q", value: state.q });
if (state.sort) hidden.push({ name: "sort", value: state.sort });
return {
label: t("admin.permissions.pagination"),
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
pages: page.pages.map((p) =>
p.ellipsis ? { ellipsis: true }
: p.current ? { current: true, label: String(p.page) }
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
summary: { from: page.from, to: page.to, total: page.total },
};
}
// ---- create form + detail view models ----
export function buildPermissionFormModel(opts: {
csrfToken?: string;
error?: string;
memberOptions: MemberOption[];
t?: Translate;
values?: { member?: string; name?: string };
}) {
const t = opts.t ?? ADMIN_EN;
const nameField: FieldConfig = {
autocomplete: "off", hint: t("admin.permissions.field.nameHint"), icon: "i-shield",
id: "name", label: t("admin.permissions.field.name"), name: "name", required: true, value: opts.values?.name ?? "",
};
return {
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: t("admin.permissions.title") }, { label: t("common.new") }],
error: opts.error,
form: {
action: ADMIN_PERMISSIONS_BASE,
cancelHref: ADMIN_PERMISSIONS_BASE,
csrfToken: opts.csrfToken ?? "",
memberOptions: opts.memberOptions,
nameField,
selectedMember: opts.values?.member ?? "",
submitLabel: t("admin.permissions.create"),
},
title: t("admin.permissions.new"),
};
}
export function buildPermissionDetailModel(opts: {
candidates: MemberOption[];
csrfToken?: string;
effective: EffectiveUser[];
error?: string;
members: MemberView[];
permission: { name: string };
t?: Translate;
}) {
const t = opts.t ?? ADMIN_EN;
const name = opts.permission.name;
const base = detailHref(name);
const taken = new Set(opts.members.map((m) => m.subject));
const options = opts.candidates.filter((c) => !taken.has(c.value)); // members are users/groups, never the permission itself
return {
add: { action: `${base}/members`, options },
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: t("admin.permissions.title") }, { label: name }],
csrfToken: opts.csrfToken ?? "",
delete: { action: `${base}/delete` },
effective: opts.effective,
error: opts.error,
members: { action: `${base}/members/delete`, rows: opts.members },
permission: { name },
title: name,
};
}
// ---- request handler (imperative shell) ----
// instant-revoke: a permission change for a `user:<id>` member must take effect now, so revoke that
// user's live tokens (a re-mint then re-reads permissions from Keto). A `group:<name>` change is
// transitive across many users — left to lag (documented), so only direct user members revoke.
function revokeUserMember(revoke: ((sub: string) => void) | undefined, member: string): void {
if (revoke && member.startsWith("user:")) revoke(member.slice("user:".length));
}
// A permission exists exactly while it has ≥1 member (Keto has no create-object).
async function roleExists(keto: KetoClient, name: string): Promise<boolean> {
const page = await keto.listRelations({ namespace: PERMISSION_NS, object: name, relation: GRANTED, pageSize: 1 });
return page.tuples.length > 0;
}
// The distinct users who effectively hold the permission (expand → flatten → label by email). Skipped for
// an empty permission (no member tuples) so we don't expand a non-existent Keto object.
async function effectiveUsers(keto: KetoClient, name: string, hasMembers: boolean, emailById: Map<string, string>): Promise<EffectiveUser[]> {
if (!hasMembers) return [];
const tree = await keto.expand({ namespace: PERMISSION_NS, object: name, relation: GRANTED }, { maxDepth: EXPAND_MAX_DEPTH });
return expandToEffectiveUsers(tree)
.map((id) => ({ label: emailById.get(id) ?? `user:${id}` }))
.sort((a, b) => a.label.localeCompare(b.label));
}
// Shared per-request deps for the Roles screen, resolved by `withRoles`: the gate + the Keto and
// Kratos capabilities (else a themed 503). Each route below is a thin handler over these.
interface RolesDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; }
function withRoles(inner: (deps: RolesDeps) => Promise<RouteResult>): RouteHandler {
return async (ctx) => {
const user = requireAdmin(ctx);
const keto = ctx.system?.keto;
const kratosAdmin = ctx.system?.kratosAdmin;
if (!keto || !kratosAdmin) return unavailable(ctx, ctx.t("admin.capability.keto"));
return inner({ ctx, keto, kratosAdmin, revoke: ctx.system?.revoke, user });
};
}
// Same, plus the validated :name from ctx.params (an invalid permission name → themed 404).
function withRoleName(inner: (deps: RolesDeps, name: string) => Promise<RouteResult>): RouteHandler {
return withRoles((deps) => {
const name = deps.ctx.params["name"] ?? "";
if (!isValidRoleName(name)) return Promise.resolve(notFound(deps.ctx));
return inner(deps, name);
});
}
const roleFormResult = async (deps: RolesDeps, extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
const { options } = await memberCandidates(deps.keto, deps.kratosAdmin);
return { data: { chrome: deps.ctx.chrome, model: buildPermissionFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, t: deps.ctx.t, ...extra }) }, view: "permission-form" };
};
// The permission detail (members + effective access). With `error` set it's a 400 (a rejected action).
const permissionDetailResult = async (deps: RolesDeps, name: string, error?: string): Promise<RouteResult> => {
const { emailById, options } = await memberCandidates(deps.keto, deps.kratosAdmin);
const tuples = await pagedTuples(deps.keto, { namespace: PERMISSION_NS, object: name, relation: GRANTED });
const members = tuples.map((t) => memberView(t, emailById));
const effective = await effectiveUsers(deps.keto, name, tuples.length > 0, emailById);
const result: RouteResult = { data: { chrome: deps.ctx.chrome, model: buildPermissionDetailModel({ candidates: options, csrfToken: deps.ctx.chrome.csrfToken, effective, members, permission: { name }, t: deps.ctx.t, ...(error ? { error } : {}) }) }, view: "permission-detail" };
return error ? { ...result, status: 400 } : result;
};
// GET /admin/permissions — the list.
export const rolesList = withRoles(async ({ ctx, keto }) => {
const permissions = permissionsFromTuples(await pagedTuples(keto, { namespace: PERMISSION_NS, relation: GRANTED }));
return { data: { chrome: ctx.chrome, model: buildPermissionsListModel({ csrfToken: ctx.chrome.csrfToken, permissions, t: ctx.t, url: ctx.url }) }, view: "permissions" };
});
// POST /admin/permissions — create + assign the first member (a *user* grant revokes their live tokens).
export const rolesCreate = withRoles(async (deps) => {
const { ctx, keto, revoke, user } = deps;
const form = (await guardedForm(ctx))!;
const name = (form.get("name") ?? "").trim();
const member = (form.get("member") ?? "").trim();
const tuple = permissionGrantTuple(name, member);
const reject = async (error: string): Promise<RouteResult> => ({ ...(await roleFormResult(deps, { error, values: { member, name } })), status: 400 });
if (!isValidRoleName(name)) return reject(ctx.t("admin.permissions.validation.name"));
if (!tuple) return reject(ctx.t("admin.permissions.validation.member"));
if (await roleExists(keto, name)) return reject("A permission with that name already exists.");
await keto.writeTuple(tuple);
revokeUserMember(revoke, member);
ctx.log.info("admin: permission created + first member assigned", { actor: user.id, member, permission: name });
return { redirect: detailHref(name) };
});
// GET /admin/permissions/new — the create form.
export const rolesNewForm = withRoles((deps) => roleFormResult(deps, {}));
// GET /admin/permissions/:name — the detail (members + effective access via Keto expand).
export const rolesDetail = withRoleName((deps, name) => permissionDetailResult(deps, name));
// POST /admin/permissions/:name/members — assign a user/group; a *user* grant revokes their live tokens.
export const rolesAddMember = withRoleName(async (deps, name) => {
const { ctx, keto, revoke, user } = deps;
const form = (await guardedForm(ctx))!;
const member = (form.get("member") ?? "").trim();
const tuple = permissionGrantTuple(name, member); // the picker only offers real users/groups
if (tuple) { await keto.writeTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: permission assigned", { actor: user.id, member, permission: name }); }
return { redirect: detailHref(name) };
});
// GET /admin/permissions/:name/delete — confirm, except the admin permission can't be deleted.
export const rolesDeleteConfirm = withRoleName((deps, name) => {
if (name === ADMIN_PERMISSION) return permissionDetailResult(deps, name, deps.ctx.t("admin.permissions.error.adminUndeletable"));
const base = detailHref(name);
const tt = deps.ctx.t;
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: tt("admin.permissions.title") }, { href: base, label: name }, { label: tt("common.delete") }],
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.permissions.delete"),
message: tt("admin.permissions.deleteMessage", { name }), title: tt("admin.permissions.delete"),
}) }, view: "confirm" });
});
// POST /admin/permissions/:name/delete — remove every member tuple (a whole-permission delete lags per the
// documented instant-revoke tradeoff; the admin permission is protected).
export const rolesDelete = withRoleName(async (deps, name) => {
const { ctx, keto, user } = deps;
await guardedForm(ctx); // CSRF-verify the POST
if (name === ADMIN_PERMISSION) return permissionDetailResult(deps, name, deps.ctx.t("admin.permissions.error.adminUndeletable"));
await keto.deleteTuple({ namespace: PERMISSION_NS, object: name, relation: GRANTED });
ctx.log.info("admin: permission deleted", { actor: user.id, permission: name });
return { redirect: ADMIN_PERMISSIONS_BASE };
});
// POST /admin/permissions/:name/members/delete — unassign; a *user* unassign revokes their live tokens.
// Self-protection: an admin can't revoke their own *direct* admin grant (a group-held admin isn't
// covered — the robust "last effective admin" check is deferred).
export const rolesRemoveMember = withRoleName(async (deps, name) => {
const { ctx, keto, revoke, user } = deps;
const form = (await guardedForm(ctx))!;
const member = (form.get("member") ?? "").trim();
if (name === ADMIN_PERMISSION && member === `user:${user.id}`) return permissionDetailResult(deps, name, deps.ctx.t("admin.permissions.error.selfRevoke"));
const tuple = permissionGrantTuple(name, member);
if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: permission unassigned", { actor: user.id, member, permission: name }); }
return { redirect: detailHref(name) };
});
+43 -15
View File
@@ -1,15 +1,16 @@
// Direct units for the admin plugin's shared nav + auth helpers. They're security-critical // Direct units for the admin plugin's shared nav + auth helpers. They're security-critical
// (requireAdmin/guardedForm gate every admin write) and reused across all four screens, so pin the // (requirePermission/guardedForm gate every admin write) and reused across all three screens, so pin the
// contract here in isolation; the HTTP routing/gate/CSRF is exercised end-to-end in src/http/app.test.ts. // contract here in isolation; the HTTP routing/gate/CSRF is exercised end-to-end in src/http/app.test.ts.
// Import only from the #plugin-api barrel — the same contract boundary the plugin code uses. // Import only from the #plugin-api barrel — the same contract boundary the plugin code uses.
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import { test } from "node:test"; import { test } from "node:test";
import { GuardError, type Log, type PageChrome, type RequestContext, type User } from "#plugin-api"; import { GuardError, isValidPermissionName, type Log, type PageChrome, type RequestContext, type User } from "#plugin-api";
import { ADMIN_EN, ADMIN_NAV, ADMIN_PERMISSION, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts"; import { ADMIN_EN, ADMIN_NAV, ADMIN_USERS_BASE, actionForMethod, buildConfirmModel, guardedForm, permissionName, requirePermission } from "./admin-shared.ts";
const admin: User = { email: "ada@x.io", id: "u1", permissions: ["admin"] }; const reader: User = { email: "ada@x.io", id: "u1", permissions: ["users:read"] };
const writer: User = { email: "cy@x.io", id: "u3", permissions: ["users:read", "users:write"] };
const member: User = { email: "bo@x.io", id: "u2", permissions: ["scheduling:read"] }; const member: User = { email: "bo@x.io", id: "u2", permissions: ["scheduling:read"] };
const CHROME = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } } as PageChrome; const CHROME = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } } as PageChrome;
@@ -18,7 +19,7 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage; const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
req.method = opts.method ?? "GET"; req.method = opts.method ?? "GET";
return { return {
chrome: CHROME, user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: {} as Log, params: {}, chrome: CHROME, declaredPermissions: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: {} as Log, params: {},
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.user?.permissions ?? [], t: ADMIN_EN, url, query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.user?.permissions ?? [], t: ADMIN_EN, url,
verifyCsrf: opts.verifyCsrf ?? (() => true), verifyCsrf: opts.verifyCsrf ?? (() => true),
}; };
@@ -26,24 +27,51 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver
// ---- nav fragment ---- // ---- nav fragment ----
test("ADMIN_NAV: a gated Admin header over the four screens; no per-request current/open state", () => { test("ADMIN_NAV: an ungated Admin header whose three screens each gate on their own read permission", () => {
assert.equal(ADMIN_NAV.id, "admin"); assert.equal(ADMIN_NAV.id, "admin");
assert.equal(ADMIN_NAV.permission, ADMIN_PERMISSION); // gate on the header ⇒ composeNav drops the whole subtree for a non-admin // No gate on the header: a user may hold one screen's permission and not another's. composeNav
// drops a header left with no visible children, so holding none of the three hides the section.
// Both halves matter — give the header an `href` and it survives the filter as a visible leaf,
// ungated, for anonymous visitors included.
assert.equal(ADMIN_NAV.permission, 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/permissions", "/admin/clients"]); assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/clients"]);
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.permission), ["users:read", "groups:read", "oauth2-clients: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.
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["admin.nav.users", "admin.nav.groups", "admin.nav.permissions", "admin.nav.clients"]); assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["admin.nav.users", "admin.nav.groups", "admin.nav.clients"]);
assert.deepEqual(ADMIN_NAV.children?.map((c) => ADMIN_EN(c.label)), ["Users", "Groups", "Permissions", "OAuth2 clients"]); assert.deepEqual(ADMIN_NAV.children?.map((c) => ADMIN_EN(c.label)), ["Users", "Groups", "OAuth2 clients"]);
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined && c.permission === undefined)); // the header's gate covers the subtree assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined));
});
// ---- permission naming ----
test("permissionName builds <resource>:<action>, and the host agrees the result is well-formed", () => {
assert.equal(permissionName("users", "read"), "users:read");
assert.equal(permissionName("oauth2-clients", "write"), "oauth2-clients:write");
assert.ok(isValidPermissionName(permissionName("oauth2-clients", "write"))); // the rule discovery enforces
});
test("actionForMethod: read for GET/HEAD, write for every mutation", () => {
assert.equal(actionForMethod("GET"), "read");
assert.equal(actionForMethod("HEAD"), "read"); // a GET route also answers HEAD
assert.equal(actionForMethod("POST"), "write");
assert.equal(actionForMethod("DELETE"), "write"); // anything that isn't a read is a write
assert.equal(actionForMethod("get"), "read"); // method case is the caller's
}); });
// ---- auth gates ---- // ---- auth gates ----
test("requireAdmin: anonymous → 401→/login, signed-in non-admin → 403, admin → the user", () => { test("requirePermission: anonymous → 401→/login, wrong permission → 403, and read never grants write", () => {
assert.throws(() => requireAdmin(fakeCtx({ user: null })), (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=%2Fadmin%2Fusers"); // bounce remembers the page
assert.throws(() => requireAdmin(fakeCtx({ user: member })), (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(requireAdmin(fakeCtx({ user: admin })), admin); 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.
assert.throws(() => requirePermission(fakeCtx({ method: "POST", user: reader }), "users"), (e: unknown) => e instanceof GuardError && e.status === 403);
assert.equal(requirePermission(fakeCtx({ method: "POST", user: writer }), "users"), writer);
// Resources don't leak into each other: a users holder is not a groups holder.
assert.throws(() => requirePermission(fakeCtx({ user: writer }), "groups"), (e: unknown) => e instanceof GuardError && e.status === 403);
}); });
test("guardedForm: valid double-submit → the parsed body, bad token → 403, non-POST → undefined", async () => { test("guardedForm: valid double-submit → the parsed body, bad token → 403, non-POST → undefined", async () => {
+40 -16
View File
@@ -11,36 +11,60 @@ import enUS from "./i18n/en-US.ts";
// ctx.t, which reads this catalog in the visitor's locale first, then the host's. // ctx.t, which reads this catalog in the visitor's locale first, then the host's.
export const ADMIN_EN: Translate = englishTranslator(enUS); export const ADMIN_EN: Translate = englishTranslator(enUS);
export const ADMIN_PERMISSION = "admin"; // the permission gating the whole admin section
export const ADMIN_USERS_BASE = "/admin/users"; export const ADMIN_USERS_BASE = "/admin/users";
export const ADMIN_GROUPS_BASE = "/admin/groups"; export const ADMIN_GROUPS_BASE = "/admin/groups";
export const ADMIN_PERMISSIONS_BASE = "/admin/permissions";
export const ADMIN_CLIENTS_BASE = "/admin/clients"; export const ADMIN_CLIENTS_BASE = "/admin/clients";
export type AdminScreen = "clients" | "groups" | "permissions" | "users"; // 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.
// There is no `permissions` resource: permissions are declared in plugin code, not created here, so
// holding a grant is a property of a user or a group and is edited on those two screens.
export type AdminResource = "groups" | "oauth2-clients" | "users";
// The plugin's nav fragment: the gated "Admin" header + its four screens. The host composes it into export type AdminAction = "read" | "write";
// the one global menu, filters per user (the header's `permission` drops the whole subtree for a
// non-admin), and current-marks the active item — so there is no `current`/`open` state here. // `<resource>:<action>` (README → Naming a permission).
export function permissionName(resource: AdminResource, action: AdminAction): string {
return `${resource}:${action}`;
}
// This plugin's mapping from method to action: every screen reads on GET/HEAD and mutates on POST.
// The manifest's route table and the in-handler guard both go through it rather than each spelling
// the permission out, so they cannot drift into gating on different names. Deliberately local — as
// a general mechanism it would make authorization a function of the transport verb, and a route
// table should answer "what does this need?" on its own (AGENTS.md).
export function actionForMethod(method: string): AdminAction {
const verb = method.toUpperCase();
return verb === "GET" || verb === "HEAD" ? "read" : "write";
}
// The plugin's nav fragment: an ungated "Admin" header + its three screens, each gated on its own
// read permission. The header carries no `permission` because a user may hold one screen's and not
// another's; composeNav drops a header left with no visible children, so a user holding none of the
// three never sees the section. The host current-marks the active item — no `current`/`open` here.
export const ADMIN_NAV: NavNode = { export const ADMIN_NAV: NavNode = {
children: [ children: [
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users" }, { href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users", permission: permissionName("users", "read") },
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "admin.nav.groups" }, { href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "admin.nav.groups", permission: permissionName("groups", "read") },
{ href: ADMIN_PERMISSIONS_BASE, icon: "i-shield", id: "permissions", label: "admin.nav.permissions" }, { href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "admin.nav.clients", permission: permissionName("oauth2-clients", "read") },
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "admin.nav.clients" },
], ],
icon: "i-shield", icon: "i-shield",
id: "admin", id: "admin",
label: "admin.nav.section", // a key in this plugin's catalog; the host translates nav labels label: "admin.nav.section", // a key in this plugin's catalog; the host translates nav labels
permission: ADMIN_PERMISSION,
}; };
// The admin gate: a signed-in admin only. Each route already declares `permission: "admin"`, so the // The screen gate: a signed-in user holding this request's `<resource>:<action>`. Each route already
// host enforces this before the handler runs; this is defence-in-depth and what a direct unit test // declares the same permission, so the host enforces it before the handler runs; this is
// relies on. Returns the (non-null) user for the handler to thread on. GuardError → /login or 403. // defence-in-depth and what a direct unit test relies on. Returns the (non-null) user for the
export function requireAdmin(ctx: RequestContext): User { // handler to thread on. GuardError → /login or 403.
// `action` defaults to the method's, and is passed explicitly by a *write-intent GET* — a create form
// or a delete-confirm page, whose only purpose is to start a write. Those refuse a reader honestly
// instead of rendering a form whose submit would 403; the route table declares the same override, so
// the two still cannot disagree.
export function requirePermission(ctx: RequestContext, resource: AdminResource, action?: AdminAction): User {
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept) const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
if (!can(ctx, ADMIN_PERMISSION)) throw new GuardError(403, "admin permission required"); const permission = permissionName(resource, action ?? actionForMethod(ctx.req.method ?? "GET"));
if (!can(ctx, permission)) throw new GuardError(403, `${permission} required`);
return user; return user;
} }
+93 -20
View File
@@ -4,8 +4,9 @@
// models; below them are thin per-route handlers (keyed on ctx.params) over a shared `withUser` gate // models; below them are thin per-route handlers (keyed on ctx.params) over a shared `withUser` gate
// — admin-only, CSRF-guarded, each returning a RouteResult (a view, or a redirect after a write — PRG). // — admin-only, CSRF-guarded, each returning a RouteResult (a view, or a redirect after a write — PRG).
import { type Identity, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "#plugin-api"; import { can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "#plugin-api";
import { ADMIN_EN, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts"; import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD, userSubject } from "./admin-grants.ts";
import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
const DEFAULT_PAGE_SIZE = 25; const DEFAULT_PAGE_SIZE = 25;
@@ -105,6 +106,7 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
} }
export function buildUsersListModel(opts: { export function buildUsersListModel(opts: {
canWrite?: boolean;
csrfToken?: string; csrfToken?: string;
identities: Identity[]; identities: Identity[];
t?: Translate; t?: Translate;
@@ -134,6 +136,7 @@ export function buildUsersListModel(opts: {
return { return {
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.nav.section") }, { label: t("admin.users.title") }], breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.nav.section") }, { label: t("admin.users.title") }],
canWrite: opts.canWrite !== false,
filterBar: listFilterBar(state, all.length, t), filterBar: listFilterBar(state, all.length, t),
pagination: listPagination(state, page, t), pagination: listPagination(state, page, t),
table: listTable(rows, state, sort, t), table: listTable(rows, state, sort, t),
@@ -216,9 +219,11 @@ export interface FieldConfig {
} }
export function buildUserFormModel(opts: { export function buildUserFormModel(opts: {
canWrite?: boolean; // false ⇒ a `users:read` holder: show the state, render no write affordance
csrfToken?: string; csrfToken?: string;
error?: string; error?: string;
identity?: Identity | null; identity?: Identity | null;
permissions?: PermissionPicker; // editing only — a user that doesn't exist yet can hold nothing
recovery?: RecoveryCode; recovery?: RecoveryCode;
t?: Translate; t?: Translate;
values?: Partial<UserInput>; values?: Partial<UserInput>;
@@ -238,8 +243,10 @@ export function buildUserFormModel(opts: {
]; ];
if (!editing) fields.push({ autocomplete: "new-password", hint: t("admin.users.field.passwordHint"), icon: "i-lock", id: "password", label: t("admin.users.field.password"), name: "password", optional: true, type: "password" }); if (!editing) fields.push({ autocomplete: "new-password", hint: t("admin.users.field.passwordHint"), icon: "i-lock", id: "password", label: t("admin.users.field.password"), name: "password", optional: true, type: "password" });
const canWrite = opts.canWrite !== false;
return { return {
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.users.title") }, { label: editing ? t("common.edit") : t("common.new") }], breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.users.title") }, { label: editing ? t("common.edit") : t("common.new") }],
canWrite, // the view drops every write affordance when false; the host already 403s the POSTs
edit: editing ? { edit: editing ? {
deleteAction: `${idPath}/delete`, deleteAction: `${idPath}/delete`,
id: view!.id, id: view!.id,
@@ -250,6 +257,7 @@ export function buildUserFormModel(opts: {
} : undefined, } : undefined,
error: opts.error, error: opts.error,
form: { action: idPath, cancelHref: ADMIN_USERS_BASE, csrfToken: opts.csrfToken ?? "", fields, submitLabel: editing ? t("admin.users.save") : t("admin.users.create") }, form: { action: idPath, cancelHref: ADMIN_USERS_BASE, csrfToken: opts.csrfToken ?? "", fields, submitLabel: editing ? t("admin.users.save") : t("admin.users.create") },
permissions: editing ? opts.permissions : undefined,
recovery: opts.recovery, recovery: opts.recovery,
title: editing ? t("admin.users.edit") : t("admin.users.new"), title: editing ? t("admin.users.edit") : t("admin.users.new"),
}; };
@@ -266,30 +274,33 @@ function readUserInput(form: URLSearchParams): UserInput {
}; };
} }
// Shared per-request deps for the Users screen, resolved by `withUser`: the gate (admin only) and // Shared per-request deps for the Users screen, resolved by `withUser`: the gate (`users:read` on a
// the Kratos capability (else a themed 503). Each route below is a thin handler over these. // GET, `users:write` on a POST) and the Kratos capability (else a themed 503). Each route below is a
interface UsersDeps { ctx: RequestContext; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; } // thin handler over these.
// `keto` is optional the way every other capability here is: without it the page still lists and
// edits users, it just can't show the permission picker.
interface UsersDeps { ctx: RequestContext; keto: KetoClient | undefined; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; }
// Resolve the shared deps, then run `inner`. The route's `permission: "admin"` already gated at the // Resolve the shared deps, then run `inner`. The route's own `permission` already gated at the host;
// host; `requireAdmin` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it. // `requirePermission` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it.
function withUser(inner: (deps: UsersDeps) => Promise<RouteResult>): RouteHandler { function withUser(inner: (deps: UsersDeps) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
return async (ctx) => { return async (ctx) => {
const user = requireAdmin(ctx); const user = requirePermission(ctx, "users", action);
const kratosAdmin = ctx.system?.kratosAdmin; const kratosAdmin = ctx.system?.kratosAdmin;
if (!kratosAdmin) return unavailable(ctx, ctx.t("admin.capability.kratos")); if (!kratosAdmin) return unavailable(ctx, ctx.t("admin.capability.kratos"));
return inner({ ctx, kratosAdmin, revoke: ctx.system?.revoke, user }); return inner({ ctx, keto: ctx.system?.keto, kratosAdmin, revoke: ctx.system?.revoke, user });
}; };
} }
// Same, plus the target identity from ctx.params.id (unknown id → themed 404). The router already // Same, plus the target identity from ctx.params.id (unknown id → themed 404). The router already
// decoded the id and 404s malformed %-encoding, so no manual decode is needed here. // decoded the id and 404s malformed %-encoding, so no manual decode is needed here.
function withTarget(inner: (deps: UsersDeps, identity: Identity, id: string) => Promise<RouteResult>): RouteHandler { function withTarget(inner: (deps: UsersDeps, identity: Identity, id: string) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
return withUser(async (deps) => { return withUser(async (deps) => {
const id = deps.ctx.params["id"] ?? ""; const id = deps.ctx.params["id"] ?? "";
const identity = await deps.kratosAdmin.getIdentity(id); const identity = await deps.kratosAdmin.getIdentity(id);
if (!identity) return notFound(deps.ctx); if (!identity) return notFound(deps.ctx);
return inner(deps, identity, id); return inner(deps, identity, id);
}); }, action);
} }
const formResult = (ctx: RequestContext, extra: Parameters<typeof buildUserFormModel>[0]): RouteResult => const formResult = (ctx: RequestContext, extra: Parameters<typeof buildUserFormModel>[0]): RouteResult =>
@@ -298,7 +309,7 @@ const formResult = (ctx: RequestContext, extra: Parameters<typeof buildUserFormM
// GET /admin/users — the filtered/sorted/paged list. // GET /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({ 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 /admin/users — create; a Kratos 4xx re-renders the form (400), keeping the input.
@@ -315,18 +326,73 @@ export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => {
}); });
// GET /admin/users/new — the empty create form. // GET /admin/users/new — the empty create form.
export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx, {}))); export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx, {})), "write");
// GET /admin/users/:id — the edit form, prefilled. // GET /admin/users/:id — the edit form, prefilled.
export const usersEditForm = withTarget((deps, identity) => Promise.resolve(formResult(deps.ctx, { identity }))); export const usersEditForm = withTarget(async (deps, identity, id) => {
const permissions = await userPermissionPicker(deps, id);
return formResult(deps.ctx, { canWrite: canWriteUsers(deps.ctx), identity, ...(permissions ? { permissions } : {}) });
});
const canWriteUsers = (ctx: RequestContext): boolean => can(ctx, permissionName("users", "write"));
// The checkbox list of declared permissions: ticked where this user holds one, and disabled where
// the grant comes from a group (real, but removed on that group). Undefined when Keto isn't wired —
// the rest of the edit page still works.
async function userPermissionPicker(deps: UsersDeps, id: string, error?: string): Promise<PermissionPicker | undefined> {
if (!deps.keto) return undefined;
const subject = userSubject(id);
const [direct, effective] = await Promise.all([
heldPermissions(deps.keto, subject),
effectivePermissions(deps.keto, subject, deps.ctx.declaredPermissions),
]);
return {
...buildPermissionPicker({
action: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}/permissions`,
declared: deps.ctx.declaredPermissions,
direct,
effective,
readOnly: !canWriteUsers(deps.ctx),
t: deps.ctx.t,
}),
...(error ? { error } : {}),
};
}
// POST /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
// revokes their live tokens so it lands now rather than at the next re-mint.
export const usersPermissions = withTarget(async (deps, identity, id) => {
const { ctx, keto, revoke, user } = deps;
const form = (await guardedForm(ctx))!;
if (!keto) return unavailable(ctx, ctx.t("admin.capability.keto"));
const subject = userSubject(id);
const diff = grantDiff(ctx.declaredPermissions, await heldPermissions(keto, subject), form.getAll(PERMISSIONS_FIELD));
// Self-lockout guard, matching the self-deactivate/self-delete ones: revoking your own grants can
// remove the last `users:write` on the deployment, and the instant-revoke hook lands it on the very
// next request. Recovery would be a curl against Keto — not something the operator persona can do.
if (id === user.id && diff.revoke.length > 0) {
ctx.log.warn("admin: refused a self-revoke of permissions", { actor: user.id, refused: diff.revoke.join(",") });
const permissions = await userPermissionPicker(deps, id, ctx.t("admin.grants.selfRevoke"));
return { ...formResult(ctx, { canWrite: canWriteUsers(ctx), identity, ...(permissions ? { permissions } : {}) }), status: 400 };
}
await applyGrants(keto, subject, diff);
if (diff.grant.length > 0 || diff.revoke.length > 0) {
revoke?.(id);
ctx.log.info("admin: user permissions changed", { actor: user.id, granted: diff.grant.join(","), revoked: diff.revoke.join(","), target: id });
}
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
});
// POST /admin/users/:id — save edits; a Kratos 4xx re-renders the form (400). // POST /admin/users/:id — save edits; a Kratos 4xx re-renders the form (400).
export const usersUpdate = withTarget(async ({ ctx, kratosAdmin }, identity, id) => { export const usersUpdate = withTarget(async (deps, identity, id) => {
const { ctx, kratosAdmin } = deps;
const input = readUserInput((await guardedForm(ctx))!); const input = readUserInput((await guardedForm(ctx))!);
try { try {
await kratosAdmin.updateIdentity(id, updateIdentityPayload(identity, input)); await kratosAdmin.updateIdentity(id, updateIdentityPayload(identity, input));
} catch (err) { } catch (err) {
if (err instanceof KratosError) return { ...formResult(ctx, { error: ctx.t("admin.users.error.save"), identity }), status: 400 }; // Re-render with the picker, or the permissions section vanishes off the page on a failed save.
if (err instanceof KratosError) return { ...formResult(ctx, { canWrite: canWriteUsers(ctx), error: ctx.t("admin.users.error.save"), identity, ...(await pickerOrNothing(deps, id)) }), status: 400 };
throw err; throw err;
} }
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` }; return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
@@ -355,7 +421,7 @@ export const usersDeleteConfirm = withTarget((deps, identity, id) => {
cancelHref: back, confirmAction: `${back}/delete`, confirmLabel: tt("admin.users.delete"), cancelHref: back, confirmAction: `${back}/delete`, confirmLabel: tt("admin.users.delete"),
message: tt("admin.users.deleteMessage", { email: view.email }), title: tt("admin.users.delete"), message: tt("admin.users.deleteMessage", { email: view.email }), title: tt("admin.users.delete"),
}) }, view: "confirm" }); }) }, view: "confirm" });
}); }, "write");
// POST /admin/users/:id/delete — perform it; revoke the gone account's live tokens. Refuses self-delete. // POST /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) => {
@@ -368,12 +434,19 @@ export const usersDelete = withTarget(async ({ ctx, kratosAdmin, revoke, user },
}); });
// POST /admin/users/:id/recovery — mint a one-time recovery code, shown on the edit page. // POST /admin/users/:id/recovery — mint a one-time recovery code, shown on the edit page.
export const usersRecovery = withTarget(async ({ ctx, kratosAdmin }, identity, id) => { export const usersRecovery = withTarget(async (deps, identity, id) => {
const { ctx, kratosAdmin } = deps;
await guardedForm(ctx); // CSRF-verify the POST await guardedForm(ctx); // CSRF-verify the POST
const recovery = await kratosAdmin.createRecoveryCode(id); const recovery = await kratosAdmin.createRecoveryCode(id);
return formResult(ctx, { identity, recovery }); return formResult(ctx, { canWrite: canWriteUsers(ctx), identity, recovery, ...(await pickerOrNothing(deps, id)) });
}); });
// The picker as a spreadable fragment, so a re-render never silently drops the section.
async function pickerOrNothing(deps: UsersDeps, id: string): Promise<{ permissions?: PermissionPicker }> {
const permissions = await userPermissionPicker(deps, id);
return permissions ? { permissions } : {};
}
function createError(err: KratosError, t: Translate): string { function createError(err: KratosError, t: Translate): string {
return err.status === 409 return err.status === 409
? t("admin.users.error.duplicate") ? t("admin.users.error.duplicate")
+10 -31
View File
@@ -48,6 +48,15 @@ const messages = {
"admin.common.type": "Type", "admin.common.type": "Type",
"admin.common.user": "User", "admin.common.user": "User",
"admin.grants.hint": "Which permissions exist is set by the plugins installed on this system. Tick to grant, untick to revoke.",
"admin.grants.hintReadOnly": "Which permissions exist is set by the plugins installed on this system. You can see these, but not change them.",
"admin.grants.inherited": "Greyed-out permissions come from a group. Change them on that group.",
"admin.grants.legend": "Permissions",
"admin.grants.none": "No installed plugin declares a permission, so there is nothing to grant.",
"admin.grants.pending": "Members get this at their next sign-in (up to 10 minutes).",
"admin.grants.save": "Save permissions",
"admin.grants.selfRevoke": "You can't revoke your own permissions — ask another administrator, so you can't lock yourself out.",
"admin.groups.actions": "Group actions", "admin.groups.actions": "Group actions",
"admin.groups.addMember": "Add a member", "admin.groups.addMember": "Add a member",
"admin.groups.allMembers": "All users and groups are already members.", "admin.groups.allMembers": "All users and groups are already members.",
@@ -55,7 +64,7 @@ const messages = {
"admin.groups.column.name": "Group", "admin.groups.column.name": "Group",
"admin.groups.create": "Create group", "admin.groups.create": "Create group",
"admin.groups.delete": "Delete group", "admin.groups.delete": "Delete group",
"admin.groups.deleteMessage": "Delete group {{name}}? This removes the group and all its memberships.", "admin.groups.deleteMessage": "Delete group {{name}}? This can't be undone.",
"admin.groups.field.name": "Group name", "admin.groups.field.name": "Group name",
"admin.groups.field.nameHint": "Lowercase letters, digits, dashes and underscores.", "admin.groups.field.nameHint": "Lowercase letters, digits, dashes and underscores.",
"admin.groups.filter": "Filter groups", "admin.groups.filter": "Filter groups",
@@ -74,42 +83,12 @@ const messages = {
"admin.nav.clients": "OAuth2 clients", "admin.nav.clients": "OAuth2 clients",
"admin.nav.groups": "Groups", "admin.nav.groups": "Groups",
"admin.nav.permissions": "Permissions",
"admin.nav.section": "Admin", "admin.nav.section": "Admin",
"admin.nav.users": "Users", "admin.nav.users": "Users",
"admin.notFound.message": "That item doesn't exist.", "admin.notFound.message": "That item doesn't exist.",
"admin.notFound.title": "Not found", "admin.notFound.title": "Not found",
"admin.permissions.actions": "Permission actions",
"admin.permissions.allAssigned": "All users and groups already have this permission.",
"admin.permissions.assign": "Assign the permission",
"admin.permissions.assignAction": "Assign",
"admin.permissions.assignTo": "Assign to",
"admin.permissions.assignedTo": "Assigned to",
"admin.permissions.column.members": "Members",
"admin.permissions.column.name": "Permission",
"admin.permissions.create": "Create permission",
"admin.permissions.delete": "Delete permission",
"admin.permissions.deleteMessage": "Delete permission {{name}}? This revokes it from everyone it's assigned to.",
"admin.permissions.error.adminUndeletable": "The admin permission can't be deleted — it would remove all admin access.",
"admin.permissions.error.selfRevoke": "You can't revoke your own admin access.",
"admin.permissions.effective": "Effective access",
"admin.permissions.effectiveHint": "Everyone who holds this permission — directly or through a group (resolved by Keto).",
"admin.permissions.field.name": "Permission name",
"admin.permissions.field.nameHint": "Lowercase letters, digits, dashes and underscores.",
"admin.permissions.filter": "Filter permissions",
"admin.permissions.new": "New permission",
"admin.permissions.noEffective": "No users hold this permission yet.",
"admin.permissions.noMembers": "Not assigned to anyone yet.",
"admin.permissions.pagination": "Permissions pagination",
"admin.permissions.revoke": "Revoke",
"admin.permissions.searchLabel": "Search permissions",
"admin.permissions.searchPlaceholder": "Search permission name…",
"admin.permissions.title": "Permissions",
"admin.permissions.validation.member": "Pick a user or group to assign the permission to.",
"admin.permissions.validation.name": "Permission names use lowercase letters, digits, dashes and underscores.",
"admin.unavailable.message": "{{what}} is not configured on this deployment.", "admin.unavailable.message": "{{what}} is not configured on this deployment.",
"admin.unavailable.title": "Admin unavailable", "admin.unavailable.title": "Admin unavailable",
+10 -31
View File
@@ -48,6 +48,15 @@ const messages: AdminMessages = {
"admin.common.type": "Typ", "admin.common.type": "Typ",
"admin.common.user": "Användare", "admin.common.user": "Användare",
"admin.grants.hint": "Vilka behörigheter som finns bestäms av de plugins som är installerade. Kryssa i för att tilldela, ur för att återkalla.",
"admin.grants.hintReadOnly": "Vilka behörigheter som finns bestäms av de plugins som är installerade. Du kan se dem, men inte ändra dem.",
"admin.grants.inherited": "Gråmarkerade behörigheter kommer från en grupp. Ändra dem på gruppen.",
"admin.grants.legend": "Behörigheter",
"admin.grants.none": "Ingen installerad plugin deklarerar någon behörighet, så det finns inget att tilldela.",
"admin.grants.pending": "Medlemmar får detta vid nästa inloggning (upp till 10 minuter).",
"admin.grants.save": "Spara behörigheter",
"admin.grants.selfRevoke": "Du kan inte återkalla dina egna behörigheter — be en annan administratör, så att du inte låser ute dig själv.",
"admin.groups.actions": "Gruppåtgärder", "admin.groups.actions": "Gruppåtgärder",
"admin.groups.addMember": "Lägg till en medlem", "admin.groups.addMember": "Lägg till en medlem",
"admin.groups.allMembers": "Alla användare och grupper är redan medlemmar.", "admin.groups.allMembers": "Alla användare och grupper är redan medlemmar.",
@@ -55,7 +64,7 @@ const messages: AdminMessages = {
"admin.groups.column.name": "Grupp", "admin.groups.column.name": "Grupp",
"admin.groups.create": "Skapa grupp", "admin.groups.create": "Skapa grupp",
"admin.groups.delete": "Radera grupp", "admin.groups.delete": "Radera grupp",
"admin.groups.deleteMessage": "Ta bort gruppen {{name}}? Det tar bort gruppen och alla dess medlemskap.", "admin.groups.deleteMessage": "Ta bort gruppen {{name}}? Det går inte att ångra.",
"admin.groups.field.name": "Gruppnamn", "admin.groups.field.name": "Gruppnamn",
"admin.groups.field.nameHint": "Små bokstäver, siffror, bindestreck och understreck.", "admin.groups.field.nameHint": "Små bokstäver, siffror, bindestreck och understreck.",
"admin.groups.filter": "Filtrera grupper", "admin.groups.filter": "Filtrera grupper",
@@ -74,42 +83,12 @@ const messages: AdminMessages = {
"admin.nav.clients": "OAuth2-klienter", "admin.nav.clients": "OAuth2-klienter",
"admin.nav.groups": "Grupper", "admin.nav.groups": "Grupper",
"admin.nav.permissions": "Behörigheter",
"admin.nav.section": "Administration", "admin.nav.section": "Administration",
"admin.nav.users": "Användare", "admin.nav.users": "Användare",
"admin.notFound.message": "Objektet finns inte.", "admin.notFound.message": "Objektet finns inte.",
"admin.notFound.title": "Hittades inte", "admin.notFound.title": "Hittades inte",
"admin.permissions.actions": "Behörighetsåtgärder",
"admin.permissions.allAssigned": "Alla användare och grupper har redan den här behörigheten.",
"admin.permissions.assign": "Tilldela behörigheten",
"admin.permissions.assignAction": "Tilldela",
"admin.permissions.assignTo": "Tilldela till",
"admin.permissions.assignedTo": "Tilldelad till",
"admin.permissions.column.members": "Medlemmar",
"admin.permissions.column.name": "Behörighet",
"admin.permissions.create": "Skapa behörighet",
"admin.permissions.delete": "Radera behörighet",
"admin.permissions.deleteMessage": "Ta bort behörigheten {{name}}? Den återkallas från alla den är tilldelad till.",
"admin.permissions.error.adminUndeletable": "Behörigheten admin kan inte tas bort — det skulle ta bort all administratörsåtkomst.",
"admin.permissions.error.selfRevoke": "Du kan inte återkalla din egen administratörsåtkomst.",
"admin.permissions.effective": "Faktisk åtkomst",
"admin.permissions.effectiveHint": "Alla som har behörigheten — direkt eller via en grupp (uppslaget av Keto).",
"admin.permissions.field.name": "Behörighetens namn",
"admin.permissions.field.nameHint": "Små bokstäver, siffror, bindestreck och understreck.",
"admin.permissions.filter": "Filtrera behörigheter",
"admin.permissions.new": "Ny behörighet",
"admin.permissions.noEffective": "Ingen användare har den här behörigheten ännu.",
"admin.permissions.noMembers": "Inte tilldelad till någon ännu.",
"admin.permissions.pagination": "Sidnavigering för behörigheter",
"admin.permissions.revoke": "Återkalla",
"admin.permissions.searchLabel": "Sök behörigheter",
"admin.permissions.searchPlaceholder": "Sök på behörighetens namn…",
"admin.permissions.title": "Behörigheter",
"admin.permissions.validation.member": "Välj en användare eller grupp att tilldela behörigheten till.",
"admin.permissions.validation.name": "Behörighetsnamn använder små bokstäver, siffror, bindestreck och understreck.",
"admin.unavailable.message": "{{what}} är inte konfigurerat i den här installationen.", "admin.unavailable.message": "{{what}} är inte konfigurerat i den här installationen.",
"admin.unavailable.title": "Administrationen är otillgänglig", "admin.unavailable.title": "Administrationen är otillgänglig",
+62
View File
@@ -0,0 +1,62 @@
// The manifest's own invariants. A route gating on a permission the manifest doesn't declare is
// silent: bootstrap seeds only declared names, so the demo admin would simply 403 on that screen
// with nothing in the logs to explain it. Pin the two halves against each other here.
import assert from "node:assert/strict";
import { test } from "node:test";
import { isValidPermissionName } from "#plugin-api";
import manifest from "./plugin.ts";
const routes = manifest.routes ?? [];
const declared = (manifest.permissions ?? []).map((p) => p.name);
test("every route is gated, and gates on a permission the manifest declares", () => {
assert.ok(routes.length > 0);
for (const route of routes) {
assert.equal(route.public, undefined, `${route.method} ${route.path} must not be public`);
assert.ok(route.permission, `${route.method} ${route.path} has no permission`);
assert.ok(declared.includes(route.permission!), `${route.method} ${route.path} gates on undeclared ${route.permission}`);
}
});
test("the manifest declares no permission it never gates on", () => {
const gated = new Set(routes.map((r) => r.permission));
for (const name of declared) assert.ok(gated.has(name), `declared but unused: ${name}`);
});
// A nav permission is a plain string the host matches against the JWT claim: a typo ("user:read")
// passes discovery's shape check and silently hides that menu item forever. Same silent-failure
// class the route checks above close, so close it on the nav side too.
test("every nav permission is one the manifest declares", () => {
const navPermissions: string[] = [];
const walk = (nodes: typeof manifest.nav): void => {
for (const node of nodes ?? []) {
if (node.permission != null) navPermissions.push(node.permission);
walk(node.children);
}
};
walk(manifest.nav);
assert.equal(navPermissions.length, 3);
for (const name of navPermissions) assert.ok(declared.includes(name), `nav gates on undeclared ${name}`);
});
test("every declared permission is <resource>:<action>, and reads and writes are split per resource", () => {
for (const name of declared) assert.ok(isValidPermissionName(name), name); // the host's rule, not a copy of it
// Three screens × read/write. There is deliberately no `permissions:` pair: permissions are
// declared in plugin code, so holding one is edited on the user or group that holds it.
assert.deepEqual([...declared].sort(), [
"groups:read", "groups:write",
"oauth2-clients:read", "oauth2-clients:write",
"users:read", "users:write",
]);
});
test("GET routes gate on read and mutations on write, so a reader can open a screen but not change it", () => {
// …except a write-intent GET — a create form or a delete-confirm page, which exists only to start a
// write. Those gate on `:write` so a reader is refused there rather than at the submit.
const writeIntent = (path: string): boolean => path.endsWith("/new") || path.endsWith("/delete");
for (const route of routes) {
const action = route.method === "GET" && !writeIntent(route.path) ? "read" : "write";
assert.ok(route.permission?.endsWith(`:${action}`), `${route.method} ${route.path}${route.permission}`);
}
assert.equal(routes.filter((r) => r.method === "GET" && writeIntent(r.path)).length, 6); // 2 per screen
});
+51 -42
View File
@@ -1,4 +1,4 @@
// Admin example plugin: the Users / Groups / Roles / OAuth2-clients screens for running the system. // Admin example plugin: the Users / Groups / OAuth2-clients screens for running the system.
// These used to ship inside the core; they were extracted here so a fresh clone has no built-in admin // These used to ship inside the core; they were extracted here so a fresh clone has no built-in admin
// GUI. Copy this folder to plugins/admin (then restart) to enable it — see README → Quick start. // GUI. Copy this folder to plugins/admin (then restart) to enable it — see README → Quick start.
// //
@@ -8,58 +8,67 @@
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "#plugin-api"; import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "#plugin-api";
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts"; import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsRemoveMember } from "./admin-groups.ts"; import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsPermissions, groupsRemoveMember } from "./admin-groups.ts";
import { rolesAddMember, rolesCreate, rolesDelete, rolesDeleteConfirm, rolesDetail, rolesList, rolesNewForm, rolesRemoveMember } from "./admin-permissions.ts"; import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersPermissions, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersRecovery, usersState, usersUpdate } from "./admin-users.ts"; import { ADMIN_NAV, actionForMethod, type AdminAction, type AdminResource, permissionName } from "./admin-shared.ts";
import { ADMIN_NAV, ADMIN_PERMISSION } from "./admin-shared.ts";
// Every admin route is gated by the one `admin` permission — the host redirects an anonymous visitor // One route factory per screen: a GET gates on `<resource>:read` and a POST on `<resource>:write`,
// to /login, gives a signed-in non-admin the 403 page, and filters the nav the same way. Handlers are // derived through the same two helpers the in-handler guard uses, so the table below cannot drift
// thin and keyed on ctx.params (the host extracts :id / :name), the idiomatic per-route style. // from it. The host redirects an anonymous visitor to /login, gives a signed-in user missing the
const r = (method: HttpMethod, path: string, handler: RouteHandler): Route => ({ handler, method, path, permission: ADMIN_PERMISSION }); // permission the 403 page, and filters the nav the same way. Handlers are thin and keyed on
// ctx.params (the host extracts :id / :name), the idiomatic per-route style.
// `action` overrides the method's default for a *write-intent GET* — a create form or a
// delete-confirm page, which exists only to start a write and so refuses a reader rather than
// rendering a form whose submit would 403. The handler's own guard takes the same override.
const on = (resource: AdminResource) => (method: HttpMethod, path: string, handler: RouteHandler, action?: AdminAction): Route =>
({ handler, method, path, permission: permissionName(resource, action ?? actionForMethod(method)) });
const users = on("users");
const groups = on("groups");
const clients = on("oauth2-clients");
export default definePlugin({ export default definePlugin({
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
nav: [ADMIN_NAV], nav: [ADMIN_NAV],
permissions: [{ description: "Administer users, groups, permissions, and OAuth2 clients", name: ADMIN_PERMISSION }], permissions: [
{ description: "View users and the permissions they hold", name: "users:read" },
{ description: "Create, edit and delete users, and grant them permissions", name: "users:write" },
{ description: "View groups, their members and the permissions they hold", name: "groups:read" },
{ description: "Create and delete groups, and change their members and permissions", name: "groups:write" },
{ description: "View OAuth2 clients", name: "oauth2-clients:read" },
{ description: "Register and delete OAuth2 clients", name: "oauth2-clients:write" },
],
routes: [ routes: [
// Users // Users
r("GET", "/users", usersList), users("GET", "/users", usersList),
r("POST", "/users", usersCreate), users("POST", "/users", usersCreate),
r("GET", "/users/new", usersNewForm), users("GET", "/users/new", usersNewForm, "write"),
r("GET", "/users/:id", usersEditForm), users("GET", "/users/:id", usersEditForm),
r("POST", "/users/:id", usersUpdate), users("POST", "/users/:id", usersUpdate),
r("POST", "/users/:id/state", usersState), users("POST", "/users/:id/state", usersState),
r("GET", "/users/:id/delete", usersDeleteConfirm), users("GET", "/users/:id/delete", usersDeleteConfirm, "write"),
r("POST", "/users/:id/delete", usersDelete), users("POST", "/users/:id/delete", usersDelete),
r("POST", "/users/:id/recovery", usersRecovery), users("POST", "/users/:id/recovery", usersRecovery),
users("POST", "/users/:id/permissions", usersPermissions),
// Groups // Groups
r("GET", "/groups", groupsList), groups("GET", "/groups", groupsList),
r("POST", "/groups", groupsCreate), groups("POST", "/groups", groupsCreate),
r("GET", "/groups/new", groupsNewForm), groups("GET", "/groups/new", groupsNewForm, "write"),
r("GET", "/groups/:name", groupsDetail), groups("GET", "/groups/:name", groupsDetail),
r("POST", "/groups/:name/members", groupsAddMember), groups("POST", "/groups/:name/members", groupsAddMember),
r("GET", "/groups/:name/delete", groupsDeleteConfirm), groups("GET", "/groups/:name/delete", groupsDeleteConfirm, "write"),
r("POST", "/groups/:name/delete", groupsDelete), groups("POST", "/groups/:name/delete", groupsDelete),
r("POST", "/groups/:name/members/delete", groupsRemoveMember), groups("POST", "/groups/:name/members/delete", groupsRemoveMember),
// Roles groups("POST", "/groups/:name/permissions", groupsPermissions),
r("GET", "/permissions", rolesList),
r("POST", "/permissions", rolesCreate),
r("GET", "/permissions/new", rolesNewForm),
r("GET", "/permissions/:name", rolesDetail),
r("POST", "/permissions/:name/members", rolesAddMember),
r("GET", "/permissions/:name/delete", rolesDeleteConfirm),
r("POST", "/permissions/:name/delete", rolesDelete),
r("POST", "/permissions/:name/members/delete", rolesRemoveMember),
// OAuth2 clients // OAuth2 clients
r("GET", "/clients", clientsList), clients("GET", "/clients", clientsList),
r("POST", "/clients", clientsCreate), clients("POST", "/clients", clientsCreate),
r("GET", "/clients/new", clientsNewForm), clients("GET", "/clients/new", clientsNewForm, "write"),
r("GET", "/clients/:id", clientsDetail), clients("GET", "/clients/:id", clientsDetail),
r("GET", "/clients/:id/delete", clientsDeleteConfirm), clients("GET", "/clients/:id/delete", clientsDeleteConfirm, "write"),
r("POST", "/clients/:id/delete", clientsDelete), clients("POST", "/clients/:id/delete", clientsDelete),
], ],
}); });
@@ -3,7 +3,7 @@
shell. Doubles as the post-register page when `created`/`secret` are set. shell. Doubles as the post-register page when `created`/`secret` are set.
%><% %><%
const nav = include("partials/nav-tree", { nodes: chrome.nav }); const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/client-detail-body", { client: model.client, created: model.created, csrfToken: chrome.csrfToken, del: model.delete, secret: model.secret }); const body = include("partials/client-detail-body", { canWrite: model.canWrite, client: model.client, created: model.created, csrfToken: chrome.csrfToken, del: model.delete, secret: model.secret });
-%> -%>
<%- include("partials/shell", { <%- include("partials/shell", {
body, body,
+3 -2
View File
@@ -1,12 +1,13 @@
<%# <%#
OAuth2 clients admin list: apps that log in *through* us (Hydra). Same building blocks as OAuth2 clients admin list: apps that log in *through* us (Hydra). Same building blocks as
the Permissions screen, around the shell, backed by live Hydra OAuth2 clients (admin-clients.ts). the Groups screen, around the shell, backed by live Hydra OAuth2 clients (admin-clients.ts).
%><% %><%
const nav = include("partials/nav-tree", { nodes: chrome.nav }); const nav = include("partials/nav-tree", { nodes: chrome.nav });
const filters = include("partials/filter-bar", model.filterBar); const filters = include("partials/filter-bar", model.filterBar);
const table = include("partials/data-table", model.table); const table = include("partials/data-table", model.table);
const pager = include("partials/pagination", model.pagination); const pager = include("partials/pagination", model.pagination);
const actions = '<a class="btn btn-primary" href="' + localeHref("/admin/clients/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.clients.registerClient") + '</a>'; // 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>';
-%> -%>
<%- include("partials/shell", { <%- include("partials/shell", {
actions, actions,
@@ -2,7 +2,7 @@
Group admin detail / membership page: the group-detail body in the app shell. Group admin detail / membership page: the group-detail body in the app shell.
%><% %><%
const nav = include("partials/nav-tree", { nodes: chrome.nav }); const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/group-detail-body", { add: model.add, csrfToken: model.csrfToken, del: model.delete, error: model.error, group: model.group, members: model.members }); const body = include("partials/group-detail-body", { add: model.add, canWrite: model.canWrite, csrfToken: model.csrfToken, del: model.delete, error: model.error, group: model.group, members: model.members, permissions: model.permissions });
-%> -%>
<%- include("partials/shell", { <%- include("partials/shell", {
body, body,
+2 -1
View File
@@ -6,7 +6,8 @@
const filters = include("partials/filter-bar", model.filterBar); const filters = include("partials/filter-bar", model.filterBar);
const table = include("partials/data-table", model.table); const table = include("partials/data-table", model.table);
const pager = include("partials/pagination", model.pagination); const pager = include("partials/pagination", model.pagination);
const actions = '<a class="btn btn-primary" href="' + localeHref("/admin/groups/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.groups.new") + '</a>'; // 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>';
-%> -%>
<%- include("partials/shell", { <%- include("partials/shell", {
actions, actions,
@@ -31,8 +31,10 @@
<dt><%= t("admin.clients.field.redirectUris") %></dt><dd><% if (c.redirectUris.length) { %><ul class="plain-list"><% c.redirectUris.forEach((u) => { %><li><%= u %></li><% }) %></ul><% } else { %>—<% } %></dd> <dt><%= t("admin.clients.field.redirectUris") %></dt><dd><% if (c.redirectUris.length) { %><ul class="plain-list"><% c.redirectUris.forEach((u) => { %><li><%= u %></li><% }) %></ul><% } else { %>—<% } %></dd>
</dl> </dl>
</section> </section>
<% if (locals.canWrite !== false) { -%>
<section class="form-card admin-actions" aria-label="<%= t("admin.clients.title") %>"> <section class="form-card admin-actions" aria-label="<%= t("admin.clients.title") %>">
<p class="field-hint"><%= t("admin.clients.rereg") %></p> <p class="field-hint"><%= t("admin.clients.rereg") %></p>
<a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.clients.delete") %></a> <a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.clients.delete") %></a>
</section> </section>
<% } -%>
</div> </div>
@@ -21,13 +21,14 @@
<% if (members.rows.length) { -%> <% if (members.rows.length) { -%>
<div class="table-wrap"><table class="table"><caption class="sr-only"><%= t("admin.groups.membersOf", { name: group.name }) %></caption><thead><tr><th scope="col"><%= t("admin.common.member") %></th><th scope="col"><%= t("admin.common.type") %></th><th class="col-actions" scope="col"><span class="sr-only"><%= t("table.actions") %></span></th></tr></thead><tbody> <div class="table-wrap"><table class="table"><caption class="sr-only"><%= t("admin.groups.membersOf", { name: group.name }) %></caption><thead><tr><th scope="col"><%= t("admin.common.member") %></th><th scope="col"><%= t("admin.common.type") %></th><th class="col-actions" scope="col"><span class="sr-only"><%= t("table.actions") %></span></th></tr></thead><tbody>
<% members.rows.forEach((m) => { -%> <% members.rows.forEach((m) => { -%>
<tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %></span></td><td class="col-actions"><form method="post" action="<%= localeHref(members.action) %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg><%= t("common.remove") %></button></form></td></tr> <tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %></span></td><td class="col-actions"><% if (locals.canWrite !== false) { %><form method="post" action="<%= localeHref(members.action) %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg><%= t("common.remove") %></button></form><% } %></td></tr>
<% }) -%> <% }) -%>
</tbody></table></div> </tbody></table></div>
<% } else { -%> <% } else { -%>
<p class="cell-muted"><%= t("admin.groups.noMembers") %></p> <p class="cell-muted"><%= t("admin.groups.noMembers") %></p>
<% } -%> <% } -%>
</section> </section>
<% if (locals.canWrite !== false) { -%>
<section class="form-card" aria-labelledby="add-h"> <section class="form-card" aria-labelledby="add-h">
<h2 class="card-title" id="add-h"><%= t("admin.groups.addMember") %></h2> <h2 class="card-title" id="add-h"><%= t("admin.groups.addMember") %></h2>
<% if (add.options.length) { -%> <% if (add.options.length) { -%>
@@ -36,7 +37,13 @@
<p class="cell-muted"><%= t("admin.groups.allMembers") %></p> <p class="cell-muted"><%= t("admin.groups.allMembers") %></p>
<% } -%> <% } -%>
</section> </section>
<% } -%>
<% if (locals.permissions) { -%>
<%- include("partials/permission-picker", { csrfToken: csrf, permissions: locals.permissions }) %>
<% } -%>
<% if (locals.canWrite !== false) { -%>
<section class="form-card admin-actions" aria-label="<%= t("admin.groups.actions") %>"> <section class="form-card admin-actions" aria-label="<%= t("admin.groups.actions") %>">
<a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.groups.delete") %></a> <a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.groups.delete") %></a>
</section> </section>
<% } -%>
</div> </div>
@@ -1,57 +0,0 @@
<%#
Admin permission detail body, captured into the shell content slot. Config:
permission { name }
members { action, rows: { kind:"group"|"identity", label, subject }[] } action = revoke endpoint
effective { label }[] users who hold the permission (expand)
add { action, options: {label,value}[] } action = assign endpoint
del { action } delete the whole permission
csrfToken, error?
%><%
const permission = locals.permission;
const members = locals.members;
const effective = locals.effective;
const add = locals.add;
const del = locals.del;
const csrf = locals.csrfToken;
-%>
<div class="form-page">
<% if (locals.error) { -%>
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%>
<section class="form-card" aria-labelledby="members-h">
<h2 class="card-title" id="members-h"><%= t("admin.permissions.assignedTo") %></h2>
<% if (members.rows.length) { -%>
<div class="table-wrap"><table class="table"><caption class="sr-only"><%= t("admin.groups.membersOf", { name: permission.name }) %></caption><thead><tr><th scope="col"><%= t("admin.common.member") %></th><th scope="col"><%= t("admin.common.type") %></th><th class="col-actions" scope="col"><span class="sr-only"><%= t("table.actions") %></span></th></tr></thead><tbody>
<% members.rows.forEach((m) => { -%>
<tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %></span></td><td class="col-actions"><form method="post" action="<%= localeHref(members.action) %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg><%= t("admin.permissions.revoke") %></button></form></td></tr>
<% }) -%>
</tbody></table></div>
<% } else { -%>
<p class="cell-muted"><%= t("admin.permissions.noMembers") %></p>
<% } -%>
</section>
<section class="form-card" aria-labelledby="effective-h">
<h2 class="card-title" id="effective-h"><%= t("admin.permissions.effective") %></h2>
<p class="field-hint"><%= t("admin.permissions.effectiveHint") %></p>
<% if (effective.length) { -%>
<ul class="plain-list">
<% effective.forEach((u) => { -%>
<li><span class="cell-strong"><%= u.label %></span></li>
<% }) -%>
</ul>
<% } else { -%>
<p class="cell-muted"><%= t("admin.permissions.noEffective") %></p>
<% } -%>
</section>
<section class="form-card" aria-labelledby="add-h">
<h2 class="card-title" id="add-h"><%= t("admin.permissions.assign") %></h2>
<% if (add.options.length) { -%>
<form class="inline-form" method="post" action="<%= localeHref(add.action) %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member"><%= t("admin.common.member") %></label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected><%= t("admin.common.chooseMember") %></option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg><%= t("admin.permissions.assignAction") %></button></form>
<% } else { -%>
<p class="cell-muted"><%= t("admin.permissions.allAssigned") %></p>
<% } -%>
</section>
<section class="form-card admin-actions" aria-label="<%= t("admin.permissions.actions") %>">
<a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.permissions.delete") %></a>
</section>
</div>
@@ -1,26 +0,0 @@
<%#
Admin permission create form body, captured into the shell content slot. Config:
form { action, csrfToken, submitLabel, cancelHref, nameField: field.ejs config,
memberOptions: {label,value}[], selectedMember }
error? string shown when a write was rejected
%><%
const form = locals.form;
-%>
<div class="form-page">
<% if (locals.error) { -%>
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%>
<form class="form-card" method="post" action="<%= localeHref(form.action) %>">
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
<%- include("partials/field", form.nameField) %>
<div class="field">
<label for="member"><%= t("admin.permissions.assignTo") %></label>
<span class="select"><select id="member" name="member" required><option value="" disabled<% if (!form.selectedMember) { %> selected<% } %>><%= t("admin.common.chooseMember") %></option><% form.memberOptions.forEach((o) => { %><option value="<%= o.value %>"<% if (form.selectedMember === o.value) { %> selected<% } %>><%= o.label %></option><% }) %></select></span>
<span class="field-hint">A permission exists once assigned; add more users or groups after creating it.</span>
</div>
<div class="form-actions">
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("common.cancel") %></a>
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
</div>
</form>
</div>
@@ -0,0 +1,49 @@
<%#
The permission picker, shared by the user-edit and group-detail pages. A fieldset of checkboxes —
one per permission the installed plugins declare — ticked where this user/group holds it. The whole
set posts back, so what is submitted IS the desired set of *direct* grants (see admin-grants.ts).
Two rows never post, by design: an `inherited` one (the grant comes from a group, so it is changed
there) and every row when `readOnly` (the viewer holds :read but not :write). Neither can be diffed
into an accidental revoke, because grantDiff compares against the direct grants only.
Locals: csrfToken, permissions ({ action, choices, empty, error, field, hint, inheritedNote, legend, pending, readOnly, submit }).
%>
<section class="form-card" aria-labelledby="permissions-h">
<h2 class="card-title" id="permissions-h"><%= permissions.legend %></h2>
<% if (permissions.error) { -%>
<%- include("partials/alert", { text: permissions.error, tone: "neg" }) %>
<% } -%>
<% if (permissions.empty) { -%>
<p class="cell-muted"><%= permissions.empty %></p>
<% } else { -%>
<p class="cell-muted"><%= permissions.hint %></p>
<% if (permissions.readOnly) { -%>
<fieldset class="check-group">
<legend class="sr-only"><%= permissions.legend %></legend>
<% permissions.choices.forEach((c) => { -%>
<label class="check"><input type="checkbox"<%= c.checked ? " checked" : "" %> disabled><span><%= c.description || c.name %></span><span class="cell-muted"><%= c.name %></span></label>
<% }) -%>
</fieldset>
<% } else { -%>
<form method="post" action="<%= localeHref(permissions.action) %>">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<fieldset class="check-group">
<legend class="sr-only"><%= permissions.legend %></legend>
<% permissions.choices.forEach((c) => { -%>
<label class="check"><input type="checkbox" name="<%= permissions.field %>" value="<%= c.name %>"<%= c.checked ? " checked" : "" %><%= c.inherited ? " disabled" : "" %>><span><%= c.description || c.name %></span><span class="cell-muted"><%= c.name %></span></label>
<% }) -%>
</fieldset>
<div class="form-actions">
<button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg><%= permissions.submit %></button>
</div>
</form>
<% } -%>
<% if (permissions.inheritedNote) { -%>
<p class="cell-muted"><%= permissions.inheritedNote %></p>
<% } -%>
<% if (permissions.pending) { -%>
<p class="cell-muted"><%= permissions.pending %></p>
<% } -%>
<% } -%>
</section>
@@ -23,10 +23,15 @@
<% }) -%> <% }) -%>
<div class="form-actions"> <div class="form-actions">
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("common.cancel") %></a> <a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("common.cancel") %></a>
<% if (locals.canWrite !== false) { -%>
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button> <button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
<% } -%>
</div> </div>
</form> </form>
<% if (edit) { -%> <% if (edit && locals.permissions) { -%>
<%- include("partials/permission-picker", { csrfToken: form.csrfToken, permissions: locals.permissions }) %>
<% } -%>
<% if (edit && locals.canWrite !== false) { -%>
<section class="form-card admin-actions" aria-label="<%= t("admin.users.actions") %>"> <section class="form-card admin-actions" aria-label="<%= t("admin.users.actions") %>">
<form method="post" action="<%= localeHref(edit.recoveryAction) %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-mail"/></svg><%= t("admin.users.recovery.generate") %></button></form> <form method="post" action="<%= localeHref(edit.recoveryAction) %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-mail"/></svg><%= t("admin.users.recovery.generate") %></button></form>
<form method="post" action="<%= localeHref(edit.stateAction) %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><%= edit.nextLabel %></button></form> <form method="post" action="<%= localeHref(edit.stateAction) %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><%= edit.nextLabel %></button></form>
@@ -1,16 +0,0 @@
<%#
Permission admin detail page: the permission-detail body (members · effective access) in the shell.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/permission-detail-body", { add: model.add, csrfToken: model.csrfToken, del: model.delete, effective: model.effective, error: model.error, members: model.members, permission: model.permission });
-%>
<%- include("partials/shell", {
body,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
@@ -1,16 +0,0 @@
<%#
Permission admin create page: the permission-form body captured into the app shell.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/permission-form-body", { error: model.error, form: model.form });
-%>
<%- include("partials/shell", {
body,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
@@ -1,21 +0,0 @@
<%#
Permissions admin list: the same building blocks as the Groups screen, around the shell, backed
by live Keto Permission subject sets (admin-permissions.ts). Filter/sort/page round-trip the URL.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const filters = include("partials/filter-bar", model.filterBar);
const table = include("partials/data-table", model.table);
const pager = include("partials/pagination", model.pagination);
const actions = '<a class="btn btn-primary" href="' + localeHref("/admin/permissions/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.permissions.new") + '</a>';
-%>
<%- include("partials/shell", {
actions,
body: filters + table + pager,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
+1 -1
View File
@@ -2,7 +2,7 @@
Users admin create/edit page: the user-form body captured into the app shell. Users admin create/edit page: the user-form body captured into the app shell.
%><% %><%
const nav = include("partials/nav-tree", { nodes: chrome.nav }); const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/user-form-body", { edit: model.edit, error: model.error, form: model.form, recovery: model.recovery }); const body = include("partials/user-form-body", { canWrite: model.canWrite, edit: model.edit, error: model.error, form: model.form, permissions: model.permissions, recovery: model.recovery });
-%> -%>
<%- include("partials/shell", { <%- include("partials/shell", {
body, body,
+2 -1
View File
@@ -6,7 +6,8 @@
const filters = include("partials/filter-bar", model.filterBar); const filters = include("partials/filter-bar", model.filterBar);
const table = include("partials/data-table", model.table); const table = include("partials/data-table", model.table);
const pager = include("partials/pagination", model.pagination); const pager = include("partials/pagination", model.pagination);
const actions = '<a class="btn btn-primary" href="' + localeHref("/admin/users/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.users.new") + '</a>'; // 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>';
-%> -%>
<%- include("partials/shell", { <%- include("partials/shell", {
actions, actions,
+1 -1
View File
@@ -18,7 +18,7 @@ function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; ve
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts"); const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage; const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
return { return {
chrome: CHROME, user: null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {}, chrome: CHROME, declaredPermissions: [], user: null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {},
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.permissions ?? [], t, url, query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.permissions ?? [], t, url,
verifyCsrf: opts.verifyCsrf ?? (() => true), verifyCsrf: opts.verifyCsrf ?? (() => true),
}; };
+8
View File
@@ -474,6 +474,14 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.check input, .radio input { width: 15px; height: 15px; accent-color: var(--accent); .check input, .radio input { width: 15px; height: 15px; accent-color: var(--accent);
margin: 0; cursor: pointer; } margin: 0; cursor: pointer; }
.check:hover, .radio:hover { color: var(--text); } .check:hover, .radio:hover { color: var(--text); }
/* A stacked group of .check rows in a <fieldset> — the right element for related checkboxes, but the
UA gives it a groove border, so reset it like .filter-field and .menu-field do. A disabled row is
still readable: it states a fact (a permission held through a group) rather than offering an edit. */
.check-group { border: 0; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.check-group .check { align-items: baseline; }
.check-group .check input:disabled { cursor: default; }
.check-group .check:has(input:disabled) { opacity: .7; cursor: default; }
.check-group .check .cell-muted { margin-left: auto; padding-left: 12px; font-size: var(--fz-xs); }
/* popover menu (language picker, profile, row kebab) — a <button popovertarget> and its [popover] /* popover menu (language picker, profile, row kebab) — a <button popovertarget> and its [popover]
panel, wrapped so the pair is one element in any layout. The browser owns open/close, and the top panel, wrapped so the pair is one element in any layout. The browser owns open/close, and the top
+28 -8
View File
@@ -30,14 +30,34 @@ test("permissionTuple grants a permission to user:<id> in the Permission namespa
}); });
}); });
test("seedPermissions unions ADMIN_PERMISSIONS (default 'admin') with the discovered plugins' declared permissions", () => { test("seedPermissions unions ADMIN_PERMISSIONS (empty by default) with the discovered plugins' declared permissions", () => {
// Clean clone: no ADMIN_PERMISSIONS, the scheduling plugin declares its two tokens → the demo admin // Clean clone: no ADMIN_PERMISSIONS, the scheduling plugin declares its two names → the demo admin
// gets exactly today's behaviour, but derived from discovery, not hardcoded in the host. // holds exactly what the installed plugins gate on, derived from discovery, not hardcoded here.
assert.deepEqual(seedPermissions(undefined, ["scheduling:read", "scheduling:write"]), ["admin", "scheduling:read", "scheduling:write"]); const names = (env: string | undefined, declared: string[]): string[] => seedPermissions(env, declared).permissions;
assert.deepEqual(seedPermissions(undefined, []), ["admin"]); // no plugins → just the base admin permission assert.deepEqual(names(undefined, ["scheduling:read", "scheduling:write"]), ["scheduling:read", "scheduling:write"]);
assert.deepEqual(seedPermissions("admin, ops ", ["inventory:read"]), ["admin", "ops", "inventory:read"]); // env trimmed + extended // No plugins → nothing to grant. A host-invented base would be a permission that gates nothing.
assert.deepEqual(seedPermissions("admin,scheduling:read", ["scheduling:read"]), ["admin", "scheduling:read"]); // dedup, no double grant assert.deepEqual(names(undefined, []), []);
assert.deepEqual(seedPermissions("admin,, ", [" scheduling:read ", ""]), ["admin", "scheduling:read"]); // blanks dropped, tokens trimmed (both sides) assert.deepEqual(names("ops:read, ops:write ", ["inventory:read"]), ["ops:read", "ops:write", "inventory:read"]); // env trimmed + extended
assert.deepEqual(names("scheduling:read", ["scheduling:read"]), ["scheduling:read"]); // dedup, no double grant
assert.deepEqual(names(",, ", [" scheduling:read ", ""]), ["scheduling:read"]); // blanks dropped, names trimmed (both sides)
});
// The regression this pins: an earlier revision *threw* here, so `ADMIN_PERMISSIONS=admin` — this
// setting's own default until 2026-08-05 — exited bootstrap 1, and bootstrap gates `web`, so a
// leftover variable bricked the whole stack on upgrade. Bootstrap must never refuse to start over
// operator env: drop what it can't use, report it, seed the rest.
test("seedPermissions drops an ADMIN_PERMISSIONS name that isn't <resource>:<action>, and never throws", () => {
const legacy = seedPermissions("admin", ["users:read"]);
assert.deepEqual(legacy, { ignored: ["admin"], permissions: ["users:read"] });
const mixed = seedPermissions("admin, ops:read ,Bad Name", ["users:read"]);
assert.deepEqual(mixed, { ignored: ["admin", "Bad Name"], permissions: ["ops:read", "users:read"] });
// Whatever an operator puts there, the boot survives it — that is the property, not the parsing.
for (const value of ["admin", "Bad Name", ":", "::", "a".repeat(200), ",,,", "ADMIN", "1"]) {
assert.doesNotThrow(() => seedPermissions(value, ["users:read"]), value);
assert.deepEqual(seedPermissions(value, ["users:read"]).permissions.includes("users:read"), true, value);
}
}); });
test("seedAdmin on a fresh stack creates the identity and grants every permission (one tuple each)", async () => { test("seedAdmin on a fresh stack creates the identity and grants every permission (one tuple each)", async () => {
+27 -11
View File
@@ -2,13 +2,14 @@
// kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`: // kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`:
// 1. generate the JWKS signing key if absent (committed dev key makes this a safety net); // 1. generate the JWKS signing key if absent (committed dev key makes this a safety net);
// 2. seed a demo admin (admin@plainpages.local / admin) in Kratos; // 2. seed a demo admin (admin@plainpages.local / admin) in Kratos;
// 3. grant it its permissions in Keto so menu/permission checks resolve out of the box — `admin` plus // 3. grant it its permissions in Keto so menu/permission checks resolve out of the box — every
// every discovered plugin's declared permission names, so a dropped-in plugin is usable by // discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so a dropped-in
// the demo admin with no host config edit (the host stays plugin-agnostic). // plugin is usable by the demo admin with no host config edit (the host stays plugin-agnostic).
// Then prints a first-run banner; fails loud on any unexpected upstream error. // Then prints a first-run banner; fails loud on any unexpected upstream error.
import { existsSync, writeFileSync } from "node:fs"; import { existsSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { discoverPlugins } from "../plugin-host/discovery.ts"; import { discoverPlugins } from "../plugin-host/discovery.ts";
import { declaredPermissions, isValidPermissionName } from "../plugin-host/plugin.ts";
import { generateJwks, type JwkSet } from "./gen-jwks.ts"; import { generateJwks, type JwkSet } from "./gen-jwks.ts";
import { createLogger, runWithLog, tracedFetch } from "../logger.ts"; import { createLogger, runWithLog, tracedFetch } from "../logger.ts";
@@ -28,13 +29,25 @@ export function permissionTuple(userId: string, permission: string) {
return { namespace: "Permission", object: permission, relation: "granted", subject_id: `user:${userId}` }; return { namespace: "Permission", object: permission, relation: "granted", subject_id: `user:${userId}` };
} }
// The permissions to grant the demo admin = the configured base (ADMIN_PERMISSIONS, default just `admin`) // The permissions to grant the demo admin = the configured base (ADMIN_PERMISSIONS, empty by default)
// unioned with every discovered plugin's declared permission names (a route/nav `permission` is a // unioned with every discovered plugin's declared permission names (a route/nav `permission` is a
// coarse permission — granted as a Keto `Permission:<token>#members` tuple). So the host names no plugin, yet a // coarse permission — granted as a Keto `Permission:<name>#granted` tuple). So the host names no plugin, yet a
// dropped-in plugin's tokens are seeded out of the box. Deduped, order-stable, blanks dropped. // dropped-in plugin's permissions are seeded out of the box. Deduped, order-stable, blanks dropped.
export function seedPermissions(adminRolesEnv: string | undefined, declaredPermissions: string[]): string[] { // The base is empty because permissions are `<resource>:<action>` and every one of them is owned by
// the plugin that gates on it — a host-invented default would gate nothing.
// ADMIN_PERMISSIONS is the one place an operator names a permission by hand, so it is held to the
// same `<resource>:<action>` rule discovery applies to a manifest — but *dropped with a warning*,
// never fatal. Fail-loud belongs at the manifest boundary, where a developer authored the mistake
// and can fix it; this is operator env, bootstrap gates `web`, and the whole stack must not refuse
// to start over a stale variable. `admin` was this setting's own default before 2026-08-05, so a
// value that bricks the boot is the *expected* leftover on any upgrade. The name it would have
// written gates nothing anyway. Declared names already passed the check at discovery.
export function seedPermissions(adminPermissionsEnv: string | undefined, declaredNames: string[]): { ignored: string[]; permissions: string[] } {
const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean); const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean);
return [...new Set([...clean((adminRolesEnv ?? "admin").split(",")), ...clean(declaredPermissions)])]; const configured = clean((adminPermissionsEnv ?? "").split(","));
const ignored = configured.filter((name) => !isValidPermissionName(name));
const valid = configured.filter((name) => isValidPermissionName(name));
return { ignored, permissions: [...new Set([...valid, ...clean(declaredNames)])] };
} }
// --- JWKS safety net ----------------------------------------------------------------- // --- JWKS safety net -----------------------------------------------------------------
@@ -143,10 +156,13 @@ async function main() {
await runWithLog(log, async () => { await runWithLog(log, async () => {
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key"); if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key");
// Seed `admin` (or ADMIN_PERMISSIONS) + every discovered plugin's declared permission names, so the // Seed every discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so the
// shipped example — and any dropped-in plugin — works for the demo admin without a host edit. // shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
const declared = (await discoverPlugins()).flatMap((p) => (p.permissions ?? []).map((d) => d.name)); const declared = declaredPermissions(await discoverPlugins()).map((decl) => decl.name);
const permissions = seedPermissions(env["ADMIN_PERMISSIONS"], declared); const { ignored, permissions } = seedPermissions(env["ADMIN_PERMISSIONS"], declared);
if (ignored.length > 0) {
log.warn("ignoring ADMIN_PERMISSIONS entries that are not <resource>:<action>", { ignored: ignored.join(", ") });
}
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local"; const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
const password = env["ADMIN_PASSWORD"] ?? "admin"; const password = env["ADMIN_PASSWORD"] ?? "admin";
const result = await seedAdmin({ const result = await seedAdmin({
+133 -82
View File
@@ -15,7 +15,7 @@ import { CSRF_COOKIE, issueCsrfToken } from "../auth/csrf.ts";
import { can, check, GuardError, requireSession } from "../auth/guards.ts"; import { can, check, GuardError, requireSession } from "../auth/guards.ts";
import { HydraError, type HydraAdmin, type OAuth2Client } from "../auth/hydra-admin.ts"; import { HydraError, type HydraAdmin, type OAuth2Client } from "../auth/hydra-admin.ts";
import { staticJwks } from "../auth/jwks.ts"; import { staticJwks } from "../auth/jwks.ts";
import type { ExpandTree, KetoClient, RelationTuple, SubjectSet } from "../auth/keto-client.ts"; import type { KetoClient, RelationTuple, SubjectSet } from "../auth/keto-client.ts";
import type { Identity, KratosAdmin } from "../auth/kratos-admin.ts"; import type { Identity, KratosAdmin } from "../auth/kratos-admin.ts";
import { KratosError, type Flow, type FlowType, type KratosPublic, type Session, type UiNode } from "../auth/kratos-public.ts"; import { KratosError, type Flow, type FlowType, type KratosPublic, type Session, type UiNode } from "../auth/kratos-public.ts";
import { SESSION_COOKIE } from "../auth/login.ts"; import { SESSION_COOKIE } from "../auth/login.ts";
@@ -871,11 +871,14 @@ async function adminHarness(t: TestContext, opts: AppOptions = {}) {
const token = issueCsrfToken(ADMIN_CSRF); const token = issueCsrfToken(ADMIN_CSRF);
const nowSec = Math.floor(Date.now() / 1000); const nowSec = Math.floor(Date.now() / 1000);
const cookie = (permissions: string[]) => `${SESSION_COOKIE}=${mintJwt({ email: "admin@x", exp: nowSec + 600, permissions, sub: "admin1" })}; ${CSRF_COOKIE}=${token}`; const cookie = (permissions: string[]) => `${SESSION_COOKIE}=${mintJwt({ email: "admin@x", exp: nowSec + 600, permissions, sub: "admin1" })}; ${CSRF_COOKIE}=${token}`;
const get = (path: string, permissions: string[] = ["admin"]) => fetch(url + path, { headers: { cookie: cookie(permissions) }, redirect: "manual" }); const get = (path: string, permissions: string[] = ADMIN_ALL) => fetch(url + path, { headers: { cookie: cookie(permissions) }, redirect: "manual" });
const post = (path: string, body: string) => const post = (path: string, body: string) =>
fetch(url + path, { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookie(["admin"]) }, method: "POST", redirect: "manual" }); fetch(url + path, { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookie(ADMIN_ALL) }, method: "POST", redirect: "manual" });
return { get, post, token, url }; return { get, post, token, url };
} }
// What the plugin itself declares — the harness holds every screen's read and write, so a screen
// test exercises the screen rather than the gate. assertAdminGate covers the refusals.
const ADMIN_ALL = (adminManifest.permissions ?? []).map((p) => p.name);
// Every admin route is gated: anonymous → /login, a signed-in non-admin → 403. // Every admin route is gated: anonymous → /login, a signed-in non-admin → 403.
async function assertAdminGate(url: string, get: (path: string, permissions?: string[]) => Promise<Response>, path: string) { async function assertAdminGate(url: string, get: (path: string, permissions?: string[]) => Promise<Response>, path: string) {
const anon = await fetch(url + path, { redirect: "manual" }); const anon = await fetch(url + path, { redirect: "manual" });
@@ -1105,11 +1108,28 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
await assertAdminGate(url, get, "/admin/users"); await assertAdminGate(url, get, "/admin/users");
// Nav: the admin plugin's section composes into the one global menu for an admin, and is filtered // Nav: the admin plugin's section composes into the one global menu, and each screen is filtered
// out for a signed-in non-admin (the gate on the section header) — proving the drop-in nav fragment. // 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.
assert.match(await (await get("/dashboard")).text(), /href="\/admin\/users"/); assert.match(await (await get("/dashboard")).text(), /href="\/admin\/users"/);
const usersOnlyNav = await (await get("/dashboard", ["users:read"])).text();
assert.match(usersOnlyNav, /href="\/admin\/users"/);
assert.doesNotMatch(usersOnlyNav, /href="\/admin\/groups"/);
assert.doesNotMatch(await (await get("/dashboard", ["scheduling:read"])).text(), /href="\/admin\/users"/); assert.doesNotMatch(await (await get("/dashboard", ["scheduling:read"])).text(), /href="\/admin\/users"/);
// 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.
assert.equal((await get("/admin/users", ["users:read"])).status, 200);
assert.equal((await get("/admin/groups", ["users:read", "users:write"])).status, 403);
const readOnlyPost = await fetch(url + "/admin/users", {
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}` },
method: "POST",
redirect: "manual",
});
assert.equal(readOnlyPost.status, 403);
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("/admin/users")).text();
assert.match(listHtml, /ada@example\.com/); assert.match(listHtml, /ada@example\.com/);
@@ -1218,107 +1238,138 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=user:${grace}`); await post("/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.
await post("/admin/groups/eng/permissions", `_csrf=${token}&permission=users%3Aread`);
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("/admin/groups/eng/delete")).text(), /Cancel/);
const del = await post("/admin/groups/eng/delete", `_csrf=${token}`); const del = await post("/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"), "/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
// 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"));
// 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("/admin/groups/Bad%20Name")).status, 404);
assert.equal((await get("/admin/groups/%ZZ")).status, 404); assert.equal((await get("/admin/groups/%ZZ")).status, 404);
}); });
// Built-in Roles admin screen: gate + list/create/assign/revoke/delete over HTTP // Granting permissions over HTTP, on the two screens that replaced the deleted Permissions screen.
// against a fake in-memory Keto whose `expand` mirrors Keto's transitive resolution, so the // The offered set is the host's catalog (ctx.declaredPermissions, from what the installed plugins
// effective-access view surfaces a user reachable only through a group. // declare), so the checkboxes are a fixed list and the POST is the desired state.
test("admin Roles screen: gate, list, create, assign user/group, effective access (expand), revoke, delete", async (t) => { test("admin permission grants: the picker offers the declared catalog, and a save is the desired set", async (t) => {
const ada = randomUUID(); const ada = randomUUID();
const grace = randomUUID(); const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
const identities: Identity[] = [ const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${ada}` }];
{ id: ada, schema_id: "default", state: "active", traits: { email: "ada@example.com" } }, const keto = fakeKeto(tuples);
{ id: grace, schema_id: "default", state: "active", traits: { email: "grace@example.com" } }, const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
]; const denylist = createDenylist();
// grace is in the `eng` group; `editor` is an existing permission whose only direct member is ada. const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin });
const tuples: RelationTuple[] = [
{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${grace}` }, // The user edit page renders one checkbox per declared permission, ticked where already held.
{ namespace: "Permission", object: "editor", relation: "granted", subject_id: `user:${ada}` }, const edit = await (await get(`/admin/users/${ada}`)).text();
]; for (const name of ["users:read", "users:write", "groups:read", "groups:write", "oauth2-clients:read", "oauth2-clients:write"]) {
// Mirror Keto's expand shape: the subject rides on `tuple`, set nodes carry members as children. assert.match(edit, new RegExp(`value="${name.replace(":", ":")}"`), name);
const expandSet = (set: SubjectSet): ExpandTree => ({ }
children: tuples assert.match(edit, /value="users:read"[^>]*checked/); // held → ticked
.filter((tp) => tp.namespace === set.namespace && tp.object === set.object && tp.relation === set.relation) assert.doesNotMatch(edit, /value="groups:write"[^>]*checked/); // not held → unticked
.map((tp) => (tp.subject_id ? { tuple: { namespace: "", object: "", relation: "", subject_id: tp.subject_id }, type: "leaf" } : expandSet(tp.subject_set!))),
tuple: { namespace: "", object: "", relation: "", subject_set: set }, // Save a new set: users:write is added, users:read is dropped — the POST is the whole truth.
type: "union", const saved = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&permission=users%3Awrite&permission=groups%3Aread`);
assert.equal(saved.status, 303);
assert.deepEqual(
tuples.filter((tp) => tp.subject_id === `user:${ada}`).map((tp) => tp.object).sort(),
["groups:read", "users:write"],
);
assert.equal(denylist.isRevoked(ada, 0), true); // a change to your own grants revokes live tokens
// A crafted POST can't grant something no plugin declares.
await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&permission=users%3Awrite&permission=superuser%3Aall`);
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.
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
await post("/admin/groups/eng/permissions", `_csrf=${token}&permission=groups%3Aread`);
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "groups:read" && tp.subject_set?.object === "eng"));
}); });
const keto = fakeKeto(tuples, { expand: async (set) => expandSet(set) });
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
const denylist = createDenylist(); // granting/revoking a *user's* permission revokes their live tokens (a group change is transitive → left to lag)
const { get, post, token, url } = await adminHarness(t, { denylist, keto, kratosAdmin });
await assertAdminGate(url, get, "/admin/permissions"); // Revoking your own grants can remove the last users:write on the deployment, and the instant-revoke
// hook lands it on the next request — recovery would be a curl against Keto. Guarded like
// self-deactivate and self-delete are. (`admin1` is the harness's own sub.)
test("admin permission grants: you can't revoke your own permissions, but you can still grant", async (t) => {
const identities: Identity[] = [{ id: "admin1", traits: { email: "you@example.com" } }];
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:write", relation: "granted", subject_id: "user:admin1" }];
const keto = fakeKeto(tuples);
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const { post, token } = await adminHarness(t, { keto, kratosAdmin });
// List: the existing permission shows + the "add" link. const refused = await post("/admin/users/admin1/permissions", `_csrf=${token}`); // every box cleared
const listHtml = await (await get("/admin/permissions")).text(); assert.equal(refused.status, 400);
assert.match(listHtml, /href="\/admin\/permissions\/editor"/); assert.match(await refused.text(), /lock yourself out/);
assert.match(listHtml, /href="\/admin\/permissions\/new"/); assert.ok(tuples.some((tp) => tp.object === "users:write" && tp.subject_id === "user:admin1"), "nothing was revoked");
// Create: a valid post writes the first-member tuple and redirects to the detail. // Granting yourself more is not a lockout, so it goes through.
assert.match(await (await get("/admin/permissions/new")).text(), /Create permission/); const granted = await post("/admin/users/admin1/permissions", `_csrf=${token}&permission=users%3Awrite&permission=groups%3Aread`);
const created = await post("/admin/permissions", `_csrf=${token}&name=viewer&member=user:${ada}`); assert.equal(granted.status, 303);
assert.equal(created.status, 303); assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === "user:admin1"));
assert.equal(created.headers.get("location"), "/admin/permissions/viewer"); });
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "viewer" && tp.subject_id === `user:${ada}`));
assert.equal(denylist.isRevoked(ada, 0), true); // assigning a permission to a user revokes their stale token so the grant lands now
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written. // The read/write split is only honest if the UI models it: a users:read holder must not be shown
const before = tuples.length; // buttons that 403 on submit. The gate already refuses them (asserted above); this is the affordance.
assert.equal((await post("/admin/permissions", `_csrf=${token}&name=Bad Name&member=user:${ada}`)).status, 400); test("admin screens render no write affordance for a read-only holder", async (t) => {
assert.equal((await post("/admin/permissions", `_csrf=${token}&name=editor&member=user:${ada}`)).status, 400); // already exists const ada = randomUUID();
assert.equal((await post("/admin/permissions", `name=x&member=user:${ada}`)).status, 403); const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
assert.equal(tuples.length, before); const keto = fakeKeto([{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` }]);
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
// Hydra is wired so the clients screen renders for real — without it the page is a 503 and the
// "no Register button" assertion below would pass without proving anything.
const reporting = { client_id: "existing", client_name: "Reporting" };
const hydra = stubHydra({ getClient: async (id) => (id === reporting.client_id ? reporting : null), listClients: async () => ({ clients: [reporting], nextPageToken: null }) });
const { get } = await adminHarness(t, { hydra, keto, kratosAdmin });
const readOnly = ["users:read", "groups:read"];
// Detail: ada (direct) is in the effective-access list; grace (only reachable via a group) is not const list = await (await get("/admin/users", readOnly)).text();
// yet — though grace appears elsewhere as an assignable candidate, so target the effective <li>. assert.doesNotMatch(list, /href="\/admin\/users\/new"/); // no "New user"
const effectiveLi = (email: string) => new RegExp(`<li><span class="cell-strong">${email.replace(".", "\\.")}`); assert.match(list, /ada@example\.com/); // but the list itself is there — that's the point of :read
const detail = await (await get("/admin/permissions/editor")).text();
assert.match(detail, effectiveLi("ada@example.com"));
assert.doesNotMatch(detail, effectiveLi("grace@example.com"));
// Assign the `eng` group to the permission → grace now holds it transitively (effective access via expand). // (The shell's own sign-out is a POST form, so assert on the affordances by name, not on <form>.)
await post("/admin/permissions/editor/members", `_csrf=${token}&member=group:eng`); const detail = await (await get(`/admin/users/${ada}`, readOnly)).text();
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "editor" && tp.subject_set?.object === "eng")); assert.doesNotMatch(detail, /Save changes/);
const withGroup = await (await get("/admin/permissions/editor")).text(); assert.doesNotMatch(detail, /Generate recovery code/);
assert.match(withGroup, effectiveLi("grace@example.com")); assert.doesNotMatch(detail, /Delete user/);
assert.doesNotMatch(detail, /Save permissions/);
assert.match(detail, /type="checkbox"[^>]*disabled/); // the permissions are shown, just not editable
// Revoke the group membership. const group = await (await get("/admin/groups/eng", readOnly)).text();
await post("/admin/permissions/editor/members/delete", `_csrf=${token}&member=group:eng`); assert.doesNotMatch(group, /Add a member/);
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.object === "editor" && tp.subject_set?.object === "eng")); assert.doesNotMatch(group, /Delete group/);
assert.doesNotMatch(group, /Save permissions/);
// Unassigning a *user* membership likewise revokes that user's live token, so the loss of access is immediate. // The OAuth2-clients screen is held to the same rule (it was the one this test was written to catch).
await post("/admin/permissions/editor/members", `_csrf=${token}&member=user:${grace}`); const clientsRes = await get("/admin/clients", ["oauth2-clients:read"]);
await post("/admin/permissions/editor/members/delete", `_csrf=${token}&member=user:${grace}`); assert.equal(clientsRes.status, 200); // a real render, not the capability-missing 503
assert.equal(denylist.isRevoked(grace, 0), true); const clients = await clientsRes.text();
assert.match(clients, /Reporting/); // the list is there — that's what :read buys
assert.doesNotMatch(clients, /href="\/admin\/clients\/new"/);
// 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();
assert.match(clientDetail, /Reporting/);
assert.doesNotMatch(clientDetail, /clients\/existing\/delete/);
// Delete the permission: a confirm step (GET) then the POST removes every member tuple, back to the list. // A write-intent GET — a create form or a delete-confirm — refuses a reader outright rather than
assert.match(await (await get("/admin/permissions/editor/delete")).text(), /Cancel/); // rendering a form whose submit would 403.
const del = await post("/admin/permissions/editor/delete", `_csrf=${token}`); for (const path of ["/admin/users/new", "/admin/groups/new", `/admin/users/${ada}/delete`, "/admin/groups/eng/delete"]) {
assert.equal(del.status, 303); assert.equal((await get(path, readOnly)).status, 403, path);
assert.equal(del.headers.get("location"), "/admin/permissions"); }
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.object === "editor")); assert.equal((await get("/admin/clients/new", ["oauth2-clients:read"])).status, 403);
// Self-protection: the admin permission can't be deleted, nor can you revoke your own admin (sub admin1). // A writer sees the affordances the reader didn't.
tuples.push({ namespace: "Permission", object: "admin", relation: "granted", subject_id: "user:admin1" }); const writable = await (await get(`/admin/users/${ada}`, ["users:read", "users:write"])).text();
assert.equal((await post("/admin/permissions/admin/delete", `_csrf=${token}`)).status, 400); assert.match(writable, /Save changes/);
assert.ok(tuples.some((tp) => tp.object === "admin")); assert.match(writable, /Save permissions/);
assert.equal((await post("/admin/permissions/admin/members/delete", `_csrf=${token}&member=user:admin1`)).status, 400);
assert.ok(tuples.some((tp) => tp.object === "admin" && tp.subject_id === "user:admin1"));
// An invalid permission name in the path → 404; malformed %-encoding doesn't 500.
assert.equal((await get("/admin/permissions/Bad%20Name")).status, 404);
assert.equal((await get("/admin/permissions/%ZZ")).status, 404);
}); });
// Built-in OAuth2 clients admin screen: gate + list/register/detail/delete over HTTP against an // Built-in OAuth2 clients admin screen: gate + list/register/detail/delete over HTTP against an
+6 -3
View File
@@ -25,7 +25,7 @@ import type { KratosPublic } from "../auth/kratos-public.ts";
import { createLogger, type Log, requestLogger, runWithLog } from "../logger.ts"; import { createLogger, type Log, requestLogger, runWithLog } from "../logger.ts";
import { remintSession } from "../auth/login.ts"; import { remintSession } from "../auth/login.ts";
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts"; import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
import type { Plugin, RouteHandler, RouteResult } from "../plugin-host/plugin.ts"; import { declaredPermissions, type Plugin, type RouteHandler, type RouteResult } from "../plugin-host/plugin.ts";
import type { SystemCapabilities } from "../plugin-host/system.ts"; import type { SystemCapabilities } from "../plugin-host/system.ts";
import { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts"; import { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts";
import { buildAuthRoutes } from "../auth/routes.ts"; import { buildAuthRoutes } from "../auth/routes.ts";
@@ -99,6 +99,9 @@ export function createApp(options: AppOptions = {}): Server {
const homePlugin = plugins.find((p): p is Plugin & { home: RouteHandler } => typeof p.home === "function"); const homePlugin = plugins.find((p): p is Plugin & { home: RouteHandler } => typeof p.home === "function");
const dashboardPlugin = plugins.find((p): p is Plugin & { dashboard: RouteHandler } => typeof p.dashboard === "function"); const dashboardPlugin = plugins.find((p): p is Plugin & { dashboard: RouteHandler } => typeof p.dashboard === "function");
// Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free). // Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free).
// The permission catalog is a property of the installed plugin set, so it is computed once at
// wiring rather than per request.
const permissionCatalog = declaredPermissions(plugins);
const anyRequestHooks = plugins.some((p) => p.hooks?.onRequest); const anyRequestHooks = plugins.some((p) => p.hooks?.onRequest);
const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse); const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse);
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR; const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
@@ -297,9 +300,9 @@ export function createApp(options: AppOptions = {}): Server {
// base context (no route params yet); reused for the built-in routes. A plugin-owned render // base context (no route params yet); reused for the built-in routes. A plugin-owned render
// (a landing slot, a hook short-circuit, a plugin route) gets `contextFor(id)` instead, so its // (a landing slot, a hook short-circuit, a plugin route) gets `contextFor(id)` instead, so its
// own catalog is what `ctx.t` reads. // own catalog is what `ctx.t` reads.
const ctx = buildContext(req, res, { chrome, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) }); const ctx = buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
const contextFor = (pluginId: string, params?: Record<string, string>): RequestContext => const contextFor = (pluginId: string, params?: Record<string, string>): RequestContext =>
buildContext(req, res, { chrome, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) }); buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) });
renderPage = viewsFor(ctx); renderPage = viewsFor(ctx);
// Plugin onRequest hooks run before routing and may short-circuit the request. // Plugin onRequest hooks run before routing and may short-circuit the request.
+7
View File
@@ -1,5 +1,6 @@
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import type { PageChrome } from "../ui/chrome.ts"; // type-only: no runtime import, so no cycle import type { PageChrome } from "../ui/chrome.ts"; // type-only: no runtime import, so no cycle
import type { PermissionDecl } from "../plugin-host/plugin.ts"; // type-only
import type { SystemCapabilities } from "../plugin-host/system.ts"; // type-only import type { SystemCapabilities } from "../plugin-host/system.ts"; // type-only
import { DEFAULT_LOCALE } from "../i18n/catalog.ts"; import { DEFAULT_LOCALE } from "../i18n/catalog.ts";
import { ENGLISH } from "../i18n/english.ts"; import { ENGLISH } from "../i18n/english.ts";
@@ -37,6 +38,10 @@ export interface RequestContext {
// log; `log.fetch(url)` for an upstream call (a client span continuing the trace). Correlates by // log; `log.fetch(url)` for an upstream call (a client span continuing the trace). Correlates by
// requestId. Additive, stable per the contract; defaults to a silent logger off the request path. // requestId. Additive, stable per the contract; defaults to a silent logger off the request path.
log: Log; log: Log;
// Every permission the installed plugins declare, deduped and sorted — the fixed list an admin
// screen offers when granting one. Pairs with `permissions` below: this is what *exists*, that is
// what *this user holds*. Empty when no installed plugin declares any.
declaredPermissions: readonly PermissionDecl[];
params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id } params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id }
permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check
query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q") query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q")
@@ -61,6 +66,7 @@ export interface BuildContextOptions {
// ctx.chrome (a json/redirect handler, or the public "/" with a standalone home, pays nothing). // ctx.chrome (a json/redirect handler, or the public "/" with a standalone home, pays nothing).
// The host's factory is memoised, so the menu composes at most once per request across contexts. // The host's factory is memoised, so the menu composes at most once per request across contexts.
chrome?: () => PageChrome; chrome?: () => PageChrome;
declaredPermissions?: readonly PermissionDecl[];
user?: User | null; user?: User | null;
locale?: string; locale?: string;
localeHref?: (href: string) => string; localeHref?: (href: string) => string;
@@ -89,6 +95,7 @@ export function buildContext(
let chromeMemo: PageChrome | undefined; // resolve the factory at most once per context let chromeMemo: PageChrome | undefined; // resolve the factory at most once per context
return { return {
get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); }, get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
declaredPermissions: options.declaredPermissions ?? [],
user, user,
locale: options.locale ?? DEFAULT_LOCALE, locale: options.locale ?? DEFAULT_LOCALE,
localeHref: options.localeHref ?? ((href) => href), localeHref: options.localeHref ?? ((href) => href),
+19 -2
View File
@@ -50,8 +50,13 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
{ name: "non-function dashboard", files: { "weirddash/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: "nope" };` }, match: /weirddash.*dashboard.*function/s }, { name: "non-function dashboard", files: { "weirddash/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: "nope" };` }, match: /weirddash.*dashboard.*function/s },
{ name: "reserved dashboard id shadows the gated dashboard", files: { "dashboard/plugin.ts": full("dashboard") }, match: /dashboard.*reserved/s }, { name: "reserved dashboard id shadows the gated dashboard", files: { "dashboard/plugin.ts": full("dashboard") }, match: /dashboard.*reserved/s },
{ name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ }, { name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ },
{ name: "a route marked public AND permission is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", public: true, permission: "x", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*permission/s }, { name: "a route marked public AND permission is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", public: true, permission: "x:read", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*permission/s },
{ name: "a nav node marked public AND permission is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", public: true, permission: "x" }] };` }, match: /contranav.*public.*permission/s }, { name: "a nav node marked public AND permission is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", public: true, permission: "x:read" }] };` }, match: /contranav.*public.*permission/s },
// A permission name is <resource>:<action> wherever the manifest mentions one. Enforced here, not
// only in the admin GUI, so it holds for a plugin installed without that GUI.
{ name: "a route gating on a bare word", files: { "bare/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", permission: "admin", handler: () => ({ html: "x" }) }] };` }, match: /bare.*admin.*<resource>:<action>/s },
{ name: "a nav node gating on a bare word", files: { "barenav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", permission: "admin" }] };` }, match: /barenav.*admin.*<resource>:<action>/s },
{ name: "a declared permission that is a bare word", files: { "baredecl/plugin.ts": `export default { apiVersion: "1.0.0", permissions: [{ name: "admin" }] };` }, match: /baredecl.*admin.*<resource>:<action>/s },
{ name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "b" }) };` }, match: /home/ }, { name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "b" }) };` }, match: /home/ },
{ name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ }, { name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ },
]; ];
@@ -62,6 +67,18 @@ for (const c of badCases) {
}); });
} }
// The reader of a discovery failure is usually an operator whose plugins/ copy went stale after an
// upgrade, not the author of the manifest — so the message has to carry the remedy, not just the
// rule. A pre-existing `plugins/admin` gating on the old `admin` permission is exactly this case.
test("a discovery failure tells the operator their plugins/ copy may just be out of date", async (t) => {
const dir = scaffold(t, { "admin/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/users", permission: "admin", handler: () => ({ html: "x" }) }] };` });
await assert.rejects(discoverPlugins({ dir }), (err: Error) => {
assert.match(err.message, /gates on "admin"/); // what is wrong
assert.match(err.message, /re-copy it/); // …and what to do about it
return true;
});
});
test("a route + nav node may be marked public and load fine", async (t) => { test("a route + nav node may be marked public and load fine", async (t) => {
const dir = scaffold(t, { "pub/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ href: "/pub", id: "n", label: "N", public: true }], routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "x" }) }] };` }); const dir = scaffold(t, { "pub/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ href: "/pub", id: "n", label: "N", public: true }], routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "x" }) }] };` });
const plugins = await discoverPlugins({ dir }); const plugins = await discoverPlugins({ dir });
+34 -2
View File
@@ -7,7 +7,7 @@
import { existsSync, readdirSync } from "node:fs"; import { existsSync, readdirSync } from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url"; import { fileURLToPath, pathToFileURL } from "node:url";
import { checkApiVersion, findConflicts, isValidPluginId, RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest } from "./plugin.ts"; import { checkApiVersion, findConflicts, isValidPermissionName, isValidPluginId, RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest } from "./plugin.ts";
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
@@ -65,7 +65,14 @@ export async function discoverPlugins(options: DiscoverOptions = {}): Promise<Pl
} }
if (errors.length) { if (errors.length) {
throw new Error(`Plugin discovery failed:\n${errors.map((e) => ` - ${e}`).join("\n")}`); // `plugins/` is a drop-in mount the operator owns, so the reader of this message often didn't
// write the manifest — they copied it. Tightening a contract rule breaks those copies at boot,
// and the rule alone doesn't tell them the remedy is one command.
throw new Error(
`Plugin discovery failed:\n${errors.map((e) => ` - ${e}`).join("\n")}\n` +
`A plugin under plugins/ is your own copy. If it came from examples/, re-copy it — ` +
`the host contract may have changed since (see README → Upgrading).`,
);
} }
return plugins; return plugins;
} }
@@ -100,6 +107,20 @@ function shapeError(manifest: PluginManifest): string | null {
} }
const navContradiction = findPublicNavContradiction(manifest.nav); const navContradiction = findPublicNavContradiction(manifest.nav);
if (navContradiction) return navContradiction; if (navContradiction) return navContradiction;
// Every permission name the manifest mentions — gated on or declared — must be `<resource>:<action>`.
// A bare word names a role, and roles are groups here (README → Naming a permission).
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
if (route?.permission != null && !isValidPermissionName(route.permission)) {
return `route "${route.method} ${route.path}" gates on "${route.permission}"; a permission name is <resource>:<action>, e.g. "things:read"`;
}
}
for (const decl of Array.isArray(manifest.permissions) ? manifest.permissions : []) {
if (decl?.name == null || !isValidPermissionName(decl.name)) {
return `declared permission "${decl?.name}" is not <resource>:<action>, e.g. "things:read"`;
}
}
const navPermission = findInvalidNavPermission(manifest.nav);
if (navPermission) return navPermission;
return null; return null;
} }
@@ -113,6 +134,17 @@ function findPublicNavContradiction(nodes: PluginManifest["nav"]): string | null
return null; return null;
} }
function findInvalidNavPermission(nodes: PluginManifest["nav"]): string | null {
for (const node of Array.isArray(nodes) ? nodes : []) {
if (node?.permission != null && !isValidPermissionName(node.permission)) {
return `nav node "${node.label ?? node.id ?? "?"}" gates on "${node.permission}"; a permission name is <resource>:<action>, e.g. "things:read"`;
}
const inChild = findInvalidNavPermission(node?.children);
if (inChild) return inChild;
}
return null;
}
function messageOf(err: unknown): string { function messageOf(err: unknown): string {
return err instanceof Error ? err.message : String(err); return err instanceof Error ? err.message : String(err);
} }
+1 -1
View File
@@ -4,7 +4,7 @@
// contract boundary in code — the host may refactor any other src/* freely as long as it holds, so // contract boundary in code — the host may refactor any other src/* freely as long as it holds, so
// a plugin should import from here, never reach into deeper modules. See README.md → Building plugins. // a plugin should import from here, never reach into deeper modules. See README.md → Building plugins.
export { definePlugin } from "./plugin.ts"; export { definePlugin, isValidPermissionName } from "./plugin.ts";
export type { HttpMethod, Plugin, PluginHooks, PluginManifest, PermissionDecl, Route, RouteHandler, RouteResult } from "./plugin.ts"; export type { HttpMethod, Plugin, PluginHooks, PluginManifest, PermissionDecl, Route, RouteHandler, RouteResult } from "./plugin.ts";
export type { RequestContext, User } from "../http/context.ts"; export type { RequestContext, User } from "../http/context.ts";
export type { PageChrome } from "../ui/chrome.ts"; export type { PageChrome } from "../ui/chrome.ts";
+24
View File
@@ -2,9 +2,11 @@ import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { import {
checkApiVersion, checkApiVersion,
declaredPermissions,
definePlugin, definePlugin,
findConflicts, findConflicts,
HOST_API_VERSION, HOST_API_VERSION,
isValidPermissionName,
isValidPluginId, isValidPluginId,
parseSemver, parseSemver,
RESERVED_PLUGIN_IDS, RESERVED_PLUGIN_IDS,
@@ -47,6 +49,28 @@ test("isValidPluginId accepts lowercase/digits/dashes anywhere and rejects every
} }
}); });
test("isValidPermissionName requires <resource>:<action> — a bare word names a role, and roles are groups", () => {
for (const ok of ["users:read", "scheduling:write", "oauth2-clients:read", "team-a:a1_b9", "invoices:approve"]) {
assert.ok(isValidPermissionName(ok), ok);
}
// "admin" is the shape this rule exists to stop: it says who someone is, not what they may do.
for (const bad of ["admin", "", "Users:read", "users:", ":read", "users:read:extra", "a b:read", "-bad:read", "a/b:read", `${"a".repeat(60)}:read`]) {
assert.ok(!isValidPermissionName(bad), bad);
}
});
test("declaredPermissions is the catalog: every plugin's declarations, deduped by name and sorted", () => {
const a: Plugin = { apiVersion: "1.0.0", id: "a", permissions: [{ description: "Write things", name: "things:write" }, { description: "Read things", name: "things:read" }] };
const b: Plugin = { apiVersion: "1.0.0", id: "b", permissions: [{ description: "b's wording", name: "things:read" }, { name: "orders:read" }] };
const c: Plugin = { apiVersion: "1.0.0", id: "c" }; // declaring none is fine
const catalog = declaredPermissions([a, b, c]);
assert.deepEqual(catalog.map((p) => p.name), ["orders:read", "things:read", "things:write"]);
// A shared name is legitimate (findConflicts only warns); the first declaration wins its wording.
assert.equal(catalog.find((p) => p.name === "things:read")?.description, "Read things");
assert.deepEqual(declaredPermissions([]), []);
});
test("parseSemver follows the semver core, rejecting ranges, prefixes, leading zeros and missing parts", () => { test("parseSemver follows the semver core, rejecting ranges, prefixes, leading zeros and missing parts", () => {
assert.deepEqual(parseSemver("1.2.3"), { major: 1, minor: 2, patch: 3 }); assert.deepEqual(parseSemver("1.2.3"), { major: 1, minor: 2, patch: 3 });
assert.deepEqual(parseSemver("1.2.3-rc.1+build.5"), { major: 1, minor: 2, patch: 3 }); // prerelease/build tolerated, ignored assert.deepEqual(parseSemver("1.2.3-rc.1+build.5"), { major: 1, minor: 2, patch: 3 }); // prerelease/build tolerated, ignored
+25 -2
View File
@@ -37,12 +37,35 @@ export interface Route {
} }
// A Keto Permission this plugin gates on — declared for docs/seeding. Permission names are a shared // A Keto Permission this plugin gates on — declared for docs/seeding. Permission names are a shared
// global namespace (so an operator grants them once in Keto); namespace as `<id>:<action>`. // global namespace (so an operator grants them once in Keto) and are always `<resource>:<action>`
// `scheduling:read`, `users:write`. A bare word names who someone is rather than what they may do,
// which is a role, and roles are groups here (README → Users, groups & permissions).
export interface PermissionDecl { export interface PermissionDecl {
description?: string; description?: string;
name: string; name: string;
} }
// `<resource>:<action>`, each half lowercase alphanumeric with dashes/underscores inside. The 64-char
// cap keeps a name usable as a Keto object and a URL path segment. Enforced at discovery like every
// other manifest rule, so the convention holds for plugins the admin GUI never touches.
const PERMISSION_NAME = /^[a-z0-9][a-z0-9_-]*:[a-z0-9][a-z0-9_-]*$/;
export function isValidPermissionName(name: string): boolean {
return name.length <= 64 && PERMISSION_NAME.test(name);
}
// Every permission the installed plugins declare, deduped by name and sorted — the fixed list the
// admin screens offer when granting. Permissions are authored in code, never invented in the GUI, so
// this *is* the catalog; a name in Keto that no plugin declares gates nothing and is not offered.
// First declaration of a name wins its description (shared names are legitimate, findConflicts warns).
export function declaredPermissions(plugins: Plugin[]): PermissionDecl[] {
const byName = new Map<string, PermissionDecl>();
for (const plugin of plugins) {
for (const decl of plugin.permissions ?? []) if (!byName.has(decl.name)) byName.set(decl.name, decl);
}
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
}
// Optional hooks on system actions. Crash-isolation is a non-goal — a throwing hook fails loud. // Optional hooks on system actions. Crash-isolation is a non-goal — a throwing hook fails loud.
export interface PluginHooks { export interface PluginHooks {
onBoot?: () => Promise<void> | void; // after discovery, before the server listens onBoot?: () => Promise<void> | void; // after discovery, before the server listens
@@ -186,7 +209,7 @@ export function findConflicts(plugins: Plugin[]): PluginConflict[] {
collect(plugins, (plugin, push) => { collect(plugins, (plugin, push) => {
for (const decl of plugin.permissions ?? []) push(decl.name); for (const decl of plugin.permissions ?? []) push(decl.name);
}).forEach((owners, name) => { }).forEach((owners, name) => {
if (owners.length > 1) out.push({ kind: "permission", level: "warn", message: `permission "${name}" declared by ${uniq(owners).length} plugins; namespace as "<id>:<action>" unless shared on purpose`, plugins: uniq(owners) }); if (owners.length > 1) out.push({ kind: "permission", level: "warn", message: `permission "${name}" declared by ${uniq(owners).length} plugins; pick a more specific "<resource>" unless shared on purpose`, plugins: uniq(owners) });
}); });
return out; return out;
+2 -2
View File
@@ -24,7 +24,7 @@ const adminLike: Plugin = {
{ href: "/admin/users", id: "users", label: "Users" }, { href: "/admin/users", id: "users", label: "Users" },
{ href: "/admin/groups", id: "groups", label: "Groups" }, { href: "/admin/groups", id: "groups", label: "Groups" },
], ],
icon: "i-shield", id: "admin", label: "Admin", permission: "admin", icon: "i-shield", id: "admin", label: "Admin", permission: "users:read",
}], }],
}; };
@@ -58,7 +58,7 @@ test("a permission holder sees the Dashboard link + plugin nav; current path ope
}); });
test("a gated section (like the admin plugin) shows to a holder; a sub-path marks its base leaf current", () => { test("a gated section (like the admin plugin) shows to a holder; a sub-path marks its base leaf current", () => {
const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, plugins: [adminLike], user: { email: "a@b.c", id: "u1", permissions: ["admin"] } }); const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, plugins: [adminLike], user: { email: "a@b.c", id: "u1", permissions: ["users:read"] } });
const admin = chrome.nav.find((n) => n.label === "Admin")!; const admin = chrome.nav.find((n) => n.label === "Admin")!;
assert.ok(admin); // gated section visible to an admin assert.ok(admin); // gated section visible to an admin
assert.equal(admin.open, true); // ancestor of the current leaf opened assert.equal(admin.open, true); // ancestor of the current leaf opened
+6 -6
View File
@@ -27,7 +27,7 @@ test("composeNav merges fragments, filters by permission, and emits clean render
test("composeNav drops gated subtrees, empty headers, and (with no permissions) all gated nodes", () => { test("composeNav drops gated subtrees, empty headers, and (with no permissions) all gated nodes", () => {
// A header the user can't reach takes its whole subtree, even visible children. // A header the user can't reach takes its whole subtree, even visible children.
const gatedHeader: NavNode[][] = [[ const gatedHeader: NavNode[][] = [[
{ id: "admin", label: "Admin", permission: "admin", children: [{ href: "/u", id: "u", label: "Users" }] }, { id: "admin", label: "Admin", permission: "users:read", children: [{ href: "/u", id: "u", label: "Users" }] },
{ id: "free", label: "Free", children: [{ href: "/d", id: "d", label: "Docs" }] }, { id: "free", label: "Free", children: [{ href: "/d", id: "d", label: "Docs" }] },
]]; ]];
assert.deepEqual(composeNav(gatedHeader, {}, []), [ assert.deepEqual(composeNav(gatedHeader, {}, []), [
@@ -36,8 +36,8 @@ test("composeNav drops gated subtrees, empty headers, and (with no permissions)
// A pure header whose children are all filtered is dropped; a header with an href survives as a leaf. // A pure header whose children are all filtered is dropped; a header with an href survives as a leaf.
const emptyHeader: NavNode[][] = [[ const emptyHeader: NavNode[][] = [[
{ id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", permission: "x" }] }, { id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", permission: "x:read" }] },
{ href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", permission: "y" }] }, { href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", permission: "y:read" }] },
]]; ]];
assert.deepEqual(composeNav(emptyHeader, {}, []), [{ href: "/hub", label: "Hub" }]); assert.deepEqual(composeNav(emptyHeader, {}, []), [{ href: "/hub", label: "Hub" }]);
@@ -66,7 +66,7 @@ test("composeNav applies the override: rename, group, order, hide (then filters)
{ href: "/a", id: "a", label: "Alpha" }, { href: "/a", id: "a", label: "Alpha" },
{ href: "/b", id: "b", label: "Beta" }, { href: "/b", id: "b", label: "Beta" },
{ href: "/c", id: "c", label: "Gamma" }, { href: "/c", id: "c", label: "Gamma" },
{ href: "/secret", id: "secret", label: "Secret", permission: "root" }, { href: "/secret", id: "secret", label: "Secret", permission: "secrets:read" },
]]; ]];
const tree = composeNav(base, { const tree = composeNav(base, {
@@ -74,9 +74,9 @@ test("composeNav applies the override: rename, group, order, hide (then filters)
groups: [{ icon: "i-box", id: "grp", label: "Group", open: true, children: ["b", "c"] }], // wrap b+c groups: [{ icon: "i-box", id: "grp", label: "Group", open: true, children: ["b", "c"] }], // wrap b+c
order: ["grp", "a"], // grp before the lone a order: ["grp", "a"], // grp before the lone a
hide: ["c"], // remove c from inside the group hide: ["c"], // remove c from inside the group
}, ["root"]); }, ["secrets:read"]);
// grp emitted (b only, c hidden), reordered before a; Secret kept now that permission "root" is present. // grp emitted (b only, c hidden), reordered before a; Secret kept now that permission "secrets:read" is present.
assert.deepEqual(tree, [ assert.deepEqual(tree, [
{ icon: "i-box", label: "Group", open: true, children: [{ href: "/b", label: "Beta" }] }, { icon: "i-box", label: "Group", open: true, children: [{ href: "/b", label: "Beta" }] },
{ href: "/a", label: "First" }, { href: "/a", label: "First" },
+11 -4
View File
@@ -2,10 +2,15 @@
## Unfinnished work ## Unfinnished work
- [ ] node_modules gets installed straight to the root folder with root permissions, it should at the very least be same owner as the one running the docker process, or built inside the docker image.
- [ ] Add a way to configure plugins directly when installing. Most reasonable is an .env file in the plugin folder, I think, but I am open to suggestions. - [ ] Add a way to configure plugins directly when installing. Most reasonable is an .env file in the plugin folder, I think, but I am open to suggestions.
- [ ] Rename the plugin "admin" to something less generic, like "auth-admin" or "users-groups-admin". - [ ] Rename the plugin "admin" to something less generic, like "auth-admin" or "users-groups-admin".
- [ ] Document permissions format so it is folled going forward: <resource>:<action>, for example scheduling:read. Permission "admin" does not match this, and needs to be users:read, users:write, groups:read, groups:write. - [ ] Guard the group paths to self-lockout, or accept them explicitly. The self-revoke guard covers only your own *direct* grants on the Users screen; unticking a permission on a group you belong to, removing yourself from that group, or deleting it can all still strip your own effective access with no warning. Same scope the deleted Permissions screen had, and recorded in AGENTS.md as a known gap — the robust fix is a "last effective holder" check, which needs a reverse Keto query. Raised by the stability review 2026-08-05.
- [ ] Permissions should be a list in code. Since no permissions exists in the database out of the box, but there are a fixed number of permissions in the plugins that the end consumer and user of plain pages can use, these permissions must surface to the UI somehow. The effects is that the permissions page should be deleted completely, and the users and groups pages should gain the functionality to add permissions to their things instead, provided the user have the right permissiosn to do so, of course. Run the product reviewer agent on this todo also. - [ ] The permission picker has no concurrency baseline, so two operators editing the same user/group silently discard each other's change (standard lost-update on a set-based form — and the natural "two of us are onboarding the new hire" workflow produces exactly it). Sketch: post the rendered set as a hidden baseline; if it no longer matches Keto, re-render with "this changed while you had the page open" rather than applying. Fits the existing "the form is the whole truth" model instead of fighting it. Raised by the product review 2026-08-05.
- [ ] A grant whose plugin was uninstalled is invisible and unremovable in the GUI. `grantDiff` deliberately never revokes an undeclared name (so an unrelated save can't drop it), but nothing *shows* it either — so it can't be audited or cleaned, and reinstalling that plugin silently reactivates access nobody remembers granting. Sketch: a read-only "held, but no installed plugin offers this" list with a remove action. Raised by the product review 2026-08-05.
- [ ] A plugin may gate a route on a permission it never declares — declaring stays optional on purpose (mandatory declaration would warn on the legitimate cross-plugin sharing case). The cost is a dead end: the picker is built from declarations only, so that route is ungrantable from the GUI with no boot error, no warning, and a permanent 403 as the operator's only clue. Sketch: a discovery *warning* (not an error) naming the gated-but-undeclared permission. Raised by the product review 2026-08-05.
- [ ] Saving permissions gives no confirmation, and a partial failure is silent. `applyGrants` loops writes then deletes with no transaction, so a Keto error midway leaves a half-applied set behind the generic error page; and a *successful* save is indistinguishable from "nothing changed" (PRG back to the same page, checkboxes as the only feedback). The `alert alert-pos` pattern the recovery-code banner uses is already available. Raised by the product review 2026-08-05.
- [ ] Add the read-only operator to README → Overview's personas. `users:read` now makes a support/helpdesk account possible for the first time, and it is a distinct persona from the three listed (end user, non-technical user, plugin author) — the one whose screens must render without write affordances. Writing it down makes read-only rendering a stated requirement rather than something the next reviewer rediscovers. Raised by the product review 2026-08-05.
- [ ] The seeded admin@plainpages.local are assigned twice to the permission "admin", should only be one, right? (the "admin" permission name can be switched after previous todos have been done) - [ ] The seeded admin@plainpages.local are assigned twice to the permission "admin", should only be one, right? (the "admin" permission name can be switched after previous todos have been done)
- [ ] In Playwright tests, try different resolutions and sizes, from BIG desktop down to tiny phone. - [ ] In Playwright tests, try different resolutions and sizes, from BIG desktop down to tiny phone.
- [ ] Decide whether `e2e-tests/` should be typechecked. It is outside `tsconfig.include`, so the gate never checks the most logic-bearing file in it (`console-guard.ts`) — Playwright strips its types without checking them. Including it needs the DOM lib and `@playwright/test` present wherever `npm run typecheck` runs, which today is the `web` image that installs neither. Raised by review 2026-08-05. - [ ] Decide whether `e2e-tests/` should be typechecked. It is outside `tsconfig.include`, so the gate never checks the most logic-bearing file in it (`console-guard.ts`) — Playwright strips its types without checking them. Including it needs the DOM lib and `@playwright/test` present wherever `npm run typecheck` runs, which today is the `web` image that installs neither. Raised by review 2026-08-05.
@@ -24,8 +29,8 @@
Prioritized. Overall verdict: architecture is sound (contract-first plugin API, functional core/imperative shell, strong test seams); these are refinements. Prioritized. Overall verdict: architecture is sound (contract-first plugin API, functional core/imperative shell, strong test seams); these are refinements.
- [ ] **MEDIUM — Add complexity/method-size static analysis to the CI gate.** Only `tsc --strict` today; a size/complexity rule would have caught the `app.ts` growth. Also when wiring CI/CD: keep the merge gate fast (typecheck + units + Ory-free `visual` suite; heavy e2e suites required-but-separate) and make the pipeline the only path to a published image (build once at tag, promote). - [ ] **MEDIUM — Add complexity/method-size static analysis to the CI gate.** Only `tsc --strict` today; a size/complexity rule would have caught the `app.ts` growth. Also when wiring CI/CD: keep the merge gate fast (typecheck + units + Ory-free `visual` suite; heavy e2e suites required-but-separate) and make the pipeline the only path to a published image (build once at tag, promote).
- [ ] **MEDIUM — De-duplicate `examples/plugins/admin/admin-groups.ts` and `admin-permissions.ts` (~80% identical).** Same "Keto membership object admin" concept twice; extract a parameterized helper keyed on `{ namespace, base, labels, columns }`, leave permissions' effective-access view as the only delta. Matters extra because this is the reference plugin people copy. - [ ] **LOW — The users list offers a pencil "Edit" row action to a `users:read` holder.** The link is harmless (it opens the read-only detail page), but the label contradicts what the reader can do. Needs `canWrite` threaded into `listTable` plus a `common.view` core catalog key and an `i-eye` entry in `ICON_NAMES` — a core registry change for a cosmetic fix, so it was left out of the permission-naming branch. Raised by the stability review 2026-08-05.
- [ ] **MEDIUM→LOW — Add a list-page view-model helper in `src/ui/`.** Every list screen (users, groups, permissions, shifts) hand-rewrites the same ~40 lines bridging `parseListQuery`/`paginate` to the EJS partials; at minimum a `buildPaginationModel(page, hrefFor)` block. - [ ] **MEDIUM→LOW — Add a list-page view-model helper in `src/ui/`.** Every list screen (users, groups, clients, shifts) hand-rewrites the same ~40 lines bridging `parseListQuery`/`paginate` to the EJS partials; at minimum a `buildPaginationModel(page, hrefFor)` block.
- [ ] **LOW→MEDIUM — Retire `src/ui/shell-context.ts`.** `ShellModel`/`buildShellContext` has one consumer left (dashboard) and duplicates `PageChrome` on almost every field, incl. identical brand-assembly in `chrome.ts` and `shell-context.ts`. Fold the dashboard onto `ctx.chrome` + title/breadcrumbs; keep `shellUser` as the shared primitive. - [ ] **LOW→MEDIUM — Retire `src/ui/shell-context.ts`.** `ShellModel`/`buildShellContext` has one consumer left (dashboard) and duplicates `PageChrome` on almost every field, incl. identical brand-assembly in `chrome.ts` and `shell-context.ts`. Fold the dashboard onto `ctx.chrome` + title/breadcrumbs; keep `shellUser` as the shared primitive.
- [ ] **LOW — Fix stale doc references to removed `docs/plugin-contract.md`** in `views/index.ejs` (user-visible dashboard text; also links /scheduling as if pre-installed) and `examples/plugins/scheduling/views/shifts.ejs`. - [ ] **LOW — Fix stale doc references to removed `docs/plugin-contract.md`** in `views/index.ejs` (user-visible dashboard text; also links /scheduling as if pre-installed) and `examples/plugins/scheduling/views/shifts.ejs`.
- [ ] **LOW — Decide (once) on a `ctx.system` facade.** `#plugin-api` exposes raw Ory client shapes, so an Ory client refactor is a major `apiVersion` bump. AGENTS.md accepts this; revisit only if external plugin authors appear. Record the decision. - [ ] **LOW — Decide (once) on a `ctx.system` facade.** `#plugin-api` exposes raw Ory client shapes, so an Ory client refactor is a major `apiVersion` bump. AGENTS.md accepts this; revisit only if external plugin authors appear. Record the decision.
@@ -33,6 +38,8 @@ Prioritized. Overall verdict: architecture is sound (contract-first plugin API,
## Finnished work ## Finnished work
- [x] Document permissions format so it is folled going forward: <resource>:<action>, for example scheduling:read. Permission "admin" does not match this, and needs to be users:read, users:write, groups:read, groups:write. (README → [Naming a permission](README.md#naming-a-permission) is the one home for the rule, and the host *enforces* it at discovery — `isValidPermissionName` in `src/plugin-host/plugin.ts`, checked by `shapeError` over every route/nav `permission` and every declared name — so a badly-named permission stops the boot like any other bad manifest, for every plugin rather than only ones the admin GUI touches. `admin` is gone, split per screen into `users:`, `groups:` and `oauth2-clients:` × `read`/`write`. The read/write split is real, not cosmetic: `users:read` opens the list and is refused on every POST, and the Admin nav header lost its own gate so each screen is filtered by its own `:read` — hold none of the three and `composeNav` drops the emptied header (which needs the header to carry no `href`, now asserted). Two things had to be fixed to get here. The permission path validator was the *group* regex with no colon, so `/admin/permissions/scheduling:read` already 404'd. And `ADMIN_PERMISSIONS` defaulting to empty exposed that `bootstrap` never bind-mounted `plugins/` at all — it discovered only the image's empty copy, so a dropped-in plugin's permissions were never seeded; the mount lives in `compose.override.yml` (dev-only, mirroring `web`'s `.:/app`) because the base file gives both services the same baked copy and a base-file mount would collide with the e2e stacks that bind plugins *inside* that path. Quick start now says `docker compose up -d`, which re-runs the one-shot. Verified end to end on a live stack.)
- [x] Permissions should be a list in code. Since no permissions exists in the database out of the box, but there are a fixed number of permissions in the plugins that the end consumer and user of plain pages can use, these permissions must surface to the UI somehow. The effects is that the permissions page should be deleted completely, and the users and groups pages should gain the functionality to add permissions to their things instead, provided the user have the right permissiosn to do so, of course. Run the product reviewer agent on this todo also. (The host collects every installed plugin's declarations into one catalog — `declaredPermissions()``ctx.declaredPermissions`, deduped and sorted, computed once at wiring — and that catalog *is* the fixed list. The Permissions screen is deleted outright: its module, tests, three views, two partials and 29 catalog keys per locale. Users and Groups each gained a checkbox list of the catalog, ticked where held; the whole set posts back, so what is submitted is the desired state and `grantDiff` turns it into grants + revokes. Two properties earn their tests: a crafted POST cannot grant a name no plugin declares, and a held-but-undeclared name — left over from an uninstalled plugin — is never silently revoked by an unrelated save, since the picker only speaks for what it showed. A user's own change revokes their live tokens; a group's reaches members at their next re-mint, the documented transitive lag. Keto stays optional on the Users screen: without it the page still lists and edits, minus the picker. Maintainer's call 2026-08-05 to keep the OAuth2-clients screen and gate it `oauth2-clients:read/write` — permissions and OAuth2 are orthogonal, scopes say what an *app* may see and permissions what a *user* may do, so the screen only ever needed *a* gate.)
- [x] In Playwright tests, check for warnings and errors in all browsers on all the steps. If they exist, that is a failure we need to fix. (Every spec takes its `test` from `e2e-tests/console-guard.ts`, which watches every page a test opens — `console.error`, `console.warning`, and uncaught page errors — and fails the test that provoked one, at whatever step. The bar is zero rather than a curated tolerance list: the app ships no client JavaScript, so a message means a broken sub-resource, a rejected attribute, or an engine refusing a feature. Two narrow escapes, both explicit: the COOP header Chromium drops because the e2e stacks serve plain http over container hostnames (a deployment serves https, where it applies), and per-test `allowConsole(/…/)` — used once, by the 404 spec, whose own navigation Chromium and WebKit log. **All browsers** is now literal for the Ory-free suites: `visual.spec.ts` + `language.spec.ts` run in Chromium, Firefox *and* WebKit — the per-test `@engines` tag is gone, and screenshots are written per project so the three don't fight over one file — which is what makes an engine-specific message visible at all. The Ory-backed suites write users, groups and sessions to one shared backend, so they stay on Chromium; widening them needs a stack per engine. Nothing in the app had to be fixed: the sweep found only the two above. Verified by negative control — an injected `console.warn` failed the test in all three engines and an injected `console.error` failed on full-flow's shared serial page — which also caught the guard registering that page twice. `src/e2e-console-guard.test.ts` locks the wiring in the *unit* gate, since a spec importing `test` straight from Playwright would run unwatched and green.) - [x] In Playwright tests, check for warnings and errors in all browsers on all the steps. If they exist, that is a failure we need to fix. (Every spec takes its `test` from `e2e-tests/console-guard.ts`, which watches every page a test opens — `console.error`, `console.warning`, and uncaught page errors — and fails the test that provoked one, at whatever step. The bar is zero rather than a curated tolerance list: the app ships no client JavaScript, so a message means a broken sub-resource, a rejected attribute, or an engine refusing a feature. Two narrow escapes, both explicit: the COOP header Chromium drops because the e2e stacks serve plain http over container hostnames (a deployment serves https, where it applies), and per-test `allowConsole(/…/)` — used once, by the 404 spec, whose own navigation Chromium and WebKit log. **All browsers** is now literal for the Ory-free suites: `visual.spec.ts` + `language.spec.ts` run in Chromium, Firefox *and* WebKit — the per-test `@engines` tag is gone, and screenshots are written per project so the three don't fight over one file — which is what makes an engine-specific message visible at all. The Ory-backed suites write users, groups and sessions to one shared backend, so they stay on Chromium; widening them needs a stack per engine. Nothing in the app had to be fixed: the sweep found only the two above. Verified by negative control — an injected `console.warn` failed the test in all three engines and an injected `console.error` failed on full-flow's shared serial page — which also caught the guard registering that page twice. `src/e2e-console-guard.test.ts` locks the wiring in the *unit* gate, since a spec importing `test` straight from Playwright would run unwatched and green.)
- [x] Don't run tests when only markdown files in the root have changed. (Already shipped for *any* `*.md`, anywhere in the tree — `ci.sh`'s `docs_only()` no-ops the gate when every path changed since `main` ends in `.md`, and the workflow still pushes the commit-hash image so a merged docs commit stays releasable. Kept wider than "in the root" deliberately: no test reads a markdown file, so a nested `examples/plugins/admin/README.md` edit is as safe to skip as `README.md`, and narrowing it would spend the full gate on one. What was actually broken was rename detection — `git mv src/app.ts notes.md` names only the destination under `git diff --name-only`, and collapses to a single `R src/app.ts -> notes.md` line under `git status --porcelain`, so **moving code onto a `.md` path skipped the gate over a source file that was gone**. Both channels now pass `--no-renames`; verified against a scratch repo across ten scenarios — docs-only, mixed, empty diff, dirty tree, untracked code, deleted doc, and the rename staged *and* committed — the last two failing before the fix and passing after. `src/ci-gate.test.ts` locks both flags; it stays a text guard because the test image is `node:alpine` with neither `git` nor `bash`.) - [x] Don't run tests when only markdown files in the root have changed. (Already shipped for *any* `*.md`, anywhere in the tree — `ci.sh`'s `docs_only()` no-ops the gate when every path changed since `main` ends in `.md`, and the workflow still pushes the commit-hash image so a merged docs commit stays releasable. Kept wider than "in the root" deliberately: no test reads a markdown file, so a nested `examples/plugins/admin/README.md` edit is as safe to skip as `README.md`, and narrowing it would spend the full gate on one. What was actually broken was rename detection — `git mv src/app.ts notes.md` names only the destination under `git diff --name-only`, and collapses to a single `R src/app.ts -> notes.md` line under `git status --porcelain`, so **moving code onto a `.md` path skipped the gate over a source file that was gone**. Both channels now pass `--no-renames`; verified against a scratch repo across ten scenarios — docs-only, mixed, empty diff, dirty tree, untracked code, deleted doc, and the rename staged *and* committed — the last two failing before the fix and passing after. `src/ci-gate.test.ts` locks both flags; it stays a text guard because the test image is `node:alpine` with neither `git` nor `bash`.)
- [x] The little menues, like when choosing language or clicking my username, they do not dissapear when clicking outside them, I must click the original trigger or choose something. See if there are more modern ways of handling this with HTML and CSS. I think there is a modal-thing or something? (The modern thing is the **Popover API**. All three popup menus — language picker, profile, row kebab — are now a `<button popovertarget>` plus a `[popover]` panel instead of `<details>`/`<summary>`, so the browser owns open/close: clicking anywhere outside dismisses one, `Esc` dismisses it and returns focus to the trigger, opening one closes the others, and the panel sits in the top layer where `.table-wrap`'s `overflow` can no longer clip a row kebab. Placement is CSS anchor positioning; the panel needs `position-anchor: auto` to bind to the button that opened it — a bare `anchor()` resolves to nothing in Chromium, Firefox and WebKit alike, measured in all three before picking the approach. `data-table.ejs` stopped hand-rolling its kebab and calls the `menu` partial, so the pattern lives in one file. Each panel is named by its caller (`locale-menu`, `profile-menu`, `row-actions-1`) and the partial fails loud without an `id`, since `popovertarget` is an idref — generated ids were tried first and dropped for being unreadable and nondeterministic. `<details>` stays in the nav tree, where it means disclosure rather than popup. A browser older than the popover API flows each panel inline under its trigger, so Sign out is never stranded behind an inert button. `e2e-tests/visual.spec.ts` drives the whole behaviour — opens, anchored to its trigger, outside-click, Esc — and runs in Firefox and WebKit as well as Chromium, because CSS anchor positioning is the newest thing in the app and every popup rests on it. Decisions recorded in AGENTS.md.) - [x] The little menues, like when choosing language or clicking my username, they do not dissapear when clicking outside them, I must click the original trigger or choose something. See if there are more modern ways of handling this with HTML and CSS. I think there is a modal-thing or something? (The modern thing is the **Popover API**. All three popup menus — language picker, profile, row kebab — are now a `<button popovertarget>` plus a `[popover]` panel instead of `<details>`/`<summary>`, so the browser owns open/close: clicking anywhere outside dismisses one, `Esc` dismisses it and returns focus to the trigger, opening one closes the others, and the panel sits in the top layer where `.table-wrap`'s `overflow` can no longer clip a row kebab. Placement is CSS anchor positioning; the panel needs `position-anchor: auto` to bind to the button that opened it — a bare `anchor()` resolves to nothing in Chromium, Firefox and WebKit alike, measured in all three before picking the approach. `data-table.ejs` stopped hand-rolling its kebab and calls the `menu` partial, so the pattern lives in one file. Each panel is named by its caller (`locale-menu`, `profile-menu`, `row-actions-1`) and the partial fails loud without an `id`, since `popovertarget` is an idref — generated ids were tried first and dropped for being unreadable and nondeterministic. `<details>` stays in the nav tree, where it means disclosure rather than popup. A browser older than the popover API flows each panel inline under its trigger, so Sign out is never stranded behind an inert button. `e2e-tests/visual.spec.ts` drives the whole behaviour — opens, anchored to its trigger, outside-click, Esc — and runs in Firefox and WebKit as well as Chromium, because CSS anchor positioning is the newest thing in the app and every popup rests on it. Decisions recorded in AGENTS.md.)