Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
195 changes: 132 additions & 63 deletions src/auth/authz-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,75 +20,135 @@
* ## 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";
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<any, any, any, any, any> = 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<string, readonly AuthzRelation[]>;

/**
* 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<string, readonly AuthzRelation[]>,
// deno-lint-ignore no-explicit-any
): AuthSchema<any, any, any, any, any> {
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<any, any, any, any, any> = buildDuneAuthzSchema();

/** TypeScript type of the Dune authorization schema — pass to `AuthSystem` generics. */
export type DuneAuthzSchema = typeof duneAuthzSchema;
Expand Down Expand Up @@ -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<string, readonly string[]>;
return relations[permission]?.includes(role) ?? false;
export function roleHasPermission(
role: string,
permission: string,
actionToRelations: Record<string, readonly string[]> = DUNE_BASE_AUTHZ_ACTIONS,
): boolean {
return actionToRelations[permission]?.includes(role) ?? false;
}

/**
Expand Down
13 changes: 12 additions & 1 deletion src/auth/authz.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<any, any, any, any, any>;
}

/** Return value of {@link createDuneAuthSystem} — the configured AuthSystem plus its underlying adapter. */
Expand Down Expand Up @@ -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<S,O> generic parameters
// don't align with the structural types from AuthzLocalAdapter — the
// implementation is fully compatible at runtime.
Expand Down
24 changes: 21 additions & 3 deletions src/cli/response-transforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, readonly string[]>;
/**
* 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
Expand Down Expand Up @@ -107,8 +116,17 @@ export interface RunResponseTransformsOptions {
export async function runPluginResponseTransforms(
opts: RunResponseTransformsOptions,
): Promise<Response> {
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);

Expand Down Expand Up @@ -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 */ }
Expand Down
40 changes: 40 additions & 0 deletions src/hooks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<string, readonly AuthzRelation[]>;
/**
* Factory for admin-context services contributed by this plugin.
*
Expand Down
Loading