diff --git a/AGENTS.md b/AGENTS.md index c197024..b94f4fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 grants (groups nest). Ory's own "permission" (the `Resource` `permits`: view/edit/delete) is the 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 `:` 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 `:`** — `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. + - `` is **global, not plugin-scoped** (hence `oauth2-clients`, not `clients`). Deliberate + cross-plugin sharing is a goal, so the pre-2026-08-05 `:` 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 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 diff --git a/README.md b/README.md index dceba3b..3024af3 100644 --- a/README.md +++ b/README.md @@ -28,14 +28,17 @@ docker compose up -d # http://localhost:3000, live-reloads on source chan **`admin@plainpages.local` / `admin`**. **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 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/). **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 — 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: -**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 [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) - [how it compares](#how-it-compares) - [Users, groups & permissions](#users-groups--permissions) + - [naming a permission](#naming-a-permission) - [a worked example](#a-worked-example) - [granting a permission](#granting-a-permission) - [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) - [CI/CD](#cicd) - [Production & deployment](#production--deployment) +- [Upgrading](#upgrading) - [Observability](#observability) - [JWT signing key & rotation](#jwt-signing-key--rotation) - [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 spot** is the **back-office and operational tooling** you'd otherwise hand-roll for the tenth time, but nothing ties it to internal-only use. The core itself ships **no domain screens at -all** — even the screens for running the system (**users, groups, permissions**) are a **drop-in +all** — even the screens for running the system (**users, groups, OAuth2 clients**) are a **drop-in plugin** you opt into ([`examples/plugins/admin/`](examples/plugins/admin/)). Everything is a plugin. **Who it's for.** Experienced developers building server-rendered web products — back-office @@ -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 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 [`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 @@ -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 > group of groups works too. +### Naming a permission + +**Every permission name is `:`.** `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. + +- **``** 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`. +- **``** 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 +`:` 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 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 - 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 @@ -282,11 +321,12 @@ JWT](#login-and-the-session-jwt)): ``` alice → permissions: ["scheduling:read", "scheduling:write"] 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` -is just another name, granting nothing except where a route gates on `admin` itself. +Note what Carol does *not* have. **Permissions do not nest, and there is no superuser** — running +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: @@ -296,11 +336,15 @@ Against the reference plugins' actual routes: | `GET /scheduling/shifts` | `scheduling:read` | ✅ | ✅ | 403 | → `/login` | | `GET /scheduling/shifts/new` | `scheduling:write` | ✅ | 403 | 403 | → `/login` | | `POST /scheduling/shifts` | `scheduling:write` | ✅ | 403 | 403 | → `/login` | -| `GET /admin/users` | `admin` | 403 | 403 | ✅ | → `/login` | +| `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 `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. +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 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 @@ -308,8 +352,8 @@ permissions, so nobody is shown a door they cannot open. ### Granting a permission -Write the tuple. The admin plugin's **Groups** and **Permissions** screens do exactly this, or use -Keto's write API directly: +Write the tuple. The admin plugin's **Users** and **Groups** screens do exactly this — each offers +the declared permissions as a checkbox list — or use Keto's write API directly: ```bash # 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 -global namespace on purpose, so an operator grants `scheduling:read` once and every plugin -referencing it is gated consistently; namespace yours as `:`. +**A permission's name is authored in plugin code; only its *grants* live in Keto.** A plugin +declares the permissions it gates on (`permissions:` in the manifest), and the host collects them +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 +[`:`](#naming-a-permission). 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. @@ -599,6 +646,7 @@ interface RequestContext { req: IncomingMessage; res: ServerResponse; 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 url: URL; 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 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 -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 `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. @@ -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 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 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 `/` prefix is unique), so this catches a plugin duplicating one of its own. | | `nav-id` | error | A nav node `id` is used more than once — the central override targets ids, so they must be unique. | | `home` / `dashboard` | error | More than one plugin declares `home` (or `dashboard`). Each landing page is a single slot, so only one may own it ([The landing pages](#the-landing-pages-home--dashboard)). | -| `permission` | warn | A permission name is declared by more than one plugin. Sharing is legitimate; namespace as `:` if unintended. | +| `permission` | warn | A permission name is declared by more than one plugin. Sharing is legitimate; pick a more specific [``](#naming-a-permission) if unintended. | There is **no separate `basePath` rule**: the mount path is the derived `/`, so its uniqueness follows from the id check. `permission` is the one intentional overlap, so it warns @@ -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 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. ### 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. 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 -every discovered plugin's declared permission names in Keto, so permission checks (and any +signing key if absent, creates the demo admin in Kratos, and grants it every discovered plugin's +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 *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 `:`** (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 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 } 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) 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 .ts here adds a language for the core, or replaces the shipped catalog for that tag wholesale; plugins//.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) 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 ci.sh The full CI gate: typecheck → unit tests → every E2E suite, each on a fresh, always-torn-down stack (`bash ci.sh`) .gitea/workflows/ Gitea Actions: ci.yml — the full gate (ci.sh) on every branch push except main; diff --git a/compose.override.yml b/compose.override.yml index efca5b4..52e3e94 100644 --- a/compose.override.yml +++ b/compose.override.yml @@ -22,6 +22,18 @@ services: # 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 + # 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 # 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 — diff --git a/compose.yml b/compose.yml index b94e056..bc24df4 100644 --- a/compose.yml +++ b/compose.yml @@ -132,7 +132,7 @@ services: ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin} # 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). - ADMIN_PERMISSIONS: ${ADMIN_PERMISSIONS:-admin} + ADMIN_PERMISSIONS: ${ADMIN_PERMISSIONS:-} APP_URL: ${APP_URL:-http://localhost:3000} # printed in the first-run login banner JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json KETO_WRITE_URL: http://keto:4467 diff --git a/e2e-tests/auth-refresh.spec.ts b/e2e-tests/auth-refresh.spec.ts index e7ad2fb..638df83 100644 --- a/e2e-tests/auth-refresh.spec.ts +++ b/e2e-tests/auth-refresh.spec.ts @@ -83,7 +83,7 @@ test("an expired session JWT is silently re-minted while Kratos lives, then clea const claims1 = jwtClaims(jwt1); expect(claims1.email).toBe(ADMIN_EMAIL); 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. 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); const claims2 = jwtClaims(jwt2); 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. const revoke = await fetch(`${KRATOS_ADMIN}/admin/identities/${claims1.sub}/sessions`, { method: "DELETE" }); diff --git a/e2e-tests/compose.auth.yml b/e2e-tests/compose.auth.yml index ef2f841..764f64e 100644 --- a/e2e-tests/compose.auth.yml +++ b/e2e-tests/compose.auth.yml @@ -30,6 +30,18 @@ services: timeout: 4s 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 + # `:` 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), # merged after the base config. kratos: diff --git a/e2e-tests/compose.full.yml b/e2e-tests/compose.full.yml index afb86a6..0c07d59 100644 --- a/e2e-tests/compose.full.yml +++ b/e2e-tests/compose.full.yml @@ -1,5 +1,5 @@ # 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 # 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 diff --git a/e2e-tests/full-flow.spec.ts b/e2e-tests/full-flow.spec.ts index c6fe508..d9b6d95 100644 --- a/e2e-tests/full-flow.spec.ts +++ b/e2e-tests/full-flow.spec.ts @@ -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 () => { - // The signed-in admin holds admin + scheduling:read/write, so both gated sections are present - // in the menu (collapsed by default → assert they're in the DOM, not necessarily visible). + // The signed-in admin holds every permission the two mounted plugins declare (the bootstrap + // 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 expect(page.locator('.sidebar a[href="/admin/users"]')).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); }); - 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 // enforces it); pick the first option (a user) from the required picker. const group = `e2e-grp-${suffix}`; @@ -146,13 +147,17 @@ test.describe.serial("authenticated admin journey", () => { await expect(page).toHaveURL(/\/admin\/groups(\?|\/|$)/); await expect(page.locator("main")).toContainText(group); - const permission = `e2e-permission-${suffix}`; - await page.goto("/admin/permissions/new"); - await page.fill('input[name="name"]', permission); - await page.locator('select[name="member"]').selectOption({ index: 1 }); - await page.locator('.form-card button[type="submit"]').click(); - await expect(page).toHaveURL(/\/admin\/permissions(\?|\/|$)/); - await expect(page.locator("main")).toContainText(permission); + // Permissions are declared in plugin code, so the group's detail page offers them as a fixed + // checkbox list rather than a create form — there is no Permissions screen to visit. + await page.goto(`/admin/groups/${group}`); + const scheduling = page.locator('input[name="permission"][value="scheduling:read"]'); + await expect(scheduling).toHaveCount(1); // declared by the reference plugin, so it's on offer + await expect(scheduling).not.toBeChecked(); + 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 () => { diff --git a/examples/config/menu.ts b/examples/config/menu.ts index b41bbe9..27b34e9 100644 --- a/examples/config/menu.ts +++ b/examples/config/menu.ts @@ -20,7 +20,7 @@ export default defineMenu({ // Operator override (rename → group → order → hide), keyed by node id. override: { // 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 // hide: ["teams"], // remove nodes (any depth) }, diff --git a/examples/plugins/admin/README.md b/examples/plugins/admin/README.md index 0ce7d75..c1fdf82 100644 --- a/examples/plugins/admin/README.md +++ b/examples/plugins/admin/README.md @@ -1,17 +1,22 @@ # 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 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: ```bash 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 -menu and the screens work immediately. +The bootstrap grants the seeded `admin@plainpages.local` every permission this plugin declares, so the +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 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: - **`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.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. @@ -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, and Hydra when configured). Where a capability is absent the screen degrades to a themed 503 rather than crashing — see `admin-shared.ts`. Everything else is an ordinary plugin: folder-discovered, -gated per route by `permission: "admin"`, rendering the core building blocks in `views/`. +gated per route by its screen's `:` 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 -- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` permission, and the - route table — one thin handler per method+path, all gated by `permission: "admin"`. -- `admin-users.ts` · `admin-groups.ts` · `admin-permissions.ts` · `admin-clients.ts` — each a set of pure +- `plugin.ts` — the manifest: the Admin nav fragment, the six permissions the plugin declares, and + the route table — one thin handler per method+path, gated via `permissionName(resource, actionForMethod(method))` + 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 `ctx.params` (the host extracts `:id`/`:name`), sharing a small `withX` wrapper that resolves the - admin gate + the needed `ctx.system` clients once. -- `admin-shared.ts` — the shared gate (`requireAdmin`), CSRF form reader (`guardedForm`), confirm + screen's permission gate + the needed `ctx.system` clients once. +- `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. - `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, …). -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 `src/http/app.test.ts` (which mounts this plugin) and end-to-end in `e2e-tests/full-flow.spec.ts`. diff --git a/examples/plugins/admin/admin-clients.ts b/examples/plugins/admin/admin-clients.ts index e200692..0ba324d 100644 --- a/examples/plugins/admin/admin-clients.ts +++ b/examples/plugins/admin/admin-clients.ts @@ -5,8 +5,8 @@ // PRG redirect (mirrors the Users "trigger recovery" one-time code). Below the builders are thin // per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded. -import { type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "#plugin-api"; -import { ADMIN_CLIENTS_BASE, ADMIN_EN, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts"; +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, type AdminAction, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts"; import type { FieldConfig } from "./admin-users.ts"; const DEFAULT_PAGE_SIZE = 25; @@ -100,6 +100,7 @@ function listHref(state: ListState, overrides: Partial = {}): string } export function buildClientsListModel(opts: { + canWrite?: boolean; clients: OAuth2Client[]; csrfToken?: string; t?: Translate; @@ -119,6 +120,7 @@ export function buildClientsListModel(opts: { return { breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.nav.section") }, { label: t("admin.clients.title") }], + canWrite: opts.canWrite !== false, filterBar: listFilterBar(state, t), pagination: listPagination(state, page, t), table: listTable(rows, t), @@ -208,6 +210,7 @@ export function buildClientFormModel(opts: { } export function buildClientDetailModel(opts: { + canWrite?: boolean; client: ClientView; created?: boolean; // just registered → success banner + the one-time secret (if any) csrfToken?: string; @@ -218,6 +221,7 @@ export function buildClientDetailModel(opts: { const base = detailHref(opts.client.id); return { breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.clients.title") }, { label: opts.client.name }], + canWrite: opts.canWrite !== false, client: opts.client, created: opts.created ?? false, 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. interface ClientsDeps { ctx: RequestContext; hydra: HydraAdmin; user: User; } -function withClients(inner: (deps: ClientsDeps) => Promise): RouteHandler { +function withClients(inner: (deps: ClientsDeps) => Promise, action?: AdminAction): RouteHandler { return async (ctx) => { - const user = requireAdmin(ctx); + const user = requirePermission(ctx, "oauth2-clients", action); const hydra = ctx.system?.hydra; if (!hydra) return unavailable(ctx, ctx.t("admin.capability.hydra")); return inner({ ctx, hydra, user }); @@ -253,24 +257,26 @@ function withClients(inner: (deps: ClientsDeps) => Promise): RouteH } // Same, plus the target client from ctx.params.id (unknown → themed 404). -function withClient(inner: (deps: ClientsDeps, client: OAuth2Client, id: string) => Promise): RouteHandler { +function withClient(inner: (deps: ClientsDeps, client: OAuth2Client, id: string) => Promise, action?: AdminAction): RouteHandler { return withClients(async (deps) => { const id = deps.ctx.params["id"] ?? ""; const client = await deps.hydra.getClient(id); if (!client) return notFound(deps.ctx); return inner(deps, client, id); - }); + }, action); } const clientFormResult = (ctx: RequestContext, extra: { error?: string; values?: Partial }): RouteResult => ({ 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 => - ({ 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. export const clientsList = withClients(async ({ ctx, hydra }) => { 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 @@ -291,7 +297,7 @@ export const clientsCreate = withClients(async ({ ctx, hydra, user }) => { }); // 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). 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"), message: tt("admin.clients.deleteMessage", { name }), title: tt("admin.clients.delete"), }) }, view: "confirm" }); -}); +}, "write"); // POST /admin/clients/:id/delete — perform it. export const clientsDelete = withClient(async ({ ctx, hydra, user }, _client, id) => { diff --git a/examples/plugins/admin/admin-grants.test.ts b/examples/plugins/admin/admin-grants.test.ts new file mode 100644 index 0000000..4965ef9 --- /dev/null +++ b/examples/plugins/admin/admin-grants.test.ts @@ -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); +}); diff --git a/examples/plugins/admin/admin-grants.ts b/examples/plugins/admin/admin-grants.ts new file mode 100644 index 0000000..d7bd671 --- /dev/null +++ b/examples/plugins/admin/admin-grants.ts @@ -0,0 +1,123 @@ +// Permission grants, shared by the Users and Groups screens. A permission is held by a user +// (`Permission:#granted@user:`) or by a whole group (`…@Group:#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 { + const held = new Set(); + 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 { + 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 { + for (const name of diff.grant) await keto.writeTuple(grantTuple(name, subject)); + for (const name of diff.revoke) await keto.deleteTuple(grantTuple(name, subject)); +} diff --git a/examples/plugins/admin/admin-groups.ts b/examples/plugins/admin/admin-groups.ts index 12ff1e4..6a40d55 100644 --- a/examples/plugins/admin/admin-groups.ts +++ b/examples/plugins/admin/admin-groups.ts @@ -6,8 +6,9 @@ // per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded, // each returning a RouteResult. -import { type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "#plugin-api"; -import { ADMIN_EN, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts"; +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 { 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"; const GROUP_NS = "Group"; @@ -110,6 +111,7 @@ function listHref(state: ListState, overrides: Partial = {}): string } export function buildGroupsListModel(opts: { + canWrite?: boolean; csrfToken?: string; groups: GroupView[]; t?: Translate; @@ -139,6 +141,7 @@ export function buildGroupsListModel(opts: { return { breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.nav.section") }, { label: t("admin.groups.title") }], + canWrite: opts.canWrite !== false, filterBar: listFilterBar(state, t), pagination: listPagination(state, page, t), table: listTable(rows, state, sort, t), @@ -224,11 +227,13 @@ export function buildGroupFormModel(opts: { } export function buildGroupDetailModel(opts: { + canWrite?: boolean; // false ⇒ a `groups:read` holder: show the members, offer no edit candidates: MemberOption[]; csrfToken?: string; error?: string; group: { name: string }; members: MemberView[]; + permissions?: PermissionPicker; t?: Translate; }) { 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 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 canWrite = opts.canWrite !== false; return { add: { action: `${base}/members`, options }, 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 ?? "", delete: { action: `${base}/delete` }, error: opts.error, group: { name }, members: { action: `${base}/members/delete`, rows: opts.members }, + permissions: opts.permissions, title: name, }; } // ---- 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 { const out: RelationTuple[] = []; let pageToken: string | undefined; @@ -285,13 +293,14 @@ async function groupExists(keto: KetoClient, name: string): Promise { return page.tuples.length > 0; } -// Shared per-request deps for the Groups screen, resolved by `withGroups`: the gate + the Keto and -// Kratos capabilities (else a themed 503). Each route below is a thin handler over these. +// Shared per-request deps for the Groups screen, resolved by `withGroups`: the gate (`groups:read` on +// 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; } -function withGroups(inner: (deps: GroupsDeps) => Promise): RouteHandler { +function withGroups(inner: (deps: GroupsDeps) => Promise, action?: AdminAction): RouteHandler { return async (ctx) => { - const user = requireAdmin(ctx); + const user = requirePermission(ctx, "groups", action); const keto = ctx.system?.keto; const kratosAdmin = ctx.system?.kratosAdmin; if (!keto || !kratosAdmin) return unavailable(ctx, ctx.t("admin.capability.keto")); @@ -300,12 +309,12 @@ function withGroups(inner: (deps: GroupsDeps) => Promise): RouteHan } // Same, plus the validated :name from ctx.params (an invalid group name → themed 404). -function withGroupName(inner: (deps: GroupsDeps, name: string) => Promise): RouteHandler { +function withGroupName(inner: (deps: GroupsDeps, name: string) => Promise, action?: AdminAction): RouteHandler { return withGroups((deps) => { const name = deps.ctx.params["name"] ?? ""; if (!isValidGroupName(name)) return Promise.resolve(notFound(deps.ctx)); return inner(deps, name); - }); + }, action); } const groupFormResult = async (deps: GroupsDeps, extra: { error?: string; values?: { member?: string; name?: string } }): Promise => { @@ -316,7 +325,7 @@ const groupFormResult = async (deps: GroupsDeps, extra: { error?: string; values // GET /admin/groups — the list. export const groupsList = withGroups(async ({ ctx, keto }) => { 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). @@ -336,13 +345,38 @@ export const groupsCreate = withGroups(async (deps) => { }); // 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. export const groupsDetail = withGroupName(async ({ ctx, keto, kratosAdmin }, name) => { const { emailById, options } = await memberCandidates(keto, kratosAdmin); 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). @@ -362,13 +396,19 @@ export const groupsDeleteConfirm = withGroupName((deps, name) => { cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.groups.delete"), message: tt("admin.groups.deleteMessage", { name }), title: tt("admin.groups.delete"), }) }, view: "confirm" }); -}); +}, "write"); // POST /admin/groups/:name/delete — remove every member tuple (the group ceases to exist). export const groupsDelete = withGroupName(async ({ ctx, keto, user }, name) => { 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 }); - 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 }; }); diff --git a/examples/plugins/admin/admin-permissions.test.ts b/examples/plugins/admin/admin-permissions.test.ts deleted file mode 100644 index f012797..0000000 --- a/examples/plugins/admin/admin-permissions.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -// Built-in Roles admin screen: the pure view-model + Keto builders. A permission is a -// Keto subject set (Permission:#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"); -}); diff --git a/examples/plugins/admin/admin-permissions.ts b/examples/plugins/admin/admin-permissions.ts deleted file mode 100644 index 7a52411..0000000 --- a/examples/plugins/admin/admin-permissions.ts +++ /dev/null @@ -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:#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:#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(); - 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 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 = {}): 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, 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:` member must take effect now, so revoke that -// user's live tokens (a re-mint then re-reads permissions from Keto). A `group:` 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 { - 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): Promise { - 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): 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): 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 => { - 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 => { - 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 => ({ ...(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) }; -}); diff --git a/examples/plugins/admin/admin-shared.test.ts b/examples/plugins/admin/admin-shared.test.ts index df27743..bc13d0d 100644 --- a/examples/plugins/admin/admin-shared.test.ts +++ b/examples/plugins/admin/admin-shared.test.ts @@ -1,15 +1,16 @@ // 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. // Import only from the #plugin-api barrel — the same contract boundary the plugin code uses. import assert from "node:assert/strict"; import type { IncomingMessage, ServerResponse } from "node:http"; import { Readable } from "node:stream"; import { test } from "node:test"; -import { GuardError, type Log, type PageChrome, type RequestContext, type User } from "#plugin-api"; -import { ADMIN_EN, ADMIN_NAV, ADMIN_PERMISSION, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts"; +import { GuardError, isValidPermissionName, type Log, type PageChrome, type RequestContext, type User } from "#plugin-api"; +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 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; req.method = opts.method ?? "GET"; 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, verifyCsrf: opts.verifyCsrf ?? (() => true), }; @@ -26,24 +27,51 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver // ---- 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.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.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 // 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) => ADMIN_EN(c.label)), ["Users", "Groups", "Permissions", "OAuth2 clients"]); - assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined && c.permission === undefined)); // the header's gate covers the subtree + assert.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", "OAuth2 clients"]); + assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined)); +}); + +// ---- permission naming ---- + +test("permissionName builds :, 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 ---- -test("requireAdmin: anonymous → 401→/login, signed-in non-admin → 403, admin → the user", () => { - 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(() => requireAdmin(fakeCtx({ user: member })), (e: unknown) => e instanceof GuardError && e.status === 403); - assert.equal(requireAdmin(fakeCtx({ user: admin })), admin); +test("requirePermission: anonymous → 401→/login, wrong permission → 403, and read never grants write", () => { + assert.throws(() => requirePermission(fakeCtx({ user: null }), "users"), (e: unknown) => e instanceof GuardError && e.status === 401 && e.location === "/login?return_to=%2Fadmin%2Fusers"); // bounce remembers the page + assert.throws(() => requirePermission(fakeCtx({ user: member }), "users"), (e: unknown) => e instanceof GuardError && e.status === 403); + 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 () => { diff --git a/examples/plugins/admin/admin-shared.ts b/examples/plugins/admin/admin-shared.ts index a4d112d..aeed4e1 100644 --- a/examples/plugins/admin/admin-shared.ts +++ b/examples/plugins/admin/admin-shared.ts @@ -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. 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_GROUPS_BASE = "/admin/groups"; -export const ADMIN_PERMISSIONS_BASE = "/admin/permissions"; export const ADMIN_CLIENTS_BASE = "/admin/clients"; -export type AdminScreen = "clients" | "groups" | "permissions" | "users"; +// One resource per screen — the `` 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 -// 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. +export type AdminAction = "read" | "write"; + +// `:` (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 = { children: [ - { href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users" }, - { href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "admin.nav.groups" }, - { 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" }, + { 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", permission: permissionName("groups", "read") }, + { href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "admin.nav.clients", permission: permissionName("oauth2-clients", "read") }, ], icon: "i-shield", id: "admin", 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 -// host enforces this before the handler runs; this is defence-in-depth and what a direct unit test -// relies on. Returns the (non-null) user for the handler to thread on. GuardError → /login or 403. -export function requireAdmin(ctx: RequestContext): User { +// The screen gate: a signed-in user holding this request's `:`. Each route already +// declares the same permission, so the host enforces it before the handler runs; this is +// defence-in-depth and what a direct unit test relies on. Returns the (non-null) user for the +// handler to thread on. GuardError → /login or 403. +// `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) - 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; } diff --git a/examples/plugins/admin/admin-users.ts b/examples/plugins/admin/admin-users.ts index d963d1b..14982b5 100644 --- a/examples/plugins/admin/admin-users.ts +++ b/examples/plugins/admin/admin-users.ts @@ -4,8 +4,9 @@ // 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). -import { type Identity, 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 { 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 { 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 DEFAULT_PAGE_SIZE = 25; @@ -105,6 +106,7 @@ function listHref(state: ListState, overrides: Partial = {}): string } export function buildUsersListModel(opts: { + canWrite?: boolean; csrfToken?: string; identities: Identity[]; t?: Translate; @@ -134,6 +136,7 @@ export function buildUsersListModel(opts: { return { 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), pagination: listPagination(state, page, t), table: listTable(rows, state, sort, t), @@ -216,9 +219,11 @@ export interface FieldConfig { } export function buildUserFormModel(opts: { + canWrite?: boolean; // false ⇒ a `users:read` holder: show the state, render no write affordance csrfToken?: string; error?: string; identity?: Identity | null; + permissions?: PermissionPicker; // editing only — a user that doesn't exist yet can hold nothing recovery?: RecoveryCode; t?: Translate; values?: Partial; @@ -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" }); + const canWrite = opts.canWrite !== false; return { 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 ? { deleteAction: `${idPath}/delete`, id: view!.id, @@ -250,6 +257,7 @@ export function buildUserFormModel(opts: { } : undefined, error: opts.error, 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, 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 -// the Kratos capability (else a themed 503). Each route below is a thin handler over these. -interface UsersDeps { ctx: RequestContext; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; } +// Shared per-request deps for the Users screen, resolved by `withUser`: the gate (`users:read` on a +// GET, `users:write` on a POST) and the Kratos capability (else a themed 503). Each route below is a +// 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 -// host; `requireAdmin` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it. -function withUser(inner: (deps: UsersDeps) => Promise): RouteHandler { +// Resolve the shared deps, then run `inner`. The route's own `permission` already gated at the host; +// `requirePermission` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it. +function withUser(inner: (deps: UsersDeps) => Promise, action?: AdminAction): RouteHandler { return async (ctx) => { - const user = requireAdmin(ctx); + const user = requirePermission(ctx, "users", action); const kratosAdmin = ctx.system?.kratosAdmin; 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 // decoded the id and 404s malformed %-encoding, so no manual decode is needed here. -function withTarget(inner: (deps: UsersDeps, identity: Identity, id: string) => Promise): RouteHandler { +function withTarget(inner: (deps: UsersDeps, identity: Identity, id: string) => Promise, action?: AdminAction): RouteHandler { return withUser(async (deps) => { const id = deps.ctx.params["id"] ?? ""; const identity = await deps.kratosAdmin.getIdentity(id); if (!identity) return notFound(deps.ctx); return inner(deps, identity, id); - }); + }, action); } const formResult = (ctx: RequestContext, extra: Parameters[0]): RouteResult => @@ -298,7 +309,7 @@ const formResult = (ctx: RequestContext, extra: Parameters { 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. @@ -315,18 +326,73 @@ export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => { }); // 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. -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 { + 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). -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))!); try { await kratosAdmin.updateIdentity(id, updateIdentityPayload(identity, input)); } 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; } 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"), message: tt("admin.users.deleteMessage", { email: view.email }), title: tt("admin.users.delete"), }) }, view: "confirm" }); -}); +}, "write"); // 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) => { @@ -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. -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 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 { return err.status === 409 ? t("admin.users.error.duplicate") diff --git a/examples/plugins/admin/i18n/en-US.ts b/examples/plugins/admin/i18n/en-US.ts index 85c0894..666f6ff 100644 --- a/examples/plugins/admin/i18n/en-US.ts +++ b/examples/plugins/admin/i18n/en-US.ts @@ -48,6 +48,15 @@ const messages = { "admin.common.type": "Type", "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.addMember": "Add a member", "admin.groups.allMembers": "All users and groups are already members.", @@ -55,7 +64,7 @@ const messages = { "admin.groups.column.name": "Group", "admin.groups.create": "Create 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.nameHint": "Lowercase letters, digits, dashes and underscores.", "admin.groups.filter": "Filter groups", @@ -74,42 +83,12 @@ const messages = { "admin.nav.clients": "OAuth2 clients", "admin.nav.groups": "Groups", - "admin.nav.permissions": "Permissions", "admin.nav.section": "Admin", "admin.nav.users": "Users", "admin.notFound.message": "That item doesn't exist.", "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.title": "Admin unavailable", diff --git a/examples/plugins/admin/i18n/sv-SE.ts b/examples/plugins/admin/i18n/sv-SE.ts index 639b160..f0224af 100644 --- a/examples/plugins/admin/i18n/sv-SE.ts +++ b/examples/plugins/admin/i18n/sv-SE.ts @@ -48,6 +48,15 @@ const messages: AdminMessages = { "admin.common.type": "Typ", "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.addMember": "Lägg till en medlem", "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.create": "Skapa 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.nameHint": "Små bokstäver, siffror, bindestreck och understreck.", "admin.groups.filter": "Filtrera grupper", @@ -74,42 +83,12 @@ const messages: AdminMessages = { "admin.nav.clients": "OAuth2-klienter", "admin.nav.groups": "Grupper", - "admin.nav.permissions": "Behörigheter", "admin.nav.section": "Administration", "admin.nav.users": "Användare", "admin.notFound.message": "Objektet finns 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.title": "Administrationen är otillgänglig", diff --git a/examples/plugins/admin/plugin.test.ts b/examples/plugins/admin/plugin.test.ts new file mode 100644 index 0000000..9c24248 --- /dev/null +++ b/examples/plugins/admin/plugin.test.ts @@ -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 :, 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 +}); diff --git a/examples/plugins/admin/plugin.ts b/examples/plugins/admin/plugin.ts index 2ba2623..c50120b 100644 --- a/examples/plugins/admin/plugin.ts +++ b/examples/plugins/admin/plugin.ts @@ -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 // 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 { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts"; -import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsRemoveMember } from "./admin-groups.ts"; -import { rolesAddMember, rolesCreate, rolesDelete, rolesDeleteConfirm, rolesDetail, rolesList, rolesNewForm, rolesRemoveMember } from "./admin-permissions.ts"; -import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersRecovery, usersState, usersUpdate } from "./admin-users.ts"; -import { ADMIN_NAV, ADMIN_PERMISSION } from "./admin-shared.ts"; +import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsPermissions, groupsRemoveMember } from "./admin-groups.ts"; +import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersPermissions, usersRecovery, usersState, usersUpdate } from "./admin-users.ts"; +import { ADMIN_NAV, actionForMethod, type AdminAction, type AdminResource, permissionName } from "./admin-shared.ts"; -// Every admin route is gated by the one `admin` permission — the host redirects an anonymous visitor -// to /login, gives a signed-in non-admin the 403 page, and filters the nav the same way. Handlers are -// thin and keyed on ctx.params (the host extracts :id / :name), the idiomatic per-route style. -const r = (method: HttpMethod, path: string, handler: RouteHandler): Route => ({ handler, method, path, permission: ADMIN_PERMISSION }); +// One route factory per screen: a GET gates on `:read` and a POST on `:write`, +// derived through the same two helpers the in-handler guard uses, so the table below cannot drift +// from it. The host redirects an anonymous visitor to /login, gives a signed-in user missing the +// 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({ apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION nav: [ADMIN_NAV], - permissions: [{ description: "Administer users, groups, 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: [ // Users - r("GET", "/users", usersList), - r("POST", "/users", usersCreate), - r("GET", "/users/new", usersNewForm), - r("GET", "/users/:id", usersEditForm), - r("POST", "/users/:id", usersUpdate), - r("POST", "/users/:id/state", usersState), - r("GET", "/users/:id/delete", usersDeleteConfirm), - r("POST", "/users/:id/delete", usersDelete), - r("POST", "/users/:id/recovery", usersRecovery), + users("GET", "/users", usersList), + users("POST", "/users", usersCreate), + users("GET", "/users/new", usersNewForm, "write"), + users("GET", "/users/:id", usersEditForm), + users("POST", "/users/:id", usersUpdate), + users("POST", "/users/:id/state", usersState), + users("GET", "/users/:id/delete", usersDeleteConfirm, "write"), + users("POST", "/users/:id/delete", usersDelete), + users("POST", "/users/:id/recovery", usersRecovery), + users("POST", "/users/:id/permissions", usersPermissions), // Groups - r("GET", "/groups", groupsList), - r("POST", "/groups", groupsCreate), - r("GET", "/groups/new", groupsNewForm), - r("GET", "/groups/:name", groupsDetail), - r("POST", "/groups/:name/members", groupsAddMember), - r("GET", "/groups/:name/delete", groupsDeleteConfirm), - r("POST", "/groups/:name/delete", groupsDelete), - r("POST", "/groups/:name/members/delete", groupsRemoveMember), - // Roles - 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), + groups("GET", "/groups", groupsList), + groups("POST", "/groups", groupsCreate), + groups("GET", "/groups/new", groupsNewForm, "write"), + groups("GET", "/groups/:name", groupsDetail), + groups("POST", "/groups/:name/members", groupsAddMember), + groups("GET", "/groups/:name/delete", groupsDeleteConfirm, "write"), + groups("POST", "/groups/:name/delete", groupsDelete), + groups("POST", "/groups/:name/members/delete", groupsRemoveMember), + groups("POST", "/groups/:name/permissions", groupsPermissions), // OAuth2 clients - r("GET", "/clients", clientsList), - r("POST", "/clients", clientsCreate), - r("GET", "/clients/new", clientsNewForm), - r("GET", "/clients/:id", clientsDetail), - r("GET", "/clients/:id/delete", clientsDeleteConfirm), - r("POST", "/clients/:id/delete", clientsDelete), + clients("GET", "/clients", clientsList), + clients("POST", "/clients", clientsCreate), + clients("GET", "/clients/new", clientsNewForm, "write"), + clients("GET", "/clients/:id", clientsDetail), + clients("GET", "/clients/:id/delete", clientsDeleteConfirm, "write"), + clients("POST", "/clients/:id/delete", clientsDelete), ], }); diff --git a/examples/plugins/admin/views/client-detail.ejs b/examples/plugins/admin/views/client-detail.ejs index 54a34f0..17f4897 100644 --- a/examples/plugins/admin/views/client-detail.ejs +++ b/examples/plugins/admin/views/client-detail.ejs @@ -3,7 +3,7 @@ shell. Doubles as the post-register page when `created`/`secret` are set. %><% 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", { body, diff --git a/examples/plugins/admin/views/clients.ejs b/examples/plugins/admin/views/clients.ejs index f0dca77..9749603 100644 --- a/examples/plugins/admin/views/clients.ejs +++ b/examples/plugins/admin/views/clients.ejs @@ -1,12 +1,13 @@ <%# 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 filters = include("partials/filter-bar", model.filterBar); const table = include("partials/data-table", model.table); const pager = include("partials/pagination", model.pagination); - const actions = '' + t("admin.clients.registerClient") + ''; + // Only offer "Register client" to an oauth2-clients:write holder — a :read one would get the 403 page. + const actions = model.canWrite === false ? "" : '' + t("admin.clients.registerClient") + ''; -%> <%- include("partials/shell", { actions, diff --git a/examples/plugins/admin/views/group-detail.ejs b/examples/plugins/admin/views/group-detail.ejs index 313b7ce..5de3371 100644 --- a/examples/plugins/admin/views/group-detail.ejs +++ b/examples/plugins/admin/views/group-detail.ejs @@ -2,7 +2,7 @@ Group admin detail / membership page: the group-detail body in the app shell. %><% 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", { body, diff --git a/examples/plugins/admin/views/groups.ejs b/examples/plugins/admin/views/groups.ejs index 40a0738..4227cb6 100644 --- a/examples/plugins/admin/views/groups.ejs +++ b/examples/plugins/admin/views/groups.ejs @@ -6,7 +6,8 @@ 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 = '' + t("admin.groups.new") + ''; + // Only offer "New group" to a groups:write holder — a groups:read one would get the 403 page. + const actions = model.canWrite === false ? "" : '' + t("admin.groups.new") + ''; -%> <%- include("partials/shell", { actions, diff --git a/examples/plugins/admin/views/partials/client-detail-body.ejs b/examples/plugins/admin/views/partials/client-detail-body.ejs index 1fee872..6eeec30 100644 --- a/examples/plugins/admin/views/partials/client-detail-body.ejs +++ b/examples/plugins/admin/views/partials/client-detail-body.ejs @@ -31,8 +31,10 @@
<%= t("admin.clients.field.redirectUris") %>
<% if (c.redirectUris.length) { %>
    <% c.redirectUris.forEach((u) => { %>
  • <%= u %>
  • <% }) %>
<% } else { %>—<% } %>
+<% if (locals.canWrite !== false) { -%>
">

<%= t("admin.clients.rereg") %>

<%= t("admin.clients.delete") %>
+<% } -%> diff --git a/examples/plugins/admin/views/partials/group-detail-body.ejs b/examples/plugins/admin/views/partials/group-detail-body.ejs index 6b0fcba..d34d4c6 100644 --- a/examples/plugins/admin/views/partials/group-detail-body.ejs +++ b/examples/plugins/admin/views/partials/group-detail-body.ejs @@ -21,13 +21,14 @@ <% if (members.rows.length) { -%>
<% members.rows.forEach((m) => { -%> - + <% }) -%>
<%= t("admin.groups.membersOf", { name: group.name }) %>
<%= t("admin.common.member") %><%= t("admin.common.type") %><%= t("table.actions") %>
<%= m.label %><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %>
<%= m.label %><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %><% if (locals.canWrite !== false) { %>
<% } %>
<% } else { -%>

<%= t("admin.groups.noMembers") %>

<% } -%> +<% if (locals.canWrite !== false) { -%>

<%= t("admin.groups.addMember") %>

<% if (add.options.length) { -%> @@ -36,7 +37,13 @@

<%= t("admin.groups.allMembers") %>

<% } -%>
+<% } -%> +<% if (locals.permissions) { -%> +<%- include("partials/permission-picker", { csrfToken: csrf, permissions: locals.permissions }) %> +<% } -%> +<% if (locals.canWrite !== false) { -%>
"> <%= t("admin.groups.delete") %>
+<% } -%> diff --git a/examples/plugins/admin/views/partials/permission-detail-body.ejs b/examples/plugins/admin/views/partials/permission-detail-body.ejs deleted file mode 100644 index 14470bd..0000000 --- a/examples/plugins/admin/views/partials/permission-detail-body.ejs +++ /dev/null @@ -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; --%> -
-<% if (locals.error) { -%> -<%- include("partials/alert", { text: locals.error, tone: "neg" }) %> -<% } -%> -
-

<%= t("admin.permissions.assignedTo") %>

-<% if (members.rows.length) { -%> -
-<% members.rows.forEach((m) => { -%> - -<% }) -%> -
<%= t("admin.groups.membersOf", { name: permission.name }) %>
<%= t("admin.common.member") %><%= t("admin.common.type") %><%= t("table.actions") %>
<%= m.label %><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %>
-<% } else { -%> -

<%= t("admin.permissions.noMembers") %>

-<% } -%> -
-
-

<%= t("admin.permissions.effective") %>

-

<%= t("admin.permissions.effectiveHint") %>

-<% if (effective.length) { -%> -
    -<% effective.forEach((u) => { -%> -
  • <%= u.label %>
  • -<% }) -%> -
-<% } else { -%> -

<%= t("admin.permissions.noEffective") %>

-<% } -%> -
-
-

<%= t("admin.permissions.assign") %>

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

<%= t("admin.permissions.allAssigned") %>

-<% } -%> -
-
"> - <%= t("admin.permissions.delete") %> -
-
diff --git a/examples/plugins/admin/views/partials/permission-form-body.ejs b/examples/plugins/admin/views/partials/permission-form-body.ejs deleted file mode 100644 index 81f748b..0000000 --- a/examples/plugins/admin/views/partials/permission-form-body.ejs +++ /dev/null @@ -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; --%> -
-<% if (locals.error) { -%> -<%- include("partials/alert", { text: locals.error, tone: "neg" }) %> -<% } -%> -
- - <%- include("partials/field", form.nameField) %> -
- - - A permission exists once assigned; add more users or groups after creating it. -
-
- <%= t("common.cancel") %> - -
-
-
diff --git a/examples/plugins/admin/views/partials/permission-picker.ejs b/examples/plugins/admin/views/partials/permission-picker.ejs new file mode 100644 index 0000000..12796b8 --- /dev/null +++ b/examples/plugins/admin/views/partials/permission-picker.ejs @@ -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 }). +%> +
+

<%= permissions.legend %>

+<% if (permissions.error) { -%> +<%- include("partials/alert", { text: permissions.error, tone: "neg" }) %> +<% } -%> +<% if (permissions.empty) { -%> +

<%= permissions.empty %>

+<% } else { -%> +

<%= permissions.hint %>

+<% if (permissions.readOnly) { -%> +
+ <%= permissions.legend %> +<% permissions.choices.forEach((c) => { -%> + +<% }) -%> +
+<% } else { -%> +
+ +
+ <%= permissions.legend %> +<% permissions.choices.forEach((c) => { -%> + +<% }) -%> +
+
+ +
+
+<% } -%> +<% if (permissions.inheritedNote) { -%> +

<%= permissions.inheritedNote %>

+<% } -%> +<% if (permissions.pending) { -%> +

<%= permissions.pending %>

+<% } -%> +<% } -%> +
diff --git a/examples/plugins/admin/views/partials/user-form-body.ejs b/examples/plugins/admin/views/partials/user-form-body.ejs index e9f2971..a9c3d78 100644 --- a/examples/plugins/admin/views/partials/user-form-body.ejs +++ b/examples/plugins/admin/views/partials/user-form-body.ejs @@ -23,10 +23,15 @@ <% }) -%>
<%= t("common.cancel") %> +<% if (locals.canWrite !== false) { -%> +<% } -%>
-<% if (edit) { -%> +<% if (edit && locals.permissions) { -%> +<%- include("partials/permission-picker", { csrfToken: form.csrfToken, permissions: locals.permissions }) %> +<% } -%> +<% if (edit && locals.canWrite !== false) { -%>
">
diff --git a/examples/plugins/admin/views/permission-detail.ejs b/examples/plugins/admin/views/permission-detail.ejs deleted file mode 100644 index 3ceed2d..0000000 --- a/examples/plugins/admin/views/permission-detail.ejs +++ /dev/null @@ -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, -}) %> diff --git a/examples/plugins/admin/views/permission-form.ejs b/examples/plugins/admin/views/permission-form.ejs deleted file mode 100644 index 89fb8cf..0000000 --- a/examples/plugins/admin/views/permission-form.ejs +++ /dev/null @@ -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, -}) %> diff --git a/examples/plugins/admin/views/permissions.ejs b/examples/plugins/admin/views/permissions.ejs deleted file mode 100644 index c8cc03f..0000000 --- a/examples/plugins/admin/views/permissions.ejs +++ /dev/null @@ -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 = '' + t("admin.permissions.new") + ''; --%> -<%- 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, -}) %> diff --git a/examples/plugins/admin/views/user-form.ejs b/examples/plugins/admin/views/user-form.ejs index 12a0b69..d0378a1 100644 --- a/examples/plugins/admin/views/user-form.ejs +++ b/examples/plugins/admin/views/user-form.ejs @@ -2,7 +2,7 @@ Users admin create/edit page: the user-form body captured into the app shell. %><% 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", { body, diff --git a/examples/plugins/admin/views/users.ejs b/examples/plugins/admin/views/users.ejs index ecef9b1..86a9350 100644 --- a/examples/plugins/admin/views/users.ejs +++ b/examples/plugins/admin/views/users.ejs @@ -6,7 +6,8 @@ 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 = '' + t("admin.users.new") + ''; + // Only offer "New user" to a users:write holder — a users:read one would get the 403 page. + const actions = model.canWrite === false ? "" : '' + t("admin.users.new") + ''; -%> <%- include("partials/shell", { actions, diff --git a/examples/plugins/scheduling/shifts.test.ts b/examples/plugins/scheduling/shifts.test.ts index a91b06c..44937a3 100644 --- a/examples/plugins/scheduling/shifts.test.ts +++ b/examples/plugins/scheduling/shifts.test.ts @@ -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 req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage; 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, verifyCsrf: opts.verifyCsrf ?? (() => true), }; diff --git a/public/css/styles.css b/public/css/styles.css index 84618e6..d55a79f 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -474,6 +474,14 @@ span.nav-self { cursor: default; } /* static / non-clickable */ .check input, .radio input { width: 15px; height: 15px; accent-color: var(--accent); margin: 0; cursor: pointer; } .check:hover, .radio:hover { color: var(--text); } +/* A stacked group of .check rows in a
— 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