From 456a4ac2160a551dd09ce096508142f887172bcf Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Sat, 29 Aug 2026 23:59:06 +0200 Subject: [PATCH 1/7] refactor(auth)!: remove ROLE_PERMISSIONS, hasPermission() entirely (3.0.0) dec-identity-unification Phase 5c/6, finally closed. This was supposed to be fully replaced by the polizy authz system when it was first introduced and instead survived as a parallel, hand-maintained table kept in sync with @dune/core's canonical actionToRelations schema by convention only, not by anything enforcing it. - Deleted ROLE_PERMISSIONS (types.ts) and AuthMiddleware.hasPermission() (auth/middleware.ts) entirely, including the public mod.ts re-export. - checkPermission()/requirePermission() (routes/api/_utils.ts) now fail closed (deny) when authz is undefined -- an in-process object construction failing at startup, essentially never hit in practice -- instead of silently degrading to the removed table. - routes/_layout.tsx's sidebar nav filtering, the one place that used ROLE_PERMISSIONS unconditionally rather than as a fallback, now reads a real permission set computed via authz.check() once per request (routes/_middleware.ts's new computeNavPermissions(), threaded through AdminState.permissions) instead of a table that could silently drift from what a route's own check would actually decide. No first-party or third-party plugin found calling the removed surface directly (checked @dune/plugin-inline-edit, @dune/plugin-meilisearch, @dune/plugin-orama, @dune/plugin-pdf) -- @dune/plugin-inline-edit's own auth.hasPermission(permission) call goes through @dune/core's published, synchronous HookContext.auth.hasPermission() hook API, whose contract is unchanged (companion @dune/core change: it now sources its answer from roleHasPermission(), a synchronous read of @dune/core's own canonical schema, instead of this package's table). 227 tests pass (5 new: computeNavPermissions()); deno check/lint clean. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 36 ++++++++++++ deno.json | 2 +- src/admin/auth/middleware.ts | 32 +--------- src/admin/context.ts | 9 ++- src/admin/mod.ts | 2 +- src/admin/routes/_layout.tsx | 8 ++- src/admin/routes/_middleware.ts | 71 +++++++++++++++++++++- src/admin/routes/api/_utils.ts | 46 +++++++-------- src/admin/types.ts | 33 +++-------- tests/admin/auth_test.ts | 48 +++------------ tests/admin/dev/apply_validation_test.ts | 10 +++- tests/admin/nav_permissions_test.ts | 74 +++++++++++++++++++++++ tests/admin/page_source_test.ts | 7 ++- tests/admin/pages_edit_blueprint_test.ts | 6 +- tests/admin/public_guards_test.ts | 75 +++++++++--------------- tests/admin/render_markdown_test.ts | 9 +-- tests/admin/theme_config_test.ts | 6 +- tests/admin/webhook_deliveries_test.ts | 11 +++- 18 files changed, 290 insertions(+), 195 deletions(-) create mode 100644 tests/admin/nav_permissions_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 44c2de4..4afc115 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,42 @@ follows [Semantic Versioning](https://semver.org). --- +## [3.0.0] — 2026-08-29 + +### Breaking + +- **Removed `ROLE_PERMISSIONS` and `AuthMiddleware.hasPermission()` entirely.** + dec-identity-unification Phase 5c's second half, finally closed — this was + supposed to be fully replaced by the polizy `authz` system back when it + was first introduced, and instead survived as a parallel, hand-maintained + table that had to be kept in sync with `@dune/core`'s canonical + `actionToRelations` schema by convention, not by anything enforcing it. + `authz.check()` is now the sole authority everywhere, with no exceptions: + `checkPermission()`/`requirePermission()` (`routes/api/_utils.ts`) fail + closed (deny) when `authz` is somehow undefined — an in-process object + construction failing at startup, essentially never hit in practice — + instead of silently degrading to the removed table. Sidebar nav filtering + (`routes/_layout.tsx`), the one place that used `ROLE_PERMISSIONS` + unconditionally rather than as a fallback, now reads a real permission + set computed via `authz.check()` once per request + (`routes/_middleware.ts`'s new `computeNavPermissions()`) instead of a + table that could silently drift from what a route's own check would + actually decide. + + **Migration**: anything importing `ROLE_PERMISSIONS` or calling + `AdminContext.auth.hasPermission()` directly has no replacement to switch + to within this package — use `checkPermission()`/`requirePermission()`/ + `withGuards()` (`@dune/plugin-admin/admin/guards`), which already do the + right thing. No first-party or third-party plugin was found calling the + removed surface directly (confirmed by search across + `@dune/plugin-inline-edit`, `@dune/plugin-meilisearch`, + `@dune/plugin-orama`, `@dune/plugin-pdf`) — `@dune/plugin-inline-edit`'s + own `auth.hasPermission(permission)` call goes through `@dune/core`'s + published, synchronous `HookContext.auth.hasPermission()` hook API, whose + contract is unchanged (companion `@dune/core` change: it now sources its + answer from `roleHasPermission()`, a synchronous read of `@dune/core`'s + own canonical schema, instead of this package's table). + ## [2.1.3] — 2026-08-27 ### Changed diff --git a/deno.json b/deno.json index f598a10..cb3ab7c 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@dune/plugin-admin", - "version": "2.1.3", + "version": "3.0.0", "license": "MIT", "minimumDependencyAge": { "age": "P1D", diff --git a/src/admin/auth/middleware.ts b/src/admin/auth/middleware.ts index bf045a6..13da8b0 100644 --- a/src/admin/auth/middleware.ts +++ b/src/admin/auth/middleware.ts @@ -6,9 +6,7 @@ import type { SessionManager } from "./sessions.ts"; import type { UserManager } from "./users.ts"; -import type { AdminPermission, AuthResult } from "../types.ts"; -import { ROLE_PERMISSIONS } from "../types.ts"; -import { highestValidRole } from "./role-utils.ts"; +import type { AuthResult } from "../types.ts"; import { clientIp } from "@dune/core/security"; /** Options for {@link createAuthMiddleware}. */ @@ -33,20 +31,10 @@ export interface AuthMiddlewareConfig { trustForwardedFor?: boolean; } -/** Validates session cookies and checks admin permissions. Obtain via {@link createAuthMiddleware}. */ +/** Validates session cookies. Obtain via {@link createAuthMiddleware}. */ export interface AuthMiddleware { /** Extract and validate session from request. Returns auth result. */ authenticate(req: Request): Promise; - /** - * Check permission against the flat `ROLE_PERMISSIONS` table only — does - * NOT consult the polizy `authz` system. Route handlers should use - * `checkPermission()`/`requirePermission()` (`routes/api/_utils.ts`) - * instead, which check `authz.check()` first when configured and only - * fall back to this method in the narrow, exceptional case where authz - * creation itself failed at startup. Calling this directly bypasses - * authz even when one is configured (dec-identity-unification Phase 5c). - */ - hasPermission(authResult: AuthResult, permission: AdminPermission): boolean; /** Create a session cookie value for Set-Cookie header */ createSessionCookie(sessionId: string, maxAge: number): string; /** Create an expired cookie to clear the session */ @@ -108,21 +96,6 @@ export function createAuthMiddleware( return { authenticated: true, user, session }; } - /** ROLE_PERMISSIONS-only check — see the doc comment on {@link AuthMiddleware.hasPermission}. */ - function hasPermission( - authResult: AuthResult, - permission: AdminPermission, - ): boolean { - if (!authResult.authenticated || !authResult.user) return false; - // A user with no admin-tier string in roles[] (e.g. a public site member - // with only content-gating tags) has no admin-panel permissions at all — - // there is no "no role" entry in ROLE_PERMISSIONS to fall back to. - const role = highestValidRole(authResult.user.roles); - if (!role) return false; - const permissions = ROLE_PERMISSIONS[role]; - return permissions.includes(permission); - } - function createSessionCookie(sessionId: string, maxAge: number): string { const secureFlag = secure ? "; Secure" : ""; return `${cookieName}=${sessionId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secureFlag}`; @@ -135,7 +108,6 @@ export function createAuthMiddleware( return { authenticate, - hasPermission, createSessionCookie, clearSessionCookie, }; diff --git a/src/admin/context.ts b/src/admin/context.ts index fd1adfa..e7958bd 100644 --- a/src/admin/context.ts +++ b/src/admin/context.ts @@ -73,9 +73,12 @@ export interface AdminContext { metrics?: MetricsCollector; mt?: MachineTranslator | null; /** - * Polizy authz system, present when auth.mode is "dune" and authzStore is "local". - * Used for admin panel access enforcement and role-change tuple sync. - * When undefined, ROLE_PERMISSIONS is the sole authority. + * Polizy authz system — the sole authority for admin panel access + * enforcement and role-change tuple sync. Undefined only when authz + * creation itself failed at startup, in which case permission checks + * fail closed rather than falling back to a role table (dec-identity- + * unification Phase 5c/6 — the ROLE_PERMISSIONS fallback this used to + * describe was removed in 3.0.0). */ authz?: DuneAuthSystem; /** diff --git a/src/admin/mod.ts b/src/admin/mod.ts index 1611943..d09f41a 100644 --- a/src/admin/mod.ts +++ b/src/admin/mod.ts @@ -38,7 +38,7 @@ export type { AuthResult, UserInfo, } from "./types.ts"; -export { ROLE_PERMISSIONS, toUserInfo } from "./types.ts"; +export { toUserInfo } from "./types.ts"; // Submissions export { createSubmissionManager } from "./submissions.ts"; diff --git a/src/admin/routes/_layout.tsx b/src/admin/routes/_layout.tsx index d358393..258c007 100644 --- a/src/admin/routes/_layout.tsx +++ b/src/admin/routes/_layout.tsx @@ -8,7 +8,6 @@ import type { h } from "preact"; import type { AdminState } from "../types.ts"; import { getNavItems } from "../nav.ts"; import { isRtl } from "@dune/core/i18n"; -import { ROLE_PERMISSIONS } from "../types.ts"; import { highestValidRole } from "../auth/role-utils.ts"; import { normalizePrefix, @@ -88,7 +87,12 @@ export default function AdminLayout( const user = state.auth?.user; const userName = user?.name ?? user?.username ?? "Admin"; const role = highestValidRole(user?.roles) ?? "author"; - const userPermissions = ROLE_PERMISSIONS[role] ?? []; + // Real authz-backed permissions, computed per request by _middleware.ts's + // computeNavPermissions() — replaces the flat ROLE_PERMISSIONS[role] + // lookup removed in 3.0.0, so the sidebar can no longer show/hide items + // based on a table that could silently drift from what authz.check() + // would actually decide for a route. + const userPermissions = state.permissions ?? []; const allNavItems = getNavItems(); const navItems = allNavItems.filter((item) => { diff --git a/src/admin/routes/_middleware.ts b/src/admin/routes/_middleware.ts index 26cfa2f..ce2ae60 100644 --- a/src/admin/routes/_middleware.ts +++ b/src/admin/routes/_middleware.ts @@ -8,7 +8,8 @@ import type { FreshContext, Middleware } from "fresh"; import { csp } from "fresh"; -import type { AdminState } from "../types.ts"; +import type { AdminPermission, AdminState } from "../types.ts"; +import type { DuneAuthSystem } from "@dune/core/auth/authz"; export const PUBLIC_PATHS = new Set(["/login", "/login/logout"]); @@ -130,6 +131,61 @@ export function toAdminRelative(pathname: string, normalizedPrefix: string): str return adminRelative; } +/** + * Every `AdminPermission` a nav item might gate on. Excludes `"admin.access"` + * deliberately — a legacy value the removed `ROLE_PERMISSIONS` table (3.0.0) + * carried on every role but which no nav item ever used and which has no + * corresponding action in `@dune/core`'s authz schema + * (`src/auth/authz-schema.ts`'s `actionToRelations` never defined it) — + * `authz.check()` has nothing to resolve it against. + */ +const NAV_PERMISSIONS: readonly AdminPermission[] = [ + "pages.create", + "pages.read", + "pages.update", + "pages.delete", + "media.upload", + "media.read", + "media.delete", + "users.create", + "users.read", + "users.update", + "users.delete", + "config.read", + "config.update", + "submissions.read", + "submissions.delete", +]; + +/** + * Real authz-backed permission set for sidebar nav filtering + * (`routes/_layout.tsx`), replacing the flat `ROLE_PERMISSIONS[role]` lookup + * removed in 3.0.0 — the nav no longer shows/hides items based on a + * hand-maintained table that could silently drift from what a route's own + * `authz.check()` would actually decide. Computed once per request here + * (where `authz.check()`'s async cost is affordable — this is an + * already-authenticated admin-panel request, not a hot public path) and + * read synchronously from `state` by the layout. + */ +export async function computeNavPermissions( + authz: DuneAuthSystem | undefined, + userId: string, +): Promise { + if (!authz) return []; + const checks = await Promise.all( + NAV_PERMISSIONS.map(async (permission) => { + const allowed = await authz.check({ + who: { type: "user", id: userId }, + // deno-lint-ignore no-explicit-any + canThey: permission as any, + onWhat: { type: "app", id: "admin" }, + }); + return allowed ? permission : null; + }), + ); + return checks.filter((p): p is AdminPermission => p !== null); +} + export async function handler( ctx: FreshContext, ): Promise { @@ -160,7 +216,11 @@ export async function handler( } } else if (authResult.user && adminCtx.authz && !PUBLIC_PATHS.has(adminRelative)) { // When polizy is wired, it is the authority for admin panel access. - // Falls back gracefully: if authz is not set, ROLE_PERMISSIONS remains the authority. + // If authz is undefined (creation failed at startup — exceptional, see + // checkPermission()'s doc comment), this top-level gate is skipped, but + // every route's own checkPermission()/requirePermission() call fails + // closed regardless — there is no ROLE_PERMISSIONS fallback anywhere + // anymore (3.0.0). // An authenticated user whose tuple has been revoked is denied before reaching routes. const canAccess = await adminCtx.authz.check({ who: { type: "user", id: authResult.user.id }, @@ -172,6 +232,13 @@ export async function handler( } } + if (authResult.authenticated && authResult.user) { + ctx.state.permissions = await computeNavPermissions( + adminCtx.authz, + authResult.user.id, + ); + } + const res = await (frameable ? adminCspFrameable : adminCsp)(ctx); return withSecurityHeaders(res, frameable); } diff --git a/src/admin/routes/api/_utils.ts b/src/admin/routes/api/_utils.ts index a57aa49..f0a34cd 100644 --- a/src/admin/routes/api/_utils.ts +++ b/src/admin/routes/api/_utils.ts @@ -122,41 +122,35 @@ export function csrfCheck(ctx: FreshContext): Response | null { /** * Permission check — the sole authority every admin route (and any - * ad-hoc ownership-or-permission check) should go through, rather than - * calling `AdminContext.auth.hasPermission()` directly. That method only - * ever consults the flat `ROLE_PERMISSIONS` table — calling it straight - * bypasses the polizy `authz` system even when one is configured, which is - * exactly the "parallel, separately-maintained flat table" dec-auth-storage - * said should be fully replaced, not coexist with (dec-identity-unification - * Phase 5c, second half). + * ad-hoc ownership-or-permission check) should go through. `authz.check()` + * is the only mechanism now (3.0.0 removed the flat `ROLE_PERMISSIONS` + * table and `AdminContext.auth.hasPermission()` entirely — dec-identity- + * unification Phase 5c/6, closing the "parallel, separately-maintained + * flat table" dec-auth-storage said should be fully replaced, not coexist + * with). * - * When the polizy authz system is wired (created whenever the admin panel - * is enabled — see `admin.authzStore`'s doc comment in - * `src/config/admin-config.ts`), uses `authz.check()` as the sole - * authority. Falls back to `ROLE_PERMISSIONS` only in the narrow, - * exceptional case where authz creation itself failed at startup (already - * logged loudly there) — not as a routine, silently-coexisting path. + * `admin.authzStore` defaults to `"local"` and `authz` is created whenever + * the admin panel is enabled, regardless of `site.auth`'s mode — see its + * doc comment in `src/config/admin-config.ts`. `authz` being undefined + * here means its creation itself failed at startup (already logged loudly + * there), an exceptional condition this fails closed on rather than + * degrading to a separate, less-audited mechanism. */ export async function checkPermission( ctx: FreshContext, permission: AdminPermission, ): Promise { - const { auth, authz } = ctx.state.adminContext; + const { authz } = ctx.state.adminContext; const authResult = ctx.state.auth; - if (authz && authResult.authenticated && authResult.user) { - // deno-lint-ignore no-explicit-any - return await authz.check({ - who: { type: "user", id: authResult.user.id }, - canThey: permission as any, - onWhat: { type: "app", id: "admin" }, - }); - } + if (!authz || !authResult.authenticated || !authResult.user) return false; - // Fallback: ROLE_PERMISSIONS (authz creation failed at startup — see the - // console.warn in src/runtime/bootstrap.ts — or, in external-jwt mode, - // authz was never configured to begin with) - return auth.hasPermission(authResult, permission); + // deno-lint-ignore no-explicit-any + return await authz.check({ + who: { type: "user", id: authResult.user.id }, + canThey: permission as any, + onWhat: { type: "app", id: "admin" }, + }); } /** Permission check — returns 403 response if denied, null if allowed. See {@link checkPermission}. */ diff --git a/src/admin/types.ts b/src/admin/types.ts index 688ba65..a5cc3f7 100644 --- a/src/admin/types.ts +++ b/src/admin/types.ts @@ -28,31 +28,6 @@ export type { Role }; import type { User } from "@dune/core/auth/types"; export type { User }; -/** Permission definitions per role */ -export const ROLE_PERMISSIONS: Record = { - admin: [ - "pages.create", "pages.read", "pages.update", "pages.delete", - "media.upload", "media.read", "media.delete", - "users.create", "users.read", "users.update", "users.delete", - "config.read", "config.update", - "submissions.read", "submissions.delete", - "admin.access", - ], - editor: [ - "pages.create", "pages.read", "pages.update", - "media.upload", "media.read", "media.delete", - "config.read", - "submissions.read", - "admin.access", - ], - author: [ - "pages.create", "pages.read", "pages.update", - "media.upload", "media.read", - "submissions.read", - "admin.access", - ], -}; - /** All possible admin permissions */ export type AdminPermission = | "pages.create" | "pages.read" | "pages.update" | "pages.delete" @@ -112,6 +87,14 @@ export interface AdminState { * has its own middleware that closes over its own AdminContext. */ adminContext: import("./context.ts").AdminContext; + /** + * The authenticated user's real, authz-backed permission set — computed + * once per request by `routes/_middleware.ts` (`computeNavPermissions()`) + * and read synchronously by `routes/_layout.tsx` for sidebar nav + * filtering. Undefined for an unauthenticated request. Replaces the flat + * `ROLE_PERMISSIONS[role]` lookup removed in 3.0.0. + */ + permissions?: AdminPermission[]; } /** Convert User to safe API response */ diff --git a/tests/admin/auth_test.ts b/tests/admin/auth_test.ts index 397ee6c..a58820f 100644 --- a/tests/admin/auth_test.ts +++ b/tests/admin/auth_test.ts @@ -7,7 +7,7 @@ import { hashPassword, verifyPassword } from "../../src/admin/auth/passwords.ts" import { createSessionManager } from "../../src/admin/auth/sessions.ts"; import { createUserManager } from "../../src/admin/auth/users.ts"; import { createAuthMiddleware } from "../../src/admin/auth/middleware.ts"; -import { ROLE_PERMISSIONS, toUserInfo } from "../../src/admin/types.ts"; +import { toUserInfo } from "../../src/admin/types.ts"; // === In-memory storage for tests === @@ -343,27 +343,6 @@ Deno.test("AuthMiddleware: authenticate succeeds with valid session", async () = assertEquals(result.user?.username, "admin"); }); -Deno.test("AuthMiddleware: hasPermission checks role", () => { - const storage = createMemoryStorage(); - const sessions = createSessionManager({ storage, sessionsDir: ".sess", lifetime: 3600 }); - const users = createUserManager({ storage, usersDir: ".users" }); - const auth = createAuthMiddleware({ sessions, users }); - - // Admin can delete pages - const adminResult = { - authenticated: true, - user: { roles: ["admin"] } as any, - }; - assertEquals(auth.hasPermission(adminResult, "pages.delete"), true); - - // Author cannot delete pages - const authorResult = { - authenticated: true, - user: { roles: ["author"] } as any, - }; - assertEquals(auth.hasPermission(authorResult, "pages.delete"), false); -}); - Deno.test("AuthMiddleware: bound session fails when forwarded IP is omitted", async () => { const storage = createMemoryStorage(); const sessions = createSessionManager({ storage, sessionsDir: ".sess", lifetime: 3600 }); @@ -413,24 +392,13 @@ Deno.test("AuthMiddleware: createSessionCookie formats correctly", () => { assertEquals(cookie.includes("Max-Age=86400"), true); }); -// === Types and permissions === - -Deno.test("ROLE_PERMISSIONS: admin has all permissions", () => { - assertEquals(ROLE_PERMISSIONS.admin.includes("pages.delete"), true); - assertEquals(ROLE_PERMISSIONS.admin.includes("users.delete"), true); - assertEquals(ROLE_PERMISSIONS.admin.includes("config.update"), true); -}); - -Deno.test("ROLE_PERMISSIONS: editor cannot delete pages or manage users", () => { - assertEquals(ROLE_PERMISSIONS.editor.includes("pages.delete"), false); - assertEquals(ROLE_PERMISSIONS.editor.includes("users.create"), false); -}); - -Deno.test("ROLE_PERMISSIONS: author has limited permissions", () => { - assertEquals(ROLE_PERMISSIONS.author.includes("pages.create"), true); - assertEquals(ROLE_PERMISSIONS.author.includes("pages.delete"), false); - assertEquals(ROLE_PERMISSIONS.author.includes("media.delete"), false); -}); +// Permission-by-role coverage moved to @dune/core's roleHasPermission() +// tests (tests/auth/authz_schema_test.ts) — ROLE_PERMISSIONS and +// AuthMiddleware.hasPermission() were removed in 3.0.0 (dec-identity- +// unification Phase 5c/6); authz.check() is the sole authority now, and +// the one place that still needs a synchronous role-only read sources it +// from @dune/core's canonical actionToRelations schema instead of a +// hand-maintained mirror. Deno.test("toUserInfo: strips password hash", () => { const user = { diff --git a/tests/admin/dev/apply_validation_test.ts b/tests/admin/dev/apply_validation_test.ts index 116c819..4c514e2 100644 --- a/tests/admin/dev/apply_validation_test.ts +++ b/tests/admin/dev/apply_validation_test.ts @@ -54,9 +54,13 @@ async function callApply( state: { auth: { authenticated: true, user: { id: "1", roles: ["admin"] } }, adminContext: { - auth: { - hasPermission: (_auth: unknown, perm: string) => - permissions ? permissions.includes(perm) : true, + // authz.check() is the sole authority now (ROLE_PERMISSIONS/ + // hasPermission() removed in 3.0.0). Defaults to allow-everything; + // pass `permissions` to restrict to specific ones. + // deno-lint-ignore no-explicit-any + authz: { + check: (args: any) => + Promise.resolve(permissions ? permissions.includes(args.canThey) : true), }, auditLogger: null, config: { diff --git a/tests/admin/nav_permissions_test.ts b/tests/admin/nav_permissions_test.ts new file mode 100644 index 0000000..f890999 --- /dev/null +++ b/tests/admin/nav_permissions_test.ts @@ -0,0 +1,74 @@ +/** + * Tests for routes/_middleware.ts's computeNavPermissions() — the real, + * authz-backed permission set that replaced the flat ROLE_PERMISSIONS[role] + * lookup _layout.tsx used for sidebar nav filtering (3.0.0, dec-identity- + * unification Phase 5c/6). + */ + +import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { computeNavPermissions } from "../../src/admin/routes/_middleware.ts"; +import type { DuneAuthSystem } from "@dune/core/auth/authz"; + +function makeAuthz(allowedPermissions: string[]) { + const calls: string[] = []; + return { + calls, + // deno-lint-ignore no-explicit-any + check(args: any): Promise { + calls.push(args.canThey); + return Promise.resolve(allowedPermissions.includes(args.canThey)); + }, + }; +} + +Deno.test("computeNavPermissions: returns only the permissions authz.check() grants", async () => { + const authz = makeAuthz(["pages.read", "pages.update", "config.read"]); + const result = await computeNavPermissions( + authz as unknown as DuneAuthSystem, + "u1", + ); + assertEquals(result.sort(), ["config.read", "pages.read", "pages.update"]); +}); + +Deno.test("computeNavPermissions: empty array when authz denies everything", async () => { + const authz = makeAuthz([]); + const result = await computeNavPermissions( + authz as unknown as DuneAuthSystem, + "u1", + ); + assertEquals(result, []); +}); + +Deno.test("computeNavPermissions: empty array (fails closed) when authz is undefined", async () => { + const result = await computeNavPermissions(undefined, "u1"); + assertEquals(result, []); +}); + +Deno.test("computeNavPermissions: never checks the legacy admin.access permission", async () => { + const authz = makeAuthz(["pages.read"]); + await computeNavPermissions(authz as unknown as DuneAuthSystem, "u1"); + assertEquals(authz.calls.includes("admin.access"), false); +}); + +Deno.test("computeNavPermissions: checks every real AdminPermission exactly once", async () => { + const authz = makeAuthz([]); + await computeNavPermissions(authz as unknown as DuneAuthSystem, "u1"); + const expected = [ + "pages.create", + "pages.read", + "pages.update", + "pages.delete", + "media.upload", + "media.read", + "media.delete", + "users.create", + "users.read", + "users.update", + "users.delete", + "config.read", + "config.update", + "submissions.read", + "submissions.delete", + ]; + assertEquals(authz.calls.sort(), [...expected].sort()); +}); diff --git a/tests/admin/page_source_test.ts b/tests/admin/page_source_test.ts index 0f1fe5c..cb6e547 100644 --- a/tests/admin/page_source_test.ts +++ b/tests/admin/page_source_test.ts @@ -62,9 +62,10 @@ function makeCtx(route: string | null, pages: PageIndex[], storageContent?: stri state: { auth: { authenticated: true, user: { id: "1", roles: ["admin"] } }, adminContext: { - auth: { - hasPermission: (_auth: unknown, _perm: string) => true, - }, + // authz.check() always allows — this test exercises page-source + // behavior, not permission logic (ROLE_PERMISSIONS/hasPermission() + // were removed in 3.0.0; authz.check() is the sole authority now). + authz: { check: () => Promise.resolve(true) }, auditLogger: null, engine: { pages, diff --git a/tests/admin/pages_edit_blueprint_test.ts b/tests/admin/pages_edit_blueprint_test.ts index 47cb38e..23ee66b 100644 --- a/tests/admin/pages_edit_blueprint_test.ts +++ b/tests/admin/pages_edit_blueprint_test.ts @@ -63,8 +63,10 @@ function makeCtx(opts: { engine, storage: opts.storage, config: { system: { content: { dir: "content" } }, admin: {} }, - auth: { hasPermission: () => true }, - authz: undefined, + // authz.check() always allows — this test exercises page-edit + // behavior, not permission logic (ROLE_PERMISSIONS/hasPermission() + // were removed in 3.0.0; authz.check() is the sole authority now). + authz: { check: () => Promise.resolve(true) }, hooks: undefined, auditLogger: undefined, }, diff --git a/tests/admin/public_guards_test.ts b/tests/admin/public_guards_test.ts index d018ea1..64c5d03 100644 --- a/tests/admin/public_guards_test.ts +++ b/tests/admin/public_guards_test.ts @@ -37,12 +37,13 @@ function makeCtx( opts: { headers?: Record; authenticated?: boolean; - permissions?: string[]; params?: Record; /** * When set, adminContext gets an `authz` field with a `check()` that - * always returns this value — exercises the authz-first path instead - * of the ROLE_PERMISSIONS fallback. + * always returns this value. When omitted, `adminContext.authz` is + * undefined — exercises the fail-closed path (3.0.0 removed the + * ROLE_PERMISSIONS fallback entirely; no authz means checkPermission() + * always denies). */ authzAllows?: boolean; } = {}, @@ -58,15 +59,9 @@ function makeCtx( state: { adminContext: { auditLogger: null, - // No `authz` field by default — exercises the ROLE_PERMISSIONS - // fallback path, the same one hit without polizy configured. ...(opts.authzAllows !== undefined ? { authz: { check: () => Promise.resolve(opts.authzAllows) } } : {}), - auth: { - hasPermission: (_authResult: unknown, permission: string) => - (opts.permissions ?? []).includes(permission), - }, }, auth: authenticated ? { authenticated: true, user: { id: "u1" } } @@ -87,58 +82,41 @@ Deno.test("public guards: csrfCheck/requirePermission/validatePagePath/withGuard assertEquals(typeof withGuards, "function"); }); -// ── checkPermission: authz-first, ROLE_PERMISSIONS fallback-only ──────────── +// ── checkPermission: authz.check() is the sole authority ──────────────────── // -// dec-identity-unification Phase 5c (second half): authz.check() must be -// the sole authority whenever it's configured — not a routine, silently- -// coexisting alternative to ROLE_PERMISSIONS. These prove checkPermission() -// actually consults authz.check() (and that its answer wins over what -// ROLE_PERMISSIONS would say), rather than just documenting the intent. - -Deno.test("checkPermission: uses authz.check() when authz is configured, ignoring ROLE_PERMISSIONS", async () => { - // ROLE_PERMISSIONS-backed hasPermission would deny (no permissions - // granted), but authz.check() allows — authz's answer must win. - const ctx = makeCtx("GET", { permissions: [], authzAllows: true }); +// dec-identity-unification Phase 5c/6: authz.check() is the only mechanism +// now — ROLE_PERMISSIONS and AuthMiddleware.hasPermission() were removed +// entirely in 3.0.0. No authz configured means fail closed (deny), not a +// fallback to a separate, less-audited mechanism. + +Deno.test("checkPermission: allows when authz.check() allows", async () => { + const ctx = makeCtx("GET", { authzAllows: true }); assertEquals(await checkPermission(ctx, "config.update" as never), true); }); -Deno.test("checkPermission: authz.check() denial wins even when ROLE_PERMISSIONS would allow", async () => { - const ctx = makeCtx("GET", { - permissions: ["config.update"], - authzAllows: false, - }); +Deno.test("checkPermission: denies when authz.check() denies", async () => { + const ctx = makeCtx("GET", { authzAllows: false }); assertEquals(await checkPermission(ctx, "config.update" as never), false); }); -Deno.test("checkPermission: falls back to ROLE_PERMISSIONS when authz is not configured", async () => { - const allowed = makeCtx("GET", { permissions: ["config.update"] }); - assertEquals(await checkPermission(allowed, "config.update" as never), true); - - const denied = makeCtx("GET", { permissions: [] }); - assertEquals(await checkPermission(denied, "config.update" as never), false); +Deno.test("checkPermission: fails closed (denies) when authz is not configured — no ROLE_PERMISSIONS fallback", async () => { + const ctx = makeCtx("GET", {}); + assertEquals(await checkPermission(ctx, "config.update" as never), false); }); -Deno.test("checkPermission: falls back to ROLE_PERMISSIONS when authz is configured but the actor isn't authenticated", async () => { - const ctx = makeCtx("GET", { - authenticated: false, - permissions: [], - authzAllows: true, - }); - // Not authenticated — no user id to check authz against, and the - // ROLE_PERMISSIONS fallback denies an unauthenticated result too. +Deno.test("checkPermission: denies when authz is configured but the actor isn't authenticated", async () => { + const ctx = makeCtx("GET", { authenticated: false, authzAllows: true }); + // Not authenticated — no user id to check authz against. assertEquals(await checkPermission(ctx, "config.update" as never), false); }); Deno.test("requirePermission: returns null (allowed) when authz.check() allows", async () => { - const ctx = makeCtx("GET", { permissions: [], authzAllows: true }); + const ctx = makeCtx("GET", { authzAllows: true }); assertEquals(await requirePermission(ctx, "config.update" as never), null); }); -Deno.test("requirePermission: returns 403 when authz.check() denies, even with ROLE_PERMISSIONS granting it", async () => { - const ctx = makeCtx("GET", { - permissions: ["config.update"], - authzAllows: false, - }); +Deno.test("requirePermission: returns 403 when authz.check() denies", async () => { + const ctx = makeCtx("GET", { authzAllows: false }); const res = await requirePermission(ctx, "config.update" as never); assertEquals(res?.status, 403); }); @@ -155,7 +133,7 @@ Deno.test("withGuards: CSRF denial short-circuits before the permission check or const res = await guarded( makeCtx("POST", { headers: { origin: "https://evil.example.com" }, - permissions: ["config.update"], + authzAllows: true, }), ); assertEquals(res.status, 403); @@ -171,7 +149,8 @@ Deno.test("withGuards: permission denial short-circuits before the handler runs return new Response("ok"); }, ); - const res = await guarded(makeCtx("POST", { permissions: [] })); + // No authz configured — fails closed, same as an explicit denial. + const res = await guarded(makeCtx("POST", {})); assertEquals(res.status, 403); assertEquals(handlerRan, false); }); @@ -202,7 +181,7 @@ Deno.test("withGuards: all guards passing reaches the handler", async () => { ); const res = await guarded( makeCtx("POST", { - permissions: ["config.update"], + authzAllows: true, params: { path: "my-plugin/settings" }, }), ); diff --git a/tests/admin/render_markdown_test.ts b/tests/admin/render_markdown_test.ts index 397e416..8d64d2b 100644 --- a/tests/admin/render_markdown_test.ts +++ b/tests/admin/render_markdown_test.ts @@ -32,9 +32,10 @@ async function callRenderMarkdown(body: unknown): Promise true, - }, + // authz.check() always allows — this test exercises render-markdown + // behavior, not permission logic (ROLE_PERMISSIONS/hasPermission() + // were removed in 3.0.0; authz.check() is the sole authority now). + authz: { check: () => Promise.resolve(true) }, auditLogger: null, config: { system: { debug: false }, @@ -130,7 +131,7 @@ Deno.test("render-markdown: returns 400 when content is missing", async () => { state: { auth: { authenticated: true, user: { id: "1", roles: ["admin"] } }, adminContext: { - auth: { hasPermission: () => true }, + authz: { check: () => Promise.resolve(true) }, auditLogger: null, config: { system: { debug: false } }, }, diff --git a/tests/admin/theme_config_test.ts b/tests/admin/theme_config_test.ts index 094b603..f9406f4 100644 --- a/tests/admin/theme_config_test.ts +++ b/tests/admin/theme_config_test.ts @@ -51,8 +51,10 @@ function makeCtx(opts: { engine, storage: opts.storage, config: { admin: { dataDir: "data" } }, - auth: { hasPermission: () => true }, - authz: undefined, + // authz.check() always allows — this test exercises theme-config + // behavior, not permission logic (ROLE_PERMISSIONS/hasPermission() + // were removed in 3.0.0; authz.check() is the sole authority now). + authz: { check: () => Promise.resolve(true) }, hooks: undefined, }, }, diff --git a/tests/admin/webhook_deliveries_test.ts b/tests/admin/webhook_deliveries_test.ts index ac2b00d..ea88b7a 100644 --- a/tests/admin/webhook_deliveries_test.ts +++ b/tests/admin/webhook_deliveries_test.ts @@ -50,9 +50,14 @@ function makeCtx( adminContext: { auditLogger: null, config: { admin: { runtimeDir: opts.runtimeDir ?? ".dune/admin" } }, - auth: { - hasPermission: (_auth: unknown, permission: string) => - (opts.permissions ?? []).includes(permission), + // Distinguishes which specific permission the mock holds — same + // role this fixture's ROLE_PERMISSIONS-backed hasPermission() used + // to play, sourced from authz.check() instead (removed in 3.0.0; + // authz.check() is the sole authority now). + // deno-lint-ignore no-explicit-any + authz: { + check: (args: any) => + Promise.resolve((opts.permissions ?? []).includes(args.canThey)), }, }, auth: { authenticated: true, user: { id: "u1" } }, From 1013704c18e9c3562902006dd2b46852f7c8209b Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Sun, 30 Aug 2026 23:22:25 +0200 Subject: [PATCH 2/7] fix(admin): top-level admin access gate fails closed when authz is undefined MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The _middleware.ts gate skipped the access check when adminCtx.authz was undefined (authz creation failed at startup), relying on every route's own checkPermission()/requirePermission() call to fail closed. But the PR claim is "authz.check() is the sole authority, no exceptions" — and any route that relies on this gate alone would be exposed to any authenticated user (of any role) during that state. The gate now returns 403 in that state, the same policy as every route-level check. csp_test's fixture needed an always-allowing authz mock: without one the authenticated render it tests never happens (403 before render). Found during security audit of dune#15 / plugin-admin#3 (Finding 4). --- CHANGELOG.md | 4 +++- src/admin/routes/_middleware.ts | 17 +++++++++------ tests/admin/csp_test.ts | 9 +++++++- tests/admin/nav_permissions_test.ts | 33 ++++++++++++++++++++++++++++- 4 files changed, 54 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4afc115..d01045c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,9 @@ follows [Semantic Versioning](https://semver.org). `checkPermission()`/`requirePermission()` (`routes/api/_utils.ts`) fail closed (deny) when `authz` is somehow undefined — an in-process object construction failing at startup, essentially never hit in practice — - instead of silently degrading to the removed table. Sidebar nav filtering + instead of silently degrading to the removed table, and the top-level + admin access gate in `routes/_middleware.ts` now fails closed (403) in + that same state instead of skipping the check. Sidebar nav filtering (`routes/_layout.tsx`), the one place that used `ROLE_PERMISSIONS` unconditionally rather than as a fallback, now reads a real permission set computed via `authz.check()` once per request diff --git a/src/admin/routes/_middleware.ts b/src/admin/routes/_middleware.ts index ce2ae60..86734e9 100644 --- a/src/admin/routes/_middleware.ts +++ b/src/admin/routes/_middleware.ts @@ -214,14 +214,19 @@ export async function handler( new Response(null, { status: 302, headers: { Location: loginUrl } }), ); } - } else if (authResult.user && adminCtx.authz && !PUBLIC_PATHS.has(adminRelative)) { - // When polizy is wired, it is the authority for admin panel access. - // If authz is undefined (creation failed at startup — exceptional, see - // checkPermission()'s doc comment), this top-level gate is skipped, but - // every route's own checkPermission()/requirePermission() call fails - // closed regardless — there is no ROLE_PERMISSIONS fallback anywhere + } else if (authResult.user && !PUBLIC_PATHS.has(adminRelative)) { + // polizy authz is the authority for admin panel access — and the *only* + // mechanism. If authz is undefined (creation failed at startup — + // exceptional, see checkPermission()'s doc comment), this top-level gate + // fails closed (403) exactly like every route-level + // checkPermission()/requirePermission() call does — skipping the gate + // here would let an authenticated user reach whatever a route relies on + // this gate alone to protect. No ROLE_PERMISSIONS fallback anywhere // anymore (3.0.0). // An authenticated user whose tuple has been revoked is denied before reaching routes. + if (!adminCtx.authz) { + return withSecurityHeaders(new Response("Forbidden", { status: 403 }), frameable); + } const canAccess = await adminCtx.authz.check({ who: { type: "user", id: authResult.user.id }, canThey: "access", diff --git a/tests/admin/csp_test.ts b/tests/admin/csp_test.ts index 3b5d294..3937837 100644 --- a/tests/admin/csp_test.ts +++ b/tests/admin/csp_test.ts @@ -68,7 +68,14 @@ async function buildAdminContext() { const session = await sessions.create(user.id); return { - adminContext: { prefix: "/admin", auth } as unknown as + adminContext: { + prefix: "/admin", + auth, + // authz.check() always allows — this test exercises CSP/nonce + // behavior, not permission logic. Without an authz the top-level + // admin access gate fails closed (403) and the render never happens. + authz: { check: () => Promise.resolve(true) }, + } as unknown as import("../../src/admin/context.ts").AdminContext, sessionCookie: `dune_session=${session.id}`, }; diff --git a/tests/admin/nav_permissions_test.ts b/tests/admin/nav_permissions_test.ts index f890999..e6639f4 100644 --- a/tests/admin/nav_permissions_test.ts +++ b/tests/admin/nav_permissions_test.ts @@ -6,7 +6,7 @@ */ import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; -import { computeNavPermissions } from "../../src/admin/routes/_middleware.ts"; +import { computeNavPermissions, handler } from "../../src/admin/routes/_middleware.ts"; import type { DuneAuthSystem } from "@dune/core/auth/authz"; function makeAuthz(allowedPermissions: string[]) { @@ -72,3 +72,34 @@ Deno.test("computeNavPermissions: checks every real AdminPermission exactly once ]; assertEquals(authz.calls.sort(), [...expected].sort()); }); + +// ── handler: top-level admin gate fails closed when authz is undefined ── + +Deno.test("_middleware handler: authenticated request with no authz gets 403 (fails closed)", async () => { + // authz creation failing at startup is the only way authz is undefined. + // The top-level gate used to be skipped in that state; it now denies, + // same policy as every route-level checkPermission()/requirePermission(). + let nextRan = false; + const ctx = { + url: new URL("http://localhost/admin/pages"), + state: { + adminContext: { + prefix: "/admin", + auth: { + authenticate: () => + Promise.resolve({ authenticated: true, user: { id: "u1" } }), + }, + // authz deliberately undefined + }, + }, + next: () => { + nextRan = true; + return Promise.resolve(new Response("unreachable")); + }, + // deno-lint-ignore no-explicit-any + } as any; + + const res = await handler(ctx); + assertEquals(res.status, 403); + assertEquals(nextRan, false); +}); From 4cc49207bea3f23b76f42c073903603f45dbcc5e Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Sun, 30 Aug 2026 23:53:37 +0200 Subject: [PATCH 3/7] refactor(auth): de-dupe role-utils.ts's rank table against @dune/core role-utils.ts's ROLE_RANK/highestValidRole() reimplemented the identical admin-tier rank table @dune/core's new highestAdminRole() (bdeb897, added to fix roles[0] under-privileging ResponseTransformContext) already has -- exactly the "two tables kept in sync by convention" pattern this release's ROLE_PERMISSIONS removal was about eliminating, just running in the other direction. Now derives ROLE_RANK/highestValidRole() from @dune/core's ADMIN_ROLE_RANK/highestAdminRole() (@dune/core/auth/authz-schema, companion @dune/core change adds the export) instead of keeping a second copy. VALID_ROLES/sanitizeRole()/withRole() unaffected. No behavior change; 4 new tests lock in the delegation. 232 tests pass, deno check/lint clean. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 14 +++++++++++++ src/admin/auth/role-utils.ts | 23 ++++++++++---------- tests/admin/role_utils_test.ts | 38 ++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 12 deletions(-) create mode 100644 tests/admin/role_utils_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d01045c..fba80d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,20 @@ follows [Semantic Versioning](https://semver.org). answer from `roleHasPermission()`, a synchronous read of `@dune/core`'s own canonical schema, instead of this package's table). +### Changed + +- **`role-utils.ts`'s `ROLE_RANK`/`highestValidRole()` now derive from + `@dune/core`'s canonical `ADMIN_ROLE_RANK`/`highestAdminRole()` + (`@dune/core/auth/authz-schema`), not a second, separately-maintained + copy of the same three numbers.** Spotted during review of this + release's own commits: `@dune/core`'s new `highestAdminRole()` + (added to fix `roles[0]` under-privileging `ResponseTransformContext`) + reimplemented the identical rank table this package already had — + exactly the "two tables kept in sync by convention" pattern this + release's `ROLE_PERMISSIONS` removal was about eliminating, just + running in the other direction. No behavior change; `VALID_ROLES`, + `sanitizeRole()`, and `withRole()` are unaffected. + ## [2.1.3] — 2026-08-27 ### Changed diff --git a/src/admin/auth/role-utils.ts b/src/admin/auth/role-utils.ts index 7c394be..70f8d94 100644 --- a/src/admin/auth/role-utils.ts +++ b/src/admin/auth/role-utils.ts @@ -7,9 +7,17 @@ * collapsed `role: Role` + `roles: string[]` into `roles: string[]` only); * these three strings are just conventional values inside that array, * interpreted here rather than enforced by the type. + * + * `ROLE_RANK`/`highestValidRole()` are thin wrappers over `@dune/core`'s + * `ADMIN_ROLE_RANK`/`highestAdminRole()` (`@dune/core/auth/authz-schema`), + * not a second, separately-maintained copy of the same three numbers — + * exactly the "two tables kept in sync by convention" pattern the + * `ROLE_PERMISSIONS` removal (3.0.0) eliminated elsewhere. Core owns the + * canonical ranking; this package consumes it. */ import type { Role } from "../types.ts"; +import { ADMIN_ROLE_RANK, highestAdminRole } from "@dune/core/auth/authz-schema"; export const VALID_ROLES: ReadonlySet = new Set([ "admin", @@ -17,23 +25,14 @@ export const VALID_ROLES: ReadonlySet = new Set([ "author", ]); -export const ROLE_RANK: Record = { - admin: 3, - editor: 2, - author: 1, -}; +export const ROLE_RANK: Record = ADMIN_ROLE_RANK; /** Pick the highest-ranked valid admin `Role` out of a generic roles[] array, or undefined if none present. */ export function highestValidRole( roles: string[] | undefined, ): Role | undefined { - if (!roles?.length) return undefined; - let best: Role | undefined; - for (const r of roles) { - if (!VALID_ROLES.has(r as Role)) continue; - if (!best || ROLE_RANK[r as Role] > ROLE_RANK[best]) best = r as Role; - } - return best; + const best = highestAdminRole(roles); + return best === "" ? undefined : (best as Role); } /** Validate a single role string, falling back to `fallback` if it's not a known `Role`. */ diff --git a/tests/admin/role_utils_test.ts b/tests/admin/role_utils_test.ts new file mode 100644 index 0000000..6f00296 --- /dev/null +++ b/tests/admin/role_utils_test.ts @@ -0,0 +1,38 @@ +/** + * Tests for role-utils.ts's delegation to @dune/core's canonical + * ADMIN_ROLE_RANK/highestAdminRole() (@dune/core/auth/authz-schema) — + * ROLE_RANK/highestValidRole() are thin wrappers, not a second + * hand-maintained copy of the same rank table. + */ + +import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { ADMIN_ROLE_RANK } from "@dune/core/auth/authz-schema"; +import { + highestValidRole, + ROLE_RANK, + VALID_ROLES, +} from "../../src/admin/auth/role-utils.ts"; + +Deno.test("ROLE_RANK is @dune/core's ADMIN_ROLE_RANK, not a separate copy", () => { + assertEquals(ROLE_RANK, ADMIN_ROLE_RANK); +}); + +Deno.test("highestValidRole: picks the highest admin-tier role regardless of order", () => { + assertEquals(highestValidRole(["member", "admin"]), "admin"); + assertEquals(highestValidRole(["editor", "admin"]), "admin"); + assertEquals(highestValidRole(["member", "editor", "author"]), "editor"); + assertEquals(highestValidRole(["author"]), "author"); +}); + +Deno.test("highestValidRole: undefined when no admin-tier role is present", () => { + assertEquals(highestValidRole(["member", "subscriber"]), undefined); + assertEquals(highestValidRole(undefined), undefined); + assertEquals(highestValidRole([]), undefined); +}); + +Deno.test("VALID_ROLES matches the keys of ROLE_RANK", () => { + assertEquals( + [...VALID_ROLES].sort(), + Object.keys(ROLE_RANK).sort(), + ); +}); From c1893fa688553b839d9909951abce5c6b4017be6 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Mon, 31 Aug 2026 00:03:22 +0200 Subject: [PATCH 4/7] fix(admin): log tuple-bootstrap failure without a ROLE_PERMISSIONS fallback authz is still up when bootstrapAdminTuples() throws; the access gate denies. The old warn told operators the panel still worked via a table that no longer exists. Co-authored-by: Cursor --- CHANGELOG.md | 4 ++++ mod.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fba80d6..d676d6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,10 @@ follows [Semantic Versioning](https://semver.org). release's `ROLE_PERMISSIONS` removal was about eliminating, just running in the other direction. No behavior change; `VALID_ROLES`, `sanitizeRole()`, and `withRole()` are unaffected. +- **Tuple-bootstrap failure log no longer claims a `ROLE_PERMISSIONS` + fallback.** `bootstrapAdminTuples()` throwing leaves `authz` defined but + tuples unseeded; the access gate then 403s. The warn now says that, so + operators do not debug a fallback that cannot fire. ## [2.1.3] — 2026-08-27 diff --git a/mod.ts b/mod.ts index 79ded4d..8e84705 100644 --- a/mod.ts +++ b/mod.ts @@ -367,7 +367,7 @@ export function createAdminPlugin( await bootstrapAdminTuples(bootstrap.authz, bootstrap.authzAdapter, enabledAdminUsers); } catch (err) { console.warn( - "[dune/authz] Admin authz bootstrap failed, falling back to ROLE_PERMISSIONS:", + "[dune/authz] Admin tuple bootstrap failed — authz is up but tuples were not seeded; admin access will deny (403) until bootstrap succeeds:", err, ); } From c920f8a9a74c6b20e915556aa5da1ff7f0a37b7e Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Mon, 31 Aug 2026 00:03:28 +0200 Subject: [PATCH 5/7] refactor(auth)!: remove admin.access from AdminPermission The name only existed on ROLE_PERMISSIONS. The schema action is "access" on app:admin, already enforced by the middleware gate. Leaving it on the union invited plugin pages that would 403 after the fallback was removed. Co-authored-by: Cursor --- CHANGELOG.md | 6 +++++- src/admin/routes/_middleware.ts | 11 +++++------ src/admin/types.ts | 3 +-- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d676d6a..c55b2ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,11 @@ follows [Semantic Versioning](https://semver.org). `AdminContext.auth.hasPermission()` directly has no replacement to switch to within this package — use `checkPermission()`/`requirePermission()`/ `withGuards()` (`@dune/plugin-admin/admin/guards`), which already do the - right thing. No first-party or third-party plugin was found calling the + right thing. `"admin.access"` is no longer a member of `AdminPermission` + — it never had an `actionToRelations` entry (panel access is + `canThey: "access"` on `{ type: "app", id: "admin" }`) and + `checkPermission("admin.access")` would always deny. Declare a real + schema action, or rely on the middleware access gate. No first-party or third-party plugin was found calling the removed surface directly (confirmed by search across `@dune/plugin-inline-edit`, `@dune/plugin-meilisearch`, `@dune/plugin-orama`, `@dune/plugin-pdf`) — `@dune/plugin-inline-edit`'s diff --git a/src/admin/routes/_middleware.ts b/src/admin/routes/_middleware.ts index 86734e9..395ed2e 100644 --- a/src/admin/routes/_middleware.ts +++ b/src/admin/routes/_middleware.ts @@ -132,12 +132,11 @@ export function toAdminRelative(pathname: string, normalizedPrefix: string): str } /** - * Every `AdminPermission` a nav item might gate on. Excludes `"admin.access"` - * deliberately — a legacy value the removed `ROLE_PERMISSIONS` table (3.0.0) - * carried on every role but which no nav item ever used and which has no - * corresponding action in `@dune/core`'s authz schema - * (`src/auth/authz-schema.ts`'s `actionToRelations` never defined it) — - * `authz.check()` has nothing to resolve it against. + * Every `AdminPermission` a nav item might gate on. `"admin.access"` is not + * in the union — it only ever existed on the removed `ROLE_PERMISSIONS` + * table and has no corresponding action in `@dune/core`'s authz schema + * (`actionToRelations` never defined it). Panel access is `canThey: "access"` + * on `{ type: "app", id: "admin" }`, enforced by this middleware's gate. */ const NAV_PERMISSIONS: readonly AdminPermission[] = [ "pages.create", diff --git a/src/admin/types.ts b/src/admin/types.ts index a5cc3f7..1240d0c 100644 --- a/src/admin/types.ts +++ b/src/admin/types.ts @@ -34,8 +34,7 @@ export type AdminPermission = | "media.upload" | "media.read" | "media.delete" | "users.create" | "users.read" | "users.update" | "users.delete" | "config.read" | "config.update" - | "submissions.read" | "submissions.delete" - | "admin.access"; + | "submissions.read" | "submissions.delete"; /** Admin configuration (added to DuneConfig) */ export interface AdminConfig { From 64768a366aab71ecade3e5b04a82c25422fc18dc Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Mon, 31 Aug 2026 00:03:33 +0200 Subject: [PATCH 6/7] docs(admin): requirePermission is authz-only, no role-table fallback AdminContext.auth.hasPermission() is gone; missing authz denies. Co-authored-by: Cursor --- src/admin/guards.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/admin/guards.ts b/src/admin/guards.ts index 901b5ad..59029d7 100644 --- a/src/admin/guards.ts +++ b/src/admin/guards.ts @@ -14,9 +14,9 @@ * actually defined) — a third-party plugin author is exactly as likely to * get the same details wrong (the CSRF check's Origin/Sec-Fetch-Site/Referer * fallback chain in particular is not trivial to reimplement correctly, and - * `requirePermission` must check the polizy-backed `authz` system first, - * when configured, before falling back to the role table — a detail easy to - * miss if you only reach for `AdminContext.auth.hasPermission()` directly). + * `requirePermission` consults `authz.check()` only — missing `authz` + * denies. `AdminContext.auth.hasPermission()` was removed in 3.0.0; do not + * reimplement a role table). * * `withGuards()` is the recommended entry point for a new mutation route — * it composes all three in the right order and can't have a step forgotten. From 8d269aa4566222a334765c281cfe1cfb5fab6dd9 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Mon, 31 Aug 2026 00:03:38 +0200 Subject: [PATCH 7/7] docs(deps): require companion core that exports authz-schema role-utils.ts already imports ADMIN_ROLE_RANK from that subpath. Keep the 0.34 pin (per-minor); 0.34.1 on JSR does not have the export yet. Co-authored-by: Cursor --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c55b2ca..9e7bed5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,11 @@ follows [Semantic Versioning](https://semver.org). fallback.** `bootstrapAdminTuples()` throwing leaves `authz` defined but tuples unseeded; the access gate then 403s. The warn now says that, so operators do not debug a fallback that cannot fire. +- **Requires the companion `@dune/core` release that exports + `./auth/authz-schema`** (`ADMIN_ROLE_RANK` / `highestAdminRole()`). The + existing `@dune/core@0.34` pin picks that up once published; 0.34.1 does + not have the export. Publish that core first or in the same train as + this 3.0.0. ## [2.1.3] — 2026-08-27