diff --git a/CHANGELOG.md b/CHANGELOG.md index 44c2de4..9e7bed5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,71 @@ 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, 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 + (`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. `"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 + 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). + +### 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. +- **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. +- **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 ### 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/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, ); } 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/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/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/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. 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..395ed2e 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,60 @@ export function toAdminRelative(pathname: string, normalizedPrefix: string): str return adminRelative; } +/** + * 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", + "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 { @@ -158,10 +213,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. - // Falls back gracefully: if authz is not set, ROLE_PERMISSIONS remains the authority. + } 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", @@ -172,6 +236,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..1240d0c 100644 --- a/src/admin/types.ts +++ b/src/admin/types.ts @@ -28,39 +28,13 @@ 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" | "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 { @@ -112,6 +86,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/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/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..e6639f4 --- /dev/null +++ b/tests/admin/nav_permissions_test.ts @@ -0,0 +1,105 @@ +/** + * 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, handler } 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()); +}); + +// ── 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); +}); 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/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(), + ); +}); 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" } },