diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ff42081..f85e8b48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,30 @@ split, begin at v1.0.0 — see [VERSIONING.md](VERSIONING.md) for exactly what ## [Unreleased] +### Added + +- **Page gating is inherited, and a site can gate everything by default (#157).** A page's `roles:` now + applies to everything beneath it, so gating a section is one line on its top page, not one per page and + language. `site.auth.default_roles` (same values as `roles:`, e.g. `any`) gates every page that neither + declares nor inherits a rule. The first match wins: the page's own `roles:`, then the nearest ancestor's, + then the site default. `roles: public` opts a page or a whole section out of an inherited or default gate. + `/auth/*` and theme assets are never gated. A translated page takes the rule of its section from the same + language, else from another language, and where the languages disagree a gate beats `public`. A malformed + `roles:` value now counts as "not declared" (it inherits) instead of opening the page. `PageIndex.roles` + and `gated` hold the effective rule, and `frontmatter.roles` on a loaded page does too; the page's own + declaration is kept in the new `PageIndex.ownRoles`. + +### Fixed + +- **`roles: any` did nothing of the kind.** The docs describe it as "any logged-in user", but it was read as + a role literally named `any`, so a signed-in visitor without that role got a 403. It now means any + logged-in user (the same as `roles: []`). Only the string form changes: a site that has a role literally named + `any` must rename it, and `roles: [any]` still means that literal role. +- **Files in a gated page's folder were served to anyone on `/content-media/*`.** The route-based media path + (`//file.pdf`) already checked the owning page; the legacy `/content-media//` route did + not. It now applies the owning page's rule (404 for a visitor who may not see the page, except that a logged-out visitor who opens the file directly in the + browser is sent to the login page; `private, no-store` for gated files), including audio and images. A file with no owning page stays public, unless the site sets `auth.default_roles`, which then gates it too. (#157) + ## [0.36.1] — 2026-11-12 ### Fixed diff --git a/src/auth/gating.ts b/src/auth/gating.ts index 27e92a19..057a87a7 100644 --- a/src/auth/gating.ts +++ b/src/auth/gating.ts @@ -9,7 +9,10 @@ * roles: # AND list — user must have all * all: [member, verified] * roles: [] # authenticated-only (any logged-in user) - * # absent # public access — no check performed + * roles: any # same as `[]` + * roles: public # open, even inside a gated section / gated site + * # absent # inherit: nearest ancestor's rule, else site.auth.default_roles, + * # else public (see applyGatingInheritance) * * When a polizy AuthSystem is wired via `setGatingAuthz()`, role checks go * through `authz.check()` for full hierarchy and group membership support. @@ -116,10 +119,15 @@ export function parseRolesSpec(raw: unknown): RolesSpec | null { // Absent or explicitly null → public if (raw === null || raw === undefined) return null; - // String → single role + // String → single role, or one of the two reserved words: + // "any" → any logged-in user (same as `[]`) + // "public" → explicitly open (never a gate; see parseRolesDirective) if (typeof raw === "string") { const trimmed = raw.trim(); - return trimmed.length > 0 ? trimmed : null; + if (trimmed.length === 0) return null; + if (trimmed === ANY_ROLE) return []; + if (trimmed === PUBLIC_ROLE) return null; + return trimmed; } // Array → OR list (possibly empty = authenticated-only) @@ -151,6 +159,41 @@ export function parseRolesSpec(raw: unknown): RolesSpec | null { return null; } +/** Reserved `roles:` value: any logged-in user, whatever their roles. */ +export const ANY_ROLE = "any"; + +/** Reserved `roles:` value: open to everyone, overriding an inherited gate. */ +export const PUBLIC_ROLE = "public"; + +/** + * What a page's own `roles:` value says about gating, once inheritance is in + * play: + * + * - `inherit` — nothing usable declared; take the nearest ancestor's rule, + * then the site default. + * - `public` — explicitly open (`roles: public`); ignores anything above. + * - `gate` — restricted by `spec`. + */ +export type RolesDirective = + | { kind: "inherit" } + | { kind: "public" } + | { kind: "gate"; spec: RolesSpec }; + +/** + * Classify a raw `roles` value. Unlike {@link parseRolesSpec} this tells + * "absent" (inherit) apart from "explicitly public", and treats a malformed + * value as `inherit` rather than public so that a typo cannot open a page + * inside a gated section or a gated site. + */ +export function parseRolesDirective(raw: unknown): RolesDirective { + if (raw === null || raw === undefined) return { kind: "inherit" }; + if (typeof raw === "string" && raw.trim() === PUBLIC_ROLE) { + return { kind: "public" }; + } + const spec = parseRolesSpec(raw); + return spec === null ? { kind: "inherit" } : { kind: "gate", spec }; +} + // ── Checking ────────────────────────────────────────────────────────────────── /** @@ -358,6 +401,41 @@ export async function canAccessIndexEntry( return denied === null; } +/** + * The denial `req` gets for a gated index entry, or null when it may have it. + * The Response is the page-level one from {@link enforceRoles} (302 to login + * for an anonymous visitor, 403 otherwise). + */ +export async function denialForIndexEntry( + req: Request, + page: GatedIndexEntry, + authzOverride?: DuneAuthSystem | null, +): Promise { + if (page.gated !== true) return null; + const spec = parseRolesSpec(page.roles); + if (spec === null) return null; + return await enforceRolesFromRequest(req, spec, authzOverride); +} + +/** + * What a *file* request is told when {@link denialForIndexEntry} denied it. + * + * A logged-out visitor who navigates straight to a gated file (a clicked PDF + * link, `Sec-Fetch-Dest: document`) is sent to the login page, as for a gated + * page. Every other denial is a 404, which also does not confirm that the file + * exists: subresources (``, `