Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
5480f7e
feat(config)!: runtime_dir and max_revisions are core settings, not a…
zumbrunn Nov 6, 2026
0ea5229
feat(plugin-api)!: the admin declares where it is mounted (admin.pref…
zumbrunn Nov 6, 2026
439d7e4
chore: drop the deno.lock change that slipped into the step 2 commit
zumbrunn Nov 6, 2026
8ad89d5
feat(plugins)!: core no longer loads the admin, it is a plugin you li…
zumbrunn Nov 6, 2026
f72041b
feat(plugin-api)!: machine translation and the audit trail leave core…
zumbrunn Nov 6, 2026
3e609c4
feat(config)!: the admin's settings are the admin plugin's config, no…
zumbrunn Nov 7, 2026
145b7ab
feat(config)!: machine_translation moves into the admin plugin's conf…
zumbrunn Nov 7, 2026
c5de0fa
chore: drop the deno.lock change that slipped into the step 5 commits
zumbrunn Nov 7, 2026
b0d38c0
fix(authz)!: site users' roles grant editing, checked on app:site (#130)
zumbrunn Nov 7, 2026
2191190
fix(authz): a role granted or revoked in a running app takes effect a…
zumbrunn Nov 7, 2026
ca675bb
Merge origin/main into feat/explicit-admin-loading
zumbrunn Nov 7, 2026
bf41539
Merge feat/explicit-admin-loading into feat/mt-audit-to-admin
zumbrunn Nov 7, 2026
25067b4
Merge feat/mt-audit-to-admin into feat/admin-settings-in-plugin
zumbrunn Nov 7, 2026
87db9e4
Merge feat/admin-settings-in-plugin into fix/site-user-editing
zumbrunn Nov 7, 2026
979fc52
fix: headless smoke boots without the admin entry; headless scaffold …
zumbrunn Nov 8, 2026
c5ed518
fix(auth): log a failed role-grant bootstrap, own-property override l…
zumbrunn Nov 8, 2026
5f85916
Merge branch 'feat/explicit-admin-loading' into feat/mt-audit-to-admin
zumbrunn Nov 8, 2026
0588f2b
Merge branch 'feat/mt-audit-to-admin' into feat/admin-settings-in-plugin
zumbrunn Nov 8, 2026
42f5f23
test: the overlay admin.enabled warning, with #131's manual-move exit
zumbrunn Nov 8, 2026
e648e84
Merge branch 'feat/admin-settings-in-plugin' into fix/site-user-editing
zumbrunn Nov 8, 2026
b204168
chore: keep deno.lock as on the base branch
zumbrunn Nov 8, 2026
84547bd
fix(cli): headless scaffold registers plugin islands with registerIsland
zumbrunn Nov 8, 2026
83b8207
Merge remote-tracking branch 'origin/feat/explicit-admin-loading' int…
zumbrunn Nov 10, 2026
db082f9
Merge origin/main into feat/mt-audit-to-admin
zumbrunn Nov 10, 2026
7445cfb
fix(migrate:config): keep core settings of a disabled admin block; ne…
zumbrunn Nov 10, 2026
89a25ec
Merge remote-tracking branch 'origin/feat/mt-audit-to-admin' into fea…
zumbrunn Nov 10, 2026
5672444
Merge remote-tracking branch 'origin/main' into feat/admin-settings-i…
zumbrunn Nov 10, 2026
2b50403
Merge remote-tracking branch 'origin/feat/admin-settings-in-plugin' i…
zumbrunn Nov 10, 2026
70f485b
Merge remote-tracking branch 'origin/main' into fix/site-user-editing
zumbrunn Nov 10, 2026
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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,14 @@ where the setting lives now (see `dune migrate:config`, which reports these too)

### Changed — BREAKING

- **Site-wide permissions are checked on `app:site`, not `app:admin` (#130).** `pages.update`
and the other admin-tier actions name what they protect (this site's content), not the panel
that happens to administer it, so the same check works on a site with no admin, one admin
plugin or another. Tuples are derived data and are re-seeded at every start; a store protected
with `DUNE_AUTHZ_HMAC_SECRET` needs `dune authz:sign` once after upgrading. The old `app:admin` tuples are not removed: nothing reads them any more, and they can be
deleted from `data/permissions/` at leisure. A plugin that
checks the resource itself uses `SITE_RESOURCE` (`@dune/core`); `@dune/plugin-admin` 4.0.0
and `@dune/testing` 2.0.0 already do.
- **Core no longer assumes the built-in admin's session cookie or its `admin.enabled`
switch (#97).** The `editor.session` provider declares the cookies that carry its session
(`RequestAuthenticator.sessionCookies`); core uses them to decide whether to call
Expand Down Expand Up @@ -345,6 +353,23 @@ where the setting lives now (see `dune migrate:config`, which reports these too)

### Fixed

- **A signed-in site user with an editing role can edit inline without the admin plugin
(#130).** The authz system is now created whenever the site has a login (`site.auth`), a
plugin provides `editor.session`, or a plugin declares `authzActions`; `mountDuneAuth` reuses
that one instance. The `admin`, `editor` and `author` roles are granted once at group level
(`group:editor` holds `editor` on `app:site`), so a user's membership, which site auth
already keeps in sync, is enough. No per-user tuple and no admin plugin needed. A role that
is only a content tag (`member`, `subscriber`, ...) still grants nothing. Also applies to an
`external-jwt` site: a role claim of `admin`, `editor` or `author` in the identity provider's
token now carries that permission.
- **A role granted or revoked while the app runs now takes effect without a restart.** Group
membership (`member of group:<role>`), which page gating and the grants above read, was only
seeded at startup, so a `roles[]` change made by an admin, a plugin or the CLI waited for the
next restart. User stores created by core (`services.users.createLocalStore()`, and the site
auth store) are now wrapped with `withRoleSync`: every `create`, `update` of `roles`, and
`delete` makes membership match the record. `dune users:create`, `users:grant-role` and
`users:revoke-role` do the same. The payments webhook no longer writes the membership twice on
such a store.
- **`search.custom_fields`, `search.fields`, `search.highlight` and
`search.excerpt_length` now do what the docs say (#97).** They were validated
and documented, but bootstrap never passed them to the search engine, so setting
Expand Down
22 changes: 16 additions & 6 deletions src/auth/authz-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*
* Covers all authorization layers uniformly:
* - Content gating (`roles:` frontmatter) — "access" on object type "group"
* - Admin panel access + granular admin permissions — actions on object type "app"
* - Site-wide editing permissions — actions on object `app:site` ({@link SITE_RESOURCE})
* - Inline editing — "edit" on object type "resource"
* - Resource-level grants — "owner" relation on object type "resource"
*
Expand All @@ -16,11 +16,11 @@
* `roles: member` → authz.check({ who: user, canThey: "access", onWhat: { type: "group", id: "member" } })
* authz.addMember({ member: { type: "user", id }, group: { type: "group", id: "member" } })
*
* ## Admin panel access (top-level)
* authz.check({ who: adminUser, canThey: "access", onWhat: { type: "app", id: "admin" } })
* ## Site access (top-level)
* authz.check({ who: adminUser, canThey: "access", onWhat: { type: "app", id: "site" } })
*
* ## Granular admin permissions
* authz.check({ who: adminUser, canThey: "pages.create", onWhat: { type: "app", id: "admin" } })
* ## Granular permissions
* authz.check({ who: adminUser, canThey: "pages.create", onWhat: { type: "app", id: "site" } })
*
* The admin permission actions below are the sole, canonical *built-in*
* permission definition — `@dune/plugin-admin` no longer keeps its own
Expand All @@ -35,6 +35,16 @@ import { defineSchema } from "polizy";
import type { AuthSchema } from "polizy";
import type { Role } from "../config/role.ts";

/**
* The object every site-wide permission is checked against: "may this user
* edit this site's content". It names what is protected, not who administers
* it, so it is the same on a site with no admin panel and for any admin
* plugin. Admin-tier roles hold their relation on it: site users through
* their role's group ({@link bootstrapRoleGrants}), the admin's own accounts
* through direct tuples (`AuthzService.seedAdminTuples`).
*/
export const SITE_RESOURCE = { type: "app", id: "site" } as const;

/**
* A polizy relation name a plugin-contributed action can require. Limited
* to Dune's existing built-in relations (no plugin can define a *new*
Expand Down Expand Up @@ -136,7 +146,7 @@ export function buildDuneAuthzSchema(
...DUNE_BASE_AUTHZ_ACTIONS,
...pluginActions,
},
subjectTypes: ["user"] as const,
subjectTypes: ["user", "group"] as const,
objectTypes: ["group", "app", "resource"] as const,
});
}
Expand Down
29 changes: 26 additions & 3 deletions src/auth/authz.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@

import { AuthSystem } from "polizy";
import type { AuthSchema } from "polizy";
import { duneAuthzSchema } from "./authz-schema.ts";
import { duneAuthzSchema, SITE_RESOURCE } from "./authz-schema.ts";
import { AuthzLocalAdapter } from "./authz-adapter-local.ts";
import { AuthzDbAdapter } from "./authz-adapter-db.ts";
import type { StorageAdapter } from "../storage/types.ts";
Expand Down Expand Up @@ -209,7 +209,7 @@ export async function bootstrapRoleTuples(
/**
* Bootstrap permission tuples for existing admin User records (@dune/plugin-admin).
*
* Each admin user gets a direct relation tuple on `{ type: "app", id: "admin" }`
* Each admin user gets a direct relation tuple on {@link SITE_RESOURCE}
* matching their role ("admin", "editor", or "author"). Idempotent — skips
* tuples that already exist.
*
Expand All @@ -223,7 +223,7 @@ export async function bootstrapAdminTuples(
): Promise<void> {
for (const user of adminUsers) {
const subject = { type: "user" as const, id: user.id };
const object = { type: "app" as const, id: "admin" };
const object = SITE_RESOURCE;
const exists = await adapter.hasTuple(subject, user.role, object);
if (!exists) {
await authz.allow({
Expand All @@ -234,3 +234,26 @@ export async function bootstrapAdminTuples(
}
}
}

/**
* Grant each admin-tier role its relation on {@link SITE_RESOURCE} once, at
* group level: `group:editor` holds `editor` on `app:site`. A site user whose
* role is `editor` is already a member of `group:editor` (site auth keeps
* that in sync, see {@link bootstrapRoleTuples}), so membership is all it
* takes, with no per-user tuple to write, forget, or leave behind on revoke.
* The admin's own accounts get direct tuples ({@link bootstrapAdminTuples});
* the two do not conflict, since `authz.check()` is true if either holds.
*
* Idempotent. Called wherever an authz system is created, so a site that has
* a login but no admin plugin still lets its editors edit.
*/
export async function bootstrapRoleGrants(
authz: DuneAuthSystem,
adapter: AuthzAdapterLike,
): Promise<void> {
for (const role of ["admin", "editor", "author"] as const) {
const group = { type: "group" as const, id: role };
if (await adapter.hasTuple(group, role, SITE_RESOURCE)) continue;
await authz.allow({ who: group, toBe: role, onWhat: SITE_RESOURCE });
}
}
28 changes: 27 additions & 1 deletion src/auth/mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ import { createProviders } from "./providers/mod.ts";
import { type User, USER_HEADER } from "./types.ts";
import { copySocketAddr } from "../security/rate-limit.ts";
import { InMemoryMagicTokenStore } from "./magic-link.ts";
import { bootstrapRoleTuples, createDuneAuthSystem } from "./authz.ts";
import { bootstrapRoleGrants, bootstrapRoleTuples, createDuneAuthSystem } from "./authz.ts";
import { withRoleSync } from "./sync-roles.ts";
import type { DuneAuthSystem } from "./authz.ts";
import type { AuthzLocalAdapter } from "./authz-adapter-local.ts";
import { setGatingAuthz } from "./gating.ts";
Expand Down Expand Up @@ -116,6 +117,17 @@ export async function mountDuneAuth(
} else {
userStore = createLocalUserStore({ storage, usersDir });
}
// Roles granted or revoked while the app runs (payments, an admin, a
// signup flow) reach the authz store at once. The target is read per write:
// the authz system below is created after the store. external-jwt reconciles
// per request instead, and its user store is read-only.
if (mode === "dune") {
const plain = userStore;
userStore = withRoleSync(
plain,
() => mountAuthz && mountAdapter ? { authz: mountAuthz, adapter: mountAdapter } : undefined,
);
}

// ── Session manager ─────────────────────────────────────────────────────────
// Same SessionStore/SessionManager mechanism admin sessions use
Expand Down Expand Up @@ -192,6 +204,12 @@ export async function mountDuneAuth(
setGatingAuthz(bundle.authz, siteOrigin);
mountAuthz = bundle.authz;
mountAdapter = bundle.adapter;
await bootstrapRoleGrants(bundle.authz, bundle.adapter).catch((err) =>
logger.error("auth.role_grants_failed", {
error: err instanceof Error ? err.message : String(err),
effect: "users with an admin-tier role are denied site-wide permissions until the next start succeeds",
})
);
} else if (authzStoreCfg === "local") {
const existingAuthz = ctx.authz as DuneAuthSystem | undefined;
const existingAdapter = ctx.authzAdapter as AuthzLocalAdapter | undefined;
Expand All @@ -212,6 +230,14 @@ export async function mountDuneAuth(
setGatingAuthz(bundle.authz, siteOrigin);
mountAuthz = bundle.authz;
mountAdapter = bundle.adapter;
// Idempotent; bootstrap() already did this for its own bundle, a headless
// caller's fresh one has not been seeded yet.
await bootstrapRoleGrants(bundle.authz, bundle.adapter).catch((err) =>
logger.error("auth.role_grants_failed", {
error: err instanceof Error ? err.message : String(err),
effect: "users with an admin-tier role are denied site-wide permissions until the next start succeeds",
})
);

if (mode === "dune" && userStoreType !== "session") {
// Bulk-bootstrap from existing site users' roles[] — idempotent.
Expand Down
124 changes: 124 additions & 0 deletions src/auth/sync-roles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* Keep a user's group membership in the authz store equal to their `roles[]`.
*
* Page gating (`roles:` frontmatter) and the site-wide grants of the
* admin-tier roles ({@link bootstrapRoleGrants}) both read
* `member of group:<role>` tuples, while `roles[]` on the user record is
* what administrators and payment hooks change. Seeding at startup
* ({@link bootstrapRoleTuples}) is not enough on its own: a role granted or
* revoked in a running app would only take effect after a restart.
*
* Wrap the user store with {@link withRoleSync} so every writer is covered
* and none has to remember to call it.
*
* @module
*/

import type { DuneAuthSystem } from "./authz.ts";
import type { UserStore } from "./user-store.ts";
import { logger } from "../core/logger.ts";

/** What {@link syncUserRoles} needs from the authz bundle. */
export interface RoleSyncTarget {
authz: DuneAuthSystem;
adapter: {
findObjects(
subject: { type: string; id: string },
relation: string,
options?: { objectType?: string },
): Promise<{ type: string; id: string }[]>;
};
}

/**
* Make the user's `member of group:<role>` tuples match `roles`: add the
* missing ones, remove the stale ones. Idempotent.
*
* "Match" is exact: a membership that is not in `roles` is removed, including
* one a plugin added by hand with `authz.addMember()`. `roles[]` is the single
* source of truth for group membership; a grant that `roles[]` cannot express
* (a payment, a resource owner) should use `allow()` on a resource, not a group.
*/
export async function syncUserRoles(
target: RoleSyncTarget,
user: { id: string; roles: string[] },
): Promise<void> {
const who = { type: "user" as const, id: user.id };
const wanted = new Set(user.roles);
const current = new Set(
(await target.adapter.findObjects(who, "member", { objectType: "group" }))
.map((g) => g.id),
);
for (const role of wanted) {
if (!current.has(role)) {
await target.authz.addMember({ member: who, group: { type: "group", id: role } });
}
}
for (const group of current) {
if (!wanted.has(group)) {
await target.authz.disallowAllMatching({
who,
was: "member",
onWhat: { type: "group", id: group },
});
}
}
}

/** Remove every tuple a deleted user holds. */
export async function removeUserTuples(
target: RoleSyncTarget,
userId: string,
): Promise<void> {
await target.authz.disallowAllMatching({ who: { type: "user", id: userId } });
}

/**
* A user store whose writes keep group membership in step with `roles[]`.
*
* `getTarget` is read on every write, so the store can be created before the
* authz system exists; with no target (no authz system) writes pass straight
* through. A failed sync is logged, not thrown: the user write has already
* happened, and the next start re-seeds membership from the records.
*/
export function withRoleSync(
store: UserStore,
getTarget: () => RoleSyncTarget | undefined,
): UserStore {
const sync = async (fn: (t: RoleSyncTarget) => Promise<void>) => {
const target = getTarget();
if (!target) return;
try {
await fn(target);
} catch (err) {
logger.error("authz.role_sync_failed", { reason: String(err) });
}
};
const overrides: Pick<UserStore, "create" | "update" | "delete"> = {
async create(input) {
const user = await store.create(input);
await sync((t) => syncUserRoles(t, user));
return user;
},
async update(id, updates) {
const user = await store.update(id, updates);
if (user && updates.roles !== undefined) {
await sync((t) => syncUserRoles(t, user));
}
return user;
},
async delete(id) {
const deleted = await store.delete(id);
if (deleted) await sync((t) => removeUserTuples(t, id));
return deleted;
},
};
// A proxy rather than a spread, so stores written as classes keep working.
return new Proxy(store, {
get(target, prop, receiver) {
if (Object.hasOwn(overrides, prop)) return overrides[prop as keyof typeof overrides];
const value = Reflect.get(target, prop, receiver);
return typeof value === "function" ? value.bind(target) : value;
},
});
}
4 changes: 2 additions & 2 deletions src/cli/response-transforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import type { DuneConfig } from "../config/types.ts";
import type { AdminBarAction, AdminBarActionContext, DunePlugin, ResponseTransformContext } from "../hooks/types.ts";
import type { DuneAuthSystem } from "../auth/authz.ts";
import type { PageIndex } from "../content/types.ts";
import { highestAdminRole, roleHasPermission } from "../auth/authz-schema.ts";
import { highestAdminRole, roleHasPermission, SITE_RESOURCE } from "../auth/authz-schema.ts";
import { applyResponseTransforms } from "../plugins/loader.ts";
import { hasSessionCookie } from "./admin-bar-inject.ts";
import { isAdminPath } from "./serve-utils.ts";
Expand Down Expand Up @@ -158,7 +158,7 @@ export async function runPluginResponseTransforms(
who: { type: "user", id: r.user.id as string },
// deno-lint-ignore no-explicit-any
canThey: "pages.update" as any,
onWhat: { type: "app", id: "admin" },
onWhat: SITE_RESOURCE,
})
: false
);
Expand Down
Loading
Loading