Permission names are <resource>:<action>, replacing the catch-all admin permission
CI / full-gate (push) Successful in 2m43s
CI / full-gate (push) Successful in 2m43s
This commit is contained in:
@@ -93,6 +93,16 @@ them. Revisit only if the stated reason stops holding.
|
||||
gates on one operation, so it gates on a permission, and a bundle is just a group with several
|
||||
grants (groups nest). Ory's own "permission" (the `Resource` `permits`: view/edit/delete) is the
|
||||
separate per-row tier.
|
||||
- **A permission name is always `<resource>:<action>`** — `scheduling:read`, `users:write`. Enforced
|
||||
where names are minted (the admin plugin's create form, `isValidPermissionName`) rather than only
|
||||
documented, so the convention survives an operator adding one by hand. A bare word names *who
|
||||
someone is* — a role — and roles are groups here; the old catch-all `admin` permission was exactly
|
||||
that mistake and was split into `users:`/`groups:`/`permissions:`/`oauth2-clients:` × `read`/`write`
|
||||
2026-08-05. Two consequences worth keeping straight: `<resource>` is global, not plugin-scoped
|
||||
(hence `oauth2-clients`, not `clients`), and *addressing* a permission stays looser than *creating*
|
||||
one (`isPermissionPathSegment`) so a name written before the rule can still be opened and deleted
|
||||
instead of stranding in Keto. `ADMIN_PERMISSIONS` therefore defaults to empty: every permission is
|
||||
owned by the plugin that gates on it, and a host-invented default would gate nothing.
|
||||
- **Plainpages says "user" everywhere; Ory's word for it is "identity".** Kratos calls the record
|
||||
an identity, but Ory's own docs state it uses that term *interchangeably* with "users" and
|
||||
"accounts" — so this is house style, not a renamed concept, and "user" is the word readers
|
||||
|
||||
@@ -35,7 +35,8 @@ cp -r examples/plugins/admin plugins/admin
|
||||
docker compose restart web
|
||||
```
|
||||
|
||||
The seeded admin already holds the `admin` permission, so the **Admin** section now shows in the menu.
|
||||
The bootstrap grants the seeded admin every permission the installed plugins declare, so the
|
||||
**Admin** section now shows in the menu.
|
||||
See [`examples/plugins/admin/`](examples/plugins/admin/).
|
||||
|
||||
**4. Add your first plugin.** The clone is bind-mounted into the container, so a new
|
||||
@@ -70,6 +71,7 @@ From here, render real pages against the app shell and fetch upstream data — s
|
||||
- [Overview](#overview)
|
||||
- [how it compares](#how-it-compares)
|
||||
- [Users, groups & permissions](#users-groups--permissions)
|
||||
- [naming a permission](#naming-a-permission)
|
||||
- [a worked example](#a-worked-example)
|
||||
- [granting a permission](#granting-a-permission)
|
||||
- [fine-grained, per-row access](#fine-grained-per-row-access)
|
||||
@@ -258,6 +260,28 @@ transitively, through nested groups).
|
||||
> **permission**. When you want the bundle, make a group and grant it several — groups nest, so a
|
||||
> group of groups works too.
|
||||
|
||||
### Naming a permission
|
||||
|
||||
**Every permission name is `<resource>:<action>`.** `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.
|
||||
|
||||
- **`<resource>`** names the thing acted on, not the plugin that happens to own it — permission
|
||||
names are one **global namespace**, so an operator grants `scheduling:read` once and every plugin
|
||||
referencing it is gated consistently. Pick a name no other plugin would claim for something else:
|
||||
`oauth2-clients`, not `clients`.
|
||||
- **`<action>`** names the operation. `read` and `write` cover most screens; use a more specific
|
||||
verb when the operation really is distinct (`invoices:approve`).
|
||||
|
||||
A bare word is the mistake this rule exists to stop. `admin` says *who someone is*, not *what they
|
||||
may do* — that is a role, and roles are **groups** here. Split it by resource and action, then
|
||||
bundle it back up with a group if you want one grant to hand out several:
|
||||
|
||||
```
|
||||
Group:it-support ──> Permission:users:read, Permission:users:write, Permission:groups:read, …
|
||||
```
|
||||
|
||||
### A worked example
|
||||
|
||||
Alice works support and leads scheduling; Bob works support; Carol administers the system.
|
||||
@@ -272,7 +296,8 @@ Alice works support and leads scheduling; Bob works support; Carol administers t
|
||||
│
|
||||
alice ────────────> Group:sched-leads ┴──> Permission:scheduling:write
|
||||
|
||||
carol ───────────────────────────────────────────────> Permission:admin
|
||||
carol ────────────> Group:it-support ─┬──> Permission:users:read
|
||||
└──> Permission:users:write
|
||||
```
|
||||
|
||||
At login the host asks Keto which permissions the user holds, walking those arrows
|
||||
@@ -282,11 +307,12 @@ JWT](#login-and-the-session-jwt)):
|
||||
```
|
||||
alice → permissions: ["scheduling:read", "scheduling:write"]
|
||||
bob → permissions: ["scheduling:read"]
|
||||
carol → permissions: ["admin"]
|
||||
carol → permissions: ["users:read", "users:write"]
|
||||
```
|
||||
|
||||
Note what Carol does *not* have. **Permissions do not nest, and there is no superuser** — `admin`
|
||||
is just another name, granting nothing except where a route gates on `admin` itself.
|
||||
Note what Carol does *not* have. **Permissions do not nest, and there is no superuser** — running
|
||||
the Users screen grants nothing on Groups, and nothing at all on `/scheduling`. `it-support` is the
|
||||
bundle; it is a **group**, not a permission.
|
||||
|
||||
Against the reference plugins' actual routes:
|
||||
|
||||
@@ -296,11 +322,15 @@ Against the reference plugins' actual routes:
|
||||
| `GET /scheduling/shifts` | `scheduling:read` | ✅ | ✅ | 403 | → `/login` |
|
||||
| `GET /scheduling/shifts/new` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
|
||||
| `POST /scheduling/shifts` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
|
||||
| `GET /admin/users` | `admin` | 403 | 403 | ✅ | → `/login` |
|
||||
| `GET /admin/users` | `users:read` | 403 | 403 | ✅ | → `/login` |
|
||||
| `POST /admin/users` | `users:write` | 403 | 403 | ✅ | → `/login` |
|
||||
| `GET /admin/groups` | `groups:read` | 403 | 403 | 403 | → `/login` |
|
||||
|
||||
Bob reaches the shifts list with no direct grant: he is in `support`, support's members are
|
||||
`staff`, and staff holds `scheduling:read` — two hops, resolved by Keto at his login. He is
|
||||
refused the new-shift form because `scheduling:write` hangs off `sched-leads`, which he is not in.
|
||||
Carol reads *and* writes users because `it-support` holds both halves, but the Groups screen is a
|
||||
different resource and she was never granted it.
|
||||
An anonymous visitor gets a **redirect**, not a 403, carrying `return_to` so signing in lands them
|
||||
on the page they asked for; a signed-in user who merely lacks the permission gets the 403 page,
|
||||
because there is nothing to sign in *as* that would help. The menu is filtered by the same
|
||||
@@ -319,9 +349,8 @@ curl -X PUT http://keto:4467/admin/relation-tuples -H 'content-type: application
|
||||
}'
|
||||
```
|
||||
|
||||
Permissions are authored **only in Keto** — nothing else writes them. Their names are a shared
|
||||
global namespace on purpose, so an operator grants `scheduling:read` once and every plugin
|
||||
referencing it is gated consistently; namespace yours as `<id>:<action>`.
|
||||
Permissions are authored **only in Keto** — nothing else writes them, and a name exists only while
|
||||
some tuple carries it. Name yours [`<resource>:<action>`](#naming-a-permission).
|
||||
|
||||
A change takes effect on the user's **next login or JWT re-mint** (~10 min) — see [Instant
|
||||
revoke](#instant-revoke-the-optional-denylist) when you need it sooner.
|
||||
@@ -741,7 +770,7 @@ with `findConflicts` and resolves them **loudly — never last-write-wins**. `er
|
||||
| `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/<id>` prefix is unique), so this catches a plugin duplicating one of its own. |
|
||||
| `nav-id` | error | A nav node `id` is used more than once — the central override targets ids, so they must be unique. |
|
||||
| `home` / `dashboard` | error | More than one plugin declares `home` (or `dashboard`). Each landing page is a single slot, so only one may own it ([The landing pages](#the-landing-pages-home--dashboard)). |
|
||||
| `permission` | warn | A permission name is declared by more than one plugin. Sharing is legitimate; namespace as `<id>:<action>` if unintended. |
|
||||
| `permission` | warn | A permission name is declared by more than one plugin. Sharing is legitimate; pick a more specific [`<resource>`](#naming-a-permission) if unintended. |
|
||||
|
||||
There is **no separate `basePath` rule**: the mount path is the derived `/<id>`, so its
|
||||
uniqueness follows from the id check. `permission` is the one intentional overlap, so it warns
|
||||
@@ -1682,8 +1711,8 @@ The server drains in-flight requests on `SIGTERM`/`SIGINT` rather than cutting t
|
||||
mid-response, so container restarts are clean.
|
||||
|
||||
The first-boot **bootstrap** is idempotent and runs on every `up` — it generates the JWT
|
||||
signing key if absent, creates the demo admin in Kratos, and grants it the `admin` permission plus
|
||||
every discovered plugin's declared permission names in Keto, so permission checks (and any
|
||||
signing key if absent, creates the demo admin in Kratos, and grants it every discovered plugin's
|
||||
declared permission names in Keto (plus any `ADMIN_PERMISSIONS`), so permission checks (and any
|
||||
dropped-in plugin) resolve out of the box. The web app waits for Kratos + Keto to be healthy
|
||||
*and* the bootstrap to finish before starting. **Change the demo admin before production.**
|
||||
|
||||
|
||||
+1
-1
@@ -132,7 +132,7 @@ services:
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
|
||||
# Base permissions for the demo admin; bootstrap also grants every discovered plugin's declared
|
||||
# permission names (so the reference plugin — and any drop-in — works out of the box).
|
||||
ADMIN_PERMISSIONS: ${ADMIN_PERMISSIONS:-admin}
|
||||
ADMIN_PERMISSIONS: ${ADMIN_PERMISSIONS:-}
|
||||
APP_URL: ${APP_URL:-http://localhost:3000} # printed in the first-run login banner
|
||||
JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json
|
||||
KETO_WRITE_URL: http://keto:4467
|
||||
|
||||
@@ -83,7 +83,7 @@ test("an expired session JWT is silently re-minted while Kratos lives, then clea
|
||||
const claims1 = jwtClaims(jwt1);
|
||||
expect(claims1.email).toBe(ADMIN_EMAIL);
|
||||
expect(claims1.sub, "sub is the Kratos identity id").toBeTruthy();
|
||||
expect(claims1.permissions, "permissions are projected from Keto").toContain("admin");
|
||||
expect(claims1.permissions, "permissions are projected from Keto").toContain("users:read");
|
||||
|
||||
// 2. Token timeout → refresh: once the 8s TTL lapses, the next request re-mints a fresh JWT.
|
||||
const jwt2Line = await awaitJwtSetCookie(session, jwt1);
|
||||
@@ -91,7 +91,7 @@ test("an expired session JWT is silently re-minted while Kratos lives, then clea
|
||||
expect(jwt2, "a different token was minted").not.toBe(jwt1);
|
||||
const claims2 = jwtClaims(jwt2);
|
||||
expect(claims2.exp, "the new token expires later").toBeGreaterThan(claims1.exp);
|
||||
expect(claims2.permissions, "re-mint re-reads permissions from Keto").toContain("admin");
|
||||
expect(claims2.permissions, "re-mint re-reads permissions from Keto").toContain("users:read");
|
||||
|
||||
// 3. Kill the Kratos session: now the lapsed token cannot refresh — the cookie is cleared.
|
||||
const revoke = await fetch(`${KRATOS_ADMIN}/admin/identities/${claims1.sub}/sessions`, { method: "DELETE" });
|
||||
|
||||
@@ -30,6 +30,13 @@ services:
|
||||
timeout: 4s
|
||||
retries: 30
|
||||
|
||||
# This stack mounts no plugins, so nothing declares a permission for the bootstrap to seed — and
|
||||
# the suite asserts that Keto's grants reach the JWT claim. Name one explicitly so there is
|
||||
# something to project.
|
||||
bootstrap:
|
||||
environment:
|
||||
ADMIN_PERMISSIONS: users:read
|
||||
|
||||
# Shorten the session→JWT TTL and expose a network-resolvable base_url (ory/kratos/e2e.yml),
|
||||
# merged after the base config.
|
||||
kratos:
|
||||
|
||||
@@ -102,8 +102,9 @@ test.describe.serial("authenticated admin journey", () => {
|
||||
});
|
||||
|
||||
test("menu filters by permission: an admin sees the gated Admin section + the plugin", async () => {
|
||||
// The signed-in admin holds admin + scheduling:read/write, so both gated sections are present
|
||||
// in the menu (collapsed by default → assert they're in the DOM, not necessarily visible).
|
||||
// The signed-in admin holds every permission the two mounted plugins declare (the bootstrap
|
||||
// seeds exactly those), so both gated sections are present in the menu (collapsed by default →
|
||||
// assert they're in the DOM, not necessarily visible).
|
||||
await page.goto("/dashboard");
|
||||
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(1);
|
||||
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(1);
|
||||
@@ -146,7 +147,7 @@ test.describe.serial("authenticated admin journey", () => {
|
||||
await expect(page).toHaveURL(/\/admin\/groups(\?|\/|$)/);
|
||||
await expect(page.locator("main")).toContainText(group);
|
||||
|
||||
const permission = `e2e-permission-${suffix}`;
|
||||
const permission = `e2e-${suffix}:read`; // permission names are <resource>:<action>; 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 });
|
||||
|
||||
@@ -10,8 +10,8 @@ cp -r examples/plugins/admin plugins/admin
|
||||
docker compose restart web
|
||||
```
|
||||
|
||||
The seeded `admin@plainpages.local` already holds the `admin` permission, so the section appears in the
|
||||
menu and the screens work immediately.
|
||||
The bootstrap grants the seeded `admin@plainpages.local` every permission this plugin declares, so the
|
||||
section appears in the menu and the screens work immediately.
|
||||
|
||||
Every string it renders comes from its own catalogs (`i18n/en-US.ts`, `i18n/sv-SE.ts`) — the nav
|
||||
labels included, which are catalog keys in `admin-shared.ts`. Each pure view-model builder takes an
|
||||
@@ -33,17 +33,24 @@ stack**, so they use the privileged **`ctx.system`** surface the host exposes to
|
||||
`ctx.system` is populated only when the host wired those services (the dev stack wires Kratos + Keto,
|
||||
and Hydra when configured). Where a capability is absent the screen degrades to a themed 503 rather
|
||||
than crashing — see `admin-shared.ts`. Everything else is an ordinary plugin: folder-discovered,
|
||||
gated per route by `permission: "admin"`, rendering the core building blocks in `views/`.
|
||||
gated per route by its screen's `<resource>:<action>` 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.
|
||||
|
||||
## Layout
|
||||
|
||||
- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` permission, and the
|
||||
route table — one thin handler per method+path, all gated by `permission: "admin"`.
|
||||
- `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
|
||||
view-model builders (unit-tested in the matching `*.test.ts`) plus thin per-route handlers keyed on
|
||||
`ctx.params` (the host extracts `:id`/`:name`), sharing a small `withX` wrapper that resolves the
|
||||
admin gate + the needed `ctx.system` clients once.
|
||||
- `admin-shared.ts` — the shared gate (`requireAdmin`), CSRF form reader (`guardedForm`), confirm
|
||||
screen's permission gate + the needed `ctx.system` clients once.
|
||||
- `admin-shared.ts` — the permission naming (`adminPermission`), the shared gate
|
||||
(`requirePermission`), CSRF form reader (`guardedForm`), confirm
|
||||
model, nav fragment, and the not-found / unavailable helpers.
|
||||
- `views/` — the screens' EJS, plus the admin-specific body partials under `views/partials/`. They
|
||||
`include()` the core building-block partials (shell, data-table, filter-bar, field, …).
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded.
|
||||
|
||||
import { type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "#plugin-api";
|
||||
import { ADMIN_CLIENTS_BASE, ADMIN_EN, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
||||
import { ADMIN_CLIENTS_BASE, ADMIN_EN, buildConfirmModel, guardedForm, notFound, requirePermission, unavailable } from "./admin-shared.ts";
|
||||
import type { FieldConfig } from "./admin-users.ts";
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
@@ -245,7 +245,7 @@ interface ClientsDeps { ctx: RequestContext; hydra: HydraAdmin; user: User; }
|
||||
|
||||
function withClients(inner: (deps: ClientsDeps) => Promise<RouteResult>): RouteHandler {
|
||||
return async (ctx) => {
|
||||
const user = requireAdmin(ctx);
|
||||
const user = requirePermission(ctx, "oauth2-clients");
|
||||
const hydra = ctx.system?.hydra;
|
||||
if (!hydra) return unavailable(ctx, ctx.t("admin.capability.hydra"));
|
||||
return inner({ ctx, hydra, user });
|
||||
|
||||
@@ -7,7 +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 { ADMIN_EN, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
||||
import { ADMIN_EN, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, requirePermission, unavailable } from "./admin-shared.ts";
|
||||
import type { FieldConfig } from "./admin-users.ts";
|
||||
|
||||
const GROUP_NS = "Group";
|
||||
@@ -285,13 +285,14 @@ async function groupExists(keto: KetoClient, name: string): Promise<boolean> {
|
||||
return page.tuples.length > 0;
|
||||
}
|
||||
|
||||
// Shared per-request deps for the Groups screen, resolved by `withGroups`: the gate + the Keto and
|
||||
// Kratos capabilities (else a themed 503). Each route below is a thin handler over these.
|
||||
// Shared per-request deps for the Groups screen, resolved by `withGroups`: the gate (`groups:read` on
|
||||
// a GET, `groups:write` on a POST) + the Keto and Kratos capabilities (else a themed 503). Each route
|
||||
// below is a thin handler over these.
|
||||
interface GroupsDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; user: User; }
|
||||
|
||||
function withGroups(inner: (deps: GroupsDeps) => Promise<RouteResult>): RouteHandler {
|
||||
return async (ctx) => {
|
||||
const user = requireAdmin(ctx);
|
||||
const user = requirePermission(ctx, "groups");
|
||||
const keto = ctx.system?.keto;
|
||||
const kratosAdmin = ctx.system?.kratosAdmin;
|
||||
if (!keto || !kratosAdmin) return unavailable(ctx, ctx.t("admin.capability.keto"));
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
buildPermissionFormModel,
|
||||
buildPermissionsListModel,
|
||||
expandToEffectiveUsers,
|
||||
isValidRoleName,
|
||||
isPermissionPathSegment,
|
||||
isValidPermissionName,
|
||||
permissionGrantTuple,
|
||||
} from "./admin-permissions.ts";
|
||||
import type { ExpandTree, RelationTuple } from "#plugin-api";
|
||||
@@ -22,13 +23,25 @@ const userTuple = (permission: string, n: number): RelationTuple =>
|
||||
const groupTuple = (permission: string, group: string): RelationTuple =>
|
||||
({ namespace: "Permission", object: permission, relation: "granted", subject_set: { namespace: "Group", object: group, relation: "members" } });
|
||||
|
||||
test("isValidRoleName + permissionGrantTuple map the form value to a Permission tuple over a user/group (else null)", () => {
|
||||
for (const ok of ["admin", "editor", "team-a", "a1_b9"]) assert.equal(isValidRoleName(ok), true, ok);
|
||||
for (const bad of ["", "Admin", "a b", "-bad", "a".repeat(65)]) assert.equal(isValidRoleName(bad), false, bad);
|
||||
test("isValidPermissionName requires <resource>:<action> so the convention holds for anything created here", () => {
|
||||
for (const ok of ["users:read", "scheduling:write", "oauth2-clients:read", "team-a:a1_b9"]) assert.equal(isValidPermissionName(ok), true, ok);
|
||||
// A bare word is what this rule exists to stop — "admin" says who you are, not what you may do.
|
||||
for (const bad of ["admin", "", "Users:read", "users:", ":read", "users:read:extra", "a b:read", "-bad:read", `${"a".repeat(60)}:read`]) {
|
||||
assert.equal(isValidPermissionName(bad), false, bad);
|
||||
}
|
||||
});
|
||||
|
||||
assert.deepEqual(permissionGrantTuple("editor", `user:${uid(2)}`), { namespace: "Permission", object: "editor", relation: "granted", subject_id: `user:${uid(2)}` });
|
||||
assert.deepEqual(permissionGrantTuple("editor", "group:eng"), { namespace: "Permission", object: "editor", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
|
||||
for (const bad of ["", "user:not-a-uuid", "group:Bad Name", "nope:x"]) assert.equal(permissionGrantTuple("editor", bad), null, bad);
|
||||
test("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", () => {
|
||||
@@ -87,7 +100,7 @@ test("buildPermissionFormModel: a create form with a required name field + membe
|
||||
});
|
||||
|
||||
test("buildPermissionDetailModel: members → rows, add-options exclude current members, effective access listed, actions wired", () => {
|
||||
const members = [memberView(userTuple("admin", 1), new Map([[uid(1), "ada@example.com"]])), memberView(groupTuple("admin", "eng"), new Map())];
|
||||
const 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)}` },
|
||||
@@ -95,12 +108,14 @@ test("buildPermissionDetailModel: members → rows, add-options exclude current
|
||||
{ label: "ops (group)", value: "group:ops" },
|
||||
];
|
||||
const effective = [{ label: "ada@example.com" }, { label: "grace@example.com" }]; // ada direct, grace via eng
|
||||
const m = buildPermissionDetailModel({ candidates, effective, members, permission: { name: "admin" } });
|
||||
assert.equal(m.title, "admin");
|
||||
const m = buildPermissionDetailModel({ candidates, effective, members, permission: { name: "users:read" } });
|
||||
assert.equal(m.title, "users:read");
|
||||
assert.equal(m.members.rows.length, 2);
|
||||
assert.equal(m.members.action, "/admin/permissions/admin/members/delete");
|
||||
assert.equal(m.add.action, "/admin/permissions/admin/members");
|
||||
// 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/admin/delete");
|
||||
assert.equal(m.delete.action, "/admin/permissions/users%3Aread/delete");
|
||||
});
|
||||
|
||||
@@ -9,11 +9,10 @@
|
||||
// ctx.params) over a shared `withRoles` gate — admin-only, CSRF-guarded.
|
||||
|
||||
import { type ExpandTree, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "#plugin-api";
|
||||
import { ADMIN_EN, ADMIN_PERMISSION, ADMIN_PERMISSIONS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
||||
import { ADMIN_EN, ADMIN_PERMISSIONS_BASE, adminPermission, buildConfirmModel, guardedForm, notFound, requirePermission, unavailable } from "./admin-shared.ts";
|
||||
import {
|
||||
type GroupView,
|
||||
groupsFromTuples,
|
||||
isValidGroupName,
|
||||
memberCandidates,
|
||||
type MemberOption,
|
||||
type MemberView,
|
||||
@@ -25,16 +24,33 @@ 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 = adminPermission("permissions", "POST");
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
const PAGE_SIZES = [25, 50, 100];
|
||||
// Expand far past any sane group-nesting depth so the effective-access view never silently
|
||||
// under-reports the deepest members (Keto's own default is shallow).
|
||||
const EXPAND_MAX_DEPTH = 50;
|
||||
|
||||
// A permission and a group share the URL-safe name rule and the user|group membership model.
|
||||
// A permission and a group share the user|group membership model, but not the name rule: a
|
||||
// permission is `<resource>:<action>` (README → Users, groups & permissions).
|
||||
export type PermissionView = GroupView;
|
||||
export const isValidRoleName = isValidGroupName;
|
||||
export const permissionsFromTuples = groupsFromTuples;
|
||||
|
||||
const PERMISSION_NAME = /^[a-z0-9][a-z0-9_-]*:[a-z0-9][a-z0-9_-]*$/;
|
||||
const PERMISSION_SEGMENT = /^[a-z0-9][a-z0-9_:-]*$/;
|
||||
|
||||
// Creating one enforces the convention, so it holds going forward.
|
||||
export function isValidPermissionName(name: string): boolean {
|
||||
return name.length <= 64 && PERMISSION_NAME.test(name);
|
||||
}
|
||||
|
||||
// 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.
|
||||
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)
|
||||
}
|
||||
@@ -266,7 +282,7 @@ interface RolesDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: Kratos
|
||||
|
||||
function withRoles(inner: (deps: RolesDeps) => Promise<RouteResult>): RouteHandler {
|
||||
return async (ctx) => {
|
||||
const user = requireAdmin(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"));
|
||||
@@ -278,7 +294,7 @@ function withRoles(inner: (deps: RolesDeps) => Promise<RouteResult>): RouteHandl
|
||||
function withRoleName(inner: (deps: RolesDeps, name: string) => Promise<RouteResult>): RouteHandler {
|
||||
return withRoles((deps) => {
|
||||
const name = deps.ctx.params["name"] ?? "";
|
||||
if (!isValidRoleName(name)) return Promise.resolve(notFound(deps.ctx));
|
||||
if (!isPermissionPathSegment(name)) return Promise.resolve(notFound(deps.ctx));
|
||||
return inner(deps, name);
|
||||
});
|
||||
}
|
||||
@@ -312,7 +328,7 @@ export const rolesCreate = withRoles(async (deps) => {
|
||||
const member = (form.get("member") ?? "").trim();
|
||||
const tuple = permissionGrantTuple(name, member);
|
||||
const reject = async (error: string): Promise<RouteResult> => ({ ...(await roleFormResult(deps, { error, values: { member, name } })), status: 400 });
|
||||
if (!isValidRoleName(name)) return reject(ctx.t("admin.permissions.validation.name"));
|
||||
if (!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);
|
||||
@@ -337,9 +353,9 @@ export const rolesAddMember = withRoleName(async (deps, name) => {
|
||||
return { redirect: detailHref(name) };
|
||||
});
|
||||
|
||||
// GET /admin/permissions/:name/delete — confirm, except the admin permission can't be deleted.
|
||||
// GET /admin/permissions/:name/delete — confirm, except the lockout permission can't be deleted.
|
||||
export const rolesDeleteConfirm = withRoleName((deps, name) => {
|
||||
if (name === ADMIN_PERMISSION) return permissionDetailResult(deps, name, deps.ctx.t("admin.permissions.error.adminUndeletable"));
|
||||
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({
|
||||
@@ -350,24 +366,24 @@ export const rolesDeleteConfirm = withRoleName((deps, name) => {
|
||||
});
|
||||
|
||||
// POST /admin/permissions/:name/delete — remove every member tuple (a whole-permission delete lags per the
|
||||
// documented instant-revoke tradeoff; the admin permission is protected).
|
||||
// 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 === ADMIN_PERMISSION) return permissionDetailResult(deps, name, deps.ctx.t("admin.permissions.error.adminUndeletable"));
|
||||
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: an admin can't revoke their own *direct* admin grant (a group-held admin isn't
|
||||
// covered — the robust "last effective admin" check is deferred).
|
||||
// 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 === ADMIN_PERMISSION && member === `user:${user.id}`) return permissionDetailResult(deps, name, deps.ctx.t("admin.permissions.error.selfRevoke"));
|
||||
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) };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Direct units for the admin plugin's shared nav + auth helpers. They're security-critical
|
||||
// (requireAdmin/guardedForm gate every admin write) and reused across all four screens, so pin the
|
||||
// (requirePermission/guardedForm gate every admin write) and reused across all four screens, so pin the
|
||||
// contract here in isolation; the HTTP routing/gate/CSRF is exercised end-to-end in src/http/app.test.ts.
|
||||
// Import only from the #plugin-api barrel — the same contract boundary the plugin code uses.
|
||||
import assert from "node:assert/strict";
|
||||
@@ -7,9 +7,10 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Readable } from "node:stream";
|
||||
import { test } from "node:test";
|
||||
import { GuardError, type Log, type PageChrome, type RequestContext, type User } from "#plugin-api";
|
||||
import { ADMIN_EN, ADMIN_NAV, ADMIN_PERMISSION, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts";
|
||||
import { ADMIN_EN, ADMIN_NAV, ADMIN_USERS_BASE, adminPermission, buildConfirmModel, guardedForm, requirePermission } from "./admin-shared.ts";
|
||||
|
||||
const admin: User = { email: "ada@x.io", id: "u1", permissions: ["admin"] };
|
||||
const reader: User = { email: "ada@x.io", id: "u1", permissions: ["users:read"] };
|
||||
const writer: User = { email: "cy@x.io", id: "u3", permissions: ["users:read", "users:write"] };
|
||||
const member: User = { email: "bo@x.io", id: "u2", permissions: ["scheduling:read"] };
|
||||
const CHROME = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } } as PageChrome;
|
||||
|
||||
@@ -26,24 +27,42 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver
|
||||
|
||||
// ---- nav fragment ----
|
||||
|
||||
test("ADMIN_NAV: a gated Admin header over the four screens; no per-request current/open state", () => {
|
||||
test("ADMIN_NAV: an ungated Admin header whose four screens each gate on their own read permission", () => {
|
||||
assert.equal(ADMIN_NAV.id, "admin");
|
||||
assert.equal(ADMIN_NAV.permission, ADMIN_PERMISSION); // gate on the header ⇒ composeNav drops the whole subtree for a non-admin
|
||||
// No gate on the header: a user may hold one screen's permission and not another's. composeNav
|
||||
// drops a header left with no visible children, so holding none of the four hides the section.
|
||||
assert.equal(ADMIN_NAV.permission, 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"]);
|
||||
// Labels are catalog keys; the host translates them with this plugin's catalog when it composes
|
||||
// the menu, so what a visitor sees is the en-US (or sv-SE …) wording behind these keys.
|
||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["admin.nav.users", "admin.nav.groups", "admin.nav.permissions", "admin.nav.clients"]);
|
||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => ADMIN_EN(c.label)), ["Users", "Groups", "Permissions", "OAuth2 clients"]);
|
||||
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined && c.permission === undefined)); // the header's gate covers the subtree
|
||||
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined));
|
||||
});
|
||||
|
||||
// ---- permission naming ----
|
||||
|
||||
test("adminPermission builds <resource>:<action> — read for GET/HEAD, write for every mutation", () => {
|
||||
assert.equal(adminPermission("users", "GET"), "users:read");
|
||||
assert.equal(adminPermission("users", "HEAD"), "users:read"); // a GET route also answers HEAD
|
||||
assert.equal(adminPermission("users", "POST"), "users:write");
|
||||
assert.equal(adminPermission("groups", "DELETE"), "groups:write"); // anything that isn't a read is a write
|
||||
assert.equal(adminPermission("oauth2-clients", "get"), "oauth2-clients:read"); // method case is the caller's
|
||||
});
|
||||
|
||||
// ---- auth gates ----
|
||||
|
||||
test("requireAdmin: anonymous → 401→/login, signed-in non-admin → 403, admin → the user", () => {
|
||||
assert.throws(() => requireAdmin(fakeCtx({ user: null })), (e: unknown) => e instanceof GuardError && e.status === 401 && e.location === "/login?return_to=%2Fadmin%2Fusers"); // bounce remembers the page
|
||||
assert.throws(() => requireAdmin(fakeCtx({ user: member })), (e: unknown) => e instanceof GuardError && e.status === 403);
|
||||
assert.equal(requireAdmin(fakeCtx({ user: admin })), admin);
|
||||
test("requirePermission: anonymous → 401→/login, wrong permission → 403, and read never grants write", () => {
|
||||
assert.throws(() => requirePermission(fakeCtx({ user: null }), "users"), (e: unknown) => e instanceof GuardError && e.status === 401 && e.location === "/login?return_to=%2Fadmin%2Fusers"); // bounce remembers the page
|
||||
assert.throws(() => requirePermission(fakeCtx({ user: member }), "users"), (e: unknown) => e instanceof GuardError && e.status === 403);
|
||||
assert.equal(requirePermission(fakeCtx({ user: reader }), "users"), reader);
|
||||
// The whole point of the split: users:read opens the list but not the create/delete POSTs.
|
||||
assert.throws(() => requirePermission(fakeCtx({ method: "POST", user: reader }), "users"), (e: unknown) => e instanceof GuardError && e.status === 403);
|
||||
assert.equal(requirePermission(fakeCtx({ method: "POST", user: writer }), "users"), writer);
|
||||
// Resources don't leak into each other: a users holder is not a groups holder.
|
||||
assert.throws(() => requirePermission(fakeCtx({ user: writer }), "groups"), (e: unknown) => e instanceof GuardError && e.status === 403);
|
||||
});
|
||||
|
||||
test("guardedForm: valid double-submit → the parsed body, bad token → 403, non-POST → undefined", async () => {
|
||||
|
||||
@@ -11,36 +11,47 @@ import enUS from "./i18n/en-US.ts";
|
||||
// ctx.t, which reads this catalog in the visitor's locale first, then the host's.
|
||||
export const ADMIN_EN: Translate = englishTranslator(enUS);
|
||||
|
||||
export const ADMIN_PERMISSION = "admin"; // the permission gating the whole admin section
|
||||
export const ADMIN_USERS_BASE = "/admin/users";
|
||||
export const ADMIN_GROUPS_BASE = "/admin/groups";
|
||||
export const ADMIN_PERMISSIONS_BASE = "/admin/permissions";
|
||||
export const ADMIN_CLIENTS_BASE = "/admin/clients";
|
||||
|
||||
export type AdminScreen = "clients" | "groups" | "permissions" | "users";
|
||||
// One resource per screen — the `<resource>` 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";
|
||||
|
||||
// The plugin's nav fragment: the gated "Admin" header + its four screens. The host composes it into
|
||||
// the one global menu, filters per user (the header's `permission` drops the whole subtree for a
|
||||
// non-admin), and current-marks the active item — so there is no `current`/`open` state here.
|
||||
// `<resource>:<action>` (README → Users, groups & permissions). Every screen reads on GET/HEAD and
|
||||
// mutates on POST, so the manifest's route table and the in-handler guard both derive the name here
|
||||
// rather than each spelling it out — they cannot drift into gating on different permissions.
|
||||
export function adminPermission(resource: AdminResource, method: string): string {
|
||||
const verb = method.toUpperCase();
|
||||
return `${resource}:${verb === "GET" || verb === "HEAD" ? "read" : "write"}`;
|
||||
}
|
||||
|
||||
// The plugin's nav fragment: an ungated "Admin" header + its four screens, each gated on its own
|
||||
// read permission. The header carries no `permission` because a user may hold one screen's and not
|
||||
// another's; composeNav drops a header left with no visible children, so a user holding none of the
|
||||
// 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" },
|
||||
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "admin.nav.groups" },
|
||||
{ href: ADMIN_PERMISSIONS_BASE, icon: "i-shield", id: "permissions", label: "admin.nav.permissions" },
|
||||
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "admin.nav.clients" },
|
||||
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users", permission: "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" },
|
||||
],
|
||||
icon: "i-shield",
|
||||
id: "admin",
|
||||
label: "admin.nav.section", // a key in this plugin's catalog; the host translates nav labels
|
||||
permission: ADMIN_PERMISSION,
|
||||
};
|
||||
|
||||
// The admin gate: a signed-in admin only. Each route already declares `permission: "admin"`, so the
|
||||
// host enforces this before the handler runs; this is defence-in-depth and what a direct unit test
|
||||
// relies on. Returns the (non-null) user for the handler to thread on. GuardError → /login or 403.
|
||||
export function requireAdmin(ctx: RequestContext): User {
|
||||
// The screen gate: a signed-in user holding this request's `<resource>:<action>`. Each route already
|
||||
// declares the same permission, so the host enforces it before the handler runs; this is
|
||||
// defence-in-depth and what a direct unit test relies on. Returns the (non-null) user for the
|
||||
// handler to thread on. GuardError → /login or 403.
|
||||
export function requirePermission(ctx: RequestContext, resource: AdminResource): User {
|
||||
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
|
||||
if (!can(ctx, ADMIN_PERMISSION)) throw new GuardError(403, "admin permission required");
|
||||
const permission = adminPermission(resource, ctx.req.method ?? "GET");
|
||||
if (!can(ctx, permission)) throw new GuardError(403, `${permission} required`);
|
||||
return user;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// — admin-only, CSRF-guarded, each returning a RouteResult (a view, or a redirect after a write — PRG).
|
||||
|
||||
import { type Identity, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "#plugin-api";
|
||||
import { ADMIN_EN, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
||||
import { 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
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
@@ -266,15 +266,16 @@ function readUserInput(form: URLSearchParams): UserInput {
|
||||
};
|
||||
}
|
||||
|
||||
// Shared per-request deps for the Users screen, resolved by `withUser`: the gate (admin only) and
|
||||
// the Kratos capability (else a themed 503). Each route below is a thin handler over these.
|
||||
// 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; }
|
||||
|
||||
// Resolve the shared deps, then run `inner`. The route's `permission: "admin"` already gated at the
|
||||
// host; `requireAdmin` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it.
|
||||
// Resolve the shared deps, then run `inner`. The route's own `permission` already gated at the host;
|
||||
// `requirePermission` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it.
|
||||
function withUser(inner: (deps: UsersDeps) => Promise<RouteResult>): RouteHandler {
|
||||
return async (ctx) => {
|
||||
const user = requireAdmin(ctx);
|
||||
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 });
|
||||
|
||||
@@ -92,8 +92,8 @@ const messages = {
|
||||
"admin.permissions.create": "Create permission",
|
||||
"admin.permissions.delete": "Delete permission",
|
||||
"admin.permissions.deleteMessage": "Delete permission {{name}}? This revokes it from everyone it's assigned to.",
|
||||
"admin.permissions.error.adminUndeletable": "The admin permission can't be deleted — it would remove all admin access.",
|
||||
"admin.permissions.error.selfRevoke": "You can't revoke your own admin access.",
|
||||
"admin.permissions.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",
|
||||
@@ -108,7 +108,7 @@ const messages = {
|
||||
"admin.permissions.searchPlaceholder": "Search permission name…",
|
||||
"admin.permissions.title": "Permissions",
|
||||
"admin.permissions.validation.member": "Pick a user or group to assign the permission to.",
|
||||
"admin.permissions.validation.name": "Permission names use lowercase letters, digits, dashes and underscores.",
|
||||
"admin.permissions.validation.name": "Permission names are <resource>:<action>, 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",
|
||||
|
||||
@@ -92,8 +92,8 @@ const messages: AdminMessages = {
|
||||
"admin.permissions.create": "Skapa behörighet",
|
||||
"admin.permissions.delete": "Radera behörighet",
|
||||
"admin.permissions.deleteMessage": "Ta bort behörigheten {{name}}? Den återkallas från alla den är tilldelad till.",
|
||||
"admin.permissions.error.adminUndeletable": "Behörigheten admin kan inte tas bort — det skulle ta bort all administratörsåtkomst.",
|
||||
"admin.permissions.error.selfRevoke": "Du kan inte återkalla din egen administratörsåtkomst.",
|
||||
"admin.permissions.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",
|
||||
@@ -108,7 +108,7 @@ const messages: AdminMessages = {
|
||||
"admin.permissions.searchPlaceholder": "Sök på behörighetens namn…",
|
||||
"admin.permissions.title": "Behörigheter",
|
||||
"admin.permissions.validation.member": "Välj en användare eller grupp att tilldela behörigheten till.",
|
||||
"admin.permissions.validation.name": "Behörighetsnamn använder små bokstäver, siffror, bindestreck och understreck.",
|
||||
"admin.permissions.validation.name": "Behörighetsnamn är <resurs>:<å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",
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// The manifest's own invariants. A route gating on a permission the manifest doesn't declare is
|
||||
// silent: bootstrap seeds only declared names, so the demo admin would simply 403 on that screen
|
||||
// with nothing in the logs to explain it. Pin the two halves against each other here.
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import manifest from "./plugin.ts";
|
||||
|
||||
const routes = manifest.routes ?? [];
|
||||
const declared = (manifest.permissions ?? []).map((p) => p.name);
|
||||
|
||||
test("every route is gated, and gates on a permission the manifest declares", () => {
|
||||
assert.ok(routes.length > 0);
|
||||
for (const route of routes) {
|
||||
assert.equal(route.public, undefined, `${route.method} ${route.path} must not be public`);
|
||||
assert.ok(route.permission, `${route.method} ${route.path} has no permission`);
|
||||
assert.ok(declared.includes(route.permission!), `${route.method} ${route.path} gates on undeclared ${route.permission}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the manifest declares no permission it never gates on", () => {
|
||||
const gated = new Set(routes.map((r) => r.permission));
|
||||
for (const name of declared) assert.ok(gated.has(name), `declared but unused: ${name}`);
|
||||
});
|
||||
|
||||
test("every declared permission is <resource>:<action>, and reads and writes are split per resource", () => {
|
||||
for (const name of declared) assert.match(name, /^[a-z0-9][a-z0-9_-]*:(read|write)$/, name);
|
||||
assert.deepEqual([...declared].sort(), [
|
||||
"groups:read", "groups:write",
|
||||
"oauth2-clients:read", "oauth2-clients:write",
|
||||
"permissions:read", "permissions:write",
|
||||
"users:read", "users:write",
|
||||
]);
|
||||
});
|
||||
|
||||
test("GET routes gate on read and mutations on write, so a reader can open a screen but not change it", () => {
|
||||
for (const route of routes) {
|
||||
const action = route.method === "GET" ? "read" : "write";
|
||||
assert.ok(route.permission?.endsWith(`:${action}`), `${route.method} ${route.path} → ${route.permission}`);
|
||||
}
|
||||
});
|
||||
@@ -11,55 +11,72 @@ import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clie
|
||||
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsRemoveMember } from "./admin-groups.ts";
|
||||
import { rolesAddMember, rolesCreate, rolesDelete, rolesDeleteConfirm, rolesDetail, rolesList, rolesNewForm, rolesRemoveMember } from "./admin-permissions.ts";
|
||||
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
|
||||
import { ADMIN_NAV, ADMIN_PERMISSION } from "./admin-shared.ts";
|
||||
import { ADMIN_NAV, adminPermission, type AdminResource } from "./admin-shared.ts";
|
||||
|
||||
// Every admin route is gated by the one `admin` permission — the host redirects an anonymous visitor
|
||||
// to /login, gives a signed-in non-admin the 403 page, and filters the nav the same way. Handlers are
|
||||
// thin and keyed on ctx.params (the host extracts :id / :name), the idiomatic per-route style.
|
||||
const r = (method: HttpMethod, path: string, handler: RouteHandler): Route => ({ handler, method, path, permission: ADMIN_PERMISSION });
|
||||
// One route factory per screen: `permission` is derived by `adminPermission`, so a GET gates on
|
||||
// `<resource>:read` and a POST on `<resource>:write` and the table below cannot drift from the guard
|
||||
// each handler runs. The host redirects an anonymous visitor to /login, gives a signed-in user
|
||||
// missing the permission the 403 page, and filters the nav the same way. Handlers are thin and keyed
|
||||
// on ctx.params (the host extracts :id / :name), the idiomatic per-route style.
|
||||
const on = (resource: AdminResource) => (method: HttpMethod, path: string, handler: RouteHandler): Route =>
|
||||
({ handler, method, path, permission: adminPermission(resource, method) });
|
||||
|
||||
const users = on("users");
|
||||
const groups = on("groups");
|
||||
const permissions = on("permissions");
|
||||
const clients = on("oauth2-clients");
|
||||
|
||||
export default definePlugin({
|
||||
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||
|
||||
nav: [ADMIN_NAV],
|
||||
|
||||
permissions: [{ description: "Administer users, groups, permissions, and OAuth2 clients", name: ADMIN_PERMISSION }],
|
||||
permissions: [
|
||||
{ description: "View users", 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 OAuth2 clients", name: "oauth2-clients:read" },
|
||||
{ description: "Register and delete OAuth2 clients", name: "oauth2-clients:write" },
|
||||
],
|
||||
|
||||
routes: [
|
||||
// Users
|
||||
r("GET", "/users", usersList),
|
||||
r("POST", "/users", usersCreate),
|
||||
r("GET", "/users/new", usersNewForm),
|
||||
r("GET", "/users/:id", usersEditForm),
|
||||
r("POST", "/users/:id", usersUpdate),
|
||||
r("POST", "/users/:id/state", usersState),
|
||||
r("GET", "/users/:id/delete", usersDeleteConfirm),
|
||||
r("POST", "/users/:id/delete", usersDelete),
|
||||
r("POST", "/users/:id/recovery", usersRecovery),
|
||||
users("GET", "/users", usersList),
|
||||
users("POST", "/users", usersCreate),
|
||||
users("GET", "/users/new", usersNewForm),
|
||||
users("GET", "/users/:id", usersEditForm),
|
||||
users("POST", "/users/:id", usersUpdate),
|
||||
users("POST", "/users/:id/state", usersState),
|
||||
users("GET", "/users/:id/delete", usersDeleteConfirm),
|
||||
users("POST", "/users/:id/delete", usersDelete),
|
||||
users("POST", "/users/:id/recovery", usersRecovery),
|
||||
// Groups
|
||||
r("GET", "/groups", groupsList),
|
||||
r("POST", "/groups", groupsCreate),
|
||||
r("GET", "/groups/new", groupsNewForm),
|
||||
r("GET", "/groups/:name", groupsDetail),
|
||||
r("POST", "/groups/:name/members", groupsAddMember),
|
||||
r("GET", "/groups/:name/delete", groupsDeleteConfirm),
|
||||
r("POST", "/groups/:name/delete", groupsDelete),
|
||||
r("POST", "/groups/:name/members/delete", groupsRemoveMember),
|
||||
// Roles
|
||||
r("GET", "/permissions", rolesList),
|
||||
r("POST", "/permissions", rolesCreate),
|
||||
r("GET", "/permissions/new", rolesNewForm),
|
||||
r("GET", "/permissions/:name", rolesDetail),
|
||||
r("POST", "/permissions/:name/members", rolesAddMember),
|
||||
r("GET", "/permissions/:name/delete", rolesDeleteConfirm),
|
||||
r("POST", "/permissions/:name/delete", rolesDelete),
|
||||
r("POST", "/permissions/:name/members/delete", rolesRemoveMember),
|
||||
groups("GET", "/groups", groupsList),
|
||||
groups("POST", "/groups", groupsCreate),
|
||||
groups("GET", "/groups/new", groupsNewForm),
|
||||
groups("GET", "/groups/:name", groupsDetail),
|
||||
groups("POST", "/groups/:name/members", groupsAddMember),
|
||||
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),
|
||||
// OAuth2 clients
|
||||
r("GET", "/clients", clientsList),
|
||||
r("POST", "/clients", clientsCreate),
|
||||
r("GET", "/clients/new", clientsNewForm),
|
||||
r("GET", "/clients/:id", clientsDetail),
|
||||
r("GET", "/clients/:id/delete", clientsDeleteConfirm),
|
||||
r("POST", "/clients/:id/delete", clientsDelete),
|
||||
clients("GET", "/clients", clientsList),
|
||||
clients("POST", "/clients", clientsCreate),
|
||||
clients("GET", "/clients/new", clientsNewForm),
|
||||
clients("GET", "/clients/:id", clientsDetail),
|
||||
clients("GET", "/clients/:id/delete", clientsDeleteConfirm),
|
||||
clients("POST", "/clients/:id/delete", clientsDelete),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -30,14 +30,15 @@ test("permissionTuple grants a permission to user:<id> in the Permission namespa
|
||||
});
|
||||
});
|
||||
|
||||
test("seedPermissions unions ADMIN_PERMISSIONS (default 'admin') with the discovered plugins' declared permissions", () => {
|
||||
// Clean clone: no ADMIN_PERMISSIONS, the scheduling plugin declares its two tokens → the demo admin
|
||||
// gets exactly today's behaviour, but derived from discovery, not hardcoded in the host.
|
||||
assert.deepEqual(seedPermissions(undefined, ["scheduling:read", "scheduling:write"]), ["admin", "scheduling:read", "scheduling:write"]);
|
||||
assert.deepEqual(seedPermissions(undefined, []), ["admin"]); // no plugins → just the base admin permission
|
||||
assert.deepEqual(seedPermissions("admin, ops ", ["inventory:read"]), ["admin", "ops", "inventory:read"]); // env trimmed + extended
|
||||
assert.deepEqual(seedPermissions("admin,scheduling:read", ["scheduling:read"]), ["admin", "scheduling:read"]); // dedup, no double grant
|
||||
assert.deepEqual(seedPermissions("admin,, ", [" scheduling:read ", ""]), ["admin", "scheduling:read"]); // blanks dropped, tokens trimmed (both sides)
|
||||
test("seedPermissions unions ADMIN_PERMISSIONS (empty by default) with the discovered plugins' declared permissions", () => {
|
||||
// Clean clone: no ADMIN_PERMISSIONS, the scheduling plugin declares its two names → the demo admin
|
||||
// holds exactly what the installed plugins gate on, derived from discovery, not hardcoded here.
|
||||
assert.deepEqual(seedPermissions(undefined, ["scheduling:read", "scheduling:write"]), ["scheduling:read", "scheduling:write"]);
|
||||
// No plugins → nothing to grant. A host-invented base would be a permission that gates nothing.
|
||||
assert.deepEqual(seedPermissions(undefined, []), []);
|
||||
assert.deepEqual(seedPermissions("ops:read, ops:write ", ["inventory:read"]), ["ops:read", "ops:write", "inventory:read"]); // env trimmed + extended
|
||||
assert.deepEqual(seedPermissions("scheduling:read", ["scheduling:read"]), ["scheduling:read"]); // dedup, no double grant
|
||||
assert.deepEqual(seedPermissions(",, ", [" scheduling:read ", ""]), ["scheduling:read"]); // blanks dropped, names trimmed (both sides)
|
||||
});
|
||||
|
||||
test("seedAdmin on a fresh stack creates the identity and grants every permission (one tuple each)", async () => {
|
||||
|
||||
+11
-9
@@ -2,9 +2,9 @@
|
||||
// kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`:
|
||||
// 1. generate the JWKS signing key if absent (committed dev key makes this a safety net);
|
||||
// 2. seed a demo admin (admin@plainpages.local / admin) in Kratos;
|
||||
// 3. grant it its permissions in Keto so menu/permission checks resolve out of the box — `admin` plus
|
||||
// every discovered plugin's declared permission names, so a dropped-in plugin is usable by
|
||||
// the demo admin with no host config edit (the host stays plugin-agnostic).
|
||||
// 3. grant it its permissions in Keto so menu/permission checks resolve out of the box — every
|
||||
// discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so a dropped-in
|
||||
// plugin is usable by the demo admin with no host config edit (the host stays plugin-agnostic).
|
||||
// Then prints a first-run banner; fails loud on any unexpected upstream error.
|
||||
import { existsSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -28,13 +28,15 @@ export function permissionTuple(userId: string, permission: string) {
|
||||
return { namespace: "Permission", object: permission, relation: "granted", subject_id: `user:${userId}` };
|
||||
}
|
||||
|
||||
// The permissions to grant the demo admin = the configured base (ADMIN_PERMISSIONS, default just `admin`)
|
||||
// The permissions to grant the demo admin = the configured base (ADMIN_PERMISSIONS, empty by default)
|
||||
// unioned with every discovered plugin's declared permission names (a route/nav `permission` is a
|
||||
// coarse permission — granted as a Keto `Permission:<token>#members` tuple). So the host names no plugin, yet a
|
||||
// dropped-in plugin's tokens are seeded out of the box. Deduped, order-stable, blanks dropped.
|
||||
export function seedPermissions(adminRolesEnv: string | undefined, declaredPermissions: string[]): string[] {
|
||||
// coarse permission — granted as a Keto `Permission:<name>#granted` tuple). So the host names no plugin, yet a
|
||||
// dropped-in plugin's permissions are seeded out of the box. Deduped, order-stable, blanks dropped.
|
||||
// The base is empty because permissions are `<resource>:<action>` and every one of them is owned by
|
||||
// the plugin that gates on it — a host-invented default would gate nothing.
|
||||
export function seedPermissions(adminPermissionsEnv: string | undefined, declaredPermissions: string[]): string[] {
|
||||
const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean);
|
||||
return [...new Set([...clean((adminRolesEnv ?? "admin").split(",")), ...clean(declaredPermissions)])];
|
||||
return [...new Set([...clean((adminPermissionsEnv ?? "").split(",")), ...clean(declaredPermissions)])];
|
||||
}
|
||||
|
||||
// --- JWKS safety net -----------------------------------------------------------------
|
||||
@@ -143,7 +145,7 @@ async function main() {
|
||||
await runWithLog(log, async () => {
|
||||
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key");
|
||||
|
||||
// Seed `admin` (or ADMIN_PERMISSIONS) + every discovered plugin's declared permission names, so the
|
||||
// Seed every discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so the
|
||||
// shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
|
||||
const declared = (await discoverPlugins()).flatMap((p) => (p.permissions ?? []).map((d) => d.name));
|
||||
const permissions = seedPermissions(env["ADMIN_PERMISSIONS"], declared);
|
||||
|
||||
+57
-28
@@ -871,11 +871,14 @@ async function adminHarness(t: TestContext, opts: AppOptions = {}) {
|
||||
const token = issueCsrfToken(ADMIN_CSRF);
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const cookie = (permissions: string[]) => `${SESSION_COOKIE}=${mintJwt({ email: "admin@x", exp: nowSec + 600, permissions, sub: "admin1" })}; ${CSRF_COOKIE}=${token}`;
|
||||
const get = (path: string, permissions: string[] = ["admin"]) => fetch(url + path, { headers: { cookie: cookie(permissions) }, redirect: "manual" });
|
||||
const get = (path: string, permissions: string[] = ADMIN_ALL) => fetch(url + path, { headers: { cookie: cookie(permissions) }, redirect: "manual" });
|
||||
const post = (path: string, body: string) =>
|
||||
fetch(url + path, { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookie(["admin"]) }, method: "POST", redirect: "manual" });
|
||||
fetch(url + path, { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookie(ADMIN_ALL) }, method: "POST", redirect: "manual" });
|
||||
return { get, post, token, url };
|
||||
}
|
||||
// What the plugin itself declares — the harness holds every screen's read and write, so a screen
|
||||
// test exercises the screen rather than the gate. assertAdminGate covers the refusals.
|
||||
const ADMIN_ALL = (adminManifest.permissions ?? []).map((p) => p.name);
|
||||
// Every admin route is gated: anonymous → /login, a signed-in non-admin → 403.
|
||||
async function assertAdminGate(url: string, get: (path: string, permissions?: string[]) => Promise<Response>, path: string) {
|
||||
const anon = await fetch(url + path, { redirect: "manual" });
|
||||
@@ -1105,11 +1108,28 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
|
||||
|
||||
await assertAdminGate(url, get, "/admin/users");
|
||||
|
||||
// Nav: the admin plugin's section composes into the one global menu for an admin, and is filtered
|
||||
// out for a signed-in non-admin (the gate on the section header) — proving the drop-in nav fragment.
|
||||
// Nav: the admin plugin's section composes into the one global menu, and each screen is filtered
|
||||
// by its own read permission — proving the drop-in nav fragment. A user holding only users:read
|
||||
// sees Users and nothing else; holding none of the four, composeNav drops the emptied header.
|
||||
assert.match(await (await get("/dashboard")).text(), /href="\/admin\/users"/);
|
||||
const usersOnlyNav = await (await get("/dashboard", ["users:read"])).text();
|
||||
assert.match(usersOnlyNav, /href="\/admin\/users"/);
|
||||
assert.doesNotMatch(usersOnlyNav, /href="\/admin\/groups"/);
|
||||
assert.doesNotMatch(await (await get("/dashboard", ["scheduling:read"])).text(), /href="\/admin\/users"/);
|
||||
|
||||
// The read/write split: users:read opens the list but is refused on every mutation, and the
|
||||
// resources don't leak — a users holder is not a groups holder.
|
||||
assert.equal((await get("/admin/users", ["users:read"])).status, 200);
|
||||
assert.equal((await get("/admin/groups", ["users:read", "users:write"])).status, 403);
|
||||
const readOnlyPost = await fetch(url + "/admin/users", {
|
||||
body: `_csrf=${token}&email=nope@example.com`,
|
||||
headers: { "content-type": "application/x-www-form-urlencoded", cookie: `${SESSION_COOKIE}=${mintJwt({ email: "r@x", exp: Math.floor(Date.now() / 1000) + 600, permissions: ["users:read"], sub: "reader1" })}; ${CSRF_COOKIE}=${token}` },
|
||||
method: "POST",
|
||||
redirect: "manual",
|
||||
});
|
||||
assert.equal(readOnlyPost.status, 403);
|
||||
assert.equal(store.some((i) => i.traits?.email === "nope@example.com"), false);
|
||||
|
||||
// List: the admin sees the rows + the "add" link; the status filter narrows server-side.
|
||||
const listHtml = await (await get("/admin/users")).text();
|
||||
assert.match(listHtml, /ada@example\.com/);
|
||||
@@ -1240,10 +1260,10 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
|
||||
{ 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; `editor` is an existing permission whose only direct member is ada.
|
||||
// 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: "editor", relation: "granted", subject_id: `user:${ada}` },
|
||||
{ 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 => ({
|
||||
@@ -1262,59 +1282,68 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
|
||||
|
||||
// List: the existing permission shows + the "add" link.
|
||||
const listHtml = await (await get("/admin/permissions")).text();
|
||||
assert.match(listHtml, /href="\/admin\/permissions\/editor"/);
|
||||
assert.match(listHtml, /href="\/admin\/permissions\/docs%3Awrite"/);
|
||||
assert.match(listHtml, /href="\/admin\/permissions\/new"/);
|
||||
|
||||
// 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=viewer&member=user:${ada}`);
|
||||
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/viewer");
|
||||
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "viewer" && tp.subject_id === `user:${ada}`));
|
||||
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
|
||||
|
||||
// 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);
|
||||
assert.equal((await post("/admin/permissions", `_csrf=${token}&name=editor&member=user:${ada}`)).status, 400); // already exists
|
||||
// A bare word has no <resource>:<action> 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 <li>.
|
||||
const effectiveLi = (email: string) => new RegExp(`<li><span class="cell-strong">${email.replace(".", "\\.")}`);
|
||||
const detail = await (await get("/admin/permissions/editor")).text();
|
||||
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/editor/members", `_csrf=${token}&member=group:eng`);
|
||||
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "editor" && tp.subject_set?.object === "eng"));
|
||||
const withGroup = await (await get("/admin/permissions/editor")).text();
|
||||
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/editor/members/delete", `_csrf=${token}&member=group:eng`);
|
||||
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.object === "editor" && tp.subject_set?.object === "eng"));
|
||||
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/editor/members", `_csrf=${token}&member=user:${grace}`);
|
||||
await post("/admin/permissions/editor/members/delete", `_csrf=${token}&member=user:${grace}`);
|
||||
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/editor/delete")).text(), /Cancel/);
|
||||
const del = await post("/admin/permissions/editor/delete", `_csrf=${token}`);
|
||||
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 === "editor"));
|
||||
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.object === "docs:write"));
|
||||
|
||||
// Self-protection: the admin permission can't be deleted, nor can you revoke your own admin (sub admin1).
|
||||
tuples.push({ namespace: "Permission", object: "admin", relation: "granted", subject_id: "user:admin1" });
|
||||
assert.equal((await post("/admin/permissions/admin/delete", `_csrf=${token}`)).status, 400);
|
||||
assert.ok(tuples.some((tp) => tp.object === "admin"));
|
||||
assert.equal((await post("/admin/permissions/admin/members/delete", `_csrf=${token}&member=user:admin1`)).status, 400);
|
||||
assert.ok(tuples.some((tp) => tp.object === "admin" && tp.subject_id === "user:admin1"));
|
||||
// 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 <resource>:<action> 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);
|
||||
|
||||
@@ -37,7 +37,9 @@ export interface Route {
|
||||
}
|
||||
|
||||
// A Keto Permission this plugin gates on — declared for docs/seeding. Permission names are a shared
|
||||
// global namespace (so an operator grants them once in Keto); namespace as `<id>:<action>`.
|
||||
// global namespace (so an operator grants them once in Keto) and are always `<resource>:<action>` —
|
||||
// `scheduling:read`, `users:write`. A bare word names who someone is rather than what they may do,
|
||||
// which is a role, and roles are groups here (README → Users, groups & permissions).
|
||||
export interface PermissionDecl {
|
||||
description?: string;
|
||||
name: string;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
## Unfinnished work
|
||||
|
||||
- [ ] Document permissions format so it is folled going forward: <resource>:<action>, for example scheduling:read. Permission "admin" does not match this, and needs to be users:read, users:write, groups:read, groups:write.
|
||||
- [ ] 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.
|
||||
@@ -31,6 +30,7 @@ Prioritized. Overall verdict: architecture is sound (contract-first plugin API,
|
||||
|
||||
## Finnished work
|
||||
|
||||
- [x] Document permissions format so it is folled going forward: <resource>:<action>, for example scheduling:read. Permission "admin" does not match this, and needs to be users:read, users:write, groups:read, groups:write. (README → [Naming a permission](README.md#naming-a-permission) is the one home for the rule, and it is *enforced* where names are minted rather than only written down: the admin plugin's create form now requires `<resource>:<action>` (`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 `<resource>` 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] 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 `<button popovertarget>` plus a `[popover]` panel instead of `<details>`/`<summary>`, so the browser owns open/close: clicking anywhere outside dismisses one, `Esc` dismisses it and returns focus to the trigger, opening one closes the others, and the panel sits in the top layer where `.table-wrap`'s `overflow` can no longer clip a row kebab. Placement is CSS anchor positioning; the panel needs `position-anchor: auto` to bind to the button that opened it — a bare `anchor()` resolves to nothing in Chromium, Firefox and WebKit alike, measured in all three before picking the approach. `data-table.ejs` stopped hand-rolling its kebab and calls the `menu` partial, so the pattern lives in one file. Each panel is named by its caller (`locale-menu`, `profile-menu`, `row-actions-1`) and the partial fails loud without an `id`, since `popovertarget` is an idref — generated ids were tried first and dropped for being unreadable and nondeterministic. `<details>` stays in the nav tree, where it means disclosure rather than popup. A browser older than the popover API flows each panel inline under its trigger, so Sign out is never stranded behind an inert button. `e2e-tests/visual.spec.ts` drives the whole behaviour — opens, anchored to its trigger, outside-click, Esc — and runs in Firefox and WebKit as well as Chromium, because CSS anchor positioning is the newest thing in the app and every popup rests on it. Decisions recorded in AGENTS.md.)
|
||||
|
||||
Reference in New Issue
Block a user