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
46 changes: 46 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,52 @@ exactly what "breaking" means and what doesn't count.

## [Unreleased]

### Changed

- **Dropped the `ROLE_PERMISSIONS`-table fallback from every core-side admin
permission decision — `authz.check()` is now the sole authority, no
exceptions.** `checkInlineEditPermission()` and
`runPluginResponseTransforms()`'s `pages.update` gate previously fell back
to a flat role table when `authz` was undefined (authz creation failing at
startup — an in-process object construction that essentially never fails
in practice). Both now fail closed (deny) instead of silently degrading to
a separate, less-audited mechanism — dec-identity-unification Phase 5c's
second half, finally closed. The site-user path to `/api/inline-edit/ws`
(`checkInlineEditPermissionForSiteUser()`) had the same kind of fallback —
a raw `roles[]` check for an editor-equivalent role when `authz` was
undefined — and now fails closed too. `runPluginResponseTransforms()`'s
published `auth.role` / `hasPermission()` now also read the *highest*
admin-tier role in the session's `roles[]` (`highestAdminRole()`, new,
`@dune/core/auth/authz-schema`) instead of `roles[0]` — a merged User's
`roles[]` can hold content-gating tags alongside the admin role in any
order, and `roles[0]` could be a tag, silently under-privileging the
synchronous approximation relative to what `authz.check()` had just
decided. The one place that genuinely can't
call the real async `authz.check()` —
`ResponseTransformContext.auth.hasPermission()`,
a published *synchronous* hook API — now reads `roleHasPermission()`
(new, `@dune/core/auth/authz-schema`), a synchronous lookup against the
same canonical `actionToRelations` schema `authz.check()` itself uses,
instead of a hand-maintained mirror. `@dune/plugin-admin` no longer needs
to keep its own `ROLE_PERMISSIONS` table in sync with this schema at all —
see its own 3.0.0 changelog entry for the corresponding removal there.
- **`ADMIN_ROLE_RANK`** exported alongside `highestAdminRole()`
(`@dune/core/auth/authz-schema`, now also reachable via a new
`"./auth/authz-schema"` export map entry — it previously had none, so
nothing outside this package could actually import from it despite
`roleHasPermission()`'s own doc already describing it as importable).
`@dune/plugin-admin`'s `role-utils.ts` now derives its `ROLE_RANK`/
`highestValidRole()` from this instead of keeping its own copy of the
same three numbers — the exact "two tables kept in sync by convention"
pattern the `ROLE_PERMISSIONS` removal was about eliminating, just
spotted a second time during review before it could take hold.
- **Built-in admin plugin pin is now `@dune/plugin-admin@^3.0`**
(`deno.json` imports and `src/plugins/builtin.ts`). 3.0.0 is the
companion that removes `ROLE_PERMISSIONS` / `hasPermission()`; a 2.x
plugin-admin still runs against this core, but the two must be released
together so sites that pick up 3.0.0 do not call a method that no
longer exists.

### Added

- **`dune plugin:update`, `plugin:install`, and `plugin:remove` now accept
Expand Down
13 changes: 7 additions & 6 deletions deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"./auth/api-guard": "./src/auth/api-guard.ts",
"./auth/mount": "./src/auth/mount.ts",
"./auth/authz": "./src/auth/authz.ts",
"./auth/authz-schema": "./src/auth/authz-schema.ts",
"./auth/authz-adapter-local": "./src/auth/authz-adapter-local.ts",
"./auth/authz-adapter-db": "./src/auth/authz-adapter-db.ts",
"./auth/types": "./src/auth/types.ts",
Expand Down Expand Up @@ -56,12 +57,12 @@
"./fresh-app": "./src/runtime/server.ts"
},
"imports": {
"@dune/plugin-admin": "jsr:@dune/plugin-admin@^2.0",
"@dune/plugin-admin/admin/mount": "jsr:@dune/plugin-admin@^2.0/admin/mount",
"@dune/plugin-admin/admin/context": "jsr:@dune/plugin-admin@^2.0/admin/context",
"@dune/plugin-admin/admin/types": "jsr:@dune/plugin-admin@^2.0/admin/types",
"@dune/plugin-admin/admin/auth/middleware": "jsr:@dune/plugin-admin@^2.0/admin/auth/middleware",
"@dune/plugin-admin/admin/auth/provider": "jsr:@dune/plugin-admin@^2.0/admin/auth/provider",
"@dune/plugin-admin": "jsr:@dune/plugin-admin@^3.0",
"@dune/plugin-admin/admin/mount": "jsr:@dune/plugin-admin@^3.0/admin/mount",
"@dune/plugin-admin/admin/context": "jsr:@dune/plugin-admin@^3.0/admin/context",
"@dune/plugin-admin/admin/types": "jsr:@dune/plugin-admin@^3.0/admin/types",
"@dune/plugin-admin/admin/auth/middleware": "jsr:@dune/plugin-admin@^3.0/admin/auth/middleware",
"@dune/plugin-admin/admin/auth/provider": "jsr:@dune/plugin-admin@^3.0/admin/auth/provider",
"fresh": "jsr:@fresh/core@^2",
"preact": "npm:preact@^10",
"preact/hooks": "npm:preact@^10/hooks",
Expand Down
2 changes: 1 addition & 1 deletion skills/dune-authz.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ const canAdmin = await getAuthz().check({
});
```

In practice, `plugin-admin`'s own route guards don't call this directly for you — `requirePermission(ctx, perm)` (internal to `@dune/plugin-admin`) already does: it calls `authz.check()` when polizy is wired (`auth.mode: dune`, `authzStore: local`), and falls back to a simpler `ROLE_PERMISSIONS` table lookup when it isn't. You don't need to duplicate this in an admin route.
In practice, `plugin-admin`'s own route guards don't call this directly for you — `requirePermission(ctx, perm)` (internal to `@dune/plugin-admin`) already does: it calls `authz.check()` and denies if `authz` is undefined. There is no role-table fallback. You don't need to duplicate this in an admin route.

### Group membership (content gating, membership sites)

Expand Down
7 changes: 4 additions & 3 deletions skills/dune-plugin-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ export default {
} satisfies DunePlugin;
```

A route registered via `mount()` is **not** automatically authenticated the way `adminPages` is — you must wrap it in `withGuards()` (or call `csrfCheck()`/`requirePermission()` from the same module directly) yourself. Reimplementing the guard sequence by hand instead of using `withGuards()` is exactly what caused three real security regressions in Dune's own admin routes (the module's own doc comment names them) — the CSRF check's Origin/Sec-Fetch-Site/Referer fallback chain in particular is not trivial to get right, and `requirePermission()` checks the polizy-backed `authz` system first when configured, falling back to the role table only when it's not — a detail that's easy to miss if you reach for `AdminContext.auth.hasPermission()` directly instead. If your plugin only needs to *display* data or trigger something a human clicks through in the admin UI, prefer `adminPages` anyway — you get all of this for free, no `withGuards()` needed.
A route registered via `mount()` is **not** automatically authenticated the way `adminPages` is — you must wrap it in `withGuards()` (or call `csrfCheck()`/`requirePermission()` from the same module directly) yourself. Reimplementing the guard sequence by hand instead of using `withGuards()` is exactly what caused three real security regressions in Dune's own admin routes (the module's own doc comment names them) — the CSRF check's Origin/Sec-Fetch-Site/Referer fallback chain in particular is not trivial to get right, and `requirePermission()` consults `authz.check()` only (missing `authz` denies; there is no role-table fallback). `AdminContext.auth.hasPermission()` was removed in `@dune/plugin-admin` 3.0.0 — do not reimplement it. If your plugin only needs to *display* data or trigger something a human clicks through in the admin UI, prefer `adminPages` anyway — you get all of this for free, no `withGuards()` needed.

### `publicRoutes` — declarative public-facing routes

Expand Down Expand Up @@ -321,7 +321,7 @@ Headless-mode developers who call `mountDuneAdmin(app, ctx)` directly (never goi

### Permission reference

Real values of the `AdminPermission` union (`plugin-admin/src/admin/types.ts`) — `adminPages[].permission` accepts any string but only these are meaningful against the built-in role table:
Real values of the `AdminPermission` union (`plugin-admin/src/admin/types.ts`) — `adminPages[].permission` accepts any string but only these map to actions in `@dune/core`'s canonical `actionToRelations` schema (`authz.check()` is the sole authority; unknown names deny):

| Permission string | Grants |
|--------------------|--------|
Expand All @@ -330,7 +330,8 @@ Real values of the `AdminPermission` union (`plugin-admin/src/admin/types.ts`)
| `"users.create"` / `"users.read"` / `"users.update"` / `"users.delete"` | User management |
| `"config.read"` / `"config.update"` | Site config |
| `"submissions.read"` / `"submissions.delete"` | Form submissions |
| `"admin.access"` | Any authenticated admin (no finer check) |

Panel access itself is `authz.check({ canThey: "access", onWhat: { type: "app", id: "admin" } })`, not a permission string. There is no `"admin.access"` — that name existed only on the removed `ROLE_PERMISSIONS` table and is now rejected as unknown.

There is no `"pages.view"`, `"users.manage"`, or `"plugins.manage"` — use the split create/read/update/delete permissions above.

Expand Down
81 changes: 76 additions & 5 deletions src/auth/authz-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,19 @@
* ## Admin panel access (top-level)
* authz.check({ who: adminUser, canThey: "access", onWhat: { type: "app", id: "admin" } })
*
* ## Granular admin permissions (mirror of ROLE_PERMISSIONS)
* ## Granular admin permissions
* authz.check({ who: adminUser, canThey: "pages.create", onWhat: { type: "app", id: "admin" } })
*
* The admin permission actions below are the canonical permission definition.
* ROLE_PERMISSIONS in src/admin/types.ts is kept for reference only and must
* stay in sync with this schema.
* 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()`.
*/

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
Expand Down Expand Up @@ -65,7 +68,7 @@ export const duneAuthzSchema: AuthSchema<any, any, any, any, any> = defineSchema

// ── Admin panel — granular permissions (maps 1:1 with AdminPermission) ─
//
// These mirror ROLE_PERMISSIONS in src/admin/types.ts. Change one → change both.
// Sole definition — nothing else mirrors this (see the module doc above).
//
"pages.create": ["admin", "editor", "author"],
"pages.read": ["admin", "editor", "author"],
Expand Down Expand Up @@ -93,3 +96,71 @@ export const duneAuthzSchema: AuthSchema<any, any, any, any, any> = defineSchema

/** TypeScript type of the Dune authorization schema — pass to `AuthSystem` generics. */
export type DuneAuthzSchema = typeof duneAuthzSchema;

/**
* Synchronous, role-only approximation of an admin permission check, sourced
* directly from this schema's `actionToRelations` — the single canonical
* permission definition, not a hand-maintained mirror of it.
*
* `authz.check()` is the real, async, sole authority for every admin route.
* This exists only for the one place in Dune that cannot call it: a
* published, synchronous hook API (`ResponseTransformContext.auth
* .hasPermission()`, `src/cli/response-transforms.ts`) that plugin authors
* already depend on. It answers exactly for the permission that gated the
* hook context into existing in the first place, and is a reasonable
* approximation for any other permission a plugin might ask about — the
* same tradeoff a flat role table always made, just without a second table
* to keep in sync with this one.
*
* ⚠️ **Not for access decisions.** This is an *approximation*: it sees only
* the role vocabulary in `actionToRelations` and knows nothing of what
* `authz.check()` knows beyond roles — revoked tuples, per-user direct
* grants (`owner`/`editor` relations), or group membership. Its answer can
* therefore diverge from the sole authority in either direction. Do NOT use
* 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.
*/
export function roleHasPermission(role: string, permission: string): boolean {
const relations = duneAuthzSchema.actionToRelations as Record<string, readonly string[]>;
return relations[permission]?.includes(role) ?? false;
}

/**
* Canonical admin-tier role ranking (`"admin" > "editor" > "author"`) —
* the single source of truth `highestAdminRole()` below uses, and the one
* `@dune/plugin-admin`'s `role-utils.ts` imports for its own direct rank
* comparisons (`provisioner.ts`'s role-escalation guards) instead of
* keeping a second, hand-maintained copy of the same three numbers. This
* is the exact "two tables that must be kept in sync by convention"
* pattern the `ROLE_PERMISSIONS` removal (3.0.0) was about eliminating —
* codified once, here, rather than repeated.
*/
export const ADMIN_ROLE_RANK: Record<Role, number> = {
admin: 3,
editor: 2,
author: 1,
};

/**
* Pick the highest-ranked admin-tier role out of a generic `roles: string[]`
* array, or `""` if none is present.
*
* A merged `User`'s `roles[]` mixes admin-tier roles with content-gating
* tags (e.g. `["member", "admin"]`) in no guaranteed order, so `roles[0]`
* is not necessarily an admin role at all. Permission answers sourced from
* a single `role` string must use the highest admin-tier entry — matching
* what `authz.check()` (which unions all the user's relations) would
* decide — or the synchronous approximation under-privileges relative to
* the sole authority. Used for `ResponseTransformContext.auth.role` /
* `.hasPermission()`, the one place that can't call the real async check.
*/
export function highestAdminRole(roles: string[] | undefined): string {
let best = "";
for (const r of roles ?? []) {
const rr = ADMIN_ROLE_RANK[r as Role] ?? 0;
if (rr === 0) continue; // unknown / content-gating tag — never wins
if (!best || rr > (ADMIN_ROLE_RANK[best as Role] ?? 0)) best = r;
}
return best;
}
50 changes: 26 additions & 24 deletions src/cli/response-transforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@
* - Resolve the auth context once per request, enforcing the documented
* contract: `auth` is non-null only for a valid admin session that holds
* the `pages.update` permission. Plugins can rely on this and must not
* need to re-authenticate. The permission check itself goes through the
* polizy `authz` system (`authz.check()`) when configured — the same
* sole-authority contract every other admin permission check follows
* (dec-identity-unification Phase 5c/7) — not the flat `ROLE_PERMISSIONS`
* table `auth.hasPermission()` alone would consult.
* need to re-authenticate. The permission check itself is `authz.check()` —
* the sole authority every admin permission check follows
* (dec-identity-unification Phase 5c/6/7); if `authz` is somehow
* undefined (authz creation itself failed at startup — already logged
* loudly there), this fails closed rather than falling back to a
* separate, less-audited mechanism.
* - Never run transforms on admin-panel paths (defense in depth — admin
* routes are mounted separately and should never reach the content
* catch-all, but the pipeline must not depend on routing order alone).
Expand All @@ -31,18 +32,16 @@
/**
* Minimal auth middleware interface — concrete type is from `@dune/plugin-admin`.
* `authenticate()` is still the only way to validate an admin session cookie
* (core doesn't own admin session cookie config), but `hasPermission()` is
* used only as the fallback when `authz` (below) isn't configured — see the
* doc comment on `runPluginResponseTransforms` for why.
* (core doesn't own admin session cookie config).
*/
interface AdminAuthMiddleware {
authenticate(req: Request): Promise<unknown>;
hasPermission(result: unknown, permission: string): boolean;
}
import type { DuneConfig } from "../config/types.ts";
import type { DunePlugin, ResponseTransformContext } from "../hooks/types.ts";
import type { DuneAuthSystem } from "../auth/authz.ts";
import type { PageIndex } from "../content/types.ts";
import { highestAdminRole, roleHasPermission } from "../auth/authz-schema.ts";
import { applyResponseTransforms } from "../plugins/loader.ts";
import { hasAdminSessionCookie } from "./admin-bar-inject.ts";
import { isAdminPath } from "./serve-utils.ts";
Expand Down Expand Up @@ -72,11 +71,10 @@ export interface RunResponseTransformsOptions {
*/
auth: AdminAuthMiddleware | null;
/**
* Polizy authz system — when present, is the sole authority for the
* `pages.update` check below (dec-identity-unification Phase 7). Falls
* back to `auth.hasPermission()` only in the narrow, exceptional case
* where authz creation itself failed at startup (already logged loudly
* there — see `src/runtime/bootstrap.ts`).
* Polizy authz system — the sole authority for the `pages.update` check
* below (dec-identity-unification Phase 5c/7). Undefined only when authz
* creation itself failed at startup (already logged loudly there — see
* `src/runtime/bootstrap.ts`), in which case the check fails closed.
*/
authz?: DuneAuthSystem;
/**
Expand Down Expand Up @@ -134,22 +132,26 @@ export async function runPluginResponseTransforms(
canThey: "pages.update" as any,
onWhat: { type: "app", id: "admin" },
})
: auth.hasPermission(result, "pages.update")
: false
);
if (canUpdatePages) {
const user = r.user as Record<string, unknown>;
// dec-identity-unification Phase 5b: the merged User type has no
// single `role` field, only a generic `roles: string[]` — an admin
// session always has exactly one entry in it (set by
// @dune/plugin-admin's UserManager), so the first entry is the
// session's admin role. This context field keeps its original
// `role: string` shape (a published hook type, ResponseTransformContext
// — minimize churn for plugin authors) rather than switching to `roles`.
// This context field keeps its original `role: string` shape (a
// published hook type, ResponseTransformContext — minimize churn for
// plugin authors) rather than switching to `roles`.
const roles = user.roles as string[] | undefined;
// The merged User type mixes admin-tier roles with content-gating
// tags in roles[] (e.g. ["member", "admin"]) with no guaranteed
// order — roles[0] is not necessarily an admin role. The published
// role string and hasPermission() answers sourced from it must use
// the highest admin-tier entry, or the synchronous approximation
// would under-privilege relative to what authz.check() (which
// unions all the user's relations) decided just above.
const role = highestAdminRole(roles);
transformAuth = {
username: user.username as string,
role: roles?.[0] ?? "",
hasPermission: (perm) => auth.hasPermission(result, perm as string),
role,
hasPermission: (perm) => roleHasPermission(role, perm),
};
}
} catch { /* invalid session — treat as unauthenticated */ }
Expand Down
Loading