diff --git a/CHANGELOG.md b/CHANGELOG.md index da83ff4d..e8919e60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -170,6 +170,17 @@ where the setting lives now (see `dune migrate:config`, which reports these too) keyed by string. `ResponseTransformContext.barActions` and `.adminPrefix` are replaced by `ResponseTransformContext.capabilities`, the same reader `MountApi.capabilities` offers: a bar-owning plugin reads `list(BAR_ACTIONS)` and `get("admin.prefix")` per request. +- **`system.role_grants` chooses which groups hold the admin-tier relations (#139).** The default is + what the entry below describes: groups `admin`, `editor` and `author` each hold their own relation + on the site. Set your own group onto a relation (`staff: editor`), keep a subset, or `{}` to turn + the group-level grants off. The relations are fixed by the authz schema; this only chooses which + group gets which. The grants are reconciled at every start: a grant no longer configured is + revoked (every group → admin/editor/author tuple on the site belongs to this setting). + `EditorIdentity.relation` tells an inline-edit provider the relation core decided, so an `author` + limit follows the mapping, not role names: core reads the relation from the authz system (what the + user actually holds), so a default-named group remapped *down* (`editor: author`) is limited too. + `role_grants` in an environment overlay replaces the base mapping instead of merging into it. An + admin plugin's own accounts keep their rights whatever the mapping says. `dune doctor --boot` and the start-up note follow the mapping. - **Holding `admin`, `editor` or `author` in `roles[]` carries editing rights for any signed-in user, with or without the admin plugin (#130).** Before, a site user got them only after a restart and only on a site that ran the admin with the local user store; now the roles are granted at group diff --git a/skills/dune-auth.md b/skills/dune-auth.md index 1a599e6f..35cbb7f1 100644 --- a/skills/dune-auth.md +++ b/skills/dune-auth.md @@ -147,7 +147,7 @@ Generated CRUD route handlers (from `dune codegen`) and other code that only has ## Site users can edit -A signed-in site user whose `roles` include `admin`, `editor` or `author` may edit pages in place (the editing bar and `/api/inline-edit/*` from `@dune/plugin-inline-edit`), with no admin plugin installed: core checks `pages.update` for them like for an admin account. Their role is also a group membership, kept in step with `roles[]` live, so changing a user's roles takes effect on their next request. Content-gating tags (`member`, `subscriber`) never grant editing. An `author` edits only the pages it created (`createdBy`); `admin` and `editor` edit any page. **Do not use `editor` or `author` as a content tier** (in `roles:` frontmatter, a payment product's `role`, a sign-up default): holding the name is what grants editing. Granting them on purpose, for example to paid contributors, is fine; core logs `auth.automatic_admin_tier_grant` at start and `dune doctor --boot` lists where the config does it. Responses for any signed-in user bypass the shared page cache. +A signed-in site user whose `roles` include `admin`, `editor` or `author` may edit pages in place (the editing bar and `/api/inline-edit/*` from `@dune/plugin-inline-edit`), with no admin plugin installed: core checks `pages.update` for them like for an admin account. Their role is also a group membership, kept in step with `roles[]` live, so changing a user's roles takes effect on their next request. Content-gating tags (`member`, `subscriber`) never grant editing. An `author` edits only the pages it created (`createdBy`); `admin` and `editor` edit any page. **Do not use `editor` or `author` as a content tier** (or set `role_grants` in `system.yaml`, a mapping of group to relation, to remap or switch off the group-level grants: `role_grants: {}`) (in `roles:` frontmatter, a payment product's `role`, a sign-up default): holding the name is what grants editing. Granting them on purpose, for example to paid contributors, is fine; core logs `auth.automatic_admin_tier_grant` at start and `dune doctor --boot` lists where the config does it. Responses for any signed-in user bypass the shared page cache. --- diff --git a/skills/dune-authz.md b/skills/dune-authz.md index a939b393..0ab45272 100644 --- a/skills/dune-authz.md +++ b/skills/dune-authz.md @@ -74,7 +74,7 @@ const canEdit = await getAuthz().check({ }); ``` -Who holds what: a **site user** gets their admin-tier role (`admin`/`editor`/`author` in `roles[]`) through the role's group: Dune writes `(group:, , app:site)` once at startup (`bootstrapRoleGrants`) and the user is a `member` of `group:`. The **admin plugin's own accounts** get direct tuples (`services.authz.seedAdminTuples([{ id, role }])` from a plugin). Either way `authz.check()` answers the same, so a signed-in site user with the `editor` role may edit inline with no admin plugin installed. +Who holds what: a **site user** gets their admin-tier role (`admin`/`editor`/`author` in `roles[]`) through the role's group: Dune writes `(group:, , app:site)` at startup (`bootstrapRoleGrants`), as `system.role_grants` in `system.yaml` says: a mapping of group to relation, default `admin: admin, editor: editor, author: author`. Remap your own group (`staff: editor`), keep a subset, or `{}` for none; grants no longer configured are revoked at start and the user is a `member` of `group:`. The **admin plugin's own accounts** get direct tuples (`services.authz.seedAdminTuples([{ id, role }])` from a plugin). Either way `authz.check()` answers the same, so a signed-in site user with the `editor` role may edit inline with no admin plugin installed. You rarely call this yourself: the admin's route guards (`requirePermission`) and core's inline-edit gate already do, and deny when `authz` is undefined. There is no role-table fallback. diff --git a/src/auth/authz-schema.ts b/src/auth/authz-schema.ts index b182ac46..6630ffa4 100644 --- a/src/auth/authz-schema.ts +++ b/src/auth/authz-schema.ts @@ -9,7 +9,7 @@ * - Inline editing — "edit" on object type "resource" * - Resource-level grants — "owner" relation on object type "resource" * - * Subject types: "user" + * Subject types: "user" | "group" (a group holds a relation too: see `bootstrapRoleGrants`) * Object types: "group" | "app" | "resource" * * ## Content gating @@ -45,6 +45,16 @@ import type { Role } from "../config/role.ts"; */ export const SITE_RESOURCE = { type: "app", id: "site" } as const; +/** + * Built-in actions that tell the admin-tier relations apart, for code that must + * know which relation a user holds without the user's role names (those can be + * remapped by `system.role_grants`): `pages.delete` is satisfied by `admin` + * alone, `config.read` by `admin` and `editor`; anyone who can only + * `pages.update` holds `author`. A test pins these to + * {@link DUNE_BASE_AUTHZ_ACTIONS}. + */ +export const RELATION_PROBE_ACTIONS = { admin: "pages.delete", editor: "config.read" } 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* diff --git a/src/auth/authz.ts b/src/auth/authz.ts index 9cddc988..37eeedea 100644 --- a/src/auth/authz.ts +++ b/src/auth/authz.ts @@ -43,6 +43,7 @@ import { AuthSystem } from "polizy"; import type { AuthSchema } from "polizy"; import { duneAuthzSchema, SITE_RESOURCE } from "./authz-schema.ts"; +import { DEFAULT_ROLE_GRANTS, GRANT_RELATIONS, type RoleGrants } from "./role-grants.ts"; import { AuthzLocalAdapter } from "./authz-adapter-local.ts"; import { AuthzDbAdapter } from "./authz-adapter-db.ts"; import type { StorageAdapter } from "../storage/types.ts"; @@ -178,6 +179,15 @@ export interface AuthzAdapterLike { hasTuple(subject: { type: string; id: string }, relation: string, object: { type: string; id: string }): Promise; } +/** What {@link bootstrapRoleGrants} needs besides {@link AuthzAdapterLike}: finding who holds a relation. */ +export interface RoleGrantAdapter extends AuthzAdapterLike { + findSubjects( + object: { type: string; id: string }, + relation: string, + options?: { subjectType?: string }, + ): Promise<{ type: string; id: string }[]>; +} + /** * Bootstrap role-based permission tuples from existing User `roles[]`. * @@ -236,24 +246,38 @@ 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. + * Make the groups named in `grants` hold their relation on {@link SITE_RESOURCE}, + * and nothing else: `group:editor` holds `editor` on `app:site` by default. A + * site user whose `roles[]` has `editor` is 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. `grants` comes from `system.role_grants` ({@link resolveRoleGrants}). * - * Idempotent. Called wherever an authz system is created, so a site that has - * a login but no admin plugin still lets its editors edit. + * Reconciles, not only adds: every (group → admin/editor/author) tuple on the + * site belongs to this setting, so one that is no longer configured is revoked. + * A plugin that wants a group-level grant on the site writes it through + * `system.role_grants`, not by hand. + * + * An admin plugin's own accounts get direct tuples ({@link bootstrapAdminTuples}); + * `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, + adapter: RoleGrantAdapter, + grants: RoleGrants = DEFAULT_ROLE_GRANTS, ): 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 }); + for (const relation of GRANT_RELATIONS) { + for (const subject of await adapter.findSubjects(SITE_RESOURCE, relation, { subjectType: "group" })) { + if (grants[subject.id] !== relation) { + await authz.disallowAllMatching({ who: { type: "group", id: subject.id }, was: relation, onWhat: SITE_RESOURCE }); + } + } + } + for (const [group, relation] of Object.entries(grants)) { + const who = { type: "group" as const, id: group }; + if (await adapter.hasTuple(who, relation, SITE_RESOURCE)) continue; + await authz.allow({ who, toBe: relation, onWhat: SITE_RESOURCE }); } } diff --git a/src/auth/automatic-grants.ts b/src/auth/automatic-grants.ts index 057c74c2..2a223ea6 100644 --- a/src/auth/automatic-grants.ts +++ b/src/auth/automatic-grants.ts @@ -15,6 +15,7 @@ */ import type { DuneConfig } from "../config/types.ts"; +import { resolveRoleGrants } from "./role-grants.ts"; /** One automatic path to an admin-tier role. */ export interface AutomaticGrant { @@ -29,33 +30,36 @@ const CAN: Record = { author: "create pages and edit the pages they created", }; -const ADMIN_TIER = ["admin", "editor", "author"]; - -/** The automatic paths to an admin-tier role that `config` sets up. Empty for most sites. */ +/** The automatic paths to an editing role that `config` sets up. Empty for most sites. */ export function automaticAdminTierGrants(config: DuneConfig): AutomaticGrant[] { const out: AutomaticGrant[] = []; const site = config.site; + const grants = resolveRoleGrants(config); const payments = site?.payments; if (payments?.secret_key) { for (const product of payments.products ?? []) { const role = product.role; - if (role && ADMIN_TIER.includes(role)) { + const relation = role ? grants[role] : undefined; + if (role && relation) { out.push({ - severity: role === "admin" ? "warning" : "info", - message: `payments product "${product.id}" gives every buyer the "${role}" role, which can ${CAN[role]}. ` + - `Intended? If "${role}" is only a content tier here, use another role name.`, + severity: relation === "admin" ? "warning" : "info", + message: `payments product "${product.id}" gives every buyer the "${role}" role, which ` + + `${role === relation ? "is" : `is mapped by system.role_grants to "${relation}" and`} can ${CAN[relation]}. ` + + `Intended? If "${role}" is only a content tier here, use another role name` + + `${role === relation ? "" : " or change role_grants"}.`, }); } } } - if (site?.auth?.mode === "external-jwt") { + const mapped = Object.entries(grants); + if (site?.auth?.mode === "external-jwt" && mapped.length > 0) { out.push({ severity: "info", message: `the external-jwt login trusts the token's "${site.auth.jwt?.roles_claim ?? "roles"}" claim: a token that ` + - `carries "admin", "editor" or "author" grants editing rights (author: ${CAN.author}; editor: ${CAN.editor}; ` + - `admin: ${CAN.admin}). Make sure the identity provider only issues those to staff.`, + `carries ${mapped.map(([g, r]) => `"${g}" (${r}: ${CAN[r]})`).join(", ")} grants editing rights. ` + + `Make sure the identity provider only issues those to staff.`, }); } return out; diff --git a/src/auth/editor.ts b/src/auth/editor.ts index 117374aa..fbd94ab7 100644 --- a/src/auth/editor.ts +++ b/src/auth/editor.ts @@ -14,8 +14,9 @@ import type { CapabilityReader } from "../plugin-api/capabilities.ts"; import type { EditorIdentity } from "../inline-edit/types.ts"; import type { DuneAuthSystem } from "./authz.ts"; -import { SITE_RESOURCE } from "./authz-schema.ts"; +import { RELATION_PROBE_ACTIONS, SITE_RESOURCE } from "./authz-schema.ts"; import { getUser } from "./types.ts"; +import type { GrantRelation } from "./role-grants.ts"; import { hasSessionCookie } from "../cli/admin-bar-inject.ts"; /** Outcome of {@link EditorResolver}: an editor, or why there is none. */ @@ -43,15 +44,28 @@ function displayName(user: { id: string; username?: string; name?: string; email return user.username ?? user.name ?? user.email ?? user.id; } -function canUpdatePages(authz: DuneAuthSystem | undefined, userId: string): Promise { +/** + * The admin-tier relation `userId` actually holds on the site, read from the + * authz system, or null when they may not edit pages at all. Asking authz (and + * not inferring from role names) is what keeps this right under + * `system.role_grants`: a user in `group:editor` remapped to `author` holds + * `author`, whatever their role is called, and an account seeded with a direct + * tuple holds what the tuple says. + */ +async function editorRelation(authz: DuneAuthSystem | undefined, userId: string): Promise { // Fail closed: no authz system means nobody is an editor. - if (!authz) return Promise.resolve(false); - return authz.check({ - who: { type: "user", id: userId }, - // deno-lint-ignore no-explicit-any - canThey: "pages.update" as any, - onWhat: SITE_RESOURCE, - }).catch(() => false); + if (!authz) return null; + const can = (action: string) => + authz.check({ + who: { type: "user", id: userId }, + // deno-lint-ignore no-explicit-any + canThey: action as any, + onWhat: SITE_RESOURCE, + }).catch(() => false); + if (!await can("pages.update")) return null; + if (await can(RELATION_PROBE_ACTIONS.admin)) return "admin"; + if (await can(RELATION_PROBE_ACTIONS.editor)) return "editor"; + return "author"; } /** Build the {@link EditorResolver} of a site. */ @@ -59,10 +73,17 @@ export function createEditorResolver(opts: EditorResolverOptions): EditorResolve return async (req) => { const siteUser = getUser(req); if (siteUser) { - if (!await canUpdatePages(opts.siteAuthz(), siteUser.id)) return { ok: false, status: 403 }; + const relation = await editorRelation(opts.siteAuthz(), siteUser.id); + if (!relation) return { ok: false, status: 403 }; return { ok: true, - editor: { id: siteUser.id, username: displayName(siteUser), roles: siteUser.roles, source: "site" }, + editor: { + id: siteUser.id, + username: displayName(siteUser), + roles: siteUser.roles, + relation, + source: "site", + }, }; } @@ -72,13 +93,15 @@ export function createEditorResolver(opts: EditorResolverOptions): EditorResolve // deno-lint-ignore no-explicit-any const user = (result as any)?.authenticated ? (result as any).user : null; if (!user?.id) return { ok: false, status: 401 }; - if (!await canUpdatePages(opts.sessionAuthz(), user.id as string)) return { ok: false, status: 403 }; + const relation = await editorRelation(opts.sessionAuthz(), user.id as string); + if (!relation) return { ok: false, status: 403 }; return { ok: true, editor: { id: user.id as string, username: displayName(user), roles: (user.roles as string[] | undefined) ?? [], + relation, source: "session", }, }; @@ -86,9 +109,9 @@ export function createEditorResolver(opts: EditorResolverOptions): EditorResolve } /** - * Whether `editor` may edit the page with this frontmatter. An `admin` or - * `editor` may edit any page; anyone else (an `author`) only the pages they - * created, recorded as `createdBy` in the frontmatter. `null` is a path that is + * Whether `editor` may edit the page with this frontmatter. An editor whose + * {@link EditorIdentity.relation} is `admin` or `editor` may edit any page; an + * `author` only the pages it created, recorded as `createdBy` in the frontmatter. `null` is a path that is * not a page, which nobody may edit. * * The HTTP routes of the inline-edit provider apply the same rule at commit; @@ -96,10 +119,10 @@ export function createEditorResolver(opts: EditorResolverOptions): EditorResolve * page whose commit would be refused. */ export function mayEditPage( - editor: Pick, + editor: Pick, frontmatter: Record | null, ): boolean { if (!frontmatter) return false; - if (editor.roles.includes("admin") || editor.roles.includes("editor")) return true; + if (editor.relation === "admin" || editor.relation === "editor") return true; return frontmatter.createdBy === editor.id; } diff --git a/src/auth/mount.ts b/src/auth/mount.ts index 440db7be..5cf57dda 100644 --- a/src/auth/mount.ts +++ b/src/auth/mount.ts @@ -26,6 +26,7 @@ import { type User, USER_HEADER } from "./types.ts"; import { copySocketAddr } from "../security/rate-limit.ts"; import { InMemoryMagicTokenStore } from "./magic-link.ts"; import { bootstrapRoleGrants, bootstrapRoleTuples, createDuneAuthSystem } from "./authz.ts"; +import { resolveRoleGrants } from "./role-grants.ts"; import { withRoleSync } from "./sync-roles.ts"; import type { DuneAuthSystem } from "./authz.ts"; import type { AuthzLocalAdapter } from "./authz-adapter-local.ts"; @@ -204,7 +205,7 @@ export async function mountDuneAuth( setGatingAuthz(bundle.authz, siteOrigin); mountAuthz = bundle.authz; mountAdapter = bundle.adapter; - await bootstrapRoleGrants(bundle.authz, bundle.adapter).catch((err) => + await bootstrapRoleGrants(bundle.authz, bundle.adapter, resolveRoleGrants(config)).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", @@ -232,7 +233,7 @@ export async function mountDuneAuth( 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) => + await bootstrapRoleGrants(bundle.authz, bundle.adapter, resolveRoleGrants(config)).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", diff --git a/src/auth/role-grants.ts b/src/auth/role-grants.ts new file mode 100644 index 00000000..e003acc3 --- /dev/null +++ b/src/auth/role-grants.ts @@ -0,0 +1,52 @@ +/** + * Which groups hold the admin-tier relations on the site. + * + * `admin`, `editor` and `author` are relations in Dune's authz schema (plugins + * cannot add relations). By default a group of the same name holds each one on + * the site resource, so a user whose `roles[]` has `editor` is in `group:editor` + * and may edit. `system.role_grants` lets a site change which group gets which + * relation: remap its own group (`staff: editor`), keep a subset, or turn the + * group-level grants off with `{}`. + * + * @module + */ + +import type { DuneConfig } from "../config/types.ts"; + +/** A relation of the authz schema that can be granted on the site. */ +export type GrantRelation = "admin" | "editor" | "author"; + +/** Group name → the relation that group holds on the site. */ +export type RoleGrants = Record; + +/** The grants a site has when it does not set `system.role_grants`. */ +export const DEFAULT_ROLE_GRANTS: Readonly = Object.freeze({ + admin: "admin", + editor: "editor", + author: "author", +}); + +/** The relations a group may be granted. */ +export const GRANT_RELATIONS: readonly GrantRelation[] = ["admin", "editor", "author"]; + +/** The site's grants: `system.role_grants` when set (even `{}`), else the defaults. */ +export function resolveRoleGrants(config: DuneConfig): RoleGrants { + const configured = config.system?.role_grants; + return configured === undefined ? { ...DEFAULT_ROLE_GRANTS } : { ...configured }; +} + +/** Problems with a `system.role_grants` value, or an empty list. */ +export function roleGrantProblems(value: unknown): string[] { + if (value === undefined) return []; + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return ["must be a mapping of group name to relation (admin, editor or author)"]; + } + const problems: string[] = []; + for (const [group, relation] of Object.entries(value)) { + if (group.trim() === "") problems.push("group names must not be empty"); + if (!GRANT_RELATIONS.includes(relation as GrantRelation)) { + problems.push(`"${group}": ${JSON.stringify(relation)} is not a relation; use admin, editor or author`); + } + } + return problems; +} diff --git a/src/cli/response-transforms.ts b/src/cli/response-transforms.ts index 06897d9f..f7c6ea17 100644 --- a/src/cli/response-transforms.ts +++ b/src/cli/response-transforms.ts @@ -32,7 +32,7 @@ import type { CapabilityReader } from "../plugin-api/capabilities.ts"; import type { EditorResolver } from "../auth/editor.ts"; import type { PageIndex } from "../content/types.ts"; -import { highestAdminRole, roleHasPermission } from "../auth/authz-schema.ts"; +import { roleHasPermission } from "../auth/authz-schema.ts"; import { applyResponseTransforms } from "../plugins/loader.ts"; import { isAdminPath } from "./serve-utils.ts"; import { scrubMarkersFromResponse } from "./marker-scrub.ts"; @@ -131,13 +131,12 @@ export async function runPluginResponseTransforms( if (resolveEditor) { const resolved = await resolveEditor(req); if (resolved.ok) { - // The context keeps its original `role: string` shape (a published hook - // type) rather than switching to `roles`. The roles list mixes admin-tier - // roles with content-gating tags in no guaranteed order, so the published - // role and the synchronous hasPermission() answers use the highest - // admin-tier entry, or they would under-privilege relative to the - // authz.check() that already decided this user may edit. - const role = highestAdminRole(resolved.editor.roles); + // The context keeps its original `role: string` shape (a published hook type). + // The role is the relation authz reports for this user, not their highest + // role *name*: under `system.role_grants` a user in `group:editor` mapped to + // `author` holds `author`, and the synchronous hasPermission() answers must + // not exceed what authz.check() decided. + const role: string = resolved.editor.relation; transformAuth = { username: resolved.editor.username, role, diff --git a/src/config/loader.ts b/src/config/loader.ts index f51c3e63..8b699cb9 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -29,7 +29,16 @@ import { validateConfig } from "./validator.ts"; const warnedIgnored = new Set(); /** - * Deep merge two objects. Arrays are replaced, not concatenated. + * Keys whose value an override replaces whole instead of merging into. A mapping + * that is a complete answer (`system.role_grants`: which groups hold which + * relation) means "exactly this" in an environment overlay; merged key by key, + * an overlay `role_grants: {}` could never switch the base's grants off. + */ +const REPLACED_KEYS = new Set(["role_grants"]); + +/** + * Deep merge two objects. Arrays are replaced, not concatenated, and so are + * the values under {@link REPLACED_KEYS}. * * @param depth Internal recursion depth guard — callers should not pass this. * Limits recursion to 20 levels to prevent a stack overflow from @@ -52,6 +61,7 @@ export function deepMerge>( const overVal = override[key]; if ( + !REPLACED_KEYS.has(key) && overVal !== null && typeof overVal === "object" && !Array.isArray(overVal) && diff --git a/src/config/system-config.ts b/src/config/system-config.ts index 788b2adf..8f0c4d2d 100644 --- a/src/config/system-config.ts +++ b/src/config/system-config.ts @@ -252,6 +252,32 @@ export interface SystemConfig { * `admin.runtime_dir`. */ runtime_dir?: string; + /** + * Which groups hold the admin-tier relations on the site: group name → + * `admin` | `editor` | `author`. Group membership comes from a user's + * `roles[]`, so a user whose roles include a mapped group gets that relation + * (an `editor` may edit any page, an `author` the pages it created). + * + * Default: `{ admin: admin, editor: editor, author: author }`. Set your own + * group onto a relation (`staff: editor`), keep a subset, or `{}` to turn the + * group-level grants off. The relations are fixed by the authz schema; this + * chooses which group gets which. Accounts an admin plugin seeds itself + * (its password accounts) keep their rights regardless. + * + * In an environment overlay (`config/env//system.yaml`) `role_grants` replaces the base mapping + * instead of merging into it, so one environment can switch the grants off with `{}`. + * + * Grants no longer configured are revoked at start: every + * (group → admin/editor/author) tuple on the site belongs to this setting. + * + * @example + * ```yaml + * role_grants: + * staff: editor # your own group + * editor: author # a content tier that should only create and edit its own pages + * ``` + */ + role_grants?: Record; /** * Revisions kept per page; the oldest is pruned on each save. 0 keeps all. * Default: 50. Replaces `admin.max_revisions`. diff --git a/src/config/validator.ts b/src/config/validator.ts index 8fedadce..d649140c 100644 --- a/src/config/validator.ts +++ b/src/config/validator.ts @@ -8,6 +8,7 @@ */ import type { DuneConfig, PluginEntry, SiteConfig, SystemConfig, ThemeConfig, ThemePackageEntry } from "./types.ts"; +import { roleGrantProblems } from "../auth/role-grants.ts"; import { assertPinnedThemeSpecifier, assertThemeName, @@ -201,6 +202,10 @@ function validateSystem(system: SystemConfig, errors: ValidationError[]): void { } } + for (const message of roleGrantProblems(system.role_grants)) { + errors.push({ path: "system.role_grants", message, got: system.role_grants }); + } + if (typeof system.debug !== "boolean") { errors.push({ path: "system.debug", diff --git a/src/inline-edit/types.ts b/src/inline-edit/types.ts index 38b4e50c..13364e7f 100644 --- a/src/inline-edit/types.ts +++ b/src/inline-edit/types.ts @@ -42,6 +42,15 @@ export interface EditorIdentity { username: string; /** Every role on the user record (admin-tier roles and content-gating tags mixed). */ roles: string[]; + /** + * The admin-tier relation this editor holds on the site: `admin` and `editor` may edit any + * page, `author` only the pages it created. Read by core from the authz system (what the + * user actually holds, through the site's `system.role_grants` or a direct tuple), not + * inferred from role names: a group `staff` mapped to `editor` makes its members `editor`, + * and an `editor` role mapped down to `author` makes them `author`. A provider applies its + * ownership rules to this and not to role names. + */ + relation: "admin" | "editor" | "author"; /** `site`: signed in through the site's own login; `session`: through the `editor.session` provider. */ source: "site" | "session"; } diff --git a/src/mod.ts b/src/mod.ts index d939caac..c353119f 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -321,6 +321,12 @@ export { /** Grant the admin-tier roles their relation on SITE_RESOURCE at group level. @since 0.36.0 */ bootstrapRoleGrants, } from "./auth/authz.ts"; +export { + /** Group → relation grants on the site: defaults, resolution from `system.role_grants`. @since 0.36.0 */ + DEFAULT_ROLE_GRANTS, + resolveRoleGrants, +} from "./auth/role-grants.ts"; +export type { GrantRelation, RoleGrants } from "./auth/role-grants.ts"; export { /** Make a user's `member of group:` tuples match their `roles[]`. @since 0.36.0 */ syncUserRoles, diff --git a/src/runtime/bootstrap.ts b/src/runtime/bootstrap.ts index 832cd0ce..1b7c79a3 100644 --- a/src/runtime/bootstrap.ts +++ b/src/runtime/bootstrap.ts @@ -41,6 +41,7 @@ import { loadPluginAdminConfigs, loadPlugins } from "../plugins/loader.ts"; import { MetricsCollector } from "../metrics/mod.ts"; import { bootstrapRoleGrants, createDuneAuthSystem } from "../auth/authz.ts"; import { automaticAdminTierGrants } from "../auth/automatic-grants.ts"; +import { resolveRoleGrants } from "../auth/role-grants.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"; @@ -552,7 +553,7 @@ export async function bootstrap( 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); + await bootstrapRoleGrants(bundle.authz, bundle.adapter, resolveRoleGrants(config)); // Say where the config hands out those roles by itself (a payment product's // role, an identity provider's role claims). Informational: it is often the point. for (const g of automaticAdminTierGrants(config)) { diff --git a/src/schema/config-schema.ts b/src/schema/config-schema.ts index eb5bd01e..10709c4f 100644 --- a/src/schema/config-schema.ts +++ b/src/schema/config-schema.ts @@ -360,6 +360,13 @@ export const CONFIG_SCHEMA = { description: "Machine-local runtime state (gitignored): job state, revision history, scheduled actions, search analytics. Replaces admin.runtime_dir.", }, + role_grants: { + type: "object", + description: + "Which groups hold the admin-tier relations on the site: group name -> admin | editor | author. A user is a member of every group named in roles[]. Default: admin, editor and author each hold their own relation. Remap (staff: editor), keep a subset, or {} to turn the group-level grants off.", + additionalProperties: { type: "string", enum: ["admin", "editor", "author"] }, + default: { admin: "admin", editor: "editor", author: "author" }, + }, max_revisions: { type: "integer", minimum: 0, diff --git a/tests/auth/automatic_grants_test.ts b/tests/auth/automatic_grants_test.ts index 027221fb..f5063495 100644 --- a/tests/auth/automatic_grants_test.ts +++ b/tests/auth/automatic_grants_test.ts @@ -47,3 +47,21 @@ Deno.test("doctor lists them as access findings that never fail the run", () => assertEquals(access.map((f) => f.severity), ["warning", "info"]); assertEquals(access.some((f) => f.severity === "error"), false); }); + +Deno.test("the notes follow system.role_grants: a remapped group is named, an unmapped one is not", () => { + const payments = (role: string) => ({ secret_key: "sk", products: [{ id: "pro", name: "Pro", priceId: "p", role }] }); + const withGrants = (role: string, role_grants: Record) => + ({ site: { payments: payments(role) }, system: { role_grants } }) as unknown as DuneConfig; + + // "staff" is not a default name, but it is mapped to editor here. + const [staff] = automaticAdminTierGrants(withGrants("staff", { staff: "editor" })); + assertStringIncludes(staff.message, 'mapped by system.role_grants to "editor"'); + // "editor" is no longer mapped: a payment giving it grants nothing site-wide. + assertEquals(automaticAdminTierGrants(withGrants("editor", { staff: "editor" })), []); + // {} turns it all off, including the external-jwt note. + assertEquals(automaticAdminTierGrants(withGrants("editor", {})), []); + assertEquals( + automaticAdminTierGrants({ site: { auth: { mode: "external-jwt" } }, system: { role_grants: {} } } as unknown as DuneConfig), + [], + ); +}); diff --git a/tests/auth/editor_test.ts b/tests/auth/editor_test.ts index 2484ab81..288906aa 100644 --- a/tests/auth/editor_test.ts +++ b/tests/auth/editor_test.ts @@ -7,7 +7,7 @@ */ import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; -import { createEditorResolver } from "../../src/auth/editor.ts"; +import { createEditorResolver, mayEditPage } from "../../src/auth/editor.ts"; import type { DuneAuthSystem } from "../../src/auth/authz.ts"; import { USER_HEADER } from "../../src/auth/types.ts"; @@ -44,10 +44,11 @@ function siteRequest(user: Record): Request { } Deno.test("editor: a site user holding pages.update is an editor, named by username, then name, then email", async () => { + // This fake authz says yes to everything, so the relation read from it is the highest, whatever the role is called. const authz = makeAuthz(true); const resolve = createEditorResolver({ capabilities: caps(), siteAuthz: () => authz, sessionAuthz: () => undefined }); const r = await resolve(siteRequest({ id: "u1", roles: ["editor"], email: "e@x.test", name: "Ed" })); - assertEquals(r, { ok: true, editor: { id: "u1", username: "Ed", roles: ["editor"], source: "site" } }); + assertEquals(r, { ok: true, editor: { id: "u1", username: "Ed", roles: ["editor"], relation: "admin", source: "site" } }); assertEquals((authz.calls[0] as { onWhat: unknown }).onWhat, { type: "app", id: "site" }); }); @@ -71,7 +72,7 @@ Deno.test("editor: a session-provider user is an editor when authz allows; the s sessionAuthz: () => session, }); const r = await resolve(new Request("https://site.test/", { headers: { cookie: "sid=abc" } })); - assertEquals(r, { ok: true, editor: { id: "a1", username: "ada", roles: ["admin"], source: "session" } }); + assertEquals(r, { ok: true, editor: { id: "a1", username: "ada", roles: ["admin"], relation: "admin", source: "session" } }); assertEquals(site.calls.length, 0); }); @@ -112,14 +113,72 @@ Deno.test("editor: a session user without pages.update is 403", async () => { assertEquals(r, { ok: false, status: 403 }); }); -import { mayEditPage } from "../../src/auth/editor.ts"; -Deno.test("mayEditPage: admin and editor edit any page; an author only the pages they created; no page, no edit", () => { +Deno.test("mayEditPage: admin and editor relations edit any page; an author only the pages it created; no page, no edit", () => { const page = { createdBy: "a1" }; - assertEquals(mayEditPage({ id: "x", roles: ["admin"] }, page), true); - assertEquals(mayEditPage({ id: "x", roles: ["member", "editor"] }, page), true); - assertEquals(mayEditPage({ id: "a1", roles: ["author"] }, page), true); - assertEquals(mayEditPage({ id: "a2", roles: ["author"] }, page), false); - assertEquals(mayEditPage({ id: "a1", roles: ["author"] }, {}), false); - assertEquals(mayEditPage({ id: "x", roles: ["admin"] }, null), false); + assertEquals(mayEditPage({ id: "x", relation: "admin" }, page), true); + assertEquals(mayEditPage({ id: "x", relation: "editor" }, page), true); + assertEquals(mayEditPage({ id: "a1", relation: "author" }, page), true); + assertEquals(mayEditPage({ id: "a2", relation: "author" }, page), false); + assertEquals(mayEditPage({ id: "a1", relation: "author" }, {}), false); + assertEquals(mayEditPage({ id: "x", relation: "admin" }, null), false); +}); + +// ── The relation is what authz says the user holds, not what their role is called ── + +import { createStorage } from "../../src/storage/mod.ts"; +import { bootstrapRoleGrants, createDuneAuthSystem } from "../../src/auth/authz.ts"; +import { DUNE_BASE_AUTHZ_ACTIONS, RELATION_PROBE_ACTIONS, SITE_RESOURCE } from "../../src/auth/authz-schema.ts"; +import type { RoleGrants } from "../../src/auth/role-grants.ts"; + +Deno.test("the relation probes tell the three relations apart in the built-in schema", () => { + assertEquals([...DUNE_BASE_AUTHZ_ACTIONS[RELATION_PROBE_ACTIONS.admin]], ["admin"]); + assertEquals([...DUNE_BASE_AUTHZ_ACTIONS[RELATION_PROBE_ACTIONS.editor]], ["admin", "editor"]); + assertEquals([...DUNE_BASE_AUTHZ_ACTIONS["pages.update"]], ["admin", "editor", "author"]); +}); + +async function relationUnder(grants: RoleGrants, roles: string[], direct?: "admin" | "editor" | "author") { + const root = await Deno.makeTempDir({ prefix: "dune_test_editor_relation_" }); + try { + const { authz, adapter } = createDuneAuthSystem({ authzStore: "local", dataDir: "data" }, createStorage({ rootDir: root })); + await bootstrapRoleGrants(authz, adapter, grants); + for (const role of roles) { + await authz.addMember({ member: { type: "user", id: "u1" }, group: { type: "group", id: role } }); + } + if (direct) await authz.allow({ who: { type: "user", id: "u1" }, toBe: direct, onWhat: SITE_RESOURCE }); + const resolve = createEditorResolver({ capabilities: caps(), siteAuthz: () => authz, sessionAuthz: () => undefined }); + const r = await resolve(siteRequest({ id: "u1", roles })); + return r.ok ? r.editor.relation : r.status; + } finally { + await Deno.remove(root, { recursive: true }).catch(() => {}); + } +} + +const DEFAULTS: RoleGrants = { admin: "admin", editor: "editor", author: "author" }; + +Deno.test("with the default grants each role holds its own relation", async () => { + assertEquals(await relationUnder(DEFAULTS, ["admin"]), "admin"); + assertEquals(await relationUnder(DEFAULTS, ["member", "editor"]), "editor"); + assertEquals(await relationUnder(DEFAULTS, ["author"]), "author"); +}); + +Deno.test("a remap to a LOWER relation lowers the reported relation (editor -> author, admin -> editor)", async () => { + assertEquals(await relationUnder({ editor: "author" }, ["editor"]), "author"); + assertEquals(await relationUnder({ admin: "editor" }, ["admin"]), "editor"); + // and the author limit then applies: an author edits only the pages it created. + assertEquals(mayEditPage({ id: "u1", relation: "author" }, { createdBy: "someone-else" }), false); +}); + +Deno.test("a remap to a higher relation, or of an own group, raises it", async () => { + assertEquals(await relationUnder({ staff: "editor" }, ["staff"]), "editor"); + assertEquals(await relationUnder({ author: "admin" }, ["author"]), "admin"); +}); + +Deno.test("an account with a direct tuple holds what the tuple says, whatever its groups say; the highest of both wins", async () => { + assertEquals(await relationUnder({}, ["admin"], "admin"), "admin"); + assertEquals(await relationUnder({ editor: "author" }, ["editor"], "editor"), "editor"); +}); + +Deno.test("with the grants off and no direct tuple a role grants nothing", async () => { + assertEquals(await relationUnder({}, ["editor"]), 403); }); diff --git a/tests/auth/role_grants_test.ts b/tests/auth/role_grants_test.ts new file mode 100644 index 00000000..e3271cb1 --- /dev/null +++ b/tests/auth/role_grants_test.ts @@ -0,0 +1,100 @@ +/** + * `system.role_grants`: which groups hold the admin-tier relations on the site. + * Default: admin, editor and author each hold their own. The bootstrap + * reconciles, so a grant that is no longer configured is revoked. + */ + +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 { DEFAULT_ROLE_GRANTS, resolveRoleGrants, roleGrantProblems, type RoleGrants } from "../../src/auth/role-grants.ts"; +import { validateConfig } from "../../src/config/validator.ts"; +import { DEFAULT_CONFIG } from "../../src/config/defaults.ts"; +import type { DuneConfig } from "../../src/config/types.ts"; + +async function withAuthz(fn: (t: { + grants: (g: RoleGrants) => Promise; + holders: (relation: string) => Promise; + join: (userId: string, group: string) => Promise; + canEdit: (userId: string) => Promise; +}) => Promise) { + const root = await Deno.makeTempDir({ prefix: "dune_test_role_grants_" }); + try { + const { authz, adapter } = createDuneAuthSystem({ authzStore: "local", dataDir: "data" }, createStorage({ rootDir: root })); + await fn({ + grants: (g) => bootstrapRoleGrants(authz, adapter, g), + holders: async (relation) => + (await adapter.findSubjects(SITE_RESOURCE, relation, { subjectType: "group" })).map((s) => s.id).sort(), + join: async (userId, group) => { + await authz.addMember({ member: { type: "user", id: userId }, group: { type: "group", id: group } }); + }, + // deno-lint-ignore no-explicit-any + canEdit: (id) => authz.check({ who: { type: "user", id }, canThey: "pages.update" as any, onWhat: SITE_RESOURCE }), + }); + } finally { + await Deno.remove(root, { recursive: true }).catch(() => {}); + } +} + +const cfg = (system: Record) => ({ ...DEFAULT_CONFIG, system: { ...DEFAULT_CONFIG.system, ...system } }) as DuneConfig; + +Deno.test("with nothing configured the three admin-tier groups hold their own relation", async () => { + assertEquals(resolveRoleGrants(cfg({})), { ...DEFAULT_ROLE_GRANTS }); + await withAuthz(async ({ grants, holders }) => { + await grants(resolveRoleGrants(cfg({}))); + assertEquals(await holders("admin"), ["admin"]); + assertEquals(await holders("editor"), ["editor"]); + assertEquals(await holders("author"), ["author"]); + }); +}); + +Deno.test("{} turns the group-level grants off, and a grant written by an earlier run is revoked", async () => { + await withAuthz(async ({ grants, holders, join, canEdit }) => { + await join("u1", "editor"); + await grants(resolveRoleGrants(cfg({}))); + assertEquals(await canEdit("u1"), true); + + await grants(resolveRoleGrants(cfg({ role_grants: {} }))); + for (const r of ["admin", "editor", "author"]) assertEquals(await holders(r), []); + assertEquals(await canEdit("u1"), false, "membership alone grants nothing any more"); + }); +}); + +Deno.test("remapping gives your own group the relation and takes it from the default one", async () => { + await withAuthz(async ({ grants, holders, join, canEdit }) => { + await join("staff-member", "staff"); + await join("tier-member", "editor"); + await grants(resolveRoleGrants(cfg({}))); + assertEquals(await canEdit("tier-member"), true); + assertEquals(await canEdit("staff-member"), false); + + await grants(resolveRoleGrants(cfg({ role_grants: { staff: "editor" } }))); + assertEquals(await holders("editor"), ["staff"]); + assertEquals(await canEdit("staff-member"), true); + assertEquals(await canEdit("tier-member"), false, "'editor' is a content tier on this site now"); + }); +}); + +Deno.test("a subset keeps only what is listed; running twice changes nothing", async () => { + await withAuthz(async ({ grants, holders }) => { + const only = resolveRoleGrants(cfg({ role_grants: { author: "author" } })); + await grants(only); + await grants(only); + assertEquals(await holders("author"), ["author"]); + assertEquals(await holders("editor"), []); + assertEquals(await holders("admin"), []); + }); +}); + +Deno.test("an unknown relation or a non-mapping is a config error, not a silent default", () => { + assertEquals(roleGrantProblems(undefined), []); + assertEquals(roleGrantProblems({}), []); + assertEquals(roleGrantProblems({ staff: "editor" }), []); + assertEquals(roleGrantProblems({ staff: "owner" }).length, 1); + assertEquals(roleGrantProblems(["editor"]).length, 1); + assertEquals(roleGrantProblems("editor").length, 1); + const errors = validateConfig(cfg({ role_grants: { staff: "superuser" } })); + assertEquals(errors.some((e) => e.includes("system.role_grants")), true); + assertEquals(validateConfig(cfg({ role_grants: { staff: "editor" } })).some((e) => e.includes("role_grants")), false); +}); diff --git a/tests/cli/response_transforms_test.ts b/tests/cli/response_transforms_test.ts index a1b5f1f4..24c39dc4 100644 --- a/tests/cli/response_transforms_test.ts +++ b/tests/cli/response_transforms_test.ts @@ -16,6 +16,7 @@ import { runPluginResponseTransforms as run, } from "../../src/cli/response-transforms.ts"; import { createEditorResolver } from "../../src/auth/editor.ts"; +import { DUNE_BASE_AUTHZ_ACTIONS } from "../../src/auth/authz-schema.ts"; import type { DuneAuthSystem } from "../../src/auth/authz.ts"; import type { DunePlugin, @@ -236,7 +237,7 @@ Deno.test("runPluginResponseTransforms: valid session WITH pages.update — auth result: { authenticated: true, user: makeUser("editor") }, }); // deno-lint-ignore no-explicit-any - const authz = makeAuthz(true) as any; + const authz = makeAuthz(true, "editor") as any; const { plugin, seen } = makeRecordingPlugin(); await runPluginResponseTransforms({ req: makeReq("/about", SESSION_COOKIE), @@ -258,7 +259,7 @@ Deno.test("runPluginResponseTransforms: valid session WITH pages.update — auth assertEquals(ctxAuth?.hasPermission("pages.delete"), false); }); -Deno.test("runPluginResponseTransforms: roles[] mixing tags and admin roles — highest admin-tier role wins", async () => { +Deno.test("runPluginResponseTransforms: roles[] mixing tags and admin roles — the relation authz reports is the role", async () => { // A merged User's roles[] can hold content-gating tags alongside the // admin-tier role, in no guaranteed order. roles[0] ("member") used to // become ctx.auth.role — under-privileging hasPermission() relative to @@ -359,14 +360,16 @@ Deno.test("runPluginResponseTransforms: non-content route — page is null", asy // table to compare against anymore; these just prove authz's answer is what // actually governs `auth` in the returned context. -function makeAuthz(allowed: boolean) { +/** An authz system whose user holds `relation` (when `allowed`), answering each action as the built-in schema does. */ +function makeAuthz(allowed: boolean, relation: "admin" | "editor" | "author" = "admin") { const calls = { check: 0 }; return { calls, // deno-lint-ignore no-explicit-any - check(_args: any): Promise { + check(args: any): Promise { calls.check++; - return Promise.resolve(allowed); + const relations: readonly string[] = (DUNE_BASE_AUTHZ_ACTIONS as Record)[args.canThey] ?? []; + return Promise.resolve(allowed && relations.includes(relation)); }, }; } @@ -376,7 +379,7 @@ Deno.test("runPluginResponseTransforms: authz.check() allowing grants access", a result: { authenticated: true, user: makeUser("author") }, }); // deno-lint-ignore no-explicit-any - const authz = makeAuthz(true) as any; + const authz = makeAuthz(true, "author") as any; const { plugin, seen } = makeRecordingPlugin(); await runPluginResponseTransforms({ req: makeReq("/about", SESSION_COOKIE), @@ -388,8 +391,10 @@ Deno.test("runPluginResponseTransforms: authz.check() allowing grants access", a config, adminPrefix: "/admin", }); - assertEquals(authz.calls.check, 1); + // pages.update, then the probes that tell author from editor and admin. + assertEquals(authz.calls.check > 0, true); assertEquals(seen[0].auth?.username, "alice"); + assertEquals(seen[0].auth?.role, "author"); }); Deno.test("runPluginResponseTransforms: authz.check() denying blocks access", async () => { diff --git a/tests/config/loader_test.ts b/tests/config/loader_test.ts index 4417271f..1a4dd608 100644 --- a/tests/config/loader_test.ts +++ b/tests/config/loader_test.ts @@ -241,3 +241,13 @@ Deno.test("loadConfig: validation catches invalid config", async () => { "Invalid configuration", ); }); + +Deno.test("deepMerge: an override of role_grants replaces the mapping instead of merging into it", () => { + const base: { system: { role_grants: Record; debug: boolean } } = { + system: { role_grants: { staff: "editor", editor: "author" }, debug: false }, + }; + assertEquals(deepMerge(base, { system: { role_grants: {} } }).system.role_grants, {}); + assertEquals(deepMerge(base, { system: { role_grants: { boss: "admin" } } }).system.role_grants, { boss: "admin" }); + // Other nested objects still merge key by key. + assertEquals(deepMerge(base, { system: { debug: true } }).system, { role_grants: { staff: "editor", editor: "author" }, debug: true }); +}); diff --git a/tests/runtime/site_user_editing_test.ts b/tests/runtime/site_user_editing_test.ts index e64cd4e6..b8e2f060 100644 --- a/tests/runtime/site_user_editing_test.ts +++ b/tests/runtime/site_user_editing_test.ts @@ -53,7 +53,7 @@ const sessionProvider: DunePlugin = { }, }; -const ROLES = ["author", "editor", "admin", "member", "subscriber"]; +const ROLES = ["author", "editor", "admin", "member", "subscriber", "staff"]; /** * Run `fn` against a site with a login and one existing user per role. The @@ -67,12 +67,14 @@ async function withSite( cookieFor: (role: string) => Promise, plugin: { services: Awaited>["pluginServices"]; ids: Map }, ) => Promise, + systemYaml?: string, ) { 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 }); + if (systemYaml) await Deno.writeTextFile(join(root, "config", "system.yaml"), systemYaml); await Deno.writeTextFile( join(root, "config", "site.yaml"), "title: Test\nurl: http://localhost\nauth:\n mode: dune\n", @@ -83,6 +85,12 @@ async function withSite( const user = await userStore.create({ email: `${role}@example.com`, provider: "local", roles: [role] }); ids.set(role, user.id); } + // A page the "editor" user created (it only matters when "editor" is remapped to author). + await Deno.mkdir(join(root, "content", "03.editors"), { recursive: true }); + await Deno.writeTextFile( + join(root, "content", "03.editors", "default.md"), + `---\ntitle: Editors\ncreatedBy: ${ids.get("editor")}\n---\n\nEditors\n`, + ); // A page the author created: authors edit their own pages, not others'. await Deno.mkdir(join(root, "content", "02.mine"), { recursive: true }); await Deno.writeTextFile( @@ -189,7 +197,7 @@ Deno.test("/api/inline-edit/*: core resolves the editor, then hands the request assertEquals(res.status, 200); assertEquals(await res.json(), { method: "GET", - editor: { id: ids.get("editor"), username: "editor@example.com", roles: ["editor"], source: "site" }, + editor: { id: ids.get("editor"), username: "editor@example.com", roles: ["editor"], relation: "editor", source: "site" }, path: "content/01.home%2Fdefault.md/source", title: "Home", missing: null, @@ -214,3 +222,29 @@ Deno.test("/api/inline-edit/*: a write needs a same-origin request", opts, async assertEquals((await write({ origin: "http://localhost" })).status, 200); }); }); + +Deno.test("system.role_grants: a remapped group edits, the default one no longer does; {} switches the grants off", opts, async () => { + await withSite([], async (ws, cookieFor) => { + assertEquals((await ws(await cookieFor("staff"))).status, 204); + assertEquals((await ws(await cookieFor("editor"))).status, 403, "'editor' is a content tier on this site"); + assertEquals((await ws(await cookieFor("member"))).status, 403); + }, "role_grants:\n staff: editor\n"); + + await withSite([], async (ws, cookieFor) => { + for (const role of ["author", "editor", "admin", "staff"]) { + assertEquals((await ws(await cookieFor(role))).status, 403, role); + } + }, "role_grants: {}\n"); +}); + +Deno.test("system.role_grants: a default-named group remapped DOWN keeps the own-pages-only limit at the socket", opts, async () => { + await withSite([], async (ws, cookieFor) => { + // "editor" is a content tier here, mapped to author: its users edit their own pages only. + const editor = await cookieFor("editor"); + assertEquals((await ws(editor, "03.editors/default.md")).status, 204, "a page it created"); + assertEquals((await ws(editor, "01.home/default.md")).status, 403, "someone else's page"); + assertEquals((await ws(editor, "02.mine/default.md")).status, 403, "the author's page"); + // "author" is no longer mapped to anything, so that role grants nothing. + assertEquals((await ws(await cookieFor("author"), "02.mine/default.md")).status, 403); + }, "role_grants:\n editor: author\n"); +});