diff --git a/CHANGELOG.md b/CHANGELOG.md index c66da071..9ff10ac3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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:`), 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 diff --git a/src/auth/authz-schema.ts b/src/auth/authz-schema.ts index 39bc3a05..b182ac46 100644 --- a/src/auth/authz-schema.ts +++ b/src/auth/authz-schema.ts @@ -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" * @@ -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 @@ -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* @@ -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, }); } diff --git a/src/auth/authz.ts b/src/auth/authz.ts index 6178e0c1..9cddc988 100644 --- a/src/auth/authz.ts +++ b/src/auth/authz.ts @@ -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"; @@ -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. * @@ -223,7 +223,7 @@ export async function bootstrapAdminTuples( ): Promise { 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({ @@ -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 { + 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 }); + } +} diff --git a/src/auth/mount.ts b/src/auth/mount.ts index 77919575..440db7be 100644 --- a/src/auth/mount.ts +++ b/src/auth/mount.ts @@ -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"; @@ -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 @@ -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; @@ -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. diff --git a/src/auth/sync-roles.ts b/src/auth/sync-roles.ts new file mode 100644 index 00000000..d10becc3 --- /dev/null +++ b/src/auth/sync-roles.ts @@ -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:` 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:` 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 { + 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 { + 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) => { + const target = getTarget(); + if (!target) return; + try { + await fn(target); + } catch (err) { + logger.error("authz.role_sync_failed", { reason: String(err) }); + } + }; + const overrides: Pick = { + 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; + }, + }); +} diff --git a/src/cli/response-transforms.ts b/src/cli/response-transforms.ts index a776caba..6f506b17 100644 --- a/src/cli/response-transforms.ts +++ b/src/cli/response-transforms.ts @@ -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"; @@ -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 ); diff --git a/src/cli/users-create.ts b/src/cli/users-create.ts index 552da42f..a94717f4 100644 --- a/src/cli/users-create.ts +++ b/src/cli/users-create.ts @@ -24,7 +24,7 @@ * or an OAuth provider name. * * Reuses the same UserStore primitive users-role.ts's grant/revoke-role - * commands do, and syncs the app:admin authz tuple the same way when an + * commands do, and syncs the app:site authz tuple the same way when an * admin-tier role (admin/editor/author) is included, so a pre-granted * admin-tier role is immediately effective — not just present in roles[] * but inert until the user's first login re-syncs it. @@ -52,6 +52,8 @@ import { import type { UserStore } from "../auth/user-store.ts"; import { createDbUserStore } from "../auth/user-store-db.ts"; import { createDuneAuthSystem } from "../auth/authz.ts"; +import { SITE_RESOURCE } from "../auth/authz-schema.ts"; +import { syncUserRoles } from "../auth/sync-roles.ts"; import { loadHmacKeyFromEnv } from "../auth/authz-hmac.ts"; import { createDbAdapter } from "../db/adapters/mod.ts"; import type { DbAdapter } from "../db/types.ts"; @@ -107,27 +109,30 @@ async function openContext(root: string): Promise { return { storage, dataDir, userStore, closeDb: () => Promise.resolve() }; } -/** Grant the app:admin authz tuple for an admin-tier role, if one was requested. */ +/** Grant the app:site authz tuple for an admin-tier role, if one was requested. */ async function syncAdminTuple( ctx: Context, userId: string, role: string | undefined, + roles: string[], ): Promise { - if (!role) return; + if (roles.length === 0) return; const hmacKey = await loadHmacKeyFromEnv().catch((err) => { console.error(" ⚠️ Invalid DUNE_AUTHZ_HMAC_SECRET:", err.message); return null; }); - const { authz } = createDuneAuthSystem({ + const { authz, adapter } = createDuneAuthSystem({ authzStore: "local", dataDir: ctx.dataDir, hmacKey, }, ctx.storage); + await syncUserRoles({ authz, adapter }, { id: userId, roles }); + if (!role) return; await authz.allow({ who: { type: "user", id: userId }, toBe: role as "admin" | "editor" | "author", - onWhat: { type: "app", id: "admin" }, + onWhat: SITE_RESOURCE, }); } @@ -207,7 +212,7 @@ export async function usersCreateCommand( })`, ); if (adminTierRole) { - console.log(` ~ Would sync app:admin tuple (none → ${adminTierRole})`); + console.log(` ~ Would sync app:site tuple (none → ${adminTierRole})`); } return; } @@ -236,7 +241,7 @@ export async function usersCreateCommand( throw err; } - await syncAdminTuple(ctx, user.id, adminTierRole); + await syncAdminTuple(ctx, user.id, adminTierRole, user.roles); console.log( ` ✓ Created ${normalizedEmail}${ diff --git a/src/cli/users-role.ts b/src/cli/users-role.ts index b2839dd4..5d7ef9b7 100644 --- a/src/cli/users-role.ts +++ b/src/cli/users-role.ts @@ -7,7 +7,7 @@ * dec-identity-unification Phase 6's role-granting mechanism. The admin * panel's `PUT /admin/api/users/:id` route already does this over HTTP; these * commands do the same two things it does — update the user's `roles[]` and - * sync the `app:admin` authz tuple `authz.check()` actually reads — for + * sync the `app:site` authz tuple `authz.check()` actually reads — for * operators without (or before) web access: first-admin bootstrap on a * headless install, scripted/CI-driven site provisioning, or granting access * to an existing OAuth/magic-link account that has never had an admin-tier @@ -39,6 +39,8 @@ import { createLocalUserStore } from "../auth/user-store.ts"; import type { UserStore } from "../auth/user-store.ts"; import { createDbUserStore } from "../auth/user-store-db.ts"; import { createDuneAuthSystem } from "../auth/authz.ts"; +import { SITE_RESOURCE } from "../auth/authz-schema.ts"; +import { syncUserRoles } from "../auth/sync-roles.ts"; import { loadHmacKeyFromEnv } from "../auth/authz-hmac.ts"; import { createDbAdapter } from "../db/adapters/mod.ts"; import type { DbAdapter } from "../db/types.ts"; @@ -82,28 +84,32 @@ async function openContext(root: string): Promise { return { storage, dataDir, userStore, closeDb: () => Promise.resolve() }; } -/** Sync the app:admin authz tuple to match `newRole` (undefined = no admin-tier role). */ +/** Sync the app:site authz tuple to match `newRole` (undefined = no admin-tier role). */ async function syncAdminTuple( ctx: Context, userId: string, previousRole: string | undefined, newRole: string | undefined, + roles: string[], ): Promise { const hmacKey = await loadHmacKeyFromEnv().catch((err) => { console.error(" ⚠️ Invalid DUNE_AUTHZ_HMAC_SECRET:", err.message); return null; }); - const { authz } = createDuneAuthSystem({ + const { authz, adapter } = createDuneAuthSystem({ authzStore: "local", dataDir: ctx.dataDir, hmacKey, }, ctx.storage); + // Group membership, which page gating and the site-wide role grants read. + await syncUserRoles({ authz, adapter }, { id: userId, roles }); + if (previousRole && previousRole !== newRole) { await authz.disallowAllMatching({ who: { type: "user", id: userId }, was: previousRole as "admin" | "editor" | "author", - onWhat: { type: "app", id: "admin" }, + onWhat: SITE_RESOURCE, }).catch((err) => { console.warn(` ⚠️ Failed to revoke old "${previousRole}" tuple:`, err); }); @@ -112,7 +118,7 @@ async function syncAdminTuple( await authz.allow({ who: { type: "user", id: userId }, toBe: newRole as "admin" | "editor" | "author", - onWhat: { type: "app", id: "admin" }, + onWhat: SITE_RESOURCE, }); } } @@ -179,7 +185,7 @@ async function changeRole( } — would update`, ); console.log( - ` ~ ${email}: would sync app:admin tuple (${ + ` ~ ${email}: would sync app:site tuple (${ previousRole ?? "none" } → ${newRole ?? "none"})`, ); @@ -187,7 +193,7 @@ async function changeRole( } await ctx.userStore.update(user.id, { roles: newRoles }); - await syncAdminTuple(ctx, user.id, previousRole, newRole); + await syncAdminTuple(ctx, user.id, previousRole, newRole, newRoles); console.log( grant diff --git a/src/mod.ts b/src/mod.ts index cb8c6ea4..d939caac 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -318,7 +318,19 @@ export { bootstrapRoleTuples, /** Bootstrap permission tuples for existing admin User records (@dune/plugin-admin). @since 1.0.0 */ bootstrapAdminTuples, + /** Grant the admin-tier roles their relation on SITE_RESOURCE at group level. @since 0.36.0 */ + bootstrapRoleGrants, } from "./auth/authz.ts"; +export { + /** Make a user's `member of group:` tuples match their `roles[]`. @since 0.36.0 */ + syncUserRoles, + /** Wrap a user store so create/update/delete keep group membership in step. @since 0.36.0 */ + withRoleSync, +} from "./auth/sync-roles.ts"; +export { + /** The object site-wide permissions are checked on: `app:site`. @since 0.36.0 */ + SITE_RESOURCE, +} from "./auth/authz-schema.ts"; export type { /** The Dune-configured AuthSystem type. @since 1.0.0 */ DuneAuthSystem, diff --git a/src/payments/manager.ts b/src/payments/manager.ts index 45a49d53..e53c3af4 100644 --- a/src/payments/manager.ts +++ b/src/payments/manager.ts @@ -165,13 +165,18 @@ export function createPaymentManager(config: PaymentManagerConfig): PaymentManag // Sync the new role into the authz tuple store so the grant takes effect // immediately without waiting for a restart + bootstrap. + // A user store wrapped with `withRoleSync` has already done this on + // update(); only add the membership when it is not there yet, so a synced + // store does not end up with the tuple twice. if (roleToGrant && updates.roles && authz) { - await authz.addMember({ - member: { type: "user", id: userId }, - group: { type: "group", id: roleToGrant }, - }).catch((err) => { + const group = { type: "group" as const, id: roleToGrant }; + const who = { type: "user" as const, id: userId }; + try { + const already = await authz.check({ who, canThey: "access", onWhat: group }); + if (!already) await authz.addMember({ member: who, group }); + } catch (err) { logger.error("payments.webhook.role_grant_failed", { userId, role: roleToGrant, reason: String(err) }); - }); + } } } diff --git a/src/plugin-api/create-services.ts b/src/plugin-api/create-services.ts index 88d51fb2..142bbdda 100644 --- a/src/plugin-api/create-services.ts +++ b/src/plugin-api/create-services.ts @@ -13,6 +13,7 @@ import { createWorkflowEngine } from "../workflow/engine.ts"; import { createScheduler } from "../workflow/scheduler.ts"; import { createStagingEngine } from "../staging/engine.ts"; import { bootstrapAdminTuples } from "../auth/authz.ts"; +import { withRoleSync } from "../auth/sync-roles.ts"; import { resolveBlueprint, validateFrontmatter } from "../blueprints/validator.ts"; import { resolveDataDir, resolveRuntimeDir } from "../forms/config.ts"; import { logger } from "../core/logger.ts"; @@ -56,7 +57,9 @@ export function createPluginServices(opts: PluginServicesOptions): PluginService }, }, users: { - createLocalStore: ({ usersDir }) => createLocalUserStore({ storage, usersDir }), + // Writes through this store keep group membership in step with roles[], + // so a role an admin grants takes effect without a restart. + createLocalStore: ({ usersDir }) => withRoleSync(createLocalUserStore({ storage, usersDir }), () => opts.getAuthz?.()), }, passwords: { hash: hashPassword, diff --git a/src/runtime/bootstrap.ts b/src/runtime/bootstrap.ts index ddd6f450..8311c560 100644 --- a/src/runtime/bootstrap.ts +++ b/src/runtime/bootstrap.ts @@ -39,7 +39,7 @@ import { createHistoryEngine } from "../history/engine.ts"; import { createFlexEngine } from "../flex/engine.ts"; import { loadPluginAdminConfigs, loadPlugins } from "../plugins/loader.ts"; import { MetricsCollector } from "../metrics/mod.ts"; -import { createDuneAuthSystem } from "../auth/authz.ts"; +import { bootstrapRoleGrants, createDuneAuthSystem } from "../auth/authz.ts"; import type { DuneAuthSystem } from "../auth/authz.ts"; import type { AuthzLocalAdapter } from "../auth/authz-adapter-local.ts"; import type { AuthzDbAdapter } from "../auth/authz-adapter-db.ts"; @@ -528,15 +528,17 @@ export async function bootstrap( }); // The authz system exists when something needs it: a plugin that provides - // `editor.session` has declared itself the admin (without authz its sessions - // could never be authorised: `pages.update` fails closed when authz is - // missing), or a plugin declares `authzActions` of its own. Plugins register, - // and so declare, before this point; settle async setup() first so a - // declaration made after an await is seen too. + // `editor.session` (without authz its sessions could never be authorised: + // `pages.update` fails closed when authz is missing), a plugin that declares + // `authzActions` of its own, or the site's own login (`site.auth`, whose + // users are authorised through it for page gating and inline editing). + // Plugins register, and so declare, before this point; settle async setup() + // first so a declaration made after an await is seen too. await hooks.whenSetupComplete(); const hasSessionProvider = capabilityRegistry.reader.providers().some((p) => p.name === "editor.session"); const hasAuthzActions = Object.keys(pluginAuthzActions).length > 0; - if (hasSessionProvider || hasAuthzActions) { + const hasSiteAuth = !!config.site.auth; + if (hasSessionProvider || hasAuthzActions || hasSiteAuth) { try { const bundle = createDuneAuthSystem({ authzStore: "local", @@ -547,6 +549,9 @@ export async function bootstrap( bootstrappedAuthz = bundle.authz; bootstrappedAuthzAdapter = bundle.adapter; authzForServices = { authz: bundle.authz, adapter: bundle.adapter }; + // Admin-tier roles hold their relation on the site at group level, so a + // site user's role is enough, with or without an admin plugin. + await bootstrapRoleGrants(bundle.authz, bundle.adapter); // Admin-user tuples are bootstrapped by @dune/plugin-admin's mount() hook // after it creates the UserManager. Core only creates the empty system here. } catch (err) { diff --git a/src/runtime/server.ts b/src/runtime/server.ts index b80d6090..604a303f 100644 --- a/src/runtime/server.ts +++ b/src/runtime/server.ts @@ -15,6 +15,7 @@ import { App, staticFiles } from "fresh"; import { captureHandlerSocketAddr, copySocketAddr } from "../security/rate-limit.ts"; import type { BootstrapResult } from "./bootstrap.ts"; import type { DuneAuthSystem } from "../auth/authz.ts"; +import { SITE_RESOURCE } from "../auth/authz-schema.ts"; import { mountPlugins } from "../plugins/loader.ts"; import { mountDuneAuth } from "../auth/mount.ts"; import { getUser, USER_HEADER } from "../auth/types.ts"; @@ -216,7 +217,7 @@ function checkPagesUpdateViaAuthz( who: { type: "user", id: userId }, // deno-lint-ignore no-explicit-any canThey: "pages.update" as any, - onWhat: { type: "app", id: "admin" }, + onWhat: SITE_RESOURCE, }); } @@ -456,8 +457,9 @@ export async function createDuneApp( // no /auth/* routes, no per-request middleware). mountDuneAuth() must run // after mountPlugins() so it can reuse the authz bundle bootstrap()/the // admin plugin already built (see mount.ts's own comment on this). + let publicAuthz: DuneAuthSystem | null = null; if (mountAuth && config.site.auth) { - await mountDuneAuth(app, ctx); + publicAuthz = (await mountDuneAuth(app, ctx)).authz; } // 6. Inline-edit WebSocket — in core so it works without @dune/plugin-admin. @@ -495,8 +497,10 @@ export async function createDuneApp( if (siteUser) { userId = siteUser.id; username = siteUser.username ?? siteUser.name ?? siteUser.email; + // The public-auth system is the one holding site users' tuples; when + // `site.auth.authz_store` is "db" that is not bootstrap()'s local one. permitted = await checkInlineEditPermissionForSiteUser( - ctx.authz, + publicAuthz ?? ctx.authz, siteUser, ); } else { diff --git a/tests/auth/authz_plugin_actions_test.ts b/tests/auth/authz_plugin_actions_test.ts index a27b22a7..7799f8c2 100644 --- a/tests/auth/authz_plugin_actions_test.ts +++ b/tests/auth/authz_plugin_actions_test.ts @@ -74,14 +74,14 @@ Deno.test( await authz.allow({ who: { type: "user", id: "alice" }, toBe: "admin", - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }); const allowed = await authz.check({ who: { type: "user", id: "alice" }, // deno-lint-ignore no-explicit-any canThey: "billing.manage" as any, - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }); assertEquals(allowed, true); @@ -89,7 +89,7 @@ Deno.test( who: { type: "user", id: "bob" }, // deno-lint-ignore no-explicit-any canThey: "billing.manage" as any, - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }); assertEquals(deniedForBob, false); } finally { diff --git a/tests/auth/authz_test.ts b/tests/auth/authz_test.ts index dc5e0573..ba660903 100644 --- a/tests/auth/authz_test.ts +++ b/tests/auth/authz_test.ts @@ -253,7 +253,7 @@ Deno.test("AuthzLocalAdapter: indexed lookups stay correct across many tuples an await adapter.write([{ subject: { type: "user", id: `user-${i}` }, relation: "editor", - object: { type: "app", id: "admin" }, + object: { type: "app", id: "site" }, }]); } @@ -271,12 +271,12 @@ Deno.test("AuthzLocalAdapter: indexed lookups stay correct across many tuples an assertEquals(evens.every((s) => Number(s.id.split("-")[1]) % 2 === 0), true); const editors = await adapter.findSubjects( - { type: "app", id: "admin" }, + { type: "app", id: "site" }, "editor", ); assertEquals(editors.length, 5); - // findObjects: user-0 is a member of "member" and "even", and editor of admin. + // findObjects: user-0 is a member of "member" and "even", and editor of the site. const user0Memberships = await adapter.findObjects({ type: "user", id: "user-0", @@ -288,7 +288,7 @@ Deno.test("AuthzLocalAdapter: indexed lookups stay correct across many tuples an id: "user-0", }, "editor"); assertEquals(user0EditorGrants.length, 1); - assertEquals(user0EditorGrants[0].id, "admin"); + assertEquals(user0EditorGrants[0].id, "site"); // hasTuple: exact subject+relation+object — must not match a near-miss // (right subject/object, wrong relation; right subject/relation, wrong object). @@ -411,7 +411,7 @@ Deno.test("AuthzLocalAdapter: indexes are rebuilt correctly when reloaded from d await first.write([{ subject: { type: "user", id: "henry" }, relation: "editor", - object: { type: "app", id: "admin" }, + object: { type: "app", id: "site" }, }]); // A fresh adapter instance over the same storage — forces the ensureLoaded() @@ -430,14 +430,14 @@ Deno.test("AuthzLocalAdapter: indexes are rebuilt correctly when reloaded from d assertStrictEquals( await second.hasTuple({ type: "user", id: "henry" }, "editor", { type: "app", - id: "admin", + id: "site", }), true, ); assertStrictEquals( await second.hasTuple({ type: "user", id: "henry" }, "member", { type: "app", - id: "admin", + id: "site", }), false, ); @@ -667,7 +667,7 @@ Deno.test("bootstrapAdminTuples: grants admin access via 'access' action", async await authz.check({ who: { type: "user", id: "admin-1" }, canThey: "access", - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }), true, ); @@ -675,7 +675,7 @@ Deno.test("bootstrapAdminTuples: grants admin access via 'access' action", async await authz.check({ who: { type: "user", id: "editor-1" }, canThey: "access", - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }), true, ); @@ -683,7 +683,7 @@ Deno.test("bootstrapAdminTuples: grants admin access via 'access' action", async await authz.check({ who: { type: "user", id: "author-1" }, canThey: "access", - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }), true, ); @@ -703,7 +703,7 @@ Deno.test("bootstrapAdminTuples: admin can manage users, editor cannot", async ( await authz.check({ who: { type: "user", id: "admin-1" }, canThey: action, - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }), true, `admin should have ${action}`, @@ -712,7 +712,7 @@ Deno.test("bootstrapAdminTuples: admin can manage users, editor cannot", async ( await authz.check({ who: { type: "user", id: "editor-1" }, canThey: action, - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }), false, `editor should not have ${action}`, @@ -747,7 +747,7 @@ Deno.test("bootstrapAdminTuples: unknown user has no admin access", async () => await authz.check({ who: { type: "user", id: "nobody" }, canThey: "access", - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }), false, ); diff --git a/tests/auth/sync_roles_test.ts b/tests/auth/sync_roles_test.ts new file mode 100644 index 00000000..9dcfcdfd --- /dev/null +++ b/tests/auth/sync_roles_test.ts @@ -0,0 +1,119 @@ +/** + * A role granted or revoked while the app runs reaches the authz store at + * once: group membership follows `roles[]` for every write through the user + * store, so page gating and the site-wide grants of the admin-tier roles + * need no restart. + */ + +import { assertEquals } from "@std/assert"; +import { createStorage } from "../../src/storage/mod.ts"; +import { bootstrapRoleGrants, createDuneAuthSystem } from "../../src/auth/authz.ts"; +import { SITE_RESOURCE } from "../../src/auth/authz-schema.ts"; +import { createLocalUserStore } from "../../src/auth/user-store.ts"; +import { withRoleSync } from "../../src/auth/sync-roles.ts"; + +async function withStore( + fn: ( + ctx: { + store: ReturnType; + authz: ReturnType["authz"]; + adapter: ReturnType["adapter"]; + canEdit: (id: string) => Promise; + groups: (id: string) => Promise; + }, + ) => Promise, +) { + const root = await Deno.makeTempDir({ prefix: "dune_test_sync_roles_" }); + try { + const storage = createStorage({ rootDir: root }); + const { authz, adapter } = createDuneAuthSystem({ authzStore: "local", dataDir: "data" }, storage); + await bootstrapRoleGrants(authz, adapter); + const store = withRoleSync( + createLocalUserStore({ storage, usersDir: "data/users" }), + () => ({ authz, adapter }), + ); + await fn({ + store, + authz, + adapter, + canEdit: (id) => + authz.check({ + who: { type: "user", id }, + // deno-lint-ignore no-explicit-any + canThey: "pages.update" as any, + onWhat: SITE_RESOURCE, + }), + groups: async (id) => + (await adapter.findObjects({ type: "user", id }, "member", { objectType: "group" })) + .map((g) => g.id).sort(), + }); + } finally { + await Deno.remove(root, { recursive: true }).catch(() => {}); + } +} + +Deno.test("create: a user created with roles is a member of those groups", async () => { + await withStore(async ({ store, groups, canEdit }) => { + const ed = await store.create({ email: "ed@example.com", provider: "local", roles: ["editor", "vip"] }); + assertEquals(await groups(ed.id), ["editor", "vip"]); + assertEquals(await canEdit(ed.id), true); + }); +}); + +Deno.test("update: granting and revoking a role takes effect with no restart", async () => { + await withStore(async ({ store, groups, canEdit }) => { + const u = await store.create({ email: "u@example.com", provider: "local", roles: [] }); + assertEquals(await canEdit(u.id), false); + + await store.update(u.id, { roles: ["editor"] }); + assertEquals(await groups(u.id), ["editor"]); + assertEquals(await canEdit(u.id), true); + + // editor -> subscriber: the old membership goes, so the old grant goes with it. + await store.update(u.id, { roles: ["subscriber"] }); + assertEquals(await groups(u.id), ["subscriber"]); + assertEquals(await canEdit(u.id), false); + }); +}); + +Deno.test("update without roles leaves membership alone", async () => { + await withStore(async ({ store, groups }) => { + const u = await store.create({ email: "a@example.com", provider: "local", roles: ["author"] }); + await store.update(u.id, { name: "Ann" }); + assertEquals(await groups(u.id), ["author"]); + }); +}); + +Deno.test("delete: a deleted user keeps no tuples", async () => { + await withStore(async ({ store, groups, canEdit }) => { + const u = await store.create({ email: "d@example.com", provider: "local", roles: ["admin"] }); + assertEquals(await canEdit(u.id), true); + assertEquals(await store.delete(u.id), true); + assertEquals(await groups(u.id), []); + assertEquals(await canEdit(u.id), false); + }); +}); + +Deno.test("no authz target: writes pass straight through", async () => { + const root = await Deno.makeTempDir({ prefix: "dune_test_sync_roles_none_" }); + try { + const store = withRoleSync( + createLocalUserStore({ storage: createStorage({ rootDir: root }), usersDir: "data/users" }), + () => undefined, + ); + const u = await store.create({ email: "n@example.com", provider: "local", roles: ["editor"] }); + assertEquals((await store.update(u.id, { roles: [] }))?.roles, []); + } finally { + await Deno.remove(root, { recursive: true }).catch(() => {}); + } +}); + +Deno.test("membership is exactly roles[]: a group added by hand is removed on the next role write", async () => { + await withStore(async ({ store, authz, groups }) => { + const user = await store.create({ email: "m@example.com", provider: "local", roles: ["member"] }); + await authz.addMember({ member: { type: "user", id: user.id }, group: { type: "group", id: "vip" } }); + assertEquals(await groups(user.id), ["member", "vip"]); + await store.update(user.id, { roles: ["member"] }); + assertEquals(await groups(user.id), ["member"]); + }); +}); diff --git a/tests/cli/users_create_test.ts b/tests/cli/users_create_test.ts index 229ffc62..5aad2d06 100644 --- a/tests/cli/users_create_test.ts +++ b/tests/cli/users_create_test.ts @@ -40,7 +40,7 @@ async function hasAdminTuple( return await adapter.hasTuple( { type: "user", id: userId }, relation, - { type: "app", id: "admin" }, + { type: "app", id: "site" }, ); } @@ -102,7 +102,7 @@ Deno.test("users:create sets arbitrary (non-admin-tier) role tags, no authz tupl }); }); -Deno.test("users:create syncs the app:admin authz tuple for an admin-tier role", async () => { +Deno.test("users:create syncs the app:site authz tuple for an admin-tier role", async () => { await withTempSite(async (root) => { await usersCreateCommand(root, "alice@example.com", { roles: ["editor"], diff --git a/tests/cli/users_role_test.ts b/tests/cli/users_role_test.ts index 828f76ff..24d6fa77 100644 --- a/tests/cli/users_role_test.ts +++ b/tests/cli/users_role_test.ts @@ -2,7 +2,7 @@ * Tests for `dune users:grant-role` / `dune users:revoke-role` — * dec-identity-unification Phase 6's CLI leg for the role-granting * mechanism. Grants/revokes an admin-tier role on an existing unified User - * (identified by email) and syncs the app:admin authz tuple + * (identified by email) and syncs the app:site authz tuple * `authz.check()` reads at permission-check time. */ @@ -80,7 +80,7 @@ async function hasAdminTuple( return await adapter.hasTuple( { type: "user", id: userId }, relation, - { type: "app", id: "admin" }, + { type: "app", id: "site" }, ); } @@ -127,7 +127,7 @@ Deno.test("users:grant-role adds the role to a role-less user's roles[]", async }); }); -Deno.test("users:grant-role syncs the app:admin authz tuple", async () => { +Deno.test("users:grant-role syncs the app:site authz tuple", async () => { await withTempSite(async (root) => { await writeUser(root, "u1.json", baseUser); @@ -242,7 +242,7 @@ Deno.test("users:revoke-role removes the role from roles[]", async () => { }); }); -Deno.test("users:revoke-role removes the app:admin authz tuple", async () => { +Deno.test("users:revoke-role removes the app:site authz tuple", async () => { await withTempSite(async (root) => { await writeUser(root, "u1.json", { ...baseUser, roles: [] }); await grantRoleCommand(root, "alice@example.com", "editor", {}); diff --git a/tests/payments/manager_test.ts b/tests/payments/manager_test.ts index 42e247ff..b60411ae 100644 --- a/tests/payments/manager_test.ts +++ b/tests/payments/manager_test.ts @@ -16,6 +16,7 @@ import type { PaymentProvider, CheckoutSession, WebhookEvent, Product } from ".. import type { UserStore } from "../../src/auth/user-store.ts"; import type { User, UserCreate } from "../../src/auth/types.ts"; import { createDuneAuthSystem } from "../../src/auth/authz.ts"; +import { withRoleSync } from "../../src/auth/sync-roles.ts"; // --------------------------------------------------------------------------- // Mock helpers @@ -385,6 +386,30 @@ Deno.test("handleWebhook: authz.addMember called when role is granted", async () ); }); +Deno.test("handleWebhook: on a role-synced user store the membership is written once", async () => { + const storage = makeStorage(); + const { authz, adapter } = createDuneAuthSystem({ dataDir: "data" }, storage); + + const user = makeUser({ id: "user-sync", roles: [] }); + const provider = new MockPaymentProvider(); + provider.parseWebhookResult = { type: "checkout.session.completed", userId: "user-sync", productId: "membership", raw: {} }; + const inner = new MockUserStore(); + inner.users.set(user.id, user); + + const manager = createPaymentManager({ + provider, + products: [makeProduct()], + webhookSecret: "whsec_test", + userStore: withRoleSync(inner, () => ({ authz, adapter })), + baseUrl: "https://example.com", + authz, + }); + await manager.handleWebhook(new Request("http://example.com/payments/webhook", { method: "POST", body: "{}" })); + + const groups = await adapter.findObjects({ type: "user", id: "user-sync" }, "member", { objectType: "group" }); + assertEquals(groups.map((g) => g.id), ["member"]); +}); + Deno.test("handleWebhook: authz not called when role already present", async () => { const storage = makeStorage(); const { authz } = createDuneAuthSystem({ dataDir: "data" }, storage); diff --git a/tests/runtime/site_user_editing_test.ts b/tests/runtime/site_user_editing_test.ts new file mode 100644 index 00000000..c3dbf4c9 --- /dev/null +++ b/tests/runtime/site_user_editing_test.ts @@ -0,0 +1,145 @@ +/** + * A signed-in site user whose role is an editing role may edit inline, with + * or without an admin plugin (duneorg/dune#130). + * + * The site has a login (`site.auth`) and a stub plugin providing the + * `editor.inline` slot, and no admin. Before the fix the authz system did not + * exist for such a site, and where it did, a site user's role granted nothing + * because the permission lived in per-user tuples only the admin plugin wrote. + */ + +import { assertEquals } from "@std/assert"; +import { join } from "@std/path"; +import { bootstrap } from "../../src/runtime/bootstrap.ts"; +import { createDuneApp } from "../../src/runtime/server.ts"; +import type { DunePlugin } from "../../src/hooks/types.ts"; +import { createLocalUserStore } from "../../src/auth/user-store.ts"; +import { createSessionManager, createSessionStore } from "../../src/session/mod.ts"; +import { createStorage } from "../../src/storage/mod.ts"; +import { SITE_COOKIE_NAME } from "../../src/auth/middleware.ts"; + +const inlineEdit: DunePlugin = { + name: "test-inline-edit", + version: "1.0.0", + hooks: {}, + setup(api) { + api.capabilities.provide("editor.inline", () => ({ + handleUpgrade: () => new Response(null, { status: 204 }), + } as never)); + }, +}; + +const sessionProvider: DunePlugin = { + name: "test-session-provider", + version: "1.0.0", + hooks: {}, + setup(api) { + api.capabilities.provide("editor.session", () => ({ + authenticate: () => Promise.resolve({ authenticated: false }), + })); + }, +}; + +const ROLES = ["author", "editor", "admin", "member", "subscriber"]; + +/** + * Run `fn` against a site with a login and one existing user per role. The + * users exist before the site starts because that is when site auth seeds + * their group membership. + */ +async function withSite( + plugins: DunePlugin[], + fn: ( + ws: (cookie?: string) => Promise, + cookieFor: (role: string) => Promise, + plugin: { services: Awaited>["pluginServices"]; ids: Map }, + ) => Promise, +) { + const root = await Deno.makeTempDir({ prefix: "dune_test_site_user_editing_" }); + try { + await Deno.mkdir(join(root, "content", "01.home"), { recursive: true }); + await Deno.writeTextFile(join(root, "content", "01.home", "default.md"), "---\ntitle: Home\n---\n\nHi\n"); + await Deno.mkdir(join(root, "config"), { recursive: true }); + await Deno.writeTextFile( + join(root, "config", "site.yaml"), + "title: Test\nurl: http://localhost\nauth:\n mode: dune\n", + ); + const userStore = createLocalUserStore({ storage: createStorage({ rootDir: root }), usersDir: "data/users" }); + const ids = new Map(); + for (const role of ROLES) { + const user = await userStore.create({ email: `${role}@example.com`, provider: "local", roles: [role] }); + ids.set(role, user.id); + } + const ctx = await bootstrap(root, { plugins: [inlineEdit, ...plugins] }); + const { app } = await createDuneApp(ctx, { root, port: 0, dev: false }); + const handler = app.handler(); + + const sessions = createSessionManager( + await createSessionStore({ + type: "local", + storage: ctx.storage, + sessionsDir: ".dune/admin/site-sessions", + lifetimeMs: 60_000, + }), + 60_000, + ); + const cookieFor = async (role: string) => { + const session = await sessions.create(ids.get(role)!); + return `${SITE_COOKIE_NAME}=${session.id}`; + }; + const ws = (cookie?: string) => + handler( + new Request("http://localhost/api/inline-edit/ws?path=01.home/default.md", { + headers: { + upgrade: "websocket", + origin: "http://localhost", + ...(cookie ? { cookie } : {}), + }, + }), + ); + await fn(ws, cookieFor, { services: ctx.pluginServices, ids }); + } finally { + await Deno.remove(root, { recursive: true }).catch(() => {}); + } +} + +const opts = { sanitizeOps: false, sanitizeResources: false }; + +Deno.test("login only, no admin: an editor may edit, a plain member may not", opts, async () => { + await withSite([], async (ws, cookieFor) => { + assertEquals((await ws(await cookieFor("editor"))).status, 204); + assertEquals((await ws(await cookieFor("member"))).status, 403); + assertEquals((await ws()).status, 401); + }); +}); + +Deno.test("login plus a plugin providing editor.session: same answers", opts, async () => { + await withSite([sessionProvider], async (ws, cookieFor) => { + assertEquals((await ws(await cookieFor("editor"))).status, 204); + assertEquals((await ws(await cookieFor("member"))).status, 403); + }); +}); + +Deno.test("every admin-tier role may edit pages, content tags may not", opts, async () => { + await withSite([], async (ws, cookieFor) => { + for (const role of ["author", "editor", "admin"]) { + assertEquals((await ws(await cookieFor(role))).status, 204, role); + } + assertEquals((await ws(await cookieFor("subscriber"))).status, 403); + }); +}); + +Deno.test("a role granted through a plugin's user store while the app runs applies at once", opts, async () => { + await withSite([], async (ws, cookieFor, { services, ids }) => { + const cookie = await cookieFor("subscriber"); + assertEquals((await ws(cookie)).status, 403); + + // What an admin plugin does: write the role through services.users. + const users = services.users.createLocalStore({ usersDir: "data/users" }); + await users.update(ids.get("subscriber")!, { roles: ["editor"] }); + assertEquals((await ws(cookie)).status, 204); + + await users.update(ids.get("subscriber")!, { roles: ["subscriber"] }); + assertEquals((await ws(cookie)).status, 403); + }); +});