From 6ee6b125b96695c4103dd5b940b30aff5be4c6a8 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Wed, 2 Sep 2026 10:36:23 +0200 Subject: [PATCH] feat(admin): widen AdminPermission to accept plugin-declared actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @dune/core@^0.34.4's DunePlugin.authzActions lets a plugin declare its own admin-permission action and gate a route behind it via withGuards()/requirePermission() the identical way as a built-in one. AdminPermission was a closed union listing only the built-ins, so a plugin author could only pass a custom action through with a `permission: "..." as never` type-level workaround — checkPermission()/ requirePermission()/withGuards() were already just forwarding whatever string they were given straight to authz.check(), so this is purely a type change, no runtime behavior change. guards.ts's own doc example now shows a plugin declaring authzActions and gating a mount()-registered route behind it (previously used an undeclared "settings.update" permission that would have failed deno check had the example itself ever been type-checked). New test proving a non-built-in permission string type-checks through withGuards() with no cast and forwards correctly to authz.check() in both directions (allow/deny). Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 20 +++++++++++++++++-- src/admin/guards.ts | 33 +++++++++++++++++++++++++------ src/admin/types.ts | 16 +++++++++++++-- tests/admin/public_guards_test.ts | 25 +++++++++++++++++++++++ 4 files changed, 84 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09a204d..0e91773 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ follows [Semantic Versioning](https://semver.org). --- -## [Unreleased] +## [3.1.0] — 2026-09-02 ### Fixed @@ -32,9 +32,25 @@ follows [Semantic Versioning](https://semver.org). `admin/guards.ts`'s own example and in the plugin-authoring guide (duneorg/dune-docs#4). +### Added + +- **`AdminPermission` widened to accept any string, not just the built-in + admin actions.** `@dune/core@^0.34.4`'s `DunePlugin.authzActions` lets a + plugin declare its own admin-permission action (e.g. `"billing.manage"`) + and gate a route behind it via `withGuards()`/`requirePermission()` the + identical way as a built-in one — `authz.check()` is the real authority + either way, so this package's own closed union was the only thing + actually stopping a plugin author from passing a custom action through + without a `permission: "..." as never` type-level workaround. + `guards.ts`'s own doc example now shows a plugin declaring and using + its own action. No runtime change — `checkPermission()`/ + `requirePermission()`/`withGuards()` were already just forwarding + whatever string they were given to `authz.check()`. + ### Requires -- `@dune/core@^0.34.4` or later (for `DunePlugin.mountEarly()`). +- `@dune/core@^0.34.4` or later (for `DunePlugin.mountEarly()` and + `DunePlugin.authzActions`) — already published. ## [3.0.0] — 2026-08-29 diff --git a/src/admin/guards.ts b/src/admin/guards.ts index 59029d7..e604087 100644 --- a/src/admin/guards.ts +++ b/src/admin/guards.ts @@ -23,17 +23,38 @@ * Use `csrfCheck`/`requirePermission`/`validatePagePath` directly only when * `withGuards`' shape doesn't fit. * + * `permission` isn't limited to the built-in admin actions — a plugin can + * declare its own via `DunePlugin.authzActions` (`@dune/core`) and gate a + * route behind it the identical way, instead of reusing an existing, + * semantically-mismatched permission or hand-rolling a check outside the + * authz system entirely. `AdminPermission` accepts any string for exactly + * this reason (it isn't a fully closed union) — see that type's own doc + * comment. + * * @example * ```ts + * import type { DunePlugin } from "@dune/core/hooks"; * import { withGuards } from "@dune/plugin-admin/admin/guards"; * - * app.post("/admin/my-plugin/rotate-key", withGuards( - * { permission: "settings.update" }, - * async (ctx) => { - * // csrfCheck() and requirePermission() have already run and passed. - * return Response.json({ ok: true }); + * export default { + * name: "my-billing-plugin", + * version: "1.0.0", + * // Registers a new admin permission this plugin's own routes gate on — + * // merged into the site's authz schema at bootstrap. + * authzActions: { + * "billing.manage": ["admin"], + * }, + * async mount({ app }) { + * app.post("/admin/my-plugin/rotate-key", withGuards( + * { permission: "billing.manage" }, + * async (ctx) => { + * // csrfCheck() and requirePermission() have already run and passed. + * return Response.json({ ok: true }); + * }, + * )); * }, - * )); + * hooks: {}, + * } satisfies DunePlugin; * ``` * * @module diff --git a/src/admin/types.ts b/src/admin/types.ts index 1240d0c..f52efbf 100644 --- a/src/admin/types.ts +++ b/src/admin/types.ts @@ -28,13 +28,25 @@ export type { Role }; import type { User } from "@dune/core/auth/types"; export type { User }; -/** All possible admin permissions */ +/** + * Every built-in admin permission, plus any action a plugin registered via + * `DunePlugin.authzActions` (`@dune/core`) — the `(string & {})` half + * keeps this from being a fully closed union (which could never include a + * plugin's own action, unknown to this package at its own compile time) + * while still giving IDE autocomplete for the built-ins here. Not + * type-checked against what's actually registered — `authz.check()` (or, + * for the one synchronous path, `roleHasPermission()`) is the real + * authority on whether a given string names a real action; passing one + * that doesn't exist on the site's schema just always denies. + */ export type AdminPermission = | "pages.create" | "pages.read" | "pages.update" | "pages.delete" | "media.upload" | "media.read" | "media.delete" | "users.create" | "users.read" | "users.update" | "users.delete" | "config.read" | "config.update" - | "submissions.read" | "submissions.delete"; + | "submissions.read" | "submissions.delete" + // deno-lint-ignore ban-types + | (string & {}); /** Admin configuration (added to DuneConfig) */ export interface AdminConfig { diff --git a/tests/admin/public_guards_test.ts b/tests/admin/public_guards_test.ts index 64c5d03..7f02546 100644 --- a/tests/admin/public_guards_test.ts +++ b/tests/admin/public_guards_test.ts @@ -189,6 +189,31 @@ Deno.test("withGuards: all guards passing reaches the handler", async () => { assertEquals(await res.text(), "ran with path=my-plugin/settings"); }); +Deno.test("withGuards: a plugin-declared permission (not a built-in AdminPermission) type-checks and forwards to authz.check() with no cast", async () => { + // AdminPermission widened to accept any string (`@dune/core`'s + // DunePlugin.authzActions lets a plugin declare its own action) — unlike + // the tests above, this one needs no `as never`/`as any` escape hatch to + // pass a permission string this package's own closed built-in union + // never listed. checkPermission()/requirePermission()/withGuards() don't + // themselves validate the string against a schema — that's authz.check()'s + // job (see @dune/core's authz_plugin_actions_test.ts for the real + // end-to-end resolution through a bootstrapped authz system) — this just + // proves the plumbing here accepts and forwards it correctly. + const guarded = withGuards( + { permission: "billing.manage" }, + () => new Response("ran"), + ); + const allowed = await guarded( + makeCtx("POST", { authzAllows: true }), + ); + assertEquals(allowed.status, 200); + + const denied = await guarded( + makeCtx("POST", { authzAllows: false }), + ); + assertEquals(denied.status, 403); +}); + Deno.test("withGuards: csrf: false opts out of the CSRF check", async () => { const guarded = withGuards( { csrf: false },