Permissions are a fixed list from plugin code; grant them on Users and Groups
CI / full-gate (push) Successful in 2m38s
CI / full-gate (push) Successful in 2m38s
This commit is contained in:
@@ -99,8 +99,15 @@ them. Revisit only if the stated reason stops holding.
|
|||||||
× `read`/`write` 2026-08-05. **Enforced at discovery** (`isValidPermissionName` in
|
× `read`/`write` 2026-08-05. **Enforced at discovery** (`isValidPermissionName` in
|
||||||
`plugin-host/plugin.ts`, checked by `shapeError` over every route/nav `permission` and every
|
`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
|
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
|
operator removes by not copying the example in. Decisions around it:
|
||||||
function; there is one regex. Decisions around it:
|
- **Names are authored in plugin code; only grants live in Keto.** The host collects every
|
||||||
|
installed plugin's declarations into one catalog (`declaredPermissions` → `ctx.declaredPermissions`),
|
||||||
|
and that catalog *is* the fixed list the admin screens offer. So there is **no Permissions admin
|
||||||
|
screen**: nothing in a GUI invents a name, and holding one is a property of a user or a group,
|
||||||
|
edited as a checkbox list on those two screens. A tuple in Keto naming something no installed
|
||||||
|
plugin declares gates nothing and is not offered — and a save never revokes it, since the picker
|
||||||
|
only speaks for what it showed. Decided with the maintainer 2026-08-05, replacing the CRUD
|
||||||
|
Permissions screen.
|
||||||
- `<resource>` is **global, not plugin-scoped** (hence `oauth2-clients`, not `clients`). Deliberate
|
- `<resource>` is **global, not plugin-scoped** (hence `oauth2-clients`, not `clients`). Deliberate
|
||||||
cross-plugin sharing is a goal, so the pre-2026-08-05 `<id>:<action>` guidance was wrong: users
|
cross-plugin sharing is a goal, so the pre-2026-08-05 `<id>:<action>` guidance was wrong: users
|
||||||
are the *host's*, not the admin plugin's. Cost: collision-freedom became a convention rather than
|
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
|
- **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
|
would make `findConflicts` see all overlaps, but would then warn on exactly the legitimate
|
||||||
sharing case above. Shape is enforced; declaration is not.
|
sharing case above. Shape is enforced; declaration is not.
|
||||||
- ***Addressing* a permission is looser than *minting* one** (`isPermissionPathSegment`) so a name
|
- **There is no name-minting path in the GUI at all**, which is what makes the discovery check the
|
||||||
written before the rule can still be opened and deleted instead of stranding in Keto. Both mint
|
whole story: the only way a name comes into being is a plugin declaring it, and discovery refuses
|
||||||
points are guarded — the create form, and `rolesAddMember`, whose write would otherwise create a
|
a badly-shaped declaration at boot. An earlier revision of this branch enforced the rule in the
|
||||||
permission under a hand-typed name.
|
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
|
- `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
|
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
|
`bootstrap` discovers, and a plugin dropped in after first boot therefore needs
|
||||||
dropped in after first boot needs `docker compose up -d` (which re-runs the one-shot), not
|
`docker compose up -d` (which re-runs the one-shot), not `restart web`. The base file gives
|
||||||
`restart web`. Valid while bootstrap is the only writer of grants.
|
`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
|
- **`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
|
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
|
29 routes × 2 gate sites cannot drift. As a general mechanism it would make authorization a
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ docker compose up -d # http://localhost:3000, live-reloads on source chan
|
|||||||
**`admin@plainpages.local` / `admin`**.
|
**`admin@plainpages.local` / `admin`**.
|
||||||
|
|
||||||
**3. Enable user & group admin (optional).** The core ships **no admin GUI** — the Users / Groups
|
**3. Enable user & group admin (optional).** The core ships **no admin GUI** — the Users / Groups
|
||||||
/ Permissions / OAuth2-clients screens are a drop-in plugin. Copy it in to mount them at `/admin/*`:
|
/ OAuth2-clients screens are a drop-in plugin. Copy it in to mount them at `/admin/*`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp -r examples/plugins/admin plugins/admin
|
cp -r examples/plugins/admin plugins/admin
|
||||||
@@ -266,9 +266,9 @@ transitively, through nested groups).
|
|||||||
### Naming a permission
|
### Naming a permission
|
||||||
|
|
||||||
**Every permission name is `<resource>:<action>`.** `scheduling:read`, `users:write`,
|
**Every permission name is `<resource>:<action>`.** `scheduling:read`, `users:write`,
|
||||||
`oauth2-clients:read`. Both halves are lowercase letters, digits, dashes and underscores; the admin
|
`oauth2-clients:read`. Both halves are lowercase letters, digits, dashes and underscores, and the
|
||||||
plugin's create form refuses anything else, so the convention holds for whatever an operator adds
|
host refuses a plugin that breaks the rule at discovery — so it holds for every installed plugin,
|
||||||
later.
|
not just the ones you wrote.
|
||||||
|
|
||||||
- **`<resource>`** names the thing acted on, not the plugin that happens to own it — permission
|
- **`<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
|
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
|
### Granting a permission
|
||||||
|
|
||||||
Write the tuple. The admin plugin's **Groups** and **Permissions** screens do exactly this, or use
|
Write the tuple. The admin plugin's **Users** and **Groups** screens do exactly this — each offers
|
||||||
Keto's write API directly:
|
the declared permissions as a checkbox list — or use Keto's write API directly:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# everyone in sched-leads may write shifts
|
# everyone in sched-leads may write shifts
|
||||||
@@ -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
|
**A permission's name is authored in plugin code; only its *grants* live in Keto.** A plugin
|
||||||
some tuple carries it. Name yours [`<resource>:<action>`](#naming-a-permission).
|
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
|
||||||
|
[`<resource>:<action>`](#naming-a-permission).
|
||||||
|
|
||||||
A change takes effect on the user's **next login or JWT re-mint** (~10 min) — see [Instant
|
A change takes effect on the user's **next login or JWT re-mint** (~10 min) — see [Instant
|
||||||
revoke](#instant-revoke-the-optional-denylist) when you need it sooner.
|
revoke](#instant-revoke-the-optional-denylist) when you need it sooner.
|
||||||
@@ -641,6 +645,7 @@ interface RequestContext {
|
|||||||
req: IncomingMessage;
|
req: IncomingMessage;
|
||||||
res: ServerResponse;
|
res: ServerResponse;
|
||||||
permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check
|
permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check
|
||||||
|
declaredPermissions: PermissionDecl[]; // every permission the installed plugins declare, deduped + sorted — what *exists*, vs `permissions` = what this user *holds*
|
||||||
system?: SystemCapabilities; // privileged Ory clients + instant-revoke, for a system plugin (see below); undefined unless the host wired them
|
system?: SystemCapabilities; // privileged Ory clients + instant-revoke, for a system plugin (see below); undefined unless the host wired them
|
||||||
url: URL;
|
url: URL;
|
||||||
verifyCsrf(submitted): boolean; // gate a form POST against the request's signed CSRF cookie
|
verifyCsrf(submitted): boolean; // gate a form POST against the request's signed CSRF cookie
|
||||||
@@ -708,7 +713,7 @@ interface SystemCapabilities { // every field optional — present only
|
|||||||
Hydra configured, the [revocation denylist](#instant-revoke-the-optional-denylist) enabled). A system
|
Hydra configured, the [revocation denylist](#instant-revoke-the-optional-denylist) enabled). A system
|
||||||
plugin treats every field as optional and **degrades when absent** — the host never fails a request
|
plugin treats every field as optional and **degrades when absent** — the host never fails a request
|
||||||
over it. The built-in **admin plugin** ([`examples/plugins/admin/`](examples/plugins/admin/)) is the
|
over it. The built-in **admin plugin** ([`examples/plugins/admin/`](examples/plugins/admin/)) is the
|
||||||
reference consumer: its Users screen uses `ctx.system.kratosAdmin`, Groups/Permissions use `ctx.system.keto`,
|
reference consumer: its Users screen uses `ctx.system.kratosAdmin`, Groups and the permission pickers use `ctx.system.keto`,
|
||||||
OAuth2 clients use `ctx.system.hydra`, and a deactivate/delete or user permission-change calls
|
OAuth2 clients use `ctx.system.hydra`, and a deactivate/delete or user permission-change calls
|
||||||
`ctx.system.revoke` so the change lands now instead of after the JWT TTL; where a capability is missing
|
`ctx.system.revoke` so the change lands now instead of after the JWT TTL; where a capability is missing
|
||||||
the screen renders a themed 503.
|
the screen renders a themed 503.
|
||||||
@@ -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 }
|
list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize }
|
||||||
paginate.ts paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs
|
paginate.ts paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs
|
||||||
|
|
||||||
views/ Core EJS templates, all in the one app shell: home (public "/" landing), index (instructional /dashboard), auth (themed Kratos flows), oauth-consent (OAuth2 consent), error (flow-error sink → /error), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, landing/flow/consent bodies, menu/popover, theme switch, language picker, icon sprite). Domain screens live in plugins, not here — the admin plugin ships its own views/ (incl. its Users/Groups/Permissions/Clients + confirm bodies)
|
views/ Core EJS templates, all in the one app shell: home (public "/" landing), index (instructional /dashboard), auth (themed Kratos flows), oauth-consent (OAuth2 consent), error (flow-error sink → /error), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, landing/flow/consent bodies, menu/popover, theme switch, language picker, icon sprite). Domain screens live in plugins, not here — the admin plugin ships its own views/ (incl. its Users/Groups/Clients + permission-picker + confirm bodies)
|
||||||
public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
|
public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
|
||||||
config/ Drop-in mount point for the central menu override + branding (config/menu.ts). Ships empty (.gitkeep, git-ignored otherwise) — mount your own or copy the template from examples/config/; defaults apply when absent
|
config/ Drop-in mount point for the central menu override + branding (config/menu.ts). Ships empty (.gitkeep, git-ignored otherwise) — mount your own or copy the template from examples/config/; defaults apply when absent
|
||||||
locales/ Drop-in mount point for extra (or replacement) language catalogs — a <locale>.ts here adds a language for the core, or replaces the shipped catalog for that tag wholesale; plugins/<id>/<locale>.ts does the same for an installed plugin. Ships empty (.gitkeep, git-ignored otherwise); see Languages
|
locales/ Drop-in mount point for extra (or replacement) language catalogs — a <locale>.ts here adds a language for the core, or replaces the shipped catalog for that tag wholesale; plugins/<id>/<locale>.ts does the same for an installed plugin. Ships empty (.gitkeep, git-ignored otherwise); see Languages
|
||||||
ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — permission/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs → /oauth2/*) + storage init (postgres/init/init.sql: one DB per service)
|
ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — permission/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs → /oauth2/*) + storage init (postgres/init/init.sql: one DB per service)
|
||||||
plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in). Ships empty (.gitkeep, git-ignored otherwise) — mount your own; the E2E suites bind-mount the example plugins onto /app/plugins/scheduling and /app/plugins/admin
|
plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in). Ships empty (.gitkeep, git-ignored otherwise) — mount your own; the E2E suites bind-mount the example plugins onto /app/plugins/scheduling and /app/plugins/admin
|
||||||
examples/ Copy-in reference material, mirroring the mount dirs: plugins/scheduling/ (the reference plugin — list/form over an upstream + permission-gated nav), plugins/admin/ (the system-admin plugin — Users/Groups/Permissions/OAuth2-clients over Ory via ctx.system), both copied into plugins/; and config/menu.ts (the menu/branding template copied into config/); shifts-upstream/ is the dev mock backend the scheduling plugin reads/writes (stand-in for your real service)
|
examples/ Copy-in reference material, mirroring the mount dirs: plugins/scheduling/ (the reference plugin — list/form over an upstream + permission-gated nav), plugins/admin/ (the system-admin plugin — Users/Groups/OAuth2-clients over Ory via ctx.system, permissions granted from the host's declared catalog), both copied into plugins/; and config/menu.ts (the menu/branding template copied into config/); shifts-upstream/ is the dev mock backend the scheduling plugin reads/writes (stand-in for your real service)
|
||||||
e2e-tests/ Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint) + oauth-login.spec (OAuth2 login + consent) + full-flow.spec (browser UI: password/SSO login, menu-by-permission, admin CRUD, plugin page, logout) + devstack-login.spec (regression: login works from the banner's localhost URL and 127.0.0.1 is canonicalised, on the plain `docker compose up` topology); proxy.ts (same-origin gateway) + mock-oidc.ts (mock SSO provider) back full-flow. e2e-tests/Dockerfile + e2e-tests/compose.{visual,auth,oauth,full,devstack}.yml run them
|
e2e-tests/ Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint) + oauth-login.spec (OAuth2 login + consent) + full-flow.spec (browser UI: password/SSO login, menu-by-permission, admin CRUD, plugin page, logout) + devstack-login.spec (regression: login works from the banner's localhost URL and 127.0.0.1 is canonicalised, on the plain `docker compose up` topology); proxy.ts (same-origin gateway) + mock-oidc.ts (mock SSO provider) back full-flow. e2e-tests/Dockerfile + e2e-tests/compose.{visual,auth,oauth,full,devstack}.yml run them
|
||||||
ci.sh The full CI gate: typecheck → unit tests → every E2E suite, each on a fresh, always-torn-down stack (`bash ci.sh`)
|
ci.sh The full CI gate: typecheck → unit tests → every E2E suite, each on a fresh, always-torn-down stack (`bash ci.sh`)
|
||||||
.gitea/workflows/ Gitea Actions: ci.yml — the full gate (ci.sh) on every branch push except main;
|
.gitea/workflows/ Gitea Actions: ci.yml — the full gate (ci.sh) on every branch push except main;
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ services:
|
|||||||
# Mount your own menu/branding override into the empty config/ dir (defaults apply otherwise):
|
# Mount your own menu/branding override into the empty config/ dir (defaults apply otherwise):
|
||||||
# - ./config:/app/config:ro # your config/menu.ts — see examples/config/menu.ts for a template
|
# - ./config:/app/config:ro # your config/menu.ts — see examples/config/menu.ts for a template
|
||||||
|
|
||||||
|
# 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
|
# Mock backend ready for the reference plugin (examples/plugins/scheduling): plugins/ ships empty, so
|
||||||
# the plugin is opt-in — `cp -r examples/plugins/scheduling plugins/scheduling`, restart, and this
|
# the plugin is opt-in — `cp -r examples/plugins/scheduling plugins/scheduling`, restart, and this
|
||||||
# backs it (SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
|
# backs it (SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
|
||||||
|
|||||||
@@ -139,10 +139,6 @@ services:
|
|||||||
KRATOS_ADMIN_URL: http://kratos:4434
|
KRATOS_ADMIN_URL: http://kratos:4434
|
||||||
volumes:
|
volumes:
|
||||||
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer
|
- ./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
|
command: node src/auth/bootstrap.ts
|
||||||
# Bounded retry: the seed is idempotent, so transient Ory blips recover — but a permanent
|
# 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).
|
# error must give up, not loop forever and hang `web` (gates on completion).
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
await expect(page.locator("tr", { hasText: email })).toHaveCount(0);
|
await expect(page.locator("tr", { hasText: email })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("groups + permissions CRUD: create one of each (writes go to Keto) and see them listed", async () => {
|
test("groups CRUD: create a group (writes go to Keto), see it listed, then grant it a permission", async () => {
|
||||||
// A Keto set exists only while it has ≥1 member, so create needs a first member (the form
|
// A Keto set exists only while it has ≥1 member, so create needs a first member (the form
|
||||||
// enforces it); pick the first option (a user) from the required picker.
|
// enforces it); pick the first option (a user) from the required picker.
|
||||||
const group = `e2e-grp-${suffix}`;
|
const group = `e2e-grp-${suffix}`;
|
||||||
@@ -147,13 +147,17 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
await expect(page).toHaveURL(/\/admin\/groups(\?|\/|$)/);
|
await expect(page).toHaveURL(/\/admin\/groups(\?|\/|$)/);
|
||||||
await expect(page.locator("main")).toContainText(group);
|
await expect(page.locator("main")).toContainText(group);
|
||||||
|
|
||||||
const permission = `e2e-${suffix}:read`; // permission names are <resource>:<action>; the form refuses a bare word
|
// Permissions are declared in plugin code, so the group's detail page offers them as a fixed
|
||||||
await page.goto("/admin/permissions/new");
|
// checkbox list rather than a create form — there is no Permissions screen to visit.
|
||||||
await page.fill('input[name="name"]', permission);
|
await page.goto(`/admin/groups/${group}`);
|
||||||
await page.locator('select[name="member"]').selectOption({ index: 1 });
|
const scheduling = page.locator('input[name="permission"][value="scheduling:read"]');
|
||||||
await page.locator('.form-card button[type="submit"]').click();
|
await expect(scheduling).toHaveCount(1); // declared by the reference plugin, so it's on offer
|
||||||
await expect(page).toHaveURL(/\/admin\/permissions(\?|\/|$)/);
|
await expect(scheduling).not.toBeChecked();
|
||||||
await expect(page.locator("main")).toContainText(permission);
|
await scheduling.check();
|
||||||
|
await page.locator('form:has(input[name="permission"]) button[type="submit"]').click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/admin/groups/${group}`));
|
||||||
|
await expect(page.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("OAuth2 clients CRUD: register a client (writes go to Hydra), see the one-time secret once, then delete it via the confirm step", async () => {
|
test("OAuth2 clients CRUD: register a client (writes go to Hydra), see the one-time secret once, then delete it via the confirm step", async () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Admin — the system-administration plugin
|
# Admin — the system-administration plugin
|
||||||
|
|
||||||
The Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. These used to be
|
The Users / Groups / OAuth2-clients screens for running Plainpages itself. These used to be
|
||||||
built into the core; they now ship as a **drop-in example plugin** so a fresh clone has no admin GUI
|
built into the core; they now ship as a **drop-in example plugin** so a fresh clone has no admin GUI
|
||||||
until you opt in. Copy this folder into `plugins/` (it keeps the id and mount path `admin`, so the
|
until you opt in. Copy this folder into `plugins/` (it keeps the id and mount path `admin`, so the
|
||||||
screens live at `/admin/*`) and restart:
|
screens live at `/admin/*`) and restart:
|
||||||
@@ -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:
|
stack**, so they use the privileged **`ctx.system`** surface the host exposes to a system plugin:
|
||||||
|
|
||||||
- **`ctx.system.kratosAdmin`** — create/edit/deactivate/delete Kratos identities (Users).
|
- **`ctx.system.kratosAdmin`** — create/edit/deactivate/delete Kratos identities (Users).
|
||||||
- **`ctx.system.keto`** — read/write the Keto relationship graph (Groups, Permissions).
|
- **`ctx.system.keto`** — read/write the Keto relationship graph (group membership, permission grants).
|
||||||
- **`ctx.system.hydra`** — register/list/delete Ory Hydra OAuth2 clients.
|
- **`ctx.system.hydra`** — register/list/delete Ory Hydra OAuth2 clients.
|
||||||
- **`ctx.system.revoke(sub)`** — the optional instant-revoke hook: a deactivate/delete or a
|
- **`ctx.system.revoke(sub)`** — the optional instant-revoke hook: a deactivate/delete or a
|
||||||
user's permission change kills that subject's live tokens at once instead of waiting out the JWT TTL.
|
user's permission change kills that subject's live tokens at once instead of waiting out the JWT TTL.
|
||||||
@@ -36,16 +36,22 @@ than crashing — see `admin-shared.ts`. Everything else is an ordinary plugin:
|
|||||||
gated per route by its screen's `<resource>:<action>` permission, rendering the core building blocks
|
gated per route by its screen's `<resource>:<action>` permission, rendering the core building blocks
|
||||||
in `views/`.
|
in `views/`.
|
||||||
|
|
||||||
Each screen is its own resource — `users`, `groups`, `permissions`, `oauth2-clients` — and each
|
Each screen is its own resource — `users`, `groups`, `oauth2-clients` — and each splits into `:read`
|
||||||
splits into `:read` and `:write`, so a helpdesk account can be given `users:read` alone. The nav is
|
and `:write`, so a helpdesk account can be given `users:read` alone. The nav is filtered by the same
|
||||||
filtered by the same permissions: holding none of the four hides the Admin section entirely.
|
permissions: holding none of the three hides the Admin section entirely.
|
||||||
|
|
||||||
|
There is **no Permissions screen**. Permission names are declared in plugin code, not created in a
|
||||||
|
GUI, so the host's catalog (`ctx.declaredPermissions`) is the fixed list — and holding one is a
|
||||||
|
property of a user or a group, edited as a checkbox list on those two screens (`admin-grants.ts`).
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
- `plugin.ts` — the manifest: the Admin nav fragment, the eight permissions the plugin declares, and
|
- `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)`
|
the route table — one thin handler per method+path, gated via `adminPermission(resource, method)`
|
||||||
so a GET needs `:read` and a POST `:write`.
|
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
|
view-model builders (unit-tested in the matching `*.test.ts`) plus thin per-route handlers keyed on
|
||||||
`ctx.params` (the host extracts `:id`/`:name`), sharing a small `withX` wrapper that resolves the
|
`ctx.params` (the host extracts `:id`/`:name`), sharing a small `withX` wrapper that resolves the
|
||||||
screen's permission gate + the needed `ctx.system` clients once.
|
screen's permission gate + the needed `ctx.system` clients once.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Permission grants, shared by the Users and Groups screens. A permission is held by a user
|
||||||
|
// (`Permission:<name>#granted@user:<id>`) or by a whole group (`…@Group:<name>#members`), and Keto
|
||||||
|
// resolves a group's grant transitively at login.
|
||||||
|
//
|
||||||
|
// The set of permissions that *exist* is `ctx.declaredPermissions` — the host's catalog, built from
|
||||||
|
// what the installed plugins declare in code. Nothing here invents a name, which is why the old
|
||||||
|
// Permissions screen is gone: a grant is a property of a user or a group, edited where they are.
|
||||||
|
|
||||||
|
import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "#plugin-api";
|
||||||
|
|
||||||
|
const PERMISSION_NS = "Permission";
|
||||||
|
const GRANTED = "granted";
|
||||||
|
export const PERMISSIONS_FIELD = "permission"; // the checkbox name the two forms post
|
||||||
|
|
||||||
|
export type GrantSubject = { subject_id: string } | { subject_set: SubjectSet };
|
||||||
|
|
||||||
|
export const userSubject = (id: string): GrantSubject => ({ subject_id: `user:${id}` });
|
||||||
|
export const groupSubject = (name: string): GrantSubject => ({ subject_set: { namespace: "Group", object: name, relation: "members" } });
|
||||||
|
|
||||||
|
export function grantTuple(permission: string, subject: GrantSubject): RelationTuple {
|
||||||
|
return { namespace: PERMISSION_NS, object: permission, relation: GRANTED, ...subject };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The permissions this subject holds *directly* — one Keto read filtered by the subject, not one per
|
||||||
|
// declared name. 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<string[]> {
|
||||||
|
const held = new Set<string>();
|
||||||
|
let pageToken: string | undefined;
|
||||||
|
do {
|
||||||
|
const page = await keto.listRelations({ namespace: PERMISSION_NS, relation: GRANTED, ...subject, ...(pageToken ? { pageToken } : {}) });
|
||||||
|
for (const tuple of page.tuples) held.add(tuple.object);
|
||||||
|
pageToken = page.nextPageToken ?? undefined;
|
||||||
|
} while (pageToken);
|
||||||
|
return [...held].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
for (const name of diff.grant) await keto.writeTuple(grantTuple(name, subject));
|
||||||
|
for (const name of diff.revoke) await keto.deleteTuple(grantTuple(name, subject));
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
// each returning a RouteResult.
|
// each returning a RouteResult.
|
||||||
|
|
||||||
import { type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "#plugin-api";
|
import { 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 { ADMIN_EN, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, requirePermission, unavailable } from "./admin-shared.ts";
|
||||||
import type { FieldConfig } from "./admin-users.ts";
|
import type { FieldConfig } from "./admin-users.ts";
|
||||||
|
|
||||||
@@ -229,6 +230,7 @@ export function buildGroupDetailModel(opts: {
|
|||||||
error?: string;
|
error?: string;
|
||||||
group: { name: string };
|
group: { name: string };
|
||||||
members: MemberView[];
|
members: MemberView[];
|
||||||
|
permissions?: PermissionPicker;
|
||||||
t?: Translate;
|
t?: Translate;
|
||||||
}) {
|
}) {
|
||||||
const t = opts.t ?? ADMIN_EN;
|
const t = opts.t ?? ADMIN_EN;
|
||||||
@@ -245,6 +247,7 @@ export function buildGroupDetailModel(opts: {
|
|||||||
error: opts.error,
|
error: opts.error,
|
||||||
group: { name },
|
group: { name },
|
||||||
members: { action: `${base}/members/delete`, rows: opts.members },
|
members: { action: `${base}/members/delete`, rows: opts.members },
|
||||||
|
permissions: opts.permissions,
|
||||||
title: name,
|
title: name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -343,7 +346,27 @@ export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {}));
|
|||||||
export const groupsDetail = withGroupName(async ({ ctx, keto, kratosAdmin }, name) => {
|
export const groupsDetail = withGroupName(async ({ ctx, keto, kratosAdmin }, name) => {
|
||||||
const { emailById, options } = await memberCandidates(keto, kratosAdmin);
|
const { emailById, options } = await memberCandidates(keto, kratosAdmin);
|
||||||
const members = (await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS })).map((t) => memberView(t, emailById));
|
const members = (await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS })).map((t) => memberView(t, emailById));
|
||||||
return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members, t: ctx.t }) }, view: "group-detail" };
|
const 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).
|
// POST /admin/groups/:name/members — add a member (skip an invalid member or a self-nest).
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
// Built-in Roles admin screen: the pure view-model + Keto builders. A permission is a
|
|
||||||
// Keto subject set (Permission:<name>#members); members are users (subject_id) or groups (subject_set) —
|
|
||||||
// "assign permissions to users/groups". The "effective access" view flattens a Keto `expand` tree into the
|
|
||||||
// distinct set of users who hold the permission directly or transitively via a group. The HTTP
|
|
||||||
// routing/gate/CSRF + live Keto/Kratos calls are exercised over HTTP in app.test.ts.
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { test } from "node:test";
|
|
||||||
import { memberView } from "./admin-groups.ts";
|
|
||||||
import {
|
|
||||||
buildPermissionDetailModel,
|
|
||||||
buildPermissionFormModel,
|
|
||||||
buildPermissionsListModel,
|
|
||||||
expandToEffectiveUsers,
|
|
||||||
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");
|
|
||||||
});
|
|
||||||
@@ -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:<name>#members` (OPL: members are users
|
|
||||||
// or groups, resolved transitively) — the source of truth for the JWT `permissions` claim. It shares the
|
|
||||||
// Groups screen's membership model, so the pure helpers (parseSubject, member pickers, tuple paging)
|
|
||||||
// are reused from admin-groups. The permission-specific piece is the **effective access** view:
|
|
||||||
// `keto.expand(Permission:<name>#members)` flattened to the distinct users who hold the permission directly or via
|
|
||||||
// a group — matching what login projects into the JWT (login.ts readPermissions). Writes go only to Keto;
|
|
||||||
// Kratos is read only to label members. Below the builders are thin per-route handlers (keyed on
|
|
||||||
// ctx.params) over a shared `withRoles` gate — admin-only, CSRF-guarded.
|
|
||||||
|
|
||||||
import { type ExpandTree, 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 `<resource>:<action>` (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<string>();
|
|
||||||
const walk = (node?: ExpandTree | null): void => {
|
|
||||||
if (!node) return;
|
|
||||||
const subjectId = node.tuple?.subject_id;
|
|
||||||
if (subjectId?.startsWith("user:")) ids.add(subjectId.slice("user:".length));
|
|
||||||
node.children?.forEach(walk);
|
|
||||||
};
|
|
||||||
walk(tree);
|
|
||||||
return [...ids].sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- list view model ----
|
|
||||||
|
|
||||||
interface ListState {
|
|
||||||
page: number;
|
|
||||||
pageSize: number;
|
|
||||||
q: string;
|
|
||||||
sort: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SORT: Record<string, (r: PermissionView) => number | string> = {
|
|
||||||
members: (r) => r.memberCount,
|
|
||||||
name: (r) => r.name,
|
|
||||||
};
|
|
||||||
const COLUMNS = [
|
|
||||||
{ key: "name", label: "admin.permissions.column.name" },
|
|
||||||
{ key: "members", label: "admin.permissions.column.members" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function detailHref(name: string): string {
|
|
||||||
return `${ADMIN_PERMISSIONS_BASE}/${encodeURIComponent(name)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function listHref(state: ListState, overrides: Partial<ListState> = {}): string {
|
|
||||||
const s = { ...state, ...overrides };
|
|
||||||
const p = new URLSearchParams();
|
|
||||||
if (s.q) p.set("q", s.q);
|
|
||||||
if (s.sort) p.set("sort", s.sort);
|
|
||||||
if (s.page > 1) p.set("page", String(s.page));
|
|
||||||
if (s.pageSize !== DEFAULT_PAGE_SIZE) p.set("pageSize", String(s.pageSize));
|
|
||||||
const qs = p.toString();
|
|
||||||
return qs ? `${ADMIN_PERMISSIONS_BASE}?${qs}` : ADMIN_PERMISSIONS_BASE;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildPermissionsListModel(opts: {
|
|
||||||
csrfToken?: string;
|
|
||||||
permissions: PermissionView[];
|
|
||||||
t?: Translate;
|
|
||||||
url: URL | URLSearchParams | string;
|
|
||||||
}) {
|
|
||||||
const t = opts.t ?? ADMIN_EN;
|
|
||||||
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
|
||||||
const sort = query.sort && SORT[query.sort.field] ? query.sort : null;
|
|
||||||
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
|
|
||||||
const needle = query.q.toLowerCase();
|
|
||||||
|
|
||||||
let list = opts.permissions.filter((r) => !needle || r.name.toLowerCase().includes(needle));
|
|
||||||
if (sort) {
|
|
||||||
const get = SORT[sort.field]!;
|
|
||||||
const dir = sort.dir === "desc" ? -1 : 1;
|
|
||||||
list = [...list].sort((a, b) => {
|
|
||||||
const av = get(a), bv = get(b);
|
|
||||||
const cmp = typeof av === "number" && typeof bv === "number" ? av - bv : String(av).localeCompare(String(bv));
|
|
||||||
return cmp * dir;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const page = paginate(list.length, query.page, query.pageSize, { boundaries: 1, siblings: 1 });
|
|
||||||
const start = (page.page - 1) * page.pageSize;
|
|
||||||
const rows = list.slice(start, start + page.pageSize);
|
|
||||||
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken };
|
|
||||||
|
|
||||||
return {
|
|
||||||
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: t("admin.nav.section") }, { label: t("admin.permissions.title") }],
|
|
||||||
filterBar: listFilterBar(state, t),
|
|
||||||
pagination: listPagination(state, page, t),
|
|
||||||
table: listTable(rows, state, sort, t),
|
|
||||||
title: t("admin.permissions.title"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function listTable(rows: PermissionView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null, t: Translate) {
|
|
||||||
return {
|
|
||||||
caption: t("admin.permissions.title"),
|
|
||||||
columns: COLUMNS.map((c) => {
|
|
||||||
const dir = sort && sort.field === c.key ? sort.dir : undefined;
|
|
||||||
const next = dir === "asc" ? `-${c.key}` : c.key;
|
|
||||||
return { href: listHref(state, { page: 1, sort: next }), label: t(c.label), sort: dir, sortable: true };
|
|
||||||
}),
|
|
||||||
rows: rows.map((r) => ({
|
|
||||||
cells: [{ rowHeader: { href: detailHref(r.name), text: r.name } }, String(r.memberCount)],
|
|
||||||
name: r.name,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function listFilterBar(state: ListState, t: Translate) {
|
|
||||||
const pills: { label: string; remove: string; value: string }[] = [];
|
|
||||||
if (state.q) pills.push({ label: t("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q });
|
|
||||||
return {
|
|
||||||
applyLabel: t("filter.apply"),
|
|
||||||
clearHref: ADMIN_PERMISSIONS_BASE,
|
|
||||||
label: t("admin.permissions.filter"),
|
|
||||||
pills,
|
|
||||||
rows: [[
|
|
||||||
{ label: t("admin.permissions.searchLabel"), name: "q", placeholder: t("admin.permissions.searchPlaceholder"), type: "search", value: state.q },
|
|
||||||
{ type: "spacer" },
|
|
||||||
]],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function listPagination(state: ListState, page: ReturnType<typeof paginate>, t: Translate) {
|
|
||||||
const hidden: { name: string; value: string }[] = [];
|
|
||||||
if (state.q) hidden.push({ name: "q", value: state.q });
|
|
||||||
if (state.sort) hidden.push({ name: "sort", value: state.sort });
|
|
||||||
return {
|
|
||||||
label: t("admin.permissions.pagination"),
|
|
||||||
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
|
|
||||||
pages: page.pages.map((p) =>
|
|
||||||
p.ellipsis ? { ellipsis: true }
|
|
||||||
: p.current ? { current: true, label: String(p.page) }
|
|
||||||
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
|
|
||||||
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
|
|
||||||
rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
|
|
||||||
summary: { from: page.from, to: page.to, total: page.total },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- create form + detail view models ----
|
|
||||||
|
|
||||||
export function buildPermissionFormModel(opts: {
|
|
||||||
csrfToken?: string;
|
|
||||||
error?: string;
|
|
||||||
memberOptions: MemberOption[];
|
|
||||||
t?: Translate;
|
|
||||||
values?: { member?: string; name?: string };
|
|
||||||
}) {
|
|
||||||
const t = opts.t ?? ADMIN_EN;
|
|
||||||
const nameField: FieldConfig = {
|
|
||||||
autocomplete: "off", hint: t("admin.permissions.field.nameHint"), icon: "i-shield",
|
|
||||||
id: "name", label: t("admin.permissions.field.name"), name: "name", required: true, value: opts.values?.name ?? "",
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: t("admin.permissions.title") }, { label: t("common.new") }],
|
|
||||||
error: opts.error,
|
|
||||||
form: {
|
|
||||||
action: ADMIN_PERMISSIONS_BASE,
|
|
||||||
cancelHref: ADMIN_PERMISSIONS_BASE,
|
|
||||||
csrfToken: opts.csrfToken ?? "",
|
|
||||||
memberOptions: opts.memberOptions,
|
|
||||||
nameField,
|
|
||||||
selectedMember: opts.values?.member ?? "",
|
|
||||||
submitLabel: t("admin.permissions.create"),
|
|
||||||
},
|
|
||||||
title: t("admin.permissions.new"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildPermissionDetailModel(opts: {
|
|
||||||
candidates: MemberOption[];
|
|
||||||
csrfToken?: string;
|
|
||||||
effective: EffectiveUser[];
|
|
||||||
error?: string;
|
|
||||||
members: MemberView[];
|
|
||||||
permission: { name: string };
|
|
||||||
t?: Translate;
|
|
||||||
}) {
|
|
||||||
const t = opts.t ?? ADMIN_EN;
|
|
||||||
const name = opts.permission.name;
|
|
||||||
const base = detailHref(name);
|
|
||||||
const taken = new Set(opts.members.map((m) => m.subject));
|
|
||||||
const options = opts.candidates.filter((c) => !taken.has(c.value)); // members are users/groups, never the permission itself
|
|
||||||
return {
|
|
||||||
add: { action: `${base}/members`, options },
|
|
||||||
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: t("admin.permissions.title") }, { label: name }],
|
|
||||||
csrfToken: opts.csrfToken ?? "",
|
|
||||||
delete: { action: `${base}/delete` },
|
|
||||||
effective: opts.effective,
|
|
||||||
error: opts.error,
|
|
||||||
members: { action: `${base}/members/delete`, rows: opts.members },
|
|
||||||
permission: { name },
|
|
||||||
title: name,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- request handler (imperative shell) ----
|
|
||||||
|
|
||||||
// instant-revoke: a permission change for a `user:<id>` member must take effect now, so revoke that
|
|
||||||
// user's live tokens (a re-mint then re-reads permissions from Keto). A `group:<name>` change is
|
|
||||||
// transitive across many users — left to lag (documented), so only direct user members revoke.
|
|
||||||
function revokeUserMember(revoke: ((sub: string) => void) | undefined, member: string): void {
|
|
||||||
if (revoke && member.startsWith("user:")) revoke(member.slice("user:".length));
|
|
||||||
}
|
|
||||||
|
|
||||||
// A permission exists exactly while it has ≥1 member (Keto has no create-object).
|
|
||||||
async function roleExists(keto: KetoClient, name: string): Promise<boolean> {
|
|
||||||
const page = await keto.listRelations({ namespace: PERMISSION_NS, object: name, relation: GRANTED, pageSize: 1 });
|
|
||||||
return page.tuples.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The distinct users who effectively hold the permission (expand → flatten → label by email). Skipped for
|
|
||||||
// an empty permission (no member tuples) so we don't expand a non-existent Keto object.
|
|
||||||
async function effectiveUsers(keto: KetoClient, name: string, hasMembers: boolean, emailById: Map<string, string>): Promise<EffectiveUser[]> {
|
|
||||||
if (!hasMembers) return [];
|
|
||||||
const tree = await keto.expand({ namespace: PERMISSION_NS, object: name, relation: GRANTED }, { maxDepth: EXPAND_MAX_DEPTH });
|
|
||||||
return expandToEffectiveUsers(tree)
|
|
||||||
.map((id) => ({ label: emailById.get(id) ?? `user:${id}` }))
|
|
||||||
.sort((a, b) => a.label.localeCompare(b.label));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared per-request deps for the Roles screen, resolved by `withRoles`: the gate + the Keto and
|
|
||||||
// Kratos capabilities (else a themed 503). Each route below is a thin handler over these.
|
|
||||||
interface RolesDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; }
|
|
||||||
|
|
||||||
function withRoles(inner: (deps: RolesDeps) => Promise<RouteResult>): RouteHandler {
|
|
||||||
return async (ctx) => {
|
|
||||||
const user = 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<RouteResult>): 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<RouteResult> => {
|
|
||||||
const { options } = await memberCandidates(deps.keto, deps.kratosAdmin);
|
|
||||||
return { data: { chrome: deps.ctx.chrome, model: buildPermissionFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, t: deps.ctx.t, ...extra }) }, view: "permission-form" };
|
|
||||||
};
|
|
||||||
|
|
||||||
// The permission detail (members + effective access). With `error` set it's a 400 (a rejected action).
|
|
||||||
const permissionDetailResult = async (deps: RolesDeps, name: string, error?: string): Promise<RouteResult> => {
|
|
||||||
const { emailById, options } = await memberCandidates(deps.keto, deps.kratosAdmin);
|
|
||||||
const tuples = await pagedTuples(deps.keto, { namespace: PERMISSION_NS, object: name, relation: GRANTED });
|
|
||||||
const members = tuples.map((t) => memberView(t, emailById));
|
|
||||||
const effective = await effectiveUsers(deps.keto, name, tuples.length > 0, emailById);
|
|
||||||
const result: RouteResult = { data: { chrome: deps.ctx.chrome, model: buildPermissionDetailModel({ candidates: options, csrfToken: deps.ctx.chrome.csrfToken, effective, members, permission: { name }, t: deps.ctx.t, ...(error ? { error } : {}) }) }, view: "permission-detail" };
|
|
||||||
return error ? { ...result, status: 400 } : result;
|
|
||||||
};
|
|
||||||
|
|
||||||
// GET /admin/permissions — the list.
|
|
||||||
export const rolesList = withRoles(async ({ ctx, keto }) => {
|
|
||||||
const permissions = permissionsFromTuples(await pagedTuples(keto, { namespace: PERMISSION_NS, relation: GRANTED }));
|
|
||||||
return { data: { chrome: ctx.chrome, model: buildPermissionsListModel({ csrfToken: ctx.chrome.csrfToken, permissions, t: ctx.t, url: ctx.url }) }, view: "permissions" };
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /admin/permissions — create + assign the first member (a *user* grant revokes their live tokens).
|
|
||||||
export const rolesCreate = withRoles(async (deps) => {
|
|
||||||
const { ctx, keto, revoke, user } = deps;
|
|
||||||
const form = (await guardedForm(ctx))!;
|
|
||||||
const name = (form.get("name") ?? "").trim();
|
|
||||||
const member = (form.get("member") ?? "").trim();
|
|
||||||
const tuple = permissionGrantTuple(name, member);
|
|
||||||
const reject = async (error: string): Promise<RouteResult> => ({ ...(await roleFormResult(deps, { error, values: { member, name } })), status: 400 });
|
|
||||||
if (!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
|
|
||||||
// <resource>:<action> 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) };
|
|
||||||
});
|
|
||||||
@@ -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;
|
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
||||||
req.method = opts.method ?? "GET";
|
req.method = opts.method ?? "GET";
|
||||||
return {
|
return {
|
||||||
chrome: CHROME, user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: {} as Log, params: {},
|
chrome: CHROME, declaredPermissions: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: {} as Log, params: {},
|
||||||
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.user?.permissions ?? [], t: ADMIN_EN, url,
|
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.user?.permissions ?? [], t: ADMIN_EN, url,
|
||||||
verifyCsrf: opts.verifyCsrf ?? (() => true),
|
verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||||
};
|
};
|
||||||
@@ -27,7 +27,7 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver
|
|||||||
|
|
||||||
// ---- nav fragment ----
|
// ---- 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");
|
assert.equal(ADMIN_NAV.id, "admin");
|
||||||
// No gate on the header: a user may hold one screen's permission and not another's. composeNav
|
// 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.
|
// 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.permission, undefined);
|
||||||
assert.equal(ADMIN_NAV.href, undefined);
|
assert.equal(ADMIN_NAV.href, undefined);
|
||||||
assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static
|
assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static
|
||||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/permissions", "/admin/clients"]);
|
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/clients"]);
|
||||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.permission), ["users:read", "groups:read", "permissions:read", "oauth2-clients:read"]);
|
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.permission), ["users:read", "groups:read", "oauth2-clients:read"]);
|
||||||
// Labels are catalog keys; the host translates them with this plugin's catalog when it composes
|
// Labels are catalog keys; the host translates them with this plugin's catalog when it composes
|
||||||
// the menu, so what a visitor sees is the en-US (or sv-SE …) wording behind these keys.
|
// the menu, so what a visitor sees is the en-US (or sv-SE …) wording behind these keys.
|
||||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["admin.nav.users", "admin.nav.groups", "admin.nav.permissions", "admin.nav.clients"]);
|
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["admin.nav.users", "admin.nav.groups", "admin.nav.clients"]);
|
||||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => ADMIN_EN(c.label)), ["Users", "Groups", "Permissions", "OAuth2 clients"]);
|
assert.deepEqual(ADMIN_NAV.children?.map((c) => ADMIN_EN(c.label)), ["Users", "Groups", "OAuth2 clients"]);
|
||||||
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined));
|
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ export const ADMIN_EN: Translate = englishTranslator(enUS);
|
|||||||
|
|
||||||
export const ADMIN_USERS_BASE = "/admin/users";
|
export const ADMIN_USERS_BASE = "/admin/users";
|
||||||
export const ADMIN_GROUPS_BASE = "/admin/groups";
|
export const ADMIN_GROUPS_BASE = "/admin/groups";
|
||||||
export const ADMIN_PERMISSIONS_BASE = "/admin/permissions";
|
|
||||||
export const ADMIN_CLIENTS_BASE = "/admin/clients";
|
export const ADMIN_CLIENTS_BASE = "/admin/clients";
|
||||||
|
|
||||||
// One resource per screen — the `<resource>` half of every permission this plugin gates on.
|
// One resource per screen — the `<resource>` half of every permission this plugin gates on.
|
||||||
// `oauth2-clients` rather than `clients` because permission names are one global namespace.
|
// `oauth2-clients` rather than `clients` because permission names are one global namespace.
|
||||||
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";
|
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.
|
// four never sees the section. The host current-marks the active item — no `current`/`open` here.
|
||||||
export const ADMIN_NAV: NavNode = {
|
export const ADMIN_NAV: NavNode = {
|
||||||
children: [
|
children: [
|
||||||
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users", permission: "users: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: "groups:read" },
|
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "admin.nav.groups", permission: permissionName("groups", "read") },
|
||||||
{ href: ADMIN_PERMISSIONS_BASE, icon: "i-shield", id: "permissions", label: "admin.nav.permissions", permission: "permissions:read" },
|
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "admin.nav.clients", permission: permissionName("oauth2-clients", "read") },
|
||||||
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "admin.nav.clients", permission: "oauth2-clients:read" },
|
|
||||||
],
|
],
|
||||||
icon: "i-shield",
|
icon: "i-shield",
|
||||||
id: "admin",
|
id: "admin",
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
// models; below them are thin per-route handlers (keyed on ctx.params) over a shared `withUser` gate
|
// models; below them are thin per-route handlers (keyed on ctx.params) over a shared `withUser` gate
|
||||||
// — admin-only, CSRF-guarded, each returning a RouteResult (a view, or a redirect after a write — PRG).
|
// — admin-only, CSRF-guarded, each returning a RouteResult (a view, or a redirect after a write — PRG).
|
||||||
|
|
||||||
import { type Identity, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "#plugin-api";
|
import { 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";
|
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 SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
|
||||||
@@ -219,6 +220,7 @@ export function buildUserFormModel(opts: {
|
|||||||
csrfToken?: string;
|
csrfToken?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
identity?: Identity | null;
|
identity?: Identity | null;
|
||||||
|
permissions?: PermissionPicker; // editing only — a user that doesn't exist yet can hold nothing
|
||||||
recovery?: RecoveryCode;
|
recovery?: RecoveryCode;
|
||||||
t?: Translate;
|
t?: Translate;
|
||||||
values?: Partial<UserInput>;
|
values?: Partial<UserInput>;
|
||||||
@@ -250,6 +252,7 @@ export function buildUserFormModel(opts: {
|
|||||||
} : undefined,
|
} : undefined,
|
||||||
error: opts.error,
|
error: opts.error,
|
||||||
form: { action: idPath, cancelHref: ADMIN_USERS_BASE, csrfToken: opts.csrfToken ?? "", fields, submitLabel: editing ? t("admin.users.save") : t("admin.users.create") },
|
form: { action: idPath, cancelHref: ADMIN_USERS_BASE, csrfToken: opts.csrfToken ?? "", fields, submitLabel: editing ? t("admin.users.save") : t("admin.users.create") },
|
||||||
|
permissions: editing ? opts.permissions : undefined,
|
||||||
recovery: opts.recovery,
|
recovery: opts.recovery,
|
||||||
title: editing ? t("admin.users.edit") : t("admin.users.new"),
|
title: editing ? t("admin.users.edit") : t("admin.users.new"),
|
||||||
};
|
};
|
||||||
@@ -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
|
// 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
|
// GET, `users:write` on a POST) and the Kratos capability (else a themed 503). Each route below is a
|
||||||
// thin handler over these.
|
// 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;
|
// 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.
|
// `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<RouteResult>): RouteHandle
|
|||||||
const user = requirePermission(ctx, "users");
|
const user = requirePermission(ctx, "users");
|
||||||
const kratosAdmin = ctx.system?.kratosAdmin;
|
const kratosAdmin = ctx.system?.kratosAdmin;
|
||||||
if (!kratosAdmin) return unavailable(ctx, ctx.t("admin.capability.kratos"));
|
if (!kratosAdmin) return unavailable(ctx, ctx.t("admin.capability.kratos"));
|
||||||
return inner({ ctx, kratosAdmin, revoke: ctx.system?.revoke, user });
|
return inner({ ctx, keto: ctx.system?.keto, kratosAdmin, revoke: ctx.system?.revoke, user });
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,7 +324,40 @@ export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => {
|
|||||||
export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx, {})));
|
export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx, {})));
|
||||||
|
|
||||||
// GET /admin/users/:id — the edit form, prefilled.
|
// GET /admin/users/:id — the edit form, prefilled.
|
||||||
export const usersEditForm = withTarget((deps, identity) => Promise.resolve(formResult(deps.ctx, { identity })));
|
export const usersEditForm = withTarget(async (deps, identity, id) => {
|
||||||
|
const permissions = await userPermissionPicker(deps, id);
|
||||||
|
return formResult(deps.ctx, { 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<PermissionPicker | undefined> {
|
||||||
|
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).
|
// POST /admin/users/:id — save edits; a Kratos 4xx re-renders the form (400).
|
||||||
export const usersUpdate = withTarget(async ({ ctx, kratosAdmin }, identity, id) => {
|
export const usersUpdate = withTarget(async ({ ctx, kratosAdmin }, identity, id) => {
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ const messages = {
|
|||||||
"admin.common.type": "Type",
|
"admin.common.type": "Type",
|
||||||
"admin.common.user": "User",
|
"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.actions": "Group actions",
|
||||||
"admin.groups.addMember": "Add a member",
|
"admin.groups.addMember": "Add a member",
|
||||||
"admin.groups.allMembers": "All users and groups are already members.",
|
"admin.groups.allMembers": "All users and groups are already members.",
|
||||||
@@ -74,41 +78,12 @@ const messages = {
|
|||||||
|
|
||||||
"admin.nav.clients": "OAuth2 clients",
|
"admin.nav.clients": "OAuth2 clients",
|
||||||
"admin.nav.groups": "Groups",
|
"admin.nav.groups": "Groups",
|
||||||
"admin.nav.permissions": "Permissions",
|
|
||||||
"admin.nav.section": "Admin",
|
"admin.nav.section": "Admin",
|
||||||
"admin.nav.users": "Users",
|
"admin.nav.users": "Users",
|
||||||
|
|
||||||
"admin.notFound.message": "That item doesn't exist.",
|
"admin.notFound.message": "That item doesn't exist.",
|
||||||
"admin.notFound.title": "Not found",
|
"admin.notFound.title": "Not found",
|
||||||
|
|
||||||
"admin.permissions.actions": "Permission actions",
|
|
||||||
"admin.permissions.allAssigned": "All users and groups already have this permission.",
|
|
||||||
"admin.permissions.assign": "Assign the permission",
|
|
||||||
"admin.permissions.assignAction": "Assign",
|
|
||||||
"admin.permissions.assignTo": "Assign to",
|
|
||||||
"admin.permissions.assignedTo": "Assigned to",
|
|
||||||
"admin.permissions.column.members": "Members",
|
|
||||||
"admin.permissions.column.name": "Permission",
|
|
||||||
"admin.permissions.create": "Create permission",
|
|
||||||
"admin.permissions.delete": "Delete permission",
|
|
||||||
"admin.permissions.deleteMessage": "Delete permission {{name}}? This revokes it from everyone it's assigned to.",
|
|
||||||
"admin.permissions.error.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 <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.message": "{{what}} is not configured on this deployment.",
|
||||||
"admin.unavailable.title": "Admin unavailable",
|
"admin.unavailable.title": "Admin unavailable",
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ const messages: AdminMessages = {
|
|||||||
"admin.common.type": "Typ",
|
"admin.common.type": "Typ",
|
||||||
"admin.common.user": "Användare",
|
"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.actions": "Gruppåtgärder",
|
||||||
"admin.groups.addMember": "Lägg till en medlem",
|
"admin.groups.addMember": "Lägg till en medlem",
|
||||||
"admin.groups.allMembers": "Alla användare och grupper är redan medlemmar.",
|
"admin.groups.allMembers": "Alla användare och grupper är redan medlemmar.",
|
||||||
@@ -74,41 +78,12 @@ const messages: AdminMessages = {
|
|||||||
|
|
||||||
"admin.nav.clients": "OAuth2-klienter",
|
"admin.nav.clients": "OAuth2-klienter",
|
||||||
"admin.nav.groups": "Grupper",
|
"admin.nav.groups": "Grupper",
|
||||||
"admin.nav.permissions": "Behörigheter",
|
|
||||||
"admin.nav.section": "Administration",
|
"admin.nav.section": "Administration",
|
||||||
"admin.nav.users": "Användare",
|
"admin.nav.users": "Användare",
|
||||||
|
|
||||||
"admin.notFound.message": "Objektet finns inte.",
|
"admin.notFound.message": "Objektet finns inte.",
|
||||||
"admin.notFound.title": "Hittades inte",
|
"admin.notFound.title": "Hittades inte",
|
||||||
|
|
||||||
"admin.permissions.actions": "Behörighetsåtgärder",
|
|
||||||
"admin.permissions.allAssigned": "Alla användare och grupper har redan den här behörigheten.",
|
|
||||||
"admin.permissions.assign": "Tilldela behörigheten",
|
|
||||||
"admin.permissions.assignAction": "Tilldela",
|
|
||||||
"admin.permissions.assignTo": "Tilldela till",
|
|
||||||
"admin.permissions.assignedTo": "Tilldelad till",
|
|
||||||
"admin.permissions.column.members": "Medlemmar",
|
|
||||||
"admin.permissions.column.name": "Behörighet",
|
|
||||||
"admin.permissions.create": "Skapa behörighet",
|
|
||||||
"admin.permissions.delete": "Radera behörighet",
|
|
||||||
"admin.permissions.deleteMessage": "Ta bort behörigheten {{name}}? Den återkallas från alla den är tilldelad till.",
|
|
||||||
"admin.permissions.error.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 <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.message": "{{what}} är inte konfigurerat i den här installationen.",
|
||||||
"admin.unavailable.title": "Administrationen är otillgänglig",
|
"admin.unavailable.title": "Administrationen är otillgänglig",
|
||||||
|
|||||||
@@ -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}`);
|
for (const name of declared) assert.ok(gated.has(name), `declared but unused: ${name}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A nav permission is a plain string the host matches against the JWT claim: a typo ("user:read")
|
||||||
|
// passes discovery's shape check and silently hides that menu item forever. Same silent-failure
|
||||||
|
// class the route checks above close, so close it on the nav side too.
|
||||||
|
test("every nav permission is one the manifest declares", () => {
|
||||||
|
const navPermissions: string[] = [];
|
||||||
|
const walk = (nodes: typeof manifest.nav): void => {
|
||||||
|
for (const node of nodes ?? []) {
|
||||||
|
if (node.permission != null) navPermissions.push(node.permission);
|
||||||
|
walk(node.children);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(manifest.nav);
|
||||||
|
assert.equal(navPermissions.length, 3);
|
||||||
|
for (const name of navPermissions) assert.ok(declared.includes(name), `nav gates on undeclared ${name}`);
|
||||||
|
});
|
||||||
|
|
||||||
test("every declared permission is <resource>:<action>, and reads and writes are split per resource", () => {
|
test("every declared permission is <resource>:<action>, and reads and writes are split per resource", () => {
|
||||||
for (const name of declared) assert.ok(isValidPermissionName(name), name); // the host's rule, not a copy of it
|
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(), [
|
assert.deepEqual([...declared].sort(), [
|
||||||
"groups:read", "groups:write",
|
"groups:read", "groups:write",
|
||||||
"oauth2-clients:read", "oauth2-clients:write",
|
"oauth2-clients:read", "oauth2-clients:write",
|
||||||
"permissions:read", "permissions:write",
|
|
||||||
"users:read", "users:write",
|
"users:read", "users:write",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,9 +8,8 @@
|
|||||||
|
|
||||||
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "#plugin-api";
|
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "#plugin-api";
|
||||||
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
|
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
|
||||||
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsRemoveMember } from "./admin-groups.ts";
|
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsPermissions, groupsRemoveMember } from "./admin-groups.ts";
|
||||||
import { rolesAddMember, rolesCreate, rolesDelete, rolesDeleteConfirm, rolesDetail, rolesList, rolesNewForm, rolesRemoveMember } from "./admin-permissions.ts";
|
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersPermissions, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
|
||||||
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
|
|
||||||
import { ADMIN_NAV, actionForMethod, type AdminResource, permissionName } from "./admin-shared.ts";
|
import { ADMIN_NAV, actionForMethod, type AdminResource, permissionName } from "./admin-shared.ts";
|
||||||
|
|
||||||
// One route factory per screen: a GET gates on `<resource>:read` and a POST on `<resource>:write`,
|
// One route factory per screen: a GET gates on `<resource>:read` and a POST on `<resource>:write`,
|
||||||
@@ -23,7 +22,6 @@ const on = (resource: AdminResource) => (method: HttpMethod, path: string, handl
|
|||||||
|
|
||||||
const users = on("users");
|
const users = on("users");
|
||||||
const groups = on("groups");
|
const groups = on("groups");
|
||||||
const permissions = on("permissions");
|
|
||||||
const clients = on("oauth2-clients");
|
const clients = on("oauth2-clients");
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
@@ -32,12 +30,10 @@ export default definePlugin({
|
|||||||
nav: [ADMIN_NAV],
|
nav: [ADMIN_NAV],
|
||||||
|
|
||||||
permissions: [
|
permissions: [
|
||||||
{ description: "View users", name: "users:read" },
|
{ description: "View users and the permissions they hold", name: "users:read" },
|
||||||
{ description: "Create, edit and delete users", name: "users:write" },
|
{ description: "Create, edit and delete users, and grant them permissions", name: "users:write" },
|
||||||
{ description: "View groups and their members", name: "groups:read" },
|
{ description: "View groups, their members and the permissions they hold", name: "groups:read" },
|
||||||
{ description: "Create, delete and change the membership of groups", name: "groups:write" },
|
{ description: "Create and delete groups, and change their members and permissions", 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: "View OAuth2 clients", name: "oauth2-clients:read" },
|
||||||
{ description: "Register and delete OAuth2 clients", name: "oauth2-clients:write" },
|
{ description: "Register and delete OAuth2 clients", name: "oauth2-clients:write" },
|
||||||
],
|
],
|
||||||
@@ -53,6 +49,7 @@ export default definePlugin({
|
|||||||
users("GET", "/users/:id/delete", usersDeleteConfirm),
|
users("GET", "/users/:id/delete", usersDeleteConfirm),
|
||||||
users("POST", "/users/:id/delete", usersDelete),
|
users("POST", "/users/:id/delete", usersDelete),
|
||||||
users("POST", "/users/:id/recovery", usersRecovery),
|
users("POST", "/users/:id/recovery", usersRecovery),
|
||||||
|
users("POST", "/users/:id/permissions", usersPermissions),
|
||||||
// Groups
|
// Groups
|
||||||
groups("GET", "/groups", groupsList),
|
groups("GET", "/groups", groupsList),
|
||||||
groups("POST", "/groups", groupsCreate),
|
groups("POST", "/groups", groupsCreate),
|
||||||
@@ -62,15 +59,7 @@ export default definePlugin({
|
|||||||
groups("GET", "/groups/:name/delete", groupsDeleteConfirm),
|
groups("GET", "/groups/:name/delete", groupsDeleteConfirm),
|
||||||
groups("POST", "/groups/:name/delete", groupsDelete),
|
groups("POST", "/groups/:name/delete", groupsDelete),
|
||||||
groups("POST", "/groups/:name/members/delete", groupsRemoveMember),
|
groups("POST", "/groups/:name/members/delete", groupsRemoveMember),
|
||||||
// Permissions
|
groups("POST", "/groups/:name/permissions", groupsPermissions),
|
||||||
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
|
// OAuth2 clients
|
||||||
clients("GET", "/clients", clientsList),
|
clients("GET", "/clients", clientsList),
|
||||||
clients("POST", "/clients", clientsCreate),
|
clients("POST", "/clients", clientsCreate),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Group admin detail / membership page: the group-detail body in the app shell.
|
Group admin detail / membership page: the group-detail body in the app shell.
|
||||||
%><%
|
%><%
|
||||||
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
const body = include("partials/group-detail-body", { add: model.add, csrfToken: model.csrfToken, del: model.delete, error: model.error, group: model.group, members: model.members });
|
const body = include("partials/group-detail-body", { add: model.add, csrfToken: model.csrfToken, del: model.delete, error: model.error, group: model.group, members: model.members, permissions: model.permissions });
|
||||||
-%>
|
-%>
|
||||||
<%- include("partials/shell", {
|
<%- include("partials/shell", {
|
||||||
body,
|
body,
|
||||||
|
|||||||
@@ -36,6 +36,9 @@
|
|||||||
<p class="cell-muted"><%= t("admin.groups.allMembers") %></p>
|
<p class="cell-muted"><%= t("admin.groups.allMembers") %></p>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
</section>
|
</section>
|
||||||
|
<% if (locals.permissions) { -%>
|
||||||
|
<%- include("partials/permission-picker", { csrfToken: csrf, permissions: locals.permissions }) %>
|
||||||
|
<% } -%>
|
||||||
<section class="form-card admin-actions" aria-label="<%= t("admin.groups.actions") %>">
|
<section class="form-card admin-actions" aria-label="<%= t("admin.groups.actions") %>">
|
||||||
<a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.groups.delete") %></a>
|
<a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.groups.delete") %></a>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
<%#
|
|
||||||
Admin permission detail body, captured into the shell content slot. Config:
|
|
||||||
permission { name }
|
|
||||||
members { action, rows: { kind:"group"|"identity", label, subject }[] } action = revoke endpoint
|
|
||||||
effective { label }[] users who hold the permission (expand)
|
|
||||||
add { action, options: {label,value}[] } action = assign endpoint
|
|
||||||
del { action } delete the whole permission
|
|
||||||
csrfToken, error?
|
|
||||||
%><%
|
|
||||||
const permission = locals.permission;
|
|
||||||
const members = locals.members;
|
|
||||||
const effective = locals.effective;
|
|
||||||
const add = locals.add;
|
|
||||||
const del = locals.del;
|
|
||||||
const csrf = locals.csrfToken;
|
|
||||||
-%>
|
|
||||||
<div class="form-page">
|
|
||||||
<% if (locals.error) { -%>
|
|
||||||
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
|
|
||||||
<% } -%>
|
|
||||||
<section class="form-card" aria-labelledby="members-h">
|
|
||||||
<h2 class="card-title" id="members-h"><%= t("admin.permissions.assignedTo") %></h2>
|
|
||||||
<% if (members.rows.length) { -%>
|
|
||||||
<div class="table-wrap"><table class="table"><caption class="sr-only"><%= t("admin.groups.membersOf", { name: permission.name }) %></caption><thead><tr><th scope="col"><%= t("admin.common.member") %></th><th scope="col"><%= t("admin.common.type") %></th><th class="col-actions" scope="col"><span class="sr-only"><%= t("table.actions") %></span></th></tr></thead><tbody>
|
|
||||||
<% members.rows.forEach((m) => { -%>
|
|
||||||
<tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %></span></td><td class="col-actions"><form method="post" action="<%= localeHref(members.action) %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg><%= t("admin.permissions.revoke") %></button></form></td></tr>
|
|
||||||
<% }) -%>
|
|
||||||
</tbody></table></div>
|
|
||||||
<% } else { -%>
|
|
||||||
<p class="cell-muted"><%= t("admin.permissions.noMembers") %></p>
|
|
||||||
<% } -%>
|
|
||||||
</section>
|
|
||||||
<section class="form-card" aria-labelledby="effective-h">
|
|
||||||
<h2 class="card-title" id="effective-h"><%= t("admin.permissions.effective") %></h2>
|
|
||||||
<p class="field-hint"><%= t("admin.permissions.effectiveHint") %></p>
|
|
||||||
<% if (effective.length) { -%>
|
|
||||||
<ul class="plain-list">
|
|
||||||
<% effective.forEach((u) => { -%>
|
|
||||||
<li><span class="cell-strong"><%= u.label %></span></li>
|
|
||||||
<% }) -%>
|
|
||||||
</ul>
|
|
||||||
<% } else { -%>
|
|
||||||
<p class="cell-muted"><%= t("admin.permissions.noEffective") %></p>
|
|
||||||
<% } -%>
|
|
||||||
</section>
|
|
||||||
<section class="form-card" aria-labelledby="add-h">
|
|
||||||
<h2 class="card-title" id="add-h"><%= t("admin.permissions.assign") %></h2>
|
|
||||||
<% if (add.options.length) { -%>
|
|
||||||
<form class="inline-form" method="post" action="<%= localeHref(add.action) %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member"><%= t("admin.common.member") %></label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected><%= t("admin.common.chooseMember") %></option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg><%= t("admin.permissions.assignAction") %></button></form>
|
|
||||||
<% } else { -%>
|
|
||||||
<p class="cell-muted"><%= t("admin.permissions.allAssigned") %></p>
|
|
||||||
<% } -%>
|
|
||||||
</section>
|
|
||||||
<section class="form-card admin-actions" aria-label="<%= t("admin.permissions.actions") %>">
|
|
||||||
<a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.permissions.delete") %></a>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<%#
|
|
||||||
Admin permission create form body, captured into the shell content slot. Config:
|
|
||||||
form { action, csrfToken, submitLabel, cancelHref, nameField: field.ejs config,
|
|
||||||
memberOptions: {label,value}[], selectedMember }
|
|
||||||
error? string shown when a write was rejected
|
|
||||||
%><%
|
|
||||||
const form = locals.form;
|
|
||||||
-%>
|
|
||||||
<div class="form-page">
|
|
||||||
<% if (locals.error) { -%>
|
|
||||||
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
|
|
||||||
<% } -%>
|
|
||||||
<form class="form-card" method="post" action="<%= localeHref(form.action) %>">
|
|
||||||
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
|
||||||
<%- include("partials/field", form.nameField) %>
|
|
||||||
<div class="field">
|
|
||||||
<label for="member"><%= t("admin.permissions.assignTo") %></label>
|
|
||||||
<span class="select"><select id="member" name="member" required><option value="" disabled<% if (!form.selectedMember) { %> selected<% } %>><%= t("admin.common.chooseMember") %></option><% form.memberOptions.forEach((o) => { %><option value="<%= o.value %>"<% if (form.selectedMember === o.value) { %> selected<% } %>><%= o.label %></option><% }) %></select></span>
|
|
||||||
<span class="field-hint">A permission exists once assigned; add more users or groups after creating it.</span>
|
|
||||||
</div>
|
|
||||||
<div class="form-actions">
|
|
||||||
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("common.cancel") %></a>
|
|
||||||
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
@@ -0,0 +1,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 }).
|
||||||
|
%>
|
||||||
|
<section class="form-card" aria-labelledby="permissions-h">
|
||||||
|
<h2 class="card-title" id="permissions-h"><%= permissions.legend %></h2>
|
||||||
|
<% if (permissions.empty) { -%>
|
||||||
|
<p class="cell-muted"><%= permissions.empty %></p>
|
||||||
|
<% } else { -%>
|
||||||
|
<form method="post" action="<%= localeHref(permissions.action) %>">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<fieldset class="check-list">
|
||||||
|
<legend class="sr-only"><%= permissions.legend %></legend>
|
||||||
|
<% permissions.choices.forEach((c, i) => { -%>
|
||||||
|
<div class="check-row">
|
||||||
|
<input type="checkbox" id="perm-<%= i %>" name="<%= permissions.field %>" value="<%= c.name %>"<%= c.checked ? " checked" : "" %>>
|
||||||
|
<label for="perm-<%= i %>"><span class="cell-strong"><%= c.name %></span><% if (c.description) { %><span class="cell-muted"><%= c.description %></span><% } %></label>
|
||||||
|
</div>
|
||||||
|
<% }) -%>
|
||||||
|
</fieldset>
|
||||||
|
<button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg><%= permissions.submit %></button>
|
||||||
|
</form>
|
||||||
|
<% } -%>
|
||||||
|
</section>
|
||||||
@@ -26,6 +26,9 @@
|
|||||||
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
|
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<% if (edit && locals.permissions) { -%>
|
||||||
|
<%- include("partials/permission-picker", { csrfToken: form.csrfToken, permissions: locals.permissions }) %>
|
||||||
|
<% } -%>
|
||||||
<% if (edit) { -%>
|
<% if (edit) { -%>
|
||||||
<section class="form-card admin-actions" aria-label="<%= t("admin.users.actions") %>">
|
<section class="form-card admin-actions" aria-label="<%= t("admin.users.actions") %>">
|
||||||
<form method="post" action="<%= localeHref(edit.recoveryAction) %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-mail"/></svg><%= t("admin.users.recovery.generate") %></button></form>
|
<form method="post" action="<%= localeHref(edit.recoveryAction) %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-mail"/></svg><%= t("admin.users.recovery.generate") %></button></form>
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
<%#
|
|
||||||
Permission admin detail page: the permission-detail body (members · effective access) in the shell.
|
|
||||||
%><%
|
|
||||||
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
|
||||||
const body = include("partials/permission-detail-body", { add: model.add, csrfToken: model.csrfToken, del: model.delete, effective: model.effective, error: model.error, members: model.members, permission: model.permission });
|
|
||||||
-%>
|
|
||||||
<%- include("partials/shell", {
|
|
||||||
body,
|
|
||||||
brand: chrome.brand,
|
|
||||||
breadcrumbs: model.breadcrumbs,
|
|
||||||
csrfToken: chrome.csrfToken,
|
|
||||||
nav,
|
|
||||||
theme: chrome.theme,
|
|
||||||
title: model.title,
|
|
||||||
user: chrome.user,
|
|
||||||
}) %>
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<%#
|
|
||||||
Permission admin create page: the permission-form body captured into the app shell.
|
|
||||||
%><%
|
|
||||||
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
|
||||||
const body = include("partials/permission-form-body", { error: model.error, form: model.form });
|
|
||||||
-%>
|
|
||||||
<%- include("partials/shell", {
|
|
||||||
body,
|
|
||||||
brand: chrome.brand,
|
|
||||||
breadcrumbs: model.breadcrumbs,
|
|
||||||
csrfToken: chrome.csrfToken,
|
|
||||||
nav,
|
|
||||||
theme: chrome.theme,
|
|
||||||
title: model.title,
|
|
||||||
user: chrome.user,
|
|
||||||
}) %>
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<%#
|
|
||||||
Permissions admin list: the same building blocks as the Groups screen, around the shell, backed
|
|
||||||
by live Keto Permission subject sets (admin-permissions.ts). Filter/sort/page round-trip the URL.
|
|
||||||
%><%
|
|
||||||
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
|
||||||
const filters = include("partials/filter-bar", model.filterBar);
|
|
||||||
const table = include("partials/data-table", model.table);
|
|
||||||
const pager = include("partials/pagination", model.pagination);
|
|
||||||
const actions = '<a class="btn btn-primary" href="' + localeHref("/admin/permissions/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.permissions.new") + '</a>';
|
|
||||||
-%>
|
|
||||||
<%- include("partials/shell", {
|
|
||||||
actions,
|
|
||||||
body: filters + table + pager,
|
|
||||||
brand: chrome.brand,
|
|
||||||
breadcrumbs: model.breadcrumbs,
|
|
||||||
csrfToken: chrome.csrfToken,
|
|
||||||
nav,
|
|
||||||
theme: chrome.theme,
|
|
||||||
title: model.title,
|
|
||||||
user: chrome.user,
|
|
||||||
}) %>
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
Users admin create/edit page: the user-form body captured into the app shell.
|
Users admin create/edit page: the user-form body captured into the app shell.
|
||||||
%><%
|
%><%
|
||||||
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
const body = include("partials/user-form-body", { edit: model.edit, error: model.error, form: model.form, recovery: model.recovery });
|
const body = include("partials/user-form-body", { edit: model.edit, error: model.error, form: model.form, permissions: model.permissions, recovery: model.recovery });
|
||||||
-%>
|
-%>
|
||||||
<%- include("partials/shell", {
|
<%- include("partials/shell", {
|
||||||
body,
|
body,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; ve
|
|||||||
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
|
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
|
||||||
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
||||||
return {
|
return {
|
||||||
chrome: CHROME, user: null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {},
|
chrome: CHROME, declaredPermissions: [], user: null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {},
|
||||||
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.permissions ?? [], t, url,
|
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.permissions ?? [], t, url,
|
||||||
verifyCsrf: opts.verifyCsrf ?? (() => true),
|
verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||||
};
|
};
|
||||||
|
|||||||
+33
-94
@@ -15,7 +15,7 @@ import { CSRF_COOKIE, issueCsrfToken } from "../auth/csrf.ts";
|
|||||||
import { can, check, GuardError, requireSession } from "../auth/guards.ts";
|
import { can, check, GuardError, requireSession } from "../auth/guards.ts";
|
||||||
import { HydraError, type HydraAdmin, type OAuth2Client } from "../auth/hydra-admin.ts";
|
import { HydraError, type HydraAdmin, type OAuth2Client } from "../auth/hydra-admin.ts";
|
||||||
import { staticJwks } from "../auth/jwks.ts";
|
import { staticJwks } from "../auth/jwks.ts";
|
||||||
import type { ExpandTree, KetoClient, RelationTuple, SubjectSet } from "../auth/keto-client.ts";
|
import type { KetoClient, RelationTuple, SubjectSet } from "../auth/keto-client.ts";
|
||||||
import type { Identity, KratosAdmin } from "../auth/kratos-admin.ts";
|
import type { Identity, KratosAdmin } from "../auth/kratos-admin.ts";
|
||||||
import { KratosError, type Flow, type FlowType, type KratosPublic, type Session, type UiNode } from "../auth/kratos-public.ts";
|
import { KratosError, type Flow, type FlowType, type KratosPublic, type Session, type UiNode } from "../auth/kratos-public.ts";
|
||||||
import { SESSION_COOKIE } from "../auth/login.ts";
|
import { SESSION_COOKIE } from "../auth/login.ts";
|
||||||
@@ -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);
|
assert.equal((await get("/admin/groups/%ZZ")).status, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Built-in Roles admin screen: gate + list/create/assign/revoke/delete over HTTP
|
// Granting permissions over HTTP, on the two screens that replaced the deleted Permissions screen.
|
||||||
// against a fake in-memory Keto whose `expand` mirrors Keto's transitive resolution, so the
|
// The offered set is the host's catalog (ctx.declaredPermissions, from what the installed plugins
|
||||||
// effective-access view surfaces a user reachable only through a group.
|
// declare), so the checkboxes are a fixed list and the POST is the desired state.
|
||||||
test("admin Roles screen: gate, list, create, assign user/group, effective access (expand), revoke, delete", async (t) => {
|
test("admin permission grants: the picker offers the declared catalog, and a save is the desired set", async (t) => {
|
||||||
const ada = randomUUID();
|
const ada = randomUUID();
|
||||||
const grace = randomUUID();
|
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||||
const identities: Identity[] = [
|
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${ada}` }];
|
||||||
{ id: ada, schema_id: "default", state: "active", traits: { email: "ada@example.com" } },
|
const keto = fakeKeto(tuples);
|
||||||
{ id: grace, schema_id: "default", state: "active", traits: { email: "grace@example.com" } },
|
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
];
|
const denylist = createDenylist();
|
||||||
// grace is in the `eng` group; `docs:write` is an existing permission whose only direct member is ada.
|
const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin });
|
||||||
const tuples: RelationTuple[] = [
|
|
||||||
{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${grace}` },
|
|
||||||
{ 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 });
|
|
||||||
|
|
||||||
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.
|
// Save a new set: users:write is added, users:read is dropped — the POST is the whole truth.
|
||||||
const listHtml = await (await get("/admin/permissions")).text();
|
const saved = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&permission=users%3Awrite&permission=groups%3Aread`);
|
||||||
assert.match(listHtml, /href="\/admin\/permissions\/docs%3Awrite"/);
|
assert.equal(saved.status, 303);
|
||||||
assert.match(listHtml, /href="\/admin\/permissions\/new"/);
|
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.
|
// A crafted POST can't grant something no plugin declares.
|
||||||
assert.match(await (await get("/admin/permissions/new")).text(), /Create permission/);
|
await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&permission=users%3Awrite&permission=superuser%3Aall`);
|
||||||
const created = await post("/admin/permissions", `_csrf=${token}&name=docs%3Aread&member=user:${ada}`);
|
assert.ok(!tuples.some((tp) => tp.object === "superuser:all"));
|
||||||
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
|
|
||||||
|
|
||||||
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written.
|
// The same picker on a group writes the group's subject_set, which Keto resolves transitively.
|
||||||
const before = tuples.length;
|
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
|
||||||
assert.equal((await post("/admin/permissions", `_csrf=${token}&name=Bad Name&member=user:${ada}`)).status, 400);
|
await post("/admin/groups/eng/permissions", `_csrf=${token}&permission=groups%3Aread`);
|
||||||
// A bare word has no <resource>:<action> shape — the rule the create form now enforces.
|
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "groups:read" && tp.subject_set?.object === "eng"));
|
||||||
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/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 <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);
|
|
||||||
assert.equal((await get("/admin/permissions/%ZZ")).status, 404);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Built-in OAuth2 clients admin screen: gate + list/register/detail/delete over HTTP against an
|
// Built-in OAuth2 clients admin screen: gate + list/register/detail/delete over HTTP against an
|
||||||
|
|||||||
+6
-3
@@ -25,7 +25,7 @@ import type { KratosPublic } from "../auth/kratos-public.ts";
|
|||||||
import { createLogger, type Log, requestLogger, runWithLog } from "../logger.ts";
|
import { createLogger, type Log, requestLogger, runWithLog } from "../logger.ts";
|
||||||
import { remintSession } from "../auth/login.ts";
|
import { remintSession } from "../auth/login.ts";
|
||||||
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
|
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
|
||||||
import type { Plugin, RouteHandler, RouteResult } from "../plugin-host/plugin.ts";
|
import { declaredPermissions, type Plugin, type RouteHandler, type RouteResult } from "../plugin-host/plugin.ts";
|
||||||
import type { SystemCapabilities } from "../plugin-host/system.ts";
|
import type { SystemCapabilities } from "../plugin-host/system.ts";
|
||||||
import { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts";
|
import { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts";
|
||||||
import { buildAuthRoutes } from "../auth/routes.ts";
|
import { buildAuthRoutes } from "../auth/routes.ts";
|
||||||
@@ -99,6 +99,9 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
const homePlugin = plugins.find((p): p is Plugin & { home: RouteHandler } => typeof p.home === "function");
|
const homePlugin = plugins.find((p): p is Plugin & { home: RouteHandler } => typeof p.home === "function");
|
||||||
const dashboardPlugin = plugins.find((p): p is Plugin & { dashboard: RouteHandler } => typeof p.dashboard === "function");
|
const dashboardPlugin = plugins.find((p): p is Plugin & { dashboard: RouteHandler } => typeof p.dashboard === "function");
|
||||||
// Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free).
|
// Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free).
|
||||||
|
// The permission catalog is a property of the installed plugin set, so it is computed once at
|
||||||
|
// wiring rather than per request.
|
||||||
|
const permissionCatalog = declaredPermissions(plugins);
|
||||||
const anyRequestHooks = plugins.some((p) => p.hooks?.onRequest);
|
const anyRequestHooks = plugins.some((p) => p.hooks?.onRequest);
|
||||||
const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse);
|
const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse);
|
||||||
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
|
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
|
||||||
@@ -297,9 +300,9 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
// base context (no route params yet); reused for the built-in routes. A plugin-owned render
|
// base context (no route params yet); reused for the built-in routes. A plugin-owned render
|
||||||
// (a landing slot, a hook short-circuit, a plugin route) gets `contextFor(id)` instead, so its
|
// (a landing slot, a hook short-circuit, a plugin route) gets `contextFor(id)` instead, so its
|
||||||
// own catalog is what `ctx.t` reads.
|
// own catalog is what `ctx.t` reads.
|
||||||
const ctx = buildContext(req, res, { chrome, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
|
const ctx = buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
|
||||||
const contextFor = (pluginId: string, params?: Record<string, string>): RequestContext =>
|
const contextFor = (pluginId: string, params?: Record<string, string>): RequestContext =>
|
||||||
buildContext(req, res, { chrome, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) });
|
buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) });
|
||||||
renderPage = viewsFor(ctx);
|
renderPage = viewsFor(ctx);
|
||||||
|
|
||||||
// Plugin onRequest hooks run before routing and may short-circuit the request.
|
// Plugin onRequest hooks run before routing and may short-circuit the request.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
import type { PageChrome } from "../ui/chrome.ts"; // type-only: no runtime import, so no cycle
|
import type { PageChrome } from "../ui/chrome.ts"; // type-only: no runtime import, so no cycle
|
||||||
|
import type { PermissionDecl } from "../plugin-host/plugin.ts"; // type-only
|
||||||
import type { SystemCapabilities } from "../plugin-host/system.ts"; // type-only
|
import type { SystemCapabilities } from "../plugin-host/system.ts"; // type-only
|
||||||
import { DEFAULT_LOCALE } from "../i18n/catalog.ts";
|
import { DEFAULT_LOCALE } from "../i18n/catalog.ts";
|
||||||
import { ENGLISH } from "../i18n/english.ts";
|
import { ENGLISH } from "../i18n/english.ts";
|
||||||
@@ -37,6 +38,10 @@ export interface RequestContext {
|
|||||||
// log; `log.fetch(url)` for an upstream call (a client span continuing the trace). Correlates by
|
// log; `log.fetch(url)` for an upstream call (a client span continuing the trace). Correlates by
|
||||||
// requestId. Additive, stable per the contract; defaults to a silent logger off the request path.
|
// requestId. Additive, stable per the contract; defaults to a silent logger off the request path.
|
||||||
log: Log;
|
log: Log;
|
||||||
|
// Every permission the installed plugins declare, deduped and sorted — the fixed list an admin
|
||||||
|
// screen offers when granting one. Pairs with `permissions` below: this is what *exists*, that is
|
||||||
|
// what *this user holds*. Empty when no installed plugin declares any.
|
||||||
|
declaredPermissions: PermissionDecl[];
|
||||||
params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id }
|
params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id }
|
||||||
permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check
|
permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check
|
||||||
query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q")
|
query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q")
|
||||||
@@ -61,6 +66,7 @@ export interface BuildContextOptions {
|
|||||||
// ctx.chrome (a json/redirect handler, or the public "/" with a standalone home, pays nothing).
|
// ctx.chrome (a json/redirect handler, or the public "/" with a standalone home, pays nothing).
|
||||||
// The host's factory is memoised, so the menu composes at most once per request across contexts.
|
// The host's factory is memoised, so the menu composes at most once per request across contexts.
|
||||||
chrome?: () => PageChrome;
|
chrome?: () => PageChrome;
|
||||||
|
declaredPermissions?: PermissionDecl[];
|
||||||
user?: User | null;
|
user?: User | null;
|
||||||
locale?: string;
|
locale?: string;
|
||||||
localeHref?: (href: string) => string;
|
localeHref?: (href: string) => string;
|
||||||
@@ -89,6 +95,7 @@ export function buildContext(
|
|||||||
let chromeMemo: PageChrome | undefined; // resolve the factory at most once per context
|
let chromeMemo: PageChrome | undefined; // resolve the factory at most once per context
|
||||||
return {
|
return {
|
||||||
get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
|
get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
|
||||||
|
declaredPermissions: options.declaredPermissions ?? [],
|
||||||
user,
|
user,
|
||||||
locale: options.locale ?? DEFAULT_LOCALE,
|
locale: options.locale ?? DEFAULT_LOCALE,
|
||||||
localeHref: options.localeHref ?? ((href) => href),
|
localeHref: options.localeHref ?? ((href) => href),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import {
|
import {
|
||||||
checkApiVersion,
|
checkApiVersion,
|
||||||
|
declaredPermissions,
|
||||||
definePlugin,
|
definePlugin,
|
||||||
findConflicts,
|
findConflicts,
|
||||||
HOST_API_VERSION,
|
HOST_API_VERSION,
|
||||||
@@ -58,6 +59,18 @@ test("isValidPermissionName requires <resource>:<action> — 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", () => {
|
test("parseSemver follows the semver core, rejecting ranges, prefixes, leading zeros and missing parts", () => {
|
||||||
assert.deepEqual(parseSemver("1.2.3"), { major: 1, minor: 2, patch: 3 });
|
assert.deepEqual(parseSemver("1.2.3"), { major: 1, minor: 2, patch: 3 });
|
||||||
assert.deepEqual(parseSemver("1.2.3-rc.1+build.5"), { major: 1, minor: 2, patch: 3 }); // prerelease/build tolerated, ignored
|
assert.deepEqual(parseSemver("1.2.3-rc.1+build.5"), { major: 1, minor: 2, patch: 3 }); // prerelease/build tolerated, ignored
|
||||||
|
|||||||
@@ -54,6 +54,18 @@ export function isValidPermissionName(name: string): boolean {
|
|||||||
return name.length <= 64 && PERMISSION_NAME.test(name);
|
return name.length <= 64 && PERMISSION_NAME.test(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every permission the installed plugins declare, deduped by name and sorted — the fixed list the
|
||||||
|
// admin screens offer when granting. Permissions are authored in code, never invented in the GUI, so
|
||||||
|
// this *is* the catalog; a name in Keto that no plugin declares gates nothing and is not offered.
|
||||||
|
// First declaration of a name wins its description (shared names are legitimate, findConflicts warns).
|
||||||
|
export function declaredPermissions(plugins: Plugin[]): PermissionDecl[] {
|
||||||
|
const byName = new Map<string, PermissionDecl>();
|
||||||
|
for (const plugin of plugins) {
|
||||||
|
for (const decl of plugin.permissions ?? []) if (!byName.has(decl.name)) byName.set(decl.name, decl);
|
||||||
|
}
|
||||||
|
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
// Optional hooks on system actions. Crash-isolation is a non-goal — a throwing hook fails loud.
|
// Optional hooks on system actions. Crash-isolation is a non-goal — a throwing hook fails loud.
|
||||||
export interface PluginHooks {
|
export interface PluginHooks {
|
||||||
onBoot?: () => Promise<void> | void; // after discovery, before the server listens
|
onBoot?: () => Promise<void> | void; // after discovery, before the server listens
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
## Unfinnished work
|
## 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 `<resource>:<action>` 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)
|
- [ ] The seeded admin@plainpages.local are assigned twice to the permission "admin", should only be one, right? (the "admin" permission name can be switched after previous todos have been done)
|
||||||
- [ ] In Playwright tests, try different resolutions and sizes, from BIG desktop down to tiny phone.
|
- [ ] In Playwright tests, try different resolutions and sizes, from BIG desktop down to tiny phone.
|
||||||
- [ ] Decide whether `e2e-tests/` should be typechecked. It is outside `tsconfig.include`, so the gate never checks the most logic-bearing file in it (`console-guard.ts`) — Playwright strips its types without checking them. Including it needs the DOM lib and `@playwright/test` present wherever `npm run typecheck` runs, which today is the `web` image that installs neither. Raised by review 2026-08-05.
|
- [ ] Decide whether `e2e-tests/` should be typechecked. It is outside `tsconfig.include`, so the gate never checks the most logic-bearing file in it (`console-guard.ts`) — Playwright strips its types without checking them. Including it needs the DOM lib and `@playwright/test` present wherever `npm run typecheck` runs, which today is the `web` image that installs neither. Raised by review 2026-08-05.
|
||||||
@@ -31,7 +29,8 @@ Prioritized. Overall verdict: architecture is sound (contract-first plugin API,
|
|||||||
|
|
||||||
## Finnished work
|
## Finnished work
|
||||||
|
|
||||||
- [x] Document permissions format so it is folled going forward: <resource>:<action>, for example scheduling:read. Permission "admin" does not match this, and needs to be users:read, users:write, groups:read, groups:write. (README → [Naming a permission](README.md#naming-a-permission) is the one home for the rule, and 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] Document permissions format so it is folled going forward: <resource>:<action>, for example scheduling:read. Permission "admin" does not match this, and needs to be users:read, users:write, groups:read, groups:write. (README → [Naming a permission](README.md#naming-a-permission) is the one home for the rule, and the host *enforces* it at discovery — `isValidPermissionName` in `src/plugin-host/plugin.ts`, checked by `shapeError` over every route/nav `permission` and every declared name — so a badly-named permission stops the boot like any other bad manifest, for every plugin rather than only ones the admin GUI touches. `admin` is gone, split per screen into `users:`, `groups:` and `oauth2-clients:` × `read`/`write`. The read/write split is real, not cosmetic: `users:read` opens the list and is refused on every POST, and the Admin nav header lost its own gate so each screen is filtered by its own `:read` — hold none of the three and `composeNav` drops the emptied header (which needs the header to carry no `href`, now asserted). Two things had to be fixed to get here. The permission path validator was the *group* regex with no colon, so `/admin/permissions/scheduling:read` already 404'd. And `ADMIN_PERMISSIONS` defaulting to empty exposed that `bootstrap` never bind-mounted `plugins/` at all — it discovered only the image's empty copy, so a dropped-in plugin's permissions were never seeded; the mount lives in `compose.override.yml` (dev-only, mirroring `web`'s `.:/app`) because the base file gives both services the same baked copy and a base-file mount would collide with the e2e stacks that bind plugins *inside* that path. Quick start now says `docker compose up -d`, which re-runs the one-shot. Verified end to end on a live stack.)
|
||||||
|
- [x] Permissions should be a list in code. Since no permissions exists in the database out of the box, but there are a fixed number of permissions in the plugins that the end consumer and user of plain pages can use, these permissions must surface to the UI somehow. The effects is that the permissions page should be deleted completely, and the users and groups pages should gain the functionality to add permissions to their things instead, provided the user have the right permissiosn to do so, of course. Run the product reviewer agent on this todo also. (The host collects every installed plugin's declarations into one catalog — `declaredPermissions()` → `ctx.declaredPermissions`, deduped and sorted, computed once at wiring — and that catalog *is* the fixed list. The Permissions screen is deleted outright: its module, tests, three views, two partials and 29 catalog keys per locale. Users and Groups each gained a checkbox list of the catalog, ticked where held; the whole set posts back, so what is submitted is the desired state and `grantDiff` turns it into grants + revokes. Two properties earn their tests: a crafted POST cannot grant a name no plugin declares, and a held-but-undeclared name — left over from an uninstalled plugin — is never silently revoked by an unrelated save, since the picker only speaks for what it showed. A user's own change revokes their live tokens; a group's reaches members at their next re-mint, the documented transitive lag. Keto stays optional on the Users screen: without it the page still lists and edits, minus the picker. Maintainer's call 2026-08-05 to keep the OAuth2-clients screen and gate it `oauth2-clients:read/write` — permissions and OAuth2 are orthogonal, scopes say what an *app* may see and permissions what a *user* may do, so the screen only ever needed *a* gate.)
|
||||||
- [x] In Playwright tests, check for warnings and errors in all browsers on all the steps. If they exist, that is a failure we need to fix. (Every spec takes its `test` from `e2e-tests/console-guard.ts`, which watches every page a test opens — `console.error`, `console.warning`, and uncaught page errors — and fails the test that provoked one, at whatever step. The bar is zero rather than a curated tolerance list: the app ships no client JavaScript, so a message means a broken sub-resource, a rejected attribute, or an engine refusing a feature. Two narrow escapes, both explicit: the COOP header Chromium drops because the e2e stacks serve plain http over container hostnames (a deployment serves https, where it applies), and per-test `allowConsole(/…/)` — used once, by the 404 spec, whose own navigation Chromium and WebKit log. **All browsers** is now literal for the Ory-free suites: `visual.spec.ts` + `language.spec.ts` run in Chromium, Firefox *and* WebKit — the per-test `@engines` tag is gone, and screenshots are written per project so the three don't fight over one file — which is what makes an engine-specific message visible at all. The Ory-backed suites write users, groups and sessions to one shared backend, so they stay on Chromium; widening them needs a stack per engine. Nothing in the app had to be fixed: the sweep found only the two above. Verified by negative control — an injected `console.warn` failed the test in all three engines and an injected `console.error` failed on full-flow's shared serial page — which also caught the guard registering that page twice. `src/e2e-console-guard.test.ts` locks the wiring in the *unit* gate, since a spec importing `test` straight from Playwright would run unwatched and green.)
|
- [x] In Playwright tests, check for warnings and errors in all browsers on all the steps. If they exist, that is a failure we need to fix. (Every spec takes its `test` from `e2e-tests/console-guard.ts`, which watches every page a test opens — `console.error`, `console.warning`, and uncaught page errors — and fails the test that provoked one, at whatever step. The bar is zero rather than a curated tolerance list: the app ships no client JavaScript, so a message means a broken sub-resource, a rejected attribute, or an engine refusing a feature. Two narrow escapes, both explicit: the COOP header Chromium drops because the e2e stacks serve plain http over container hostnames (a deployment serves https, where it applies), and per-test `allowConsole(/…/)` — used once, by the 404 spec, whose own navigation Chromium and WebKit log. **All browsers** is now literal for the Ory-free suites: `visual.spec.ts` + `language.spec.ts` run in Chromium, Firefox *and* WebKit — the per-test `@engines` tag is gone, and screenshots are written per project so the three don't fight over one file — which is what makes an engine-specific message visible at all. The Ory-backed suites write users, groups and sessions to one shared backend, so they stay on Chromium; widening them needs a stack per engine. Nothing in the app had to be fixed: the sweep found only the two above. Verified by negative control — an injected `console.warn` failed the test in all three engines and an injected `console.error` failed on full-flow's shared serial page — which also caught the guard registering that page twice. `src/e2e-console-guard.test.ts` locks the wiring in the *unit* gate, since a spec importing `test` straight from Playwright would run unwatched and green.)
|
||||||
- [x] Don't run tests when only markdown files in the root have changed. (Already shipped for *any* `*.md`, anywhere in the tree — `ci.sh`'s `docs_only()` no-ops the gate when every path changed since `main` ends in `.md`, and the workflow still pushes the commit-hash image so a merged docs commit stays releasable. Kept wider than "in the root" deliberately: no test reads a markdown file, so a nested `examples/plugins/admin/README.md` edit is as safe to skip as `README.md`, and narrowing it would spend the full gate on one. What was actually broken was rename detection — `git mv src/app.ts notes.md` names only the destination under `git diff --name-only`, and collapses to a single `R src/app.ts -> notes.md` line under `git status --porcelain`, so **moving code onto a `.md` path skipped the gate over a source file that was gone**. Both channels now pass `--no-renames`; verified against a scratch repo across ten scenarios — docs-only, mixed, empty diff, dirty tree, untracked code, deleted doc, and the rename staged *and* committed — the last two failing before the fix and passing after. `src/ci-gate.test.ts` locks both flags; it stays a text guard because the test image is `node:alpine` with neither `git` nor `bash`.)
|
- [x] Don't run tests when only markdown files in the root have changed. (Already shipped for *any* `*.md`, anywhere in the tree — `ci.sh`'s `docs_only()` no-ops the gate when every path changed since `main` ends in `.md`, and the workflow still pushes the commit-hash image so a merged docs commit stays releasable. Kept wider than "in the root" deliberately: no test reads a markdown file, so a nested `examples/plugins/admin/README.md` edit is as safe to skip as `README.md`, and narrowing it would spend the full gate on one. What was actually broken was rename detection — `git mv src/app.ts notes.md` names only the destination under `git diff --name-only`, and collapses to a single `R src/app.ts -> notes.md` line under `git status --porcelain`, so **moving code onto a `.md` path skipped the gate over a source file that was gone**. Both channels now pass `--no-renames`; verified against a scratch repo across ten scenarios — docs-only, mixed, empty diff, dirty tree, untracked code, deleted doc, and the rename staged *and* committed — the last two failing before the fix and passing after. `src/ci-gate.test.ts` locks both flags; it stays a text guard because the test image is `node:alpine` with neither `git` nor `bash`.)
|
||||||
- [x] The little menues, like when choosing language or clicking my username, they do not dissapear when clicking outside them, I must click the original trigger or choose something. See if there are more modern ways of handling this with HTML and CSS. I think there is a modal-thing or something? (The modern thing is the **Popover API**. All three popup menus — language picker, profile, row kebab — are now a `<button popovertarget>` plus a `[popover]` panel instead of `<details>`/`<summary>`, so the browser owns open/close: clicking anywhere outside dismisses one, `Esc` dismisses it and returns focus to the trigger, opening one closes the others, and the panel sits in the top layer where `.table-wrap`'s `overflow` can no longer clip a row kebab. Placement is CSS anchor positioning; the panel needs `position-anchor: auto` to bind to the button that opened it — a bare `anchor()` resolves to nothing in Chromium, Firefox and WebKit alike, measured in all three before picking the approach. `data-table.ejs` stopped hand-rolling its kebab and calls the `menu` partial, so the pattern lives in one file. Each panel is named by its caller (`locale-menu`, `profile-menu`, `row-actions-1`) and the partial fails loud without an `id`, since `popovertarget` is an idref — generated ids were tried first and dropped for being unreadable and nondeterministic. `<details>` stays in the nav tree, where it means disclosure rather than popup. A browser older than the popover API flows each panel inline under its trigger, so Sign out is never stranded behind an inert button. `e2e-tests/visual.spec.ts` drives the whole behaviour — opens, anchored to its trigger, outside-click, Esc — and runs in Firefox and WebKit as well as Chromium, because CSS anchor positioning is the newest thing in the app and every popup rests on it. Decisions recorded in AGENTS.md.)
|
- [x] The little menues, like when choosing language or clicking my username, they do not dissapear when clicking outside them, I must click the original trigger or choose something. See if there are more modern ways of handling this with HTML and CSS. I think there is a modal-thing or something? (The modern thing is the **Popover API**. All three popup menus — language picker, profile, row kebab — are now a `<button popovertarget>` plus a `[popover]` panel instead of `<details>`/`<summary>`, so the browser owns open/close: clicking anywhere outside dismisses one, `Esc` dismisses it and returns focus to the trigger, opening one closes the others, and the panel sits in the top layer where `.table-wrap`'s `overflow` can no longer clip a row kebab. Placement is CSS anchor positioning; the panel needs `position-anchor: auto` to bind to the button that opened it — a bare `anchor()` resolves to nothing in Chromium, Firefox and WebKit alike, measured in all three before picking the approach. `data-table.ejs` stopped hand-rolling its kebab and calls the `menu` partial, so the pattern lives in one file. Each panel is named by its caller (`locale-menu`, `profile-menu`, `row-actions-1`) and the partial fails loud without an `id`, since `popovertarget` is an idref — generated ids were tried first and dropped for being unreadable and nondeterministic. `<details>` stays in the nav tree, where it means disclosure rather than popup. A browser older than the popover API flows each panel inline under its trigger, so Sign out is never stranded behind an inert button. `e2e-tests/visual.spec.ts` drives the whole behaviour — opens, anchored to its trigger, outside-click, Esc — and runs in Firefox and WebKit as well as Chromium, because CSS anchor positioning is the newest thing in the app and every popup rests on it. Decisions recorded in AGENTS.md.)
|
||||||
|
|||||||
Reference in New Issue
Block a user