From 0011182f16ae318bdb2a7b0ef7f4174248e99cf0 Mon Sep 17 00:00:00 2001 From: lilleman Date: Wed, 5 Aug 2026 14:29:15 +0200 Subject: [PATCH] Permissions are a fixed list from plugin code; grant them on Users and Groups --- AGENTS.md | 31 +- README.md | 27 +- compose.override.yml | 9 + compose.yml | 4 - e2e-tests/full-flow.spec.ts | 20 +- examples/plugins/admin/README.md | 18 +- examples/plugins/admin/admin-grants.test.ts | 51 +++ examples/plugins/admin/admin-grants.ts | 89 ++++ examples/plugins/admin/admin-groups.ts | 25 +- .../plugins/admin/admin-permissions.test.ts | 112 ----- examples/plugins/admin/admin-permissions.ts | 389 ------------------ examples/plugins/admin/admin-shared.test.ts | 12 +- examples/plugins/admin/admin-shared.ts | 12 +- examples/plugins/admin/admin-users.ts | 46 ++- examples/plugins/admin/i18n/en-US.ts | 33 +- examples/plugins/admin/i18n/sv-SE.ts | 33 +- examples/plugins/admin/plugin.test.ts | 19 +- examples/plugins/admin/plugin.ts | 27 +- examples/plugins/admin/views/group-detail.ejs | 2 +- .../views/partials/group-detail-body.ejs | 3 + .../views/partials/permission-detail-body.ejs | 57 --- .../views/partials/permission-form-body.ejs | 26 -- .../views/partials/permission-picker.ejs | 26 ++ .../admin/views/partials/user-form-body.ejs | 3 + .../plugins/admin/views/permission-detail.ejs | 16 - .../plugins/admin/views/permission-form.ejs | 16 - examples/plugins/admin/views/permissions.ejs | 21 - examples/plugins/admin/views/user-form.ejs | 2 +- examples/plugins/scheduling/shifts.test.ts | 2 +- src/http/app.test.ts | 127 ++---- src/http/app.ts | 9 +- src/http/context.ts | 7 + src/plugin-host/plugin.test.ts | 13 + src/plugin-host/plugin.ts | 12 + todo.md | 5 +- 35 files changed, 431 insertions(+), 873 deletions(-) create mode 100644 examples/plugins/admin/admin-grants.test.ts create mode 100644 examples/plugins/admin/admin-grants.ts delete mode 100644 examples/plugins/admin/admin-permissions.test.ts delete mode 100644 examples/plugins/admin/admin-permissions.ts delete mode 100644 examples/plugins/admin/views/partials/permission-detail-body.ejs delete mode 100644 examples/plugins/admin/views/partials/permission-form-body.ejs create mode 100644 examples/plugins/admin/views/partials/permission-picker.ejs delete mode 100644 examples/plugins/admin/views/permission-detail.ejs delete mode 100644 examples/plugins/admin/views/permission-form.ejs delete mode 100644 examples/plugins/admin/views/permissions.ejs diff --git a/AGENTS.md b/AGENTS.md index 3dbb0c2..a3895a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,8 +99,15 @@ them. Revisit only if the stated reason stops holding. × `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. The admin plugin's create form calls the same host - function; there is one regex. Decisions around it: + 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 @@ -108,15 +115,21 @@ them. Revisit only if the stated reason stops holding. - **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. - - ***Addressing* a permission is looser than *minting* one** (`isPermissionPathSegment`) so a name - written before the rule can still be opened and deleted instead of stranding in Keto. Both mint - points are guarded — the create form, and `rolesAddMember`, whose write would otherwise create a - permission under a hand-typed name. + - **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. This makes the seed a function of what - `bootstrap` discovers, so `bootstrap` bind-mounts `./plugins` like `web` does, and a plugin - dropped in after first boot needs `docker compose up -d` (which re-runs the one-shot), not - `restart web`. Valid while bootstrap is the only writer of grants. + `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 diff --git a/README.md b/README.md index 73c7900..659c213 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ 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 @@ -266,9 +266,9 @@ transitively, through nested groups). ### Naming a permission **Every permission name is `:`.** `scheduling:read`, `users:write`, -`oauth2-clients:read`. Both halves are lowercase letters, digits, dashes and underscores; the admin -plugin's create form refuses anything else, so the convention holds for whatever an operator adds -later. +`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 @@ -351,8 +351,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 @@ -362,8 +362,12 @@ curl -X PUT http://keto:4467/admin/relation-tuples -H 'content-type: application }' ``` -Permissions are authored **only in Keto** — nothing else writes them, and a name exists only while -some tuple carries it. Name yours [`:`](#naming-a-permission). +**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. @@ -641,6 +645,7 @@ interface RequestContext { req: IncomingMessage; res: ServerResponse; permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check + declaredPermissions: 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 @@ -708,7 +713,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. @@ -1911,13 +1916,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..4842bcb 100644 --- a/compose.override.yml +++ b/compose.override.yml @@ -22,6 +22,15 @@ 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 + # The seed grants what the installed plugins declare, so bootstrap must discover the same plugins + # as web. Only dev needs saying: the base file gives both services the image's baked plugins/, and + # it is the `.:/app` above — dev-only — that makes web diverge onto the host tree. Mirror it here + # rather than in the base file, where it would instead desynchronise them (and collide with the + # e2e stacks, which mount individual plugins *inside* this path). + bootstrap: + volumes: + - ./plugins:/app/plugins:ro + # 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 3517741..bc24df4 100644 --- a/compose.yml +++ b/compose.yml @@ -139,10 +139,6 @@ services: KRATOS_ADMIN_URL: http://kratos:4434 volumes: - ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer - # The seed grants what the installed plugins declare, so bootstrap must see the same plugins/ - # as web. Without this it discovers only the image's (empty) copy and the demo admin is seeded - # with nothing — a drop-in plugin's screens then 403 with nothing logged. - - ./plugins:/app/plugins:ro command: node src/auth/bootstrap.ts # Bounded retry: the seed is idempotent, so transient Ory blips recover — but a permanent # error must give up, not loop forever and hang `web` (gates on completion). diff --git a/e2e-tests/full-flow.spec.ts b/e2e-tests/full-flow.spec.ts index ed481ee..d9b6d95 100644 --- a/e2e-tests/full-flow.spec.ts +++ b/e2e-tests/full-flow.spec.ts @@ -136,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}`; @@ -147,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-${suffix}:read`; // permission names are :; the form refuses a bare word - 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/plugins/admin/README.md b/examples/plugins/admin/README.md index 997fec7..f61d997 100644 --- a/examples/plugins/admin/README.md +++ b/examples/plugins/admin/README.md @@ -1,6 +1,6 @@ # 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: @@ -25,7 +25,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. @@ -36,16 +36,22 @@ than crashing — see `admin-shared.ts`. Everything else is an ordinary plugin: gated per route by its screen's `:` permission, rendering the core building blocks in `views/`. -Each screen is its own resource — `users`, `groups`, `permissions`, `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 four hides the Admin section entirely. +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 Admin nav fragment, the eight permissions the plugin declares, and the route table — one thin handler per method+path, gated via `adminPermission(resource, method)` so a GET needs `:read` and a POST `:write`. -- `admin-users.ts` · `admin-groups.ts` · `admin-permissions.ts` · `admin-clients.ts` — each a set of pure +- `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 screen's permission gate + the needed `ctx.system` clients once. diff --git a/examples/plugins/admin/admin-grants.test.ts b/examples/plugins/admin/admin-grants.test.ts new file mode 100644 index 0000000..29acb5b --- /dev/null +++ b/examples/plugins/admin/admin-grants.test.ts @@ -0,0 +1,51 @@ +// 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, held: ["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); +}); + +test("buildPermissionPicker says so when no plugin declares a permission, rather than rendering an empty box", () => { + const picker = buildPermissionPicker({ action: "/x", declared: [], held: [] }); + 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..1687f43 --- /dev/null +++ b/examples/plugins/admin/admin-grants.ts @@ -0,0 +1,89 @@ +// 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. A group's members hold them transitively; that expansion is Keto's job at login, +// and this screen edits the direct edge only. +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(); +} + +export interface PermissionChoice { + checked: boolean; + description: string; + 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 + field: string; + legend: string; + submit: string; +} + +// The checkbox list: every declared permission, ticked where this subject already holds it. A fixed +// list means the form is the whole truth — what it posts back *is* the desired set (applyGrants). +export function buildPermissionPicker(opts: { + action: string; + declared: PermissionDecl[]; + held: string[]; + t?: Translate; +}): PermissionPicker { + const t = opts.t ?? ((k: string) => k); + const heldSet = new Set(opts.held); + return { + action: opts.action, + choices: opts.declared.map((decl) => ({ checked: heldSet.has(decl.name), description: decl.description ?? "", name: decl.name })), + empty: opts.declared.length === 0 ? t("admin.grants.none") : undefined, + field: PERMISSIONS_FIELD, + legend: t("admin.grants.legend"), + 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: 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 fa9a713..0632e52 100644 --- a/examples/plugins/admin/admin-groups.ts +++ b/examples/plugins/admin/admin-groups.ts @@ -7,6 +7,7 @@ // 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 { applyGrants, buildPermissionPicker, grantDiff, groupSubject, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD } from "./admin-grants.ts"; import { ADMIN_EN, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, requirePermission, unavailable } from "./admin-shared.ts"; import type { FieldConfig } from "./admin-users.ts"; @@ -229,6 +230,7 @@ export function buildGroupDetailModel(opts: { error?: string; group: { name: string }; members: MemberView[]; + permissions?: PermissionPicker; t?: Translate; }) { const t = opts.t ?? ADMIN_EN; @@ -245,6 +247,7 @@ export function buildGroupDetailModel(opts: { error: opts.error, group: { name }, members: { action: `${base}/members/delete`, rows: opts.members }, + permissions: opts.permissions, title: name, }; } @@ -343,7 +346,27 @@ export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {})); 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 permissions = buildPermissionPicker({ + action: `${detailHref(name)}/permissions`, + declared: ctx.declaredPermissions, + held: await heldPermissions(keto, groupSubject(name)), + t: ctx.t, + }); + return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ 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). diff --git a/examples/plugins/admin/admin-permissions.test.ts b/examples/plugins/admin/admin-permissions.test.ts deleted file mode 100644 index b3fa16d..0000000 --- a/examples/plugins/admin/admin-permissions.test.ts +++ /dev/null @@ -1,112 +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, - isPermissionPathSegment, - 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("isPermissionPathSegment stays loose enough to address a permission that predates the rule", () => { - // Addressing is not creating: an "admin" tuple left in Keto must still open and delete, or it is - // stranded. It only has to be a safe URL/Keto object name. - for (const ok of ["admin", "users:read", "legacy_name"]) assert.equal(isPermissionPathSegment(ok), true, ok); - for (const bad of ["", "Admin", "a b", "-bad", "a/b", "a".repeat(65)]) assert.equal(isPermissionPathSegment(bad), false, bad); -}); - -test("permissionGrantTuple maps the form value to a Permission tuple over a user/group (else null)", () => { - assert.deepEqual(permissionGrantTuple("things:read", `user:${uid(2)}`), { namespace: "Permission", object: "things:read", relation: "granted", subject_id: `user:${uid(2)}` }); - assert.deepEqual(permissionGrantTuple("things:read", "group:eng"), { namespace: "Permission", object: "things:read", 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("things:read", 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("users:read", 1), new Map([[uid(1), "ada@example.com"]])), memberView(groupTuple("users:read", "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: "users:read" } }); - assert.equal(m.title, "users:read"); - assert.equal(m.members.rows.length, 2); - // Every permission name now carries a colon, so the percent-encoding in these action URLs is - // load-bearing: the host's router decodes the segment back to "users:read" for ctx.params. - assert.equal(m.members.action, "/admin/permissions/users%3Aread/members/delete"); - assert.equal(m.add.action, "/admin/permissions/users%3Aread/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/users%3Aread/delete"); -}); diff --git a/examples/plugins/admin/admin-permissions.ts b/examples/plugins/admin/admin-permissions.ts deleted file mode 100644 index ea1abcb..0000000 --- a/examples/plugins/admin/admin-permissions.ts +++ /dev/null @@ -1,389 +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, isValidPermissionName, 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_PERMISSIONS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts"; -import { - type GroupView, - groupsFromTuples, - 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"; -// The one irreversible move on this screen: delete this permission, or revoke your own grant of it, -// and nobody can grant anything ever again. Guarded like the `admin` permission it replaces. -const LOCKOUT_PERMISSION = permissionName("permissions", "write"); -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 user|group membership model, but not the name rule: a -// permission is `:` (README → Users, groups & permissions). -export type PermissionView = GroupView; -export const permissionsFromTuples = groupsFromTuples; - -const PERMISSION_SEGMENT = /^[a-z0-9][a-z0-9_:-]*$/; - -// Addressing one only has to recognise a name Keto can already hold: a permission written before -// this rule — or by another tool — stays viewable and deletable instead of 404ing out of reach. -// Minting one goes through the host's `isValidPermissionName`, the same rule discovery enforces. -export function isPermissionPathSegment(name: string): boolean { - return name.length <= 64 && PERMISSION_SEGMENT.test(name); -} -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 = requirePermission(ctx, "permissions"); - 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 (!isPermissionPathSegment(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 (!isValidPermissionName(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))!; - // A permission exists only while a tuple carries it, so this write would *create* one under a - // hand-typed name — the second mint point, and the one that would slip past the create form's - // : rule. Assigning to something that doesn't exist is a 404, not a create. - if (!(await roleExists(keto, name))) return notFound(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 lockout permission can't be deleted. -export const rolesDeleteConfirm = withRoleName((deps, name) => { - if (name === LOCKOUT_PERMISSION) return permissionDetailResult(deps, name, deps.ctx.t("admin.permissions.error.lockoutUndeletable")); - 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 lockout permission is protected). -export const rolesDelete = withRoleName(async (deps, name) => { - const { ctx, keto, user } = deps; - await guardedForm(ctx); // CSRF-verify the POST - if (name === LOCKOUT_PERMISSION) return permissionDetailResult(deps, name, deps.ctx.t("admin.permissions.error.lockoutUndeletable")); - 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: you can't revoke your own *direct* grant of the lockout permission (a group-held -// one isn't covered — the robust "last effective holder" 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 === LOCKOUT_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 d91f922..2455ffd 100644 --- a/examples/plugins/admin/admin-shared.test.ts +++ b/examples/plugins/admin/admin-shared.test.ts @@ -19,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), }; @@ -27,7 +27,7 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver // ---- nav fragment ---- -test("ADMIN_NAV: an ungated Admin header whose four screens each gate on their own read permission", () => { +test("ADMIN_NAV: an ungated Admin header whose three screens each gate on their own read permission", () => { assert.equal(ADMIN_NAV.id, "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 four hides the section. @@ -36,12 +36,12 @@ test("ADMIN_NAV: an ungated Admin header whose four screens each gate on their o 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.permission), ["users:read", "groups:read", "permissions:read", "oauth2-clients:read"]); + 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.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)); }); diff --git a/examples/plugins/admin/admin-shared.ts b/examples/plugins/admin/admin-shared.ts index 018da7f..edd9ee1 100644 --- a/examples/plugins/admin/admin-shared.ts +++ b/examples/plugins/admin/admin-shared.ts @@ -13,12 +13,13 @@ export const ADMIN_EN: Translate = englishTranslator(enUS); 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"; // 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. -export type AdminResource = "groups" | "oauth2-clients" | "permissions" | "users"; +// 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"; export type AdminAction = "read" | "write"; @@ -43,10 +44,9 @@ export function actionForMethod(method: string): AdminAction { // four 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", permission: "users:read" }, - { href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "admin.nav.groups", permission: "groups:read" }, - { href: ADMIN_PERMISSIONS_BASE, icon: "i-shield", id: "permissions", label: "admin.nav.permissions", permission: "permissions:read" }, - { href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "admin.nav.clients", permission: "oauth2-clients:read" }, + { 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", diff --git a/examples/plugins/admin/admin-users.ts b/examples/plugins/admin/admin-users.ts index c9a8b79..80cb21c 100644 --- a/examples/plugins/admin/admin-users.ts +++ b/examples/plugins/admin/admin-users.ts @@ -4,7 +4,8 @@ // 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 { 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, grantDiff, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD, userSubject } from "./admin-grants.ts"; import { ADMIN_EN, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, requirePermission, unavailable } from "./admin-shared.ts"; const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id @@ -219,6 +220,7 @@ export function buildUserFormModel(opts: { 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; @@ -250,6 +252,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"), }; @@ -269,7 +272,9 @@ function readUserInput(form: URLSearchParams): UserInput { // 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. -interface UsersDeps { ctx: RequestContext; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; } +// `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 own `permission` already gated at the host; // `requirePermission` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it. @@ -278,7 +283,7 @@ function withUser(inner: (deps: UsersDeps) => Promise): RouteHandle const user = requirePermission(ctx, "users"); 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 }); }; } @@ -319,7 +324,40 @@ export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => { export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx, {}))); // 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, { identity, ...(permissions ? { permissions } : {}) }); +}); + +// The checkbox list of declared permissions, ticked where this user holds one directly. Undefined +// when Keto isn't wired — the rest of the edit page still works. +async function userPermissionPicker(deps: UsersDeps, id: string): Promise { + if (!deps.keto) return undefined; + const held = await heldPermissions(deps.keto, userSubject(id)); + return buildPermissionPicker({ + action: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}/permissions`, + declared: deps.ctx.declaredPermissions, + held, + t: deps.ctx.t, + }); +} + +// POST /admin/users/:id/permissions — the submitted checkboxes are the desired set; 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)); + 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) => { diff --git a/examples/plugins/admin/i18n/en-US.ts b/examples/plugins/admin/i18n/en-US.ts index 5235f1c..654ac00 100644 --- a/examples/plugins/admin/i18n/en-US.ts +++ b/examples/plugins/admin/i18n/en-US.ts @@ -48,6 +48,10 @@ const messages = { "admin.common.type": "Type", "admin.common.user": "User", + "admin.grants.legend": "Permissions", + "admin.grants.none": "No installed plugin declares a permission, so there is nothing to grant.", + "admin.grants.save": "Save permissions", + "admin.groups.actions": "Group actions", "admin.groups.addMember": "Add a member", "admin.groups.allMembers": "All users and groups are already members.", @@ -74,41 +78,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.lockoutUndeletable": "The permissions:write permission can't be deleted — nobody could grant a permission again.", - "admin.permissions.error.selfRevoke": "You can't revoke your own permissions:write grant.", - "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 are :, like scheduling:read — lowercase letters, digits, dashes and underscores on each side of the colon.", "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 26e189b..6bf7e0c 100644 --- a/examples/plugins/admin/i18n/sv-SE.ts +++ b/examples/plugins/admin/i18n/sv-SE.ts @@ -48,6 +48,10 @@ const messages: AdminMessages = { "admin.common.type": "Typ", "admin.common.user": "Användare", + "admin.grants.legend": "Behörigheter", + "admin.grants.none": "Ingen installerad plugin deklarerar någon behörighet, så det finns inget att tilldela.", + "admin.grants.save": "Spara behörigheter", + "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.", @@ -74,41 +78,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.lockoutUndeletable": "Behörigheten permissions:write kan inte tas bort — ingen skulle kunna tilldela behörigheter igen.", - "admin.permissions.error.selfRevoke": "Du kan inte återkalla din egen tilldelning av permissions:write.", - "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 är :<åtgärd>, som scheduling:read — små bokstäver, siffror, bindestreck och understreck på var sida om kolonet.", "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 index 276e177..44b77c1 100644 --- a/examples/plugins/admin/plugin.test.ts +++ b/examples/plugins/admin/plugin.test.ts @@ -23,12 +23,29 @@ test("the manifest declares no permission it never gates on", () => { 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", - "permissions:read", "permissions:write", "users:read", "users:write", ]); }); diff --git a/examples/plugins/admin/plugin.ts b/examples/plugins/admin/plugin.ts index 6cdc204..d3125ad 100644 --- a/examples/plugins/admin/plugin.ts +++ b/examples/plugins/admin/plugin.ts @@ -8,9 +8,8 @@ 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 { 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 AdminResource, permissionName } from "./admin-shared.ts"; // One route factory per screen: a GET gates on `:read` and a POST on `:write`, @@ -23,7 +22,6 @@ const on = (resource: AdminResource) => (method: HttpMethod, path: string, handl const users = on("users"); const groups = on("groups"); -const permissions = on("permissions"); const clients = on("oauth2-clients"); export default definePlugin({ @@ -32,12 +30,10 @@ export default definePlugin({ nav: [ADMIN_NAV], permissions: [ - { description: "View users", name: "users:read" }, - { description: "Create, edit and delete users", name: "users:write" }, - { description: "View groups and their members", name: "groups:read" }, - { description: "Create, delete and change the membership of groups", name: "groups:write" }, - { description: "View permissions and who holds them", name: "permissions:read" }, - { description: "Create, delete and grant permissions", name: "permissions:write" }, + { 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" }, ], @@ -53,6 +49,7 @@ export default definePlugin({ users("GET", "/users/:id/delete", usersDeleteConfirm), users("POST", "/users/:id/delete", usersDelete), users("POST", "/users/:id/recovery", usersRecovery), + users("POST", "/users/:id/permissions", usersPermissions), // Groups groups("GET", "/groups", groupsList), groups("POST", "/groups", groupsCreate), @@ -62,15 +59,7 @@ export default definePlugin({ groups("GET", "/groups/:name/delete", groupsDeleteConfirm), groups("POST", "/groups/:name/delete", groupsDelete), groups("POST", "/groups/:name/members/delete", groupsRemoveMember), - // Permissions - permissions("GET", "/permissions", rolesList), - permissions("POST", "/permissions", rolesCreate), - permissions("GET", "/permissions/new", rolesNewForm), - permissions("GET", "/permissions/:name", rolesDetail), - permissions("POST", "/permissions/:name/members", rolesAddMember), - permissions("GET", "/permissions/:name/delete", rolesDeleteConfirm), - permissions("POST", "/permissions/:name/delete", rolesDelete), - permissions("POST", "/permissions/:name/members/delete", rolesRemoveMember), + groups("POST", "/groups/:name/permissions", groupsPermissions), // OAuth2 clients clients("GET", "/clients", clientsList), clients("POST", "/clients", clientsCreate), diff --git a/examples/plugins/admin/views/group-detail.ejs b/examples/plugins/admin/views/group-detail.ejs index 313b7ce..54b7c81 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, 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/partials/group-detail-body.ejs b/examples/plugins/admin/views/partials/group-detail-body.ejs index 6b0fcba..2fc1f06 100644 --- a/examples/plugins/admin/views/partials/group-detail-body.ejs +++ b/examples/plugins/admin/views/partials/group-detail-body.ejs @@ -36,6 +36,9 @@

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

<% } -%> +<% if (locals.permissions) { -%> +<%- include("partials/permission-picker", { csrfToken: csrf, permissions: locals.permissions }) %> +<% } -%>
"> <%= 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..ac47faa --- /dev/null +++ b/examples/plugins/admin/views/partials/permission-picker.ejs @@ -0,0 +1,26 @@ +<%# + 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 already holds it. + The whole set posts back, so what is submitted IS the desired state (see admin-grants.ts). + Locals: csrfToken, permissions ({ action, choices, empty, field, legend, submit }). +%> +
+

<%= permissions.legend %>

+<% if (permissions.empty) { -%> +

<%= permissions.empty %>

+<% } else { -%> +
+ +
+ <%= permissions.legend %> +<% permissions.choices.forEach((c, i) => { -%> +
+ > + +
+<% }) -%> +
+ +
+<% } -%> +
diff --git a/examples/plugins/admin/views/partials/user-form-body.ejs b/examples/plugins/admin/views/partials/user-form-body.ejs index e9f2971..d15b7b9 100644 --- a/examples/plugins/admin/views/partials/user-form-body.ejs +++ b/examples/plugins/admin/views/partials/user-form-body.ejs @@ -26,6 +26,9 @@ +<% if (edit && locals.permissions) { -%> +<%- include("partials/permission-picker", { csrfToken: form.csrfToken, permissions: locals.permissions }) %> +<% } -%> <% if (edit) { -%>
">
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..8498e0b 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", { edit: model.edit, error: model.error, form: model.form, permissions: model.permissions, recovery: model.recovery }); -%> <%- include("partials/shell", { body, 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/src/http/app.test.ts b/src/http/app.test.ts index 3fabd13..28dc37b 100644 --- a/src/http/app.test.ts +++ b/src/http/app.test.ts @@ -15,7 +15,7 @@ import { CSRF_COOKIE, issueCsrfToken } from "../auth/csrf.ts"; import { can, check, GuardError, requireSession } from "../auth/guards.ts"; import { HydraError, type HydraAdmin, type OAuth2Client } from "../auth/hydra-admin.ts"; import { staticJwks } from "../auth/jwks.ts"; -import type { ExpandTree, KetoClient, RelationTuple, SubjectSet } from "../auth/keto-client.ts"; +import type { KetoClient, RelationTuple, SubjectSet } from "../auth/keto-client.ts"; import type { Identity, KratosAdmin } from "../auth/kratos-admin.ts"; import { KratosError, type Flow, type FlowType, type KratosPublic, type Session, type UiNode } from "../auth/kratos-public.ts"; import { SESSION_COOKIE } from "../auth/login.ts"; @@ -1250,104 +1250,43 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g assert.equal((await get("/admin/groups/%ZZ")).status, 404); }); -// Built-in Roles admin screen: gate + list/create/assign/revoke/delete over HTTP -// against a fake in-memory Keto whose `expand` mirrors Keto's transitive resolution, so the -// effective-access view surfaces a user reachable only through a group. -test("admin Roles screen: gate, list, create, assign user/group, effective access (expand), revoke, delete", async (t) => { +// Granting permissions over HTTP, on the two screens that replaced the deleted Permissions screen. +// The offered set is the host's catalog (ctx.declaredPermissions, from what the installed plugins +// declare), so the checkboxes are a fixed list and the POST is the desired state. +test("admin permission grants: the picker offers the declared catalog, and a save is the desired set", async (t) => { const ada = randomUUID(); - const grace = randomUUID(); - const identities: Identity[] = [ - { id: ada, schema_id: "default", state: "active", traits: { email: "ada@example.com" } }, - { id: grace, schema_id: "default", state: "active", traits: { email: "grace@example.com" } }, - ]; - // grace is in the `eng` group; `docs:write` is an existing permission whose only direct member is ada. - const tuples: RelationTuple[] = [ - { namespace: "Group", object: "eng", relation: "members", subject_id: `user:${grace}` }, - { namespace: "Permission", object: "docs:write", relation: "granted", subject_id: `user:${ada}` }, - ]; - // Mirror Keto's expand shape: the subject rides on `tuple`, set nodes carry members as children. - const expandSet = (set: SubjectSet): ExpandTree => ({ - children: tuples - .filter((tp) => tp.namespace === set.namespace && tp.object === set.object && tp.relation === set.relation) - .map((tp) => (tp.subject_id ? { tuple: { namespace: "", object: "", relation: "", subject_id: tp.subject_id }, type: "leaf" } : expandSet(tp.subject_set!))), - tuple: { namespace: "", object: "", relation: "", subject_set: set }, - type: "union", - }); - const keto = fakeKeto(tuples, { expand: async (set) => expandSet(set) }); - const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) }); - const denylist = createDenylist(); // granting/revoking a *user's* permission revokes their live tokens (a group change is transitive → left to lag) - const { get, post, token, url } = await adminHarness(t, { denylist, keto, kratosAdmin }); + const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }]; + const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${ada}` }]; + const keto = fakeKeto(tuples); + const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) }); + const denylist = createDenylist(); + const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin }); - await assertAdminGate(url, get, "/admin/permissions"); + // The user edit page renders one checkbox per declared permission, ticked where already held. + const edit = await (await get(`/admin/users/${ada}`)).text(); + for (const name of ["users:read", "users:write", "groups:read", "groups:write", "oauth2-clients:read", "oauth2-clients:write"]) { + assert.match(edit, new RegExp(`value="${name.replace(":", ":")}"`), name); + } + assert.match(edit, /value="users:read"[^>]*checked/); // held → ticked + assert.doesNotMatch(edit, /value="groups:write"[^>]*checked/); // not held → unticked - // List: the existing permission shows + the "add" link. - const listHtml = await (await get("/admin/permissions")).text(); - assert.match(listHtml, /href="\/admin\/permissions\/docs%3Awrite"/); - assert.match(listHtml, /href="\/admin\/permissions\/new"/); + // Save a new set: users:write is added, users:read is dropped — the POST is the whole truth. + const saved = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&permission=users%3Awrite&permission=groups%3Aread`); + assert.equal(saved.status, 303); + assert.deepEqual( + tuples.filter((tp) => tp.subject_id === `user:${ada}`).map((tp) => tp.object).sort(), + ["groups:read", "users:write"], + ); + assert.equal(denylist.isRevoked(ada, 0), true); // a change to your own grants revokes live tokens - // Create: a valid post writes the first-member tuple and redirects to the detail. - assert.match(await (await get("/admin/permissions/new")).text(), /Create permission/); - const created = await post("/admin/permissions", `_csrf=${token}&name=docs%3Aread&member=user:${ada}`); - assert.equal(created.status, 303); - assert.equal(created.headers.get("location"), "/admin/permissions/docs%3Aread"); - assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "docs:read" && tp.subject_id === `user:${ada}`)); - assert.equal(denylist.isRevoked(ada, 0), true); // assigning a permission to a user revokes their stale token so the grant lands now + // A crafted POST can't grant something no plugin declares. + await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&permission=users%3Awrite&permission=superuser%3Aall`); + assert.ok(!tuples.some((tp) => tp.object === "superuser:all")); - // An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written. - const before = tuples.length; - assert.equal((await post("/admin/permissions", `_csrf=${token}&name=Bad Name&member=user:${ada}`)).status, 400); - // A bare word has no : shape — the rule the create form now enforces. - assert.equal((await post("/admin/permissions", `_csrf=${token}&name=editor&member=user:${ada}`)).status, 400); - assert.equal((await post("/admin/permissions", `_csrf=${token}&name=docs%3Awrite&member=user:${ada}`)).status, 400); // already exists - assert.equal((await post("/admin/permissions", `name=x&member=user:${ada}`)).status, 403); - assert.equal(tuples.length, before); - - // Detail: ada (direct) is in the effective-access list; grace (only reachable via a group) is not - // yet — though grace appears elsewhere as an assignable candidate, so target the effective
  • . - const effectiveLi = (email: string) => new RegExp(`
  • ${email.replace(".", "\\.")}`); - const detail = await (await get("/admin/permissions/docs%3Awrite")).text(); - assert.match(detail, effectiveLi("ada@example.com")); - assert.doesNotMatch(detail, effectiveLi("grace@example.com")); - - // Assign the `eng` group to the permission → grace now holds it transitively (effective access via expand). - await post("/admin/permissions/docs%3Awrite/members", `_csrf=${token}&member=group:eng`); - assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "docs:write" && tp.subject_set?.object === "eng")); - const withGroup = await (await get("/admin/permissions/docs%3Awrite")).text(); - assert.match(withGroup, effectiveLi("grace@example.com")); - - // Revoke the group membership. - await post("/admin/permissions/docs%3Awrite/members/delete", `_csrf=${token}&member=group:eng`); - assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.object === "docs:write" && tp.subject_set?.object === "eng")); - - // Unassigning a *user* membership likewise revokes that user's live token, so the loss of access is immediate. - await post("/admin/permissions/docs%3Awrite/members", `_csrf=${token}&member=user:${grace}`); - await post("/admin/permissions/docs%3Awrite/members/delete", `_csrf=${token}&member=user:${grace}`); - assert.equal(denylist.isRevoked(grace, 0), true); - - // Delete the permission: a confirm step (GET) then the POST removes every member tuple, back to the list. - assert.match(await (await get("/admin/permissions/docs%3Awrite/delete")).text(), /Cancel/); - const del = await post("/admin/permissions/docs%3Awrite/delete", `_csrf=${token}`); - assert.equal(del.status, 303); - assert.equal(del.headers.get("location"), "/admin/permissions"); - assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.object === "docs:write")); - - // Self-protection: permissions:write can't be deleted — without it nobody could grant anything - // again — nor can you revoke your own direct grant of it (sub admin1). - tuples.push({ namespace: "Permission", object: "permissions:write", relation: "granted", subject_id: "user:admin1" }); - assert.equal((await post("/admin/permissions/permissions%3Awrite/delete", `_csrf=${token}`)).status, 400); - assert.ok(tuples.some((tp) => tp.object === "permissions:write")); - assert.equal((await post("/admin/permissions/permissions%3Awrite/members/delete", `_csrf=${token}&member=user:admin1`)).status, 400); - assert.ok(tuples.some((tp) => tp.object === "permissions:write" && tp.subject_id === "user:admin1")); - - // A permission written before the : rule stays addressable, so it can be cleaned up. - tuples.push({ namespace: "Permission", object: "legacy", relation: "granted", subject_id: `user:${ada}` }); - assert.equal((await get("/admin/permissions/legacy")).status, 200); - assert.equal((await post("/admin/permissions/legacy/delete", `_csrf=${token}`)).status, 303); - assert.ok(!tuples.some((tp) => tp.object === "legacy")); - - // An invalid permission name in the path → 404; malformed %-encoding doesn't 500. - assert.equal((await get("/admin/permissions/Bad%20Name")).status, 404); - assert.equal((await get("/admin/permissions/%ZZ")).status, 404); + // The same picker on a group writes the group's subject_set, which Keto resolves transitively. + tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` }); + await post("/admin/groups/eng/permissions", `_csrf=${token}&permission=groups%3Aread`); + assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "groups:read" && tp.subject_set?.object === "eng")); }); // Built-in OAuth2 clients admin screen: gate + list/register/detail/delete over HTTP against an diff --git a/src/http/app.ts b/src/http/app.ts index 55ca751..57a5cef 100644 --- a/src/http/app.ts +++ b/src/http/app.ts @@ -25,7 +25,7 @@ import type { KratosPublic } from "../auth/kratos-public.ts"; import { createLogger, type Log, requestLogger, runWithLog } from "../logger.ts"; import { remintSession } from "../auth/login.ts"; import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts"; -import type { Plugin, RouteHandler, RouteResult } from "../plugin-host/plugin.ts"; +import { declaredPermissions, type Plugin, type RouteHandler, type RouteResult } from "../plugin-host/plugin.ts"; import type { SystemCapabilities } from "../plugin-host/system.ts"; import { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts"; import { buildAuthRoutes } from "../auth/routes.ts"; @@ -99,6 +99,9 @@ export function createApp(options: AppOptions = {}): Server { const homePlugin = plugins.find((p): p is Plugin & { home: RouteHandler } => typeof p.home === "function"); const dashboardPlugin = plugins.find((p): p is Plugin & { dashboard: RouteHandler } => typeof p.dashboard === "function"); // Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free). + // The permission catalog is a property of the installed plugin set, so it is computed once at + // wiring rather than per request. + const permissionCatalog = declaredPermissions(plugins); const anyRequestHooks = plugins.some((p) => p.hooks?.onRequest); const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse); const pluginsDir = options.pluginsDir ?? PLUGINS_DIR; @@ -297,9 +300,9 @@ export function createApp(options: AppOptions = {}): Server { // base context (no route params yet); reused for the built-in routes. A plugin-owned render // (a landing slot, a hook short-circuit, a plugin route) gets `contextFor(id)` instead, so its // own catalog is what `ctx.t` reads. - const ctx = buildContext(req, res, { chrome, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) }); + const ctx = buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) }); const contextFor = (pluginId: string, params?: Record): RequestContext => - buildContext(req, res, { chrome, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) }); + buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) }); renderPage = viewsFor(ctx); // Plugin onRequest hooks run before routing and may short-circuit the request. diff --git a/src/http/context.ts b/src/http/context.ts index bb92138..9b50235 100644 --- a/src/http/context.ts +++ b/src/http/context.ts @@ -1,5 +1,6 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { PageChrome } from "../ui/chrome.ts"; // type-only: no runtime import, so no cycle +import type { PermissionDecl } from "../plugin-host/plugin.ts"; // type-only import type { SystemCapabilities } from "../plugin-host/system.ts"; // type-only import { DEFAULT_LOCALE } from "../i18n/catalog.ts"; import { ENGLISH } from "../i18n/english.ts"; @@ -37,6 +38,10 @@ export interface RequestContext { // log; `log.fetch(url)` for an upstream call (a client span continuing the trace). Correlates by // requestId. Additive, stable per the contract; defaults to a silent logger off the request path. log: Log; + // Every permission the installed plugins declare, deduped and sorted — the fixed list an admin + // screen offers when granting one. Pairs with `permissions` below: this is what *exists*, that is + // what *this user holds*. Empty when no installed plugin declares any. + declaredPermissions: PermissionDecl[]; params: Record; // path params from the route match, e.g. /users/:id → { id } permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q") @@ -61,6 +66,7 @@ export interface BuildContextOptions { // ctx.chrome (a json/redirect handler, or the public "/" with a standalone home, pays nothing). // The host's factory is memoised, so the menu composes at most once per request across contexts. chrome?: () => PageChrome; + declaredPermissions?: PermissionDecl[]; user?: User | null; locale?: string; localeHref?: (href: string) => string; @@ -89,6 +95,7 @@ export function buildContext( let chromeMemo: PageChrome | undefined; // resolve the factory at most once per context return { get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); }, + declaredPermissions: options.declaredPermissions ?? [], user, locale: options.locale ?? DEFAULT_LOCALE, localeHref: options.localeHref ?? ((href) => href), diff --git a/src/plugin-host/plugin.test.ts b/src/plugin-host/plugin.test.ts index 0a060c1..e56ff8f 100644 --- a/src/plugin-host/plugin.test.ts +++ b/src/plugin-host/plugin.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { checkApiVersion, + declaredPermissions, definePlugin, findConflicts, HOST_API_VERSION, @@ -58,6 +59,18 @@ test("isValidPermissionName requires : — a bare word names a } }); +test("declaredPermissions is the catalog: every plugin's declarations, deduped by name and sorted", () => { + const a: Plugin = { apiVersion: "1.0.0", id: "a", permissions: [{ description: "Write things", name: "things:write" }, { description: "Read things", name: "things:read" }] }; + const b: Plugin = { apiVersion: "1.0.0", id: "b", permissions: [{ description: "b's wording", name: "things:read" }, { name: "orders:read" }] }; + const c: Plugin = { apiVersion: "1.0.0", id: "c" }; // declaring none is fine + + const catalog = declaredPermissions([a, b, c]); + assert.deepEqual(catalog.map((p) => p.name), ["orders:read", "things:read", "things:write"]); + // A shared name is legitimate (findConflicts only warns); the first declaration wins its wording. + assert.equal(catalog.find((p) => p.name === "things:read")?.description, "Read things"); + assert.deepEqual(declaredPermissions([]), []); +}); + test("parseSemver follows the semver core, rejecting ranges, prefixes, leading zeros and missing parts", () => { assert.deepEqual(parseSemver("1.2.3"), { major: 1, minor: 2, patch: 3 }); assert.deepEqual(parseSemver("1.2.3-rc.1+build.5"), { major: 1, minor: 2, patch: 3 }); // prerelease/build tolerated, ignored diff --git a/src/plugin-host/plugin.ts b/src/plugin-host/plugin.ts index a719755..f90ee77 100644 --- a/src/plugin-host/plugin.ts +++ b/src/plugin-host/plugin.ts @@ -54,6 +54,18 @@ export function isValidPermissionName(name: string): boolean { return name.length <= 64 && PERMISSION_NAME.test(name); } +// Every permission the installed plugins declare, deduped by name and sorted — the fixed list the +// admin screens offer when granting. Permissions are authored in code, never invented in the GUI, so +// this *is* the catalog; a name in Keto that no plugin declares gates nothing and is not offered. +// First declaration of a name wins its description (shared names are legitimate, findConflicts warns). +export function declaredPermissions(plugins: Plugin[]): PermissionDecl[] { + const byName = new Map(); + for (const plugin of plugins) { + for (const decl of plugin.permissions ?? []) if (!byName.has(decl.name)) byName.set(decl.name, decl); + } + return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + // Optional hooks on system actions. Crash-isolation is a non-goal — a throwing hook fails loud. export interface PluginHooks { onBoot?: () => Promise | void; // after discovery, before the server listens diff --git a/todo.md b/todo.md index c784daa..baf2f86 100644 --- a/todo.md +++ b/todo.md @@ -2,8 +2,6 @@ ## Unfinnished work -- [ ] (Three decisions inherited from the 2026-08-05 architecture review, to settle inside the next item rather than re-derive: (1) `permissions:write` should mean "may grant a permission to anyone", not "may use the Permissions screen" — so the lockout guard relocates to Users/Groups instead of dying with the screen. (2) The `:` rule is now enforced at discovery, so deleting the screen no longer removes its enforcement — but the *create form* goes, so minting moves to whatever the new picker writes. (3) Orphans get worse, not better: `isPermissionPathSegment` exists so a name predating the rule stays deletable; once permissions are a fixed list from code, any Keto name not in that list becomes invisible — either give the Users/Groups screens an "unknown permissions held" affordance or decide explicitly to leave them to `curl`.) -- [ ] Permissions should be a list in code. Since no permissions exists in the database out of the box, but there are a fixed number of permissions in the plugins that the end consumer and user of plain pages can use, these permissions must surface to the UI somehow. The effects is that the permissions page should be deleted completely, and the users and groups pages should gain the functionality to add permissions to their things instead, provided the user have the right permissiosn to do so, of course. Run the product reviewer agent on this todo also. - [ ] The seeded admin@plainpages.local are assigned twice to the permission "admin", should only be one, right? (the "admin" permission name can be switched after previous todos have been done) - [ ] In Playwright tests, try different resolutions and sizes, from BIG desktop down to tiny phone. - [ ] Decide whether `e2e-tests/` should be typechecked. It is outside `tsconfig.include`, so the gate never checks the most logic-bearing file in it (`console-guard.ts`) — Playwright strips its types without checking them. Including it needs the DOM lib and `@playwright/test` present wherever `npm run typecheck` runs, which today is the `web` image that installs neither. Raised by review 2026-08-05. @@ -31,7 +29,8 @@ Prioritized. Overall verdict: architecture is sound (contract-first plugin API, ## Finnished work -- [x] Document permissions format so it is folled going forward: :, for example scheduling:read. Permission "admin" does not match this, and needs to be users:read, users:write, groups:read, groups:write. (README → [Naming a permission](README.md#naming-a-permission) is the one home for the rule, and it is *enforced* where names are minted rather than only written down: the admin plugin's create form now requires `:` (`isValidPermissionName`). `admin` is gone, split per screen into `users:`, `groups:`, `permissions:` and `oauth2-clients:` × `read`/`write` — eight, not the four the todo named, because leaving the Permissions and OAuth2-clients screens on `admin` would have kept the very name being removed. `oauth2-clients` rather than `clients` since the `` half is one global namespace. The read/write split is real, not cosmetic: `users:read` opens the list and is refused on every POST, and the Admin nav header lost its own gate so each screen is filtered by its own `:read` — hold none of the four and `composeNav` drops the emptied header. One pre-existing bug had to be fixed to get here: `isValidRoleName` was the *group* regex, with no colon, so `/admin/permissions/scheduling:read` already 404'd — every permission having a colon would have broken the whole screen. Addressing a permission is now looser than creating one (`isPermissionPathSegment`), so an `admin` tuple left in a running Keto stays viewable and deletable instead of stranding. `ADMIN_PERMISSIONS` defaults to empty — the bootstrap already seeds every discovered plugin's declared names, and a host-invented default would gate nothing.) +- [x] Document permissions format so it is folled going forward: :, for example scheduling:read. Permission "admin" does not match this, and needs to be users:read, users:write, groups:read, groups:write. (README → [Naming a permission](README.md#naming-a-permission) is the one home for the rule, and the host *enforces* it at discovery — `isValidPermissionName` in `src/plugin-host/plugin.ts`, checked by `shapeError` over every route/nav `permission` and every declared name — so a badly-named permission stops the boot like any other bad manifest, for every plugin rather than only ones the admin GUI touches. `admin` is gone, split per screen into `users:`, `groups:` and `oauth2-clients:` × `read`/`write`. The read/write split is real, not cosmetic: `users:read` opens the list and is refused on every POST, and the Admin nav header lost its own gate so each screen is filtered by its own `:read` — hold none of the three and `composeNav` drops the emptied header (which needs the header to carry no `href`, now asserted). Two things had to be fixed to get here. The permission path validator was the *group* regex with no colon, so `/admin/permissions/scheduling:read` already 404'd. And `ADMIN_PERMISSIONS` defaulting to empty exposed that `bootstrap` never bind-mounted `plugins/` at all — it discovered only the image's empty copy, so a dropped-in plugin's permissions were never seeded; the mount lives in `compose.override.yml` (dev-only, mirroring `web`'s `.:/app`) because the base file gives both services the same baked copy and a base-file mount would collide with the e2e stacks that bind plugins *inside* that path. Quick start now says `docker compose up -d`, which re-runs the one-shot. Verified end to end on a live stack.) +- [x] Permissions should be a list in code. Since no permissions exists in the database out of the box, but there are a fixed number of permissions in the plugins that the end consumer and user of plain pages can use, these permissions must surface to the UI somehow. The effects is that the permissions page should be deleted completely, and the users and groups pages should gain the functionality to add permissions to their things instead, provided the user have the right permissiosn to do so, of course. Run the product reviewer agent on this todo also. (The host collects every installed plugin's declarations into one catalog — `declaredPermissions()` → `ctx.declaredPermissions`, deduped and sorted, computed once at wiring — and that catalog *is* the fixed list. The Permissions screen is deleted outright: its module, tests, three views, two partials and 29 catalog keys per locale. Users and Groups each gained a checkbox list of the catalog, ticked where held; the whole set posts back, so what is submitted is the desired state and `grantDiff` turns it into grants + revokes. Two properties earn their tests: a crafted POST cannot grant a name no plugin declares, and a held-but-undeclared name — left over from an uninstalled plugin — is never silently revoked by an unrelated save, since the picker only speaks for what it showed. A user's own change revokes their live tokens; a group's reaches members at their next re-mint, the documented transitive lag. Keto stays optional on the Users screen: without it the page still lists and edits, minus the picker. Maintainer's call 2026-08-05 to keep the OAuth2-clients screen and gate it `oauth2-clients:read/write` — permissions and OAuth2 are orthogonal, scopes say what an *app* may see and permissions what a *user* may do, so the screen only ever needed *a* gate.) - [x] In Playwright tests, check for warnings and errors in all browsers on all the steps. If they exist, that is a failure we need to fix. (Every spec takes its `test` from `e2e-tests/console-guard.ts`, which watches every page a test opens — `console.error`, `console.warning`, and uncaught page errors — and fails the test that provoked one, at whatever step. The bar is zero rather than a curated tolerance list: the app ships no client JavaScript, so a message means a broken sub-resource, a rejected attribute, or an engine refusing a feature. Two narrow escapes, both explicit: the COOP header Chromium drops because the e2e stacks serve plain http over container hostnames (a deployment serves https, where it applies), and per-test `allowConsole(/…/)` — used once, by the 404 spec, whose own navigation Chromium and WebKit log. **All browsers** is now literal for the Ory-free suites: `visual.spec.ts` + `language.spec.ts` run in Chromium, Firefox *and* WebKit — the per-test `@engines` tag is gone, and screenshots are written per project so the three don't fight over one file — which is what makes an engine-specific message visible at all. The Ory-backed suites write users, groups and sessions to one shared backend, so they stay on Chromium; widening them needs a stack per engine. Nothing in the app had to be fixed: the sweep found only the two above. Verified by negative control — an injected `console.warn` failed the test in all three engines and an injected `console.error` failed on full-flow's shared serial page — which also caught the guard registering that page twice. `src/e2e-console-guard.test.ts` locks the wiring in the *unit* gate, since a spec importing `test` straight from Playwright would run unwatched and green.) - [x] Don't run tests when only markdown files in the root have changed. (Already shipped for *any* `*.md`, anywhere in the tree — `ci.sh`'s `docs_only()` no-ops the gate when every path changed since `main` ends in `.md`, and the workflow still pushes the commit-hash image so a merged docs commit stays releasable. Kept wider than "in the root" deliberately: no test reads a markdown file, so a nested `examples/plugins/admin/README.md` edit is as safe to skip as `README.md`, and narrowing it would spend the full gate on one. What was actually broken was rename detection — `git mv src/app.ts notes.md` names only the destination under `git diff --name-only`, and collapses to a single `R src/app.ts -> notes.md` line under `git status --porcelain`, so **moving code onto a `.md` path skipped the gate over a source file that was gone**. Both channels now pass `--no-renames`; verified against a scratch repo across ten scenarios — docs-only, mixed, empty diff, dirty tree, untracked code, deleted doc, and the rename staged *and* committed — the last two failing before the fix and passing after. `src/ci-gate.test.ts` locks both flags; it stays a text guard because the test image is `node:alpine` with neither `git` nor `bash`.) - [x] The little menues, like when choosing language or clicking my username, they do not dissapear when clicking outside them, I must click the original trigger or choose something. See if there are more modern ways of handling this with HTML and CSS. I think there is a modal-thing or something? (The modern thing is the **Popover API**. All three popup menus — language picker, profile, row kebab — are now a `