From 9360a005068ce553ad9fa3dbe2c2c5fdba49d800 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Wed, 2 Sep 2026 10:04:59 +0200 Subject: [PATCH 1/2] docs: finalize CHANGELOG under 0.34.4, bump docs/content submodule pointer --- CHANGELOG.md | 2 +- docs/content | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67c053ed..f3325891 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ exactly what "breaking" means and what doesn't count. --- -## [Unreleased] +## [0.34.4] — 2026-09-01 ### Fixed diff --git a/docs/content b/docs/content index 73f5e969..5a804edc 160000 --- a/docs/content +++ b/docs/content @@ -1 +1 @@ -Subproject commit 73f5e969685f26376aa9e44c4086d4b0e3575225 +Subproject commit 5a804edc5deb87f302dcfab132bdd8571e9c9a88 From 200bedcdf6ebb373c3893d93566de7bd6163bdeb Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Wed, 2 Sep 2026 10:37:41 +0200 Subject: [PATCH 2/2] feat(plugins): let a plugin register its own authz-schema actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DunePlugin.authzActions lets a plugin declare a new admin-permission action (e.g. "billing.manage") and gate a route behind it the same correct way every built-in admin route is (authz.check()), instead of reusing an existing, semantically-mismatched permission or hand-rolling a check outside the authz system entirely. Relation-only — a plugin can require existing relations (member/admin/ editor/author/owner), not define a new relation type, which keeps the scope to the actual motivating case without opening the harder question of arbitrary plugin-defined relation semantics. src/auth/authz-schema.ts's module-level duneAuthzSchema constant becomes buildDuneAuthzSchema()'s zero-argument output (built-ins only, DUNE_BASE_ AUTHZ_ACTIONS exported for collision-checking) — bootstrap() now calls this once per site, after every plugin's setup() has run and their authzActions have been collected and de-duplicated (first declaration wins on a name collision, built-in or cross-plugin, dropped with a logged warning rather than silently merged/overwritten). BootstrapResult gains authzSchema (the site's actual merged schema) alongside authz, which is already built against it via createDuneAuthSystem()'s new optional schema config field. roleHasPermission() gains an optional third parameter (a site's actual actionToRelations map) so the one synchronous permission-check path (ResponseTransformContext.auth .hasPermission(), threaded through response-transforms.ts's new actionToRelations option) resolves a plugin-contributed action correctly instead of only ever seeing the built-ins. Multisite-safe by construction: the schema lives on BootstrapResult, not a process-wide singleton, so one site's plugin action can't leak into another's schema. Companion @dune/plugin-admin change needed for a plugin author to actually pass a custom action through withGuards()/requirePermission() without a type-level workaround (AdminPermission is currently a closed union) — separate PR. 3 new tests (real bootstrap() + real authz.check() end to end, including both collision cases) plus unit coverage for buildDuneAuthzSchema()/ roleHasPermission()'s new parameter. Folded into the still-unpublished 0.34.4 CHANGELOG entry, not a new version bump — 0.34.4 has not published to JSR yet. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 30 ++++ src/auth/authz-schema.ts | 195 ++++++++++++++++-------- src/auth/authz.ts | 13 +- src/cli/response-transforms.ts | 24 ++- src/hooks/types.ts | 40 +++++ src/runtime/bootstrap.ts | 41 +++++ src/runtime/register-middleware.ts | 4 +- tests/auth/authz_plugin_actions_test.ts | 160 +++++++++++++++++++ tests/auth/authz_schema_test.ts | 43 +++++- 9 files changed, 480 insertions(+), 70 deletions(-) create mode 100644 tests/auth/authz_plugin_actions_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f3325891..71d5af38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,36 @@ exactly what "breaking" means and what doesn't count. ## [0.34.4] — 2026-09-01 +### Added + +- **Plugin-extensible authz schema — `DunePlugin.authzActions`.** A plugin + can now declare its own admin-permission action (e.g. `"billing.manage"`) + and gate a route behind it exactly the way every built-in admin route + is (`authz.check()`), instead of reusing an existing, semantically + mismatched permission or hand-rolling a check outside the authz system + entirely. Relation-only (a plugin can require existing relations — + `member`/`admin`/`editor`/`author`/`owner` — not define a new relation + type). `bootstrap()` collects every registered plugin's `authzActions` + (after `setup()` has run, before the site's authz system is created) and + merges them into the schema; a name colliding with a built-in action or + another plugin's is dropped with a logged warning, not silently merged — + first declaration wins in registration order. `BootstrapResult` gains + `authzSchema` (the site's actual merged schema — built-ins plus whatever + plugins contributed) alongside the existing `authz`, which is already + built against it. `src/auth/authz-schema.ts`'s module-level + `duneAuthzSchema` constant is now `buildDuneAuthzSchema()`'s zero-argument + output (built-ins only) — unaffected for any existing caller that reads + it directly (tests, headless usage); `roleHasPermission()` gains an + optional third parameter (a site's actual `actionToRelations` map) so + the one synchronous permission-check path + (`ResponseTransformContext.auth.hasPermission()`) resolves a + plugin-contributed action correctly instead of only ever seeing the + built-ins. Requires a matching `@dune/plugin-admin` change (widening + `AdminPermission` from a closed union to accept any string) for a plugin + author to actually pass a custom action through `withGuards()`/ + `requirePermission()` without a type-level workaround — see that + package's own changelog. + ### Fixed - **The theme loader's "failed to load template" warning fired once per diff --git a/src/auth/authz-schema.ts b/src/auth/authz-schema.ts index 6860e0a9..b37d1ef0 100644 --- a/src/auth/authz-schema.ts +++ b/src/auth/authz-schema.ts @@ -20,11 +20,13 @@ * ## Granular admin permissions * authz.check({ who: adminUser, canThey: "pages.create", onWhat: { type: "app", id: "admin" } }) * - * The admin permission actions below are the sole, canonical permission - * definition — `@dune/plugin-admin` no longer keeps its own `ROLE_PERMISSIONS` - * mirror (removed alongside dec-identity-unification Phase 5c/6, 3.0.0); see - * {@link roleHasPermission} for the one place that still needs a synchronous - * read of this same data instead of `authz.check()`. + * The admin permission actions below are the sole, canonical *built-in* + * permission definition — `@dune/plugin-admin` no longer keeps its own + * `ROLE_PERMISSIONS` mirror (removed alongside dec-identity-unification + * Phase 5c/6, 3.0.0); see {@link roleHasPermission} for the one place that + * still needs a synchronous read of this same data instead of + * `authz.check()`. A plugin can extend this vocabulary with its own actions + * via `DunePlugin.authzActions` — see {@link buildDuneAuthzSchema}. */ import { defineSchema } from "polizy"; @@ -32,63 +34,121 @@ import type { AuthSchema } from "polizy"; import type { Role } from "../config/admin-config.ts"; /** - * The polizy authorization schema for Dune — defines relations and action-to-relation mappings - * for content gating, admin access, and resource ownership. + * A polizy relation name a plugin-contributed action can require. Limited + * to Dune's existing built-in relations (no plugin can define a *new* + * relation type today — see `buildDuneAuthzSchema`'s doc comment for why). */ -// deno-lint-ignore no-explicit-any -export const duneAuthzSchema: AuthSchema = defineSchema({ - relations: { - /** Group membership — used for role-based content gating. The `type: "group"` marker - * tells polizy that `addMember()` should use this relation. */ - member: { type: "group" }, - /** Admin-level direct access to an app or resource */ - admin: { type: "direct" }, - /** Editor-level access */ - editor: { type: "direct" }, - /** Author-level access */ - author: { type: "direct" }, - /** Resource ownership (per-object grant) */ - owner: { type: "direct" }, - }, - actionToRelations: { - // ── Site-user actions ────────────────────────────────────────────────── - /** General access (read/view). Satisfied by group membership or admin-tier roles. - * - * `owner` is intentionally excluded: it is a per-resource direct grant used for - * inline editing (`edit` action). Including it here would allow a user who owns - * a specific resource (e.g. a page) to pass *group-based* content gating checks - * — a confused-deputy that grants unintended access to gated content. - * - * If an owner should also be able to access gated content, grant them the - * appropriate group membership (e.g. `authz.addMember(...)`) explicitly. - */ - access: ["member", "admin", "editor", "author"], - /** Write/edit access on a specific resource */ - edit: ["owner", "admin", "editor"], +export type AuthzRelation = "member" | "admin" | "editor" | "author" | "owner"; + +/** + * Dune's built-in actions, mapped to the relations that satisfy each — + * `@dune/plugin-admin`'s canonical permission definition (see the module + * doc above). Exported (not just inlined into {@link buildDuneAuthzSchema}) + * so bootstrap can detect a plugin trying to redeclare one of these names + * before merging in plugin-contributed actions. + */ +export const DUNE_BASE_AUTHZ_ACTIONS = { + // ── Site-user actions ────────────────────────────────────────────────── + /** General access (read/view). Satisfied by group membership or admin-tier roles. + * + * `owner` is intentionally excluded: it is a per-resource direct grant used for + * inline editing (`edit` action). Including it here would allow a user who owns + * a specific resource (e.g. a page) to pass *group-based* content gating checks + * — a confused-deputy that grants unintended access to gated content. + * + * If an owner should also be able to access gated content, grant them the + * appropriate group membership (e.g. `authz.addMember(...)`) explicitly. + */ + access: ["member", "admin", "editor", "author"], + /** Write/edit access on a specific resource */ + edit: ["owner", "admin", "editor"], + + // ── Admin panel — granular permissions (maps 1:1 with AdminPermission) ─ + // + // Sole built-in definition — nothing else mirrors this (see the module doc above). + // + "pages.create": ["admin", "editor", "author"], + "pages.read": ["admin", "editor", "author"], + "pages.update": ["admin", "editor", "author"], + "pages.delete": ["admin"], + "media.upload": ["admin", "editor", "author"], + "media.read": ["admin", "editor", "author"], + "media.delete": ["admin", "editor"], + "users.create": ["admin"], + "users.read": ["admin"], + "users.update": ["admin"], + "users.delete": ["admin"], + "config.read": ["admin", "editor"], + "config.update": ["admin"], + "submissions.read": ["admin", "editor", "author"], + "submissions.delete": ["admin"], +} as const satisfies Record; + +/** + * Build Dune's polizy authorization schema — the built-in actions above, + * plus any actions plugins have contributed via `DunePlugin.authzActions`. + * + * `defineSchema()` itself is pure, synchronous, and just validates that + * every action's relations actually exist on the schema — there's no + * architectural reason a plugin can't extend the action vocabulary, only + * that `duneAuthzSchema` used to be a module-level constant built once at + * import time, before any plugin had registered. This function replaces + * that: `bootstrap()` calls it once per site, after every plugin's + * `setup()` has run and their `authzActions` have been collected and + * de-duplicated against both the built-ins and each other (a name + * collision is dropped with a logged warning, not silently merged — + * see `bootstrap.ts`). + * + * Deliberately relation-only, not a way to define new *relation* types: a + * plugin action can require `admin`/`editor`/`author`/`owner`/`member` (the + * existing structural vocabulary polizy's tuple model already understands), + * but can't invent its own relation kind. Covers the actual motivating case + * (gate a new admin capability the same correct way every built-in route + * is) without opening the larger, harder question of arbitrary + * plugin-defined relation semantics. + * + * @param pluginActions Already-merged, already-collision-checked plugin + * actions (`bootstrap.ts` builds this) — pass nothing for the plain + * built-in schema (tests, headless usage with no plugins contributing + * actions). + */ +export function buildDuneAuthzSchema( + pluginActions?: Record, + // deno-lint-ignore no-explicit-any +): AuthSchema { + return defineSchema({ + relations: { + /** Group membership — used for role-based content gating. The `type: "group"` marker + * tells polizy that `addMember()` should use this relation. */ + member: { type: "group" }, + /** Admin-level direct access to an app or resource */ + admin: { type: "direct" }, + /** Editor-level access */ + editor: { type: "direct" }, + /** Author-level access */ + author: { type: "direct" }, + /** Resource ownership (per-object grant) */ + owner: { type: "direct" }, + }, + actionToRelations: { + ...DUNE_BASE_AUTHZ_ACTIONS, + ...pluginActions, + }, + subjectTypes: ["user"] as const, + objectTypes: ["group", "app", "resource"] as const, + }); +} - // ── Admin panel — granular permissions (maps 1:1 with AdminPermission) ─ - // - // Sole definition — nothing else mirrors this (see the module doc above). - // - "pages.create": ["admin", "editor", "author"], - "pages.read": ["admin", "editor", "author"], - "pages.update": ["admin", "editor", "author"], - "pages.delete": ["admin"], - "media.upload": ["admin", "editor", "author"], - "media.read": ["admin", "editor", "author"], - "media.delete": ["admin", "editor"], - "users.create": ["admin"], - "users.read": ["admin"], - "users.update": ["admin"], - "users.delete": ["admin"], - "config.read": ["admin", "editor"], - "config.update": ["admin"], - "submissions.read": ["admin", "editor", "author"], - "submissions.delete": ["admin"], - }, - subjectTypes: ["user"] as const, - objectTypes: ["group", "app", "resource"] as const, -}); +/** + * The plain built-in schema, no plugin-contributed actions — for any + * caller that isn't a per-site bootstrap (tests, standalone/headless + * usage, or anything that predates plugin-extensible actions and just + * wants the same default as always). A real site's actual schema (with + * whatever its plugins contributed) lives on `BootstrapResult.authzSchema` + * instead — read from there when you have a `BootstrapResult` in hand. + */ +// deno-lint-ignore no-explicit-any +export const duneAuthzSchema: AuthSchema = buildDuneAuthzSchema(); /** TypeScript type of the Dune authorization schema — pass to `AuthSystem` generics. */ export type DuneAuthzSchema = typeof duneAuthzSchema; @@ -116,10 +176,19 @@ export type DuneAuthzSchema = typeof duneAuthzSchema; * it (or copy it) to gate access anywhere — use `authz.check()`. If you * need a permission decision in a synchronous context, restructure the * context so the decision is made once, asynchronously, and passed in. + * + * @param actionToRelations The schema's action-to-relations map to check + * against — pass a site's actual `BootstrapResult.authzSchema + * .actionToRelations` so a plugin-contributed action resolves correctly; + * defaults to just the built-ins (`DUNE_BASE_AUTHZ_ACTIONS`) for callers + * with no per-site schema in hand. */ -export function roleHasPermission(role: string, permission: string): boolean { - const relations = duneAuthzSchema.actionToRelations as Record; - return relations[permission]?.includes(role) ?? false; +export function roleHasPermission( + role: string, + permission: string, + actionToRelations: Record = DUNE_BASE_AUTHZ_ACTIONS, +): boolean { + return actionToRelations[permission]?.includes(role) ?? false; } /** diff --git a/src/auth/authz.ts b/src/auth/authz.ts index 2a42e324..6178e0c1 100644 --- a/src/auth/authz.ts +++ b/src/auth/authz.ts @@ -41,6 +41,7 @@ */ import { AuthSystem } from "polizy"; +import type { AuthSchema } from "polizy"; import { duneAuthzSchema } from "./authz-schema.ts"; import { AuthzLocalAdapter } from "./authz-adapter-local.ts"; import { AuthzDbAdapter } from "./authz-adapter-db.ts"; @@ -82,6 +83,16 @@ export interface AuthzConfig { * Load from DUNE_AUTHZ_HMAC_SECRET via `loadHmacKeyFromEnv()`. */ hmacKey?: CryptoKey | null; + /** + * The polizy schema to build the AuthSystem against — defaults to the + * plain built-in `duneAuthzSchema`. `bootstrap()` passes its own + * per-site `authzSchema` (built-ins plus whatever plugins contributed + * via `DunePlugin.authzActions`, see `buildDuneAuthzSchema()`); any + * other caller (tests, headless usage) gets the same default as always + * if it doesn't pass one. + */ + // deno-lint-ignore no-explicit-any + schema?: AuthSchema; } /** Return value of {@link createDuneAuthSystem} — the configured AuthSystem plus its underlying adapter. */ @@ -138,7 +149,7 @@ export function createDuneAuthSystem( adapter = new AuthzLocalAdapter({ storage, dataDir, hmacKey: config.hmacKey }); } const authz = new AuthSystem({ - schema: duneAuthzSchema, + schema: config.schema ?? duneAuthzSchema, // Cast required because polizy's StorageAdapter generic parameters // don't align with the structural types from AuthzLocalAdapter — the // implementation is fully compatible at runtime. diff --git a/src/cli/response-transforms.ts b/src/cli/response-transforms.ts index 6d9134bb..07754448 100644 --- a/src/cli/response-transforms.ts +++ b/src/cli/response-transforms.ts @@ -77,6 +77,15 @@ export interface RunResponseTransformsOptions { * `src/runtime/bootstrap.ts`), in which case the check fails closed. */ authz?: DuneAuthSystem; + /** + * This site's actual `actionToRelations` map (built-ins plus whatever + * plugins contributed via `DunePlugin.authzActions`) — + * `BootstrapResult.authzSchema.actionToRelations`. Passed to + * `roleHasPermission()` so the synchronous `ResponseTransformContext.auth + * .hasPermission()` a plugin calls resolves a plugin-contributed action + * correctly instead of only ever seeing the built-in ones. + */ + actionToRelations?: Record; /** * Resolves a URL pathname to the matching page — pass `engine.router.resolve` * so this uses the exact same home-page/language/alias-aware resolution the @@ -107,8 +116,17 @@ export interface RunResponseTransformsOptions { export async function runPluginResponseTransforms( opts: RunResponseTransformsOptions, ): Promise { - const { req, response, plugins, auth, authz, resolve, config, adminPrefix } = - opts; + const { + req, + response, + plugins, + auth, + authz, + resolve, + config, + adminPrefix, + actionToRelations, + } = opts; const transformPlugins = plugins.filter((p) => p.transformResponse); @@ -151,7 +169,7 @@ export async function runPluginResponseTransforms( transformAuth = { username: user.username as string, role, - hasPermission: (perm) => roleHasPermission(role, perm), + hasPermission: (perm) => roleHasPermission(role, perm, actionToRelations), }; } } catch { /* invalid session — treat as unauthenticated */ } diff --git a/src/hooks/types.ts b/src/hooks/types.ts index db27e1b8..bf95c495 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -11,6 +11,7 @@ import type { FreshContext } from "fresh"; import type { InlineEditManager } from "../inline-edit/types.ts"; import type { HistoryEngine } from "../history/engine.ts"; import type { ContentApi } from "../content/api.ts"; +import type { AuthzRelation } from "../auth/authz-schema.ts"; /** * All lifecycle events a plugin can subscribe to. @@ -493,6 +494,45 @@ export interface DunePlugin { * @since 0.24.0 */ islandSpecifiers?: string[]; + /** + * New admin-permission actions this plugin contributes to the polizy authz + * schema, keyed by action name and mapping to the existing relations that + * satisfy it — the same shape as `@dune/core`'s own built-in actions (see + * `src/auth/authz-schema.ts`'s `DUNE_BASE_AUTHZ_ACTIONS`), just declared + * by a plugin instead of core. + * + * Lets a plugin gate a genuinely new admin capability the correct way — + * `authz.check()`, same as every built-in admin route — instead of either + * reusing an existing, semantically-mismatched permission or hand-rolling + * a check outside the authz system entirely. `bootstrap()` collects every + * registered plugin's `authzActions` (after `setup()` has run, before the + * site's authz system is created) and merges them into the schema; an + * action name that collides with a built-in or another plugin's is + * dropped with a logged warning, not silently merged — first declaration + * wins in registration order. + * + * Relation-only, not a way to define a new relation *type*: values must + * be drawn from `"member" | "admin" | "editor" | "author" | "owner"`, the + * same structural vocabulary every built-in action already uses. + * + * @since 0.34.4 + * + * @example + * ```ts + * // Gate a plugin-specific admin capability behind its own permission, + * // reachable from any mount()-registered route via withGuards(): + * // withGuards({ permission: "billing.manage" }, handler) + * export default { + * name: "my-billing-plugin", + * version: "1.0.0", + * authzActions: { + * "billing.manage": ["admin"], + * }, + * hooks: {}, + * } satisfies DunePlugin; + * ``` + */ + authzActions?: Record; /** * Factory for admin-context services contributed by this plugin. * diff --git a/src/runtime/bootstrap.ts b/src/runtime/bootstrap.ts index f7670afb..841caf7f 100644 --- a/src/runtime/bootstrap.ts +++ b/src/runtime/bootstrap.ts @@ -36,6 +36,8 @@ import type { DuneAuthSystem } from "../auth/authz.ts"; import type { AuthzLocalAdapter } from "../auth/authz-adapter-local.ts"; import type { AuthzDbAdapter } from "../auth/authz-adapter-db.ts"; import { loadHmacKeyFromEnv } from "../auth/authz-hmac.ts"; +import { buildDuneAuthzSchema, DUNE_BASE_AUTHZ_ACTIONS } from "../auth/authz-schema.ts"; +import type { AuthzRelation } from "../auth/authz-schema.ts"; import { initTracer } from "../tracing/mod.ts"; import { sectionRegistry } from "../sections/registry.ts"; import type { SectionRegistry } from "../sections/registry.ts"; @@ -131,6 +133,16 @@ export interface BootstrapResult { authz?: DuneAuthSystem; /** Paired adapter for the authz system above — needed for hasTuple / bootstrap. */ authzAdapter?: AuthzLocalAdapter | AuthzDbAdapter; + /** + * This site's actual polizy schema — Dune's built-in actions plus + * whatever any registered plugin contributed via `DunePlugin.authzActions`. + * The `authz` system above is already built against this; exposed + * separately for any caller that needs the schema itself (e.g. + * `roleHasPermission()`'s synchronous, non-`authz.check()` path in + * `runPluginResponseTransforms()`), rather than the plain built-in-only + * `duneAuthzSchema` module export, which never sees this site's plugins. + */ + authzSchema: import("../auth/authz-schema.ts").DuneAuthzSchema; /** * Pre-loaded HMAC key for authz tuple signing — null if DUNE_AUTHZ_HMAC_SECRET * is absent. Passed to mountDuneAuth() so the env var is read exactly once. @@ -348,11 +360,38 @@ export async function bootstrap( const pluginTemplateDirs: string[] = []; const pluginPublicRoutes: import("../hooks/types.ts").PublicRouteRegistration[] = []; + // Plugin-contributed authz actions — collected here (after every plugin's + // setup() has run) so buildDuneAuthzSchema() below can merge them in + // before the site's authz system is created. First declaration wins on a + // name collision (built-in or cross-plugin) — dropped with a logged + // warning, never silently merged/overwritten, so a typo'd or malicious + // plugin can't redefine what an existing action means. + const pluginAuthzActions: Record = {}; for (const plugin of hooks.plugins()) { if (plugin.assetDir) pluginAssetDirs.set(plugin.name, plugin.assetDir); if (plugin.templateDir) pluginTemplateDirs.push(plugin.templateDir); if (plugin.publicRoutes) pluginPublicRoutes.push(...plugin.publicRoutes); + if (plugin.authzActions) { + for (const [action, relations] of Object.entries(plugin.authzActions)) { + if (action in DUNE_BASE_AUTHZ_ACTIONS) { + logger.warn("bootstrap.authz_actions.collides_with_builtin", { + plugin: plugin.name, + action, + }); + continue; + } + if (action in pluginAuthzActions) { + logger.warn("bootstrap.authz_actions.collides_with_plugin", { + plugin: plugin.name, + action, + }); + continue; + } + pluginAuthzActions[action] = relations; + } + } } + const authzSchema = buildDuneAuthzSchema(pluginAuthzActions); // Register plugin template dirs with the engine so plugins can provide // additional templates that themes can fall back to. @@ -499,6 +538,7 @@ export async function bootstrap( authzStore: "local", dataDir, hmacKey, + schema: authzSchema, }, storage); bootstrappedAuthz = bundle.authz; bootstrappedAuthzAdapter = bundle.adapter; @@ -607,6 +647,7 @@ export async function bootstrap( adminServices: null, authz: bootstrappedAuthz, authzAdapter: bootstrappedAuthzAdapter, + authzSchema, hmacKey, }; } diff --git a/src/runtime/register-middleware.ts b/src/runtime/register-middleware.ts index e326e934..23584809 100644 --- a/src/runtime/register-middleware.ts +++ b/src/runtime/register-middleware.ts @@ -86,7 +86,7 @@ export function registerContentCatchAll( adminPrefix, routes, } = opts; - const { engine, imageHandler, hooks, config, metrics, authz } = ctx; + const { engine, imageHandler, hooks, config, metrics, authz, authzSchema } = ctx; // deno-lint-ignore no-explicit-any const getAdminAuth = () => (ctx.adminContext as any)?.auth ?? null; @@ -243,6 +243,7 @@ export function registerContentCatchAll( plugins: hooks.plugins(), auth: getAdminAuth(), authz, + actionToRelations: authzSchema.actionToRelations as Record, resolve: engine.router.resolve, config, adminPrefix, @@ -311,6 +312,7 @@ export function registerContentCatchAll( plugins: hooks.plugins(), auth: getAdminAuth(), authz, + actionToRelations: authzSchema.actionToRelations as Record, resolve: engine.router.resolve, config, adminPrefix, diff --git a/tests/auth/authz_plugin_actions_test.ts b/tests/auth/authz_plugin_actions_test.ts new file mode 100644 index 00000000..a27b22a7 --- /dev/null +++ b/tests/auth/authz_plugin_actions_test.ts @@ -0,0 +1,160 @@ +/** + * End-to-end test for DunePlugin.authzActions — a plugin can register its + * own admin-permission action, gated the same correct way as every + * built-in one (authz.check()), instead of reusing a semantically + * mismatched permission or hand-rolling a check outside the authz system. + * + * Covers the real bootstrap() path: a plugin declares authzActions in its + * setup()/plugin object, bootstrap() collects and merges it into the + * site's actual authz schema (BootstrapResult.authzSchema) before the + * authz system is created, and authz.check() resolves it correctly for a + * real request-shaped check — not just buildDuneAuthzSchema() in + * isolation (see authz_schema_test.ts for the unit-level coverage of the + * builder itself). + */ + +import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { join } from "@std/path"; +import { bootstrap } from "../../src/runtime/bootstrap.ts"; +import type { DunePlugin } from "../../src/hooks/types.ts"; + +async function makeSiteRoot(prefix: string): Promise { + const root = await Deno.makeTempDir({ prefix }); + await Deno.mkdir(join(root, "content", "01.home"), { recursive: true }); + await Deno.writeTextFile( + join(root, "content", "01.home", "default.md"), + "---\ntitle: Home\n---\n\n# Home\n", + ); + return root; +} + +async function removeWithRetry(root: string): Promise { + let lastErr: unknown; + for (let attempt = 0; attempt < 5; attempt++) { + try { + await Deno.remove(root, { recursive: true }); + return; + } catch (err) { + lastErr = err; + await new Promise((r) => setTimeout(r, 50)); + } + } + throw lastErr; +} + +Deno.test( + "DunePlugin.authzActions: a plugin-contributed action is merged in and enforced end to end", + { sanitizeOps: false, sanitizeResources: false }, + async () => { + const root = await makeSiteRoot("dune_test_authz_plugin_actions_"); + try { + const billingPlugin: DunePlugin = { + name: "test-billing-plugin", + version: "1.0.0", + hooks: {}, + authzActions: { + "billing.manage": ["admin"], + }, + }; + + const ctx = await bootstrap(root, { plugins: [billingPlugin] }); + + // Merged into the site's actual schema. + const relations = ctx.authzSchema.actionToRelations as Record< + string, + readonly string[] + >; + assertEquals(relations["billing.manage"], ["admin"]); + // Built-ins are untouched. + assertEquals(relations["pages.update"], ["admin", "editor", "author"]); + + // And the real authz system — built against that same merged schema + // — actually enforces it, not just the schema data structure. + const authz = ctx.authz!; + await authz.allow({ + who: { type: "user", id: "alice" }, + toBe: "admin", + onWhat: { type: "app", id: "admin" }, + }); + + const allowed = await authz.check({ + who: { type: "user", id: "alice" }, + // deno-lint-ignore no-explicit-any + canThey: "billing.manage" as any, + onWhat: { type: "app", id: "admin" }, + }); + assertEquals(allowed, true); + + const deniedForBob = await authz.check({ + who: { type: "user", id: "bob" }, + // deno-lint-ignore no-explicit-any + canThey: "billing.manage" as any, + onWhat: { type: "app", id: "admin" }, + }); + assertEquals(deniedForBob, false); + } finally { + await removeWithRetry(root); + } + }, +); + +Deno.test( + "DunePlugin.authzActions: a name colliding with a built-in action is dropped, built-in wins", + { sanitizeOps: false, sanitizeResources: false }, + async () => { + const root = await makeSiteRoot("dune_test_authz_plugin_actions_builtin_collision_"); + try { + const maliciousPlugin: DunePlugin = { + name: "test-redefines-builtin", + version: "1.0.0", + hooks: {}, + authzActions: { + // Tries to widen who can delete pages — must not take effect. + "pages.delete": ["admin", "editor", "author"], + }, + }; + + const ctx = await bootstrap(root, { plugins: [maliciousPlugin] }); + const relations = ctx.authzSchema.actionToRelations as Record< + string, + readonly string[] + >; + // Untouched — the built-in definition still wins. + assertEquals(relations["pages.delete"], ["admin"]); + } finally { + await removeWithRetry(root); + } + }, +); + +Deno.test( + "DunePlugin.authzActions: a name colliding across two plugins keeps the first registered, drops the second", + { sanitizeOps: false, sanitizeResources: false }, + async () => { + const root = await makeSiteRoot("dune_test_authz_plugin_actions_cross_plugin_collision_"); + try { + const first: DunePlugin = { + name: "test-plugin-first", + version: "1.0.0", + hooks: {}, + authzActions: { "billing.manage": ["admin"] }, + }; + const second: DunePlugin = { + name: "test-plugin-second", + version: "1.0.0", + hooks: {}, + authzActions: { "billing.manage": ["admin", "editor", "author"] }, + }; + + const ctx = await bootstrap(root, { plugins: [first, second] }); + const relations = ctx.authzSchema.actionToRelations as Record< + string, + readonly string[] + >; + // First plugin's declaration wins — not merged, not overwritten. + assertEquals(relations["billing.manage"], ["admin"]); + } finally { + await removeWithRetry(root); + } + }, +); diff --git a/tests/auth/authz_schema_test.ts b/tests/auth/authz_schema_test.ts index 4b322439..da4f9a46 100644 --- a/tests/auth/authz_schema_test.ts +++ b/tests/auth/authz_schema_test.ts @@ -6,8 +6,13 @@ * API can't call the real, async authz.check() (response-transforms.ts). */ -import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; -import { highestAdminRole, roleHasPermission } from "../../src/auth/authz-schema.ts"; +import { assertEquals, assertThrows } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { + buildDuneAuthzSchema, + DUNE_BASE_AUTHZ_ACTIONS, + highestAdminRole, + roleHasPermission, +} from "../../src/auth/authz-schema.ts"; Deno.test("roleHasPermission: admin has every admin-tier permission", () => { assertEquals(roleHasPermission("admin", "pages.update"), true); @@ -46,3 +51,37 @@ Deno.test("highestAdminRole: ignores content-gating tags and unknown roles", () assertEquals(highestAdminRole(undefined), ""); assertEquals(highestAdminRole([]), ""); }); + +Deno.test("buildDuneAuthzSchema: with no plugin actions, matches the built-ins exactly", () => { + const schema = buildDuneAuthzSchema(); + assertEquals(schema.actionToRelations, DUNE_BASE_AUTHZ_ACTIONS); +}); + +Deno.test("buildDuneAuthzSchema: merges a plugin-contributed action in", () => { + const schema = buildDuneAuthzSchema({ "billing.manage": ["admin"] }); + assertEquals(schema.actionToRelations["billing.manage"], ["admin"]); + // Built-ins are still present, untouched. + assertEquals(schema.actionToRelations["pages.update"], ["admin", "editor", "author"]); +}); + +Deno.test("buildDuneAuthzSchema: rejects a plugin action naming an undefined relation", () => { + // defineSchema() itself validates every actionToRelations entry against + // the relations map — a plugin can't invent a new relation type by + // slipping an unknown one into its authzActions. + assertThrows(() => + buildDuneAuthzSchema({ + // deno-lint-ignore no-explicit-any + "billing.manage": ["superadmin" as any], + }) + ); +}); + +Deno.test("roleHasPermission: resolves a plugin-contributed action when its actionToRelations map is passed", () => { + const schema = buildDuneAuthzSchema({ "billing.manage": ["admin"] }); + const relations = schema.actionToRelations as Record; + assertEquals(roleHasPermission("admin", "billing.manage", relations), true); + assertEquals(roleHasPermission("editor", "billing.manage", relations), false); + // A plugin action is invisible without its site's own map — the default + // (built-ins only) correctly denies it, not silently allows. + assertEquals(roleHasPermission("admin", "billing.manage"), false); +});