diff --git a/CHANGELOG.md b/CHANGELOG.md index 67c053e..71d5af3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,37 @@ exactly what "breaking" means and what doesn't count. --- -## [Unreleased] +## [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 diff --git a/docs/content b/docs/content index 73f5e96..5a804ed 160000 --- a/docs/content +++ b/docs/content @@ -1 +1 @@ -Subproject commit 73f5e969685f26376aa9e44c4086d4b0e3575225 +Subproject commit 5a804edc5deb87f302dcfab132bdd8571e9c9a88 diff --git a/src/auth/authz-schema.ts b/src/auth/authz-schema.ts index 6860e0a..b37d1ef 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 2a42e32..6178e0c 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 6d9134b..0775444 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 db27e1b..bf95c49 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 f7670af..841caf7 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 e326e93..2358480 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 0000000..a27b22a --- /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 4b32243..da4f9a4 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); +});