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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@dune/plugin-admin",
"version": "2.1.3",
"version": "3.0.0",
"license": "MIT",
"minimumDependencyAge": {
"age": "P1D",
Expand Down
2 changes: 1 addition & 1 deletion mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
Expand Down
32 changes: 2 additions & 30 deletions src/admin/auth/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}. */
Expand All @@ -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<AuthResult>;
/**
* 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 */
Expand Down Expand Up @@ -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}`;
Expand All @@ -135,7 +108,6 @@ export function createAuthMiddleware(

return {
authenticate,
hasPermission,
createSessionCookie,
clearSessionCookie,
};
Expand Down
23 changes: 11 additions & 12 deletions src/admin/auth/role-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,33 +7,32 @@
* 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<Role> = new Set<Role>([
"admin",
"editor",
"author",
]);

export const ROLE_RANK: Record<Role, number> = {
admin: 3,
editor: 2,
author: 1,
};
export const ROLE_RANK: Record<Role, number> = 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`. */
Expand Down
9 changes: 6 additions & 3 deletions src/admin/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down
6 changes: 3 additions & 3 deletions src/admin/guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/admin/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
8 changes: 6 additions & 2 deletions src/admin/routes/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down
79 changes: 75 additions & 4 deletions src/admin/routes/_middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);

Expand Down Expand Up @@ -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<AdminPermission[]> {
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<AdminState>,
): Promise<Response> {
Expand Down Expand Up @@ -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",
Expand All @@ -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);
}
Loading