Skip to content

Key parser-cached output by the parsing user's access class - #1347

Merged
JeroenDeDauw merged 1 commit into
1059-parse-time-gatesfrom
1059-parser-cache-class
Sep 8, 2026
Merged

Key parser-cached output by the parsing user's access class#1347
JeroenDeDauw merged 1 commit into
1059-parse-time-gatesfrom
1059-parser-cache-class

Conversation

@alistair3149

@alistair3149 alistair3149 commented Sep 2, 2026

Copy link
Copy Markdown
Member

For #1059

Follows-up to #1346

With the parse-time gates in place, the parser cache still served whatever the last parse produced to every reader: a sysop's parse cached restricted values for anonymous readers, and an anonymous parse cached gaps for sysops. Such output is now keyed by the parsing user's access class.

  • A cache-varying parser option, neowikiAccessClass, registered through ParserOptionsRegister. It carries no value; the class enters the key through the PageRenderingHash hook, and only for a page whose parse recorded the option. A lazily valued option would instead be loaded for every logged-in edit of every page by core's cache-key comparison, pushing all of them onto the deferred parser-cache path.
  • The class itself, from UserAccessClass, is the parsing user's effective groups plus the wiki-level read and neowiki-query decisions, as a readable string such as *,autoconfirmed,user;read;query. Group names are encoded, since they reach the class from hooks and the database and could otherwise be named to describe like another set of groups. Every reader gets a class, the anonymous one included, so a page that reads Subjects never reuses an entry cached before this change and no upgrade purge is needed.
  • Obtaining the parsing authority (ParserAuthority::of) records the option, so every gated read keys its page by access class without a call site having to remember to. {{#view}} now resolves its page's Main Subject through the public PageSubjectsLookup instead of a parsing authority, so it keeps one entry as before.
  • ADR 27's open decision on parse-time semantics resolves to this rule. The alternatives weighed (a fixed anonymous authority, post-cache trimming, a cache-off operating rule) are recorded under Alternatives Considered.

The class is a proxy for the permission hooks: exact wherever page access follows group membership, wrong for hooks that grant per user, which is why such wikis must run with the parser cache off. The installation docs now say so next to the rights. Installs with restricted content need one refreshLinks run, noted in the upgrading docs, because MediaWiki rewrites categories and page properties only on an edit.

Not solved here, and now stated in ADR 27's consequences: data derived from the canonical parse (categories, page properties, links tables, the Page node's categories in graph projections) is computed as the anonymous user, so on a wiki where anonymous users cannot read, a category derived from a parse-time read is never set. A designated reader for canonical parses would lift that; it needs a decision.

Manual Browser Check

  1. With the parser cache enabled, restrict a page's read permission for anonymous users (for example with the Lockdown extension) and give it a Subject with a text property.
  2. On a second page, save {{#neowiki_value: <property> | page=<restricted page>}} as a sysop and view it: the value shows.
  3. View that page logged out, without purging: the value is absent. Log back in and view it again: the value shows. Each access class keeps its own cached copy.

AI-authored — Claude Code, Fable 5.1 for the implementation, Opus 5 for the review fixes; same design discussion as #1346, no redirects during implementation; same-session AI review applied across six passes, diff not yet human-reviewed; tests written first and mutation-checked, phpcs/phpstan clean, the full non-Database suite and the touched Database classes green locally, CI green.

@alistair3149
alistair3149 force-pushed the 1059-parser-cache-class branch from 8faea4e to 4946259 Compare September 2, 2026 15:08
alistair3149 added a commit that referenced this pull request Sep 2, 2026
For #1059

Every parse-time read now runs as the user the page is parsed for, taken from the parser rather than the request context, and is gated the way the REST endpoints are:

* `{{#cypher_raw}}`, `{{#sparql_raw}}`, `nw.query` and `nw.sparqlQuery` require the `neowiki-query` right. The check lives in the two query services, so every raw-query surface shares it, the REST endpoints included, which no longer check it themselves. Denial renders the function's error box, or raises a `LuaError`.
* `{{#neowiki_value}}` and the `nw` data functions check the `read` permission of the page hosting the Subject, through the existing `PageReadAuthorizer`. Denial is indistinguishable from absent data; a relation whose target sits on an unreadable page shows the target's Subject id instead of a label.
* `nw.getSchema` reads through a lookup built for the user the page is parsed for instead of the request-wide one.
* Parse-time query limits are pinned to the `default` tier: the output is parser-cached, so the tier must not vary by who parsed.

The request context's user was the wrong identity: it is the saver during the canonical parse of an edit and the job runner otherwise, neither of which matches the identity the parser cache files the output under.

**Do not deploy without #1347.** The parser cache still keeps one copy per page. On a wiki where anonymous users cannot read, the save-time parse runs as anonymous, so every edit caches a copy with all parse-time values blank and serves it to every logged-in reader until the next purge; on a public wiki with per-page restrictions the same happens for the restricted values. #1347 keys such output by the reader's permissions. The per-wiki switch to disable the parse-time functions is deferred; running with the parser cache off covers the residual case.

## Manual Browser Check

1. Restrict a page's read permission for anonymous users (for example with the Lockdown extension), and give it a Subject with a text property.
2. On another page, add `{{#neowiki_value: <property> | page=<restricted page>}}` and preview while logged in: the value shows. Log out, purge, and view the page: the value is absent.
3. Set `$wgGroupPermissions['*']['neowiki-query'] = false;`, purge a page holding `{{#cypher_raw: RETURN 1 AS n}}`, and view it logged out: "You do not have permission to run Cypher queries." renders in place of the result.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDxvurgMTmC6JieEVYRzq8
@alistair3149
alistair3149 force-pushed the 1059-parser-cache-class branch from 4946259 to 05e12c9 Compare September 2, 2026 20:08
@alistair3149
alistair3149 force-pushed the 1059-parser-cache-class branch 3 times, most recently from 125c3c7 to c33cfc6 Compare September 3, 2026 01:36
@alistair3149
alistair3149 marked this pull request as ready for review September 3, 2026 01:41
alistair3149 added a commit that referenced this pull request Sep 3, 2026
For #1059

Every parse-time read now runs as the user the page is parsed for, taken from the parser rather than the request context, and is gated the way the REST endpoints are:

* `{{#cypher_raw}}`, `{{#sparql_raw}}`, `nw.query` and `nw.sparqlQuery` require the `neowiki-query` right. The check lives in the two query services, so every raw-query surface shares it, the REST endpoints included, which no longer check it themselves. Denial renders the function's error box, or raises a `LuaError`.
* `{{#neowiki_value}}` and the `nw` data functions check the `read` permission of the page hosting the Subject, through the existing `PageReadAuthorizer`. Denial is indistinguishable from absent data; a relation whose target sits on an unreadable page shows the target's Subject id instead of a label.
* `nw.getSchema` reads through a lookup built for the user the page is parsed for instead of the request-wide one.
* Parse-time query limits are pinned to the `default` tier: the output is parser-cached, so the tier must not vary by who parsed.

The request context's user was the wrong identity: it is the saver during the canonical parse of an edit and the job runner otherwise, neither of which matches the identity the parser cache files the output under.

**Do not deploy without #1347.** The parser cache still keeps one copy per page. On a wiki where anonymous users cannot read, the save-time parse runs as anonymous, so every edit caches a copy with all parse-time values blank and serves it to every logged-in reader until the next purge; on a public wiki with per-page restrictions the same happens for the restricted values. #1347 keys such output by the reader's permissions. The per-wiki switch to disable the parse-time functions is deferred; running with the parser cache off covers the residual case.

## Manual Browser Check

1. Restrict a page's read permission for anonymous users (for example with the Lockdown extension), and give it a Subject with a text property.
2. On another page, add `{{#neowiki_value: <property> | page=<restricted page>}}` and preview while logged in: the value shows. Log out, purge, and view the page: the value is absent.
3. Set `$wgGroupPermissions['*']['neowiki-query'] = false;`, purge a page holding `{{#cypher_raw: RETURN 1 AS n}}`, and view it logged out: "You do not have permission to run Cypher queries." renders in place of the result.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDxvurgMTmC6JieEVYRzq8
@alistair3149
alistair3149 force-pushed the 1059-parser-cache-class branch from c33cfc6 to 1b9a2b5 Compare September 3, 2026 01:44
alistair3149 added a commit that referenced this pull request Sep 3, 2026
For #1059

Every parse-time read now runs as the user the page is parsed for, taken from the parser rather than the request context, and is gated the way the REST endpoints are:

* `{{#cypher_raw}}`, `{{#sparql_raw}}`, `nw.query` and `nw.sparqlQuery` require the `neowiki-query` right. The check lives in the two query services, so every raw-query surface shares it, the REST endpoints included, which no longer check it themselves. Denial renders the function's error box, or raises a `LuaError`.
* `{{#neowiki_value}}` and the `nw` data functions check the `read` permission of the page hosting the Subject, through the existing `PageReadAuthorizer`. Denial is indistinguishable from absent data; a relation whose target sits on an unreadable page shows the target's Subject id instead of a label.
* `nw.getSchema` reads through a lookup built for the user the page is parsed for instead of the request-wide one.
* Parse-time query limits are pinned to the `default` tier: the output is parser-cached, so the tier must not vary by who parsed.

The request context's user was the wrong identity: it is the saver during the canonical parse of an edit and the job runner otherwise, neither of which matches the identity the parser cache files the output under.

**Do not deploy without #1347.** The parser cache still keeps one copy per page. On a wiki where anonymous users cannot read, the save-time parse runs as anonymous, so every edit caches a copy with all parse-time values blank and serves it to every logged-in reader until the next purge; on a public wiki with per-page restrictions the same happens for the restricted values. #1347 keys such output by the reader's permissions. The per-wiki switch to disable the parse-time functions is deferred; running with the parser cache off covers the residual case.

## Manual Browser Check

1. Restrict a page's read permission for anonymous users (for example with the Lockdown extension), and give it a Subject with a text property.
2. On another page, add `{{#neowiki_value: <property> | page=<restricted page>}}` and preview while logged in: the value shows. Log out, purge, and view the page: the value is absent.
3. Set `$wgGroupPermissions['*']['neowiki-query'] = false;`, purge a page holding `{{#cypher_raw: RETURN 1 AS n}}`, and view it logged out: "You do not have permission to run Cypher queries." renders in place of the result.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDxvurgMTmC6JieEVYRzq8
@alistair3149
alistair3149 force-pushed the 1059-parser-cache-class branch from 1b9a2b5 to 385b73f Compare September 3, 2026 01:46
For #1059

Follows-up to #1346

With the parse-time gates in place, the parser cache still served whatever the last parse produced to every reader: a sysop's parse cached restricted values for anonymous readers, and an anonymous parse cached gaps for sysops. Such output is now keyed by the parsing user's access class.

* A cache-varying parser option, `neowikiAccessClass`, registered through `ParserOptionsRegister`. It carries no value; the class enters the key through the `PageRenderingHash` hook, and only for a page whose parse recorded the option. A lazily valued option would instead be loaded for every logged-in edit of every page by core's cache-key comparison, pushing all of them onto the deferred parser-cache path.
* The class itself, from `UserAccessClass`, is the parsing user's effective groups plus the wiki-level `read` and `neowiki-query` decisions, as a readable string such as `*,autoconfirmed,user;read;query`. Group names are encoded, since they reach the class from hooks and the database and could otherwise be named to describe like another set of groups. Every reader gets a class, the anonymous one included, so a page that reads Subjects never reuses an entry cached before this change and no upgrade purge is needed.
* Obtaining the parsing authority (`ParserAuthority::of`) records the option, so every gated read keys its page by access class without a call site having to remember to. `{{#view}}` now resolves its page's Main Subject through the public `PageSubjectsLookup` instead of a parsing authority, so it keeps one entry as before.
* ADR 27's open decision on parse-time semantics resolves to this rule. The alternatives weighed (a fixed anonymous authority, post-cache trimming, a cache-off operating rule) are recorded under Alternatives Considered.

The class is a proxy for the permission hooks: exact wherever page access follows group membership, wrong for hooks that grant per user, which is why such wikis must run with the parser cache off. The installation docs now say so next to the rights. Installs with restricted content need one `refreshLinks` run, noted in the upgrading docs, because MediaWiki rewrites categories and page properties only on an edit.

Not solved here, and now stated in ADR 27's consequences: data derived from the canonical parse (categories, page properties, links tables, the Page node's categories in graph projections) is computed as the anonymous user, so on a wiki where anonymous users cannot read, a category derived from a parse-time read is never set. A designated reader for canonical parses would lift that; it needs a decision.

## Manual Browser Check

1. With the parser cache enabled, restrict a page's read permission for anonymous users (for example with the Lockdown extension) and give it a Subject with a text property.
2. On a second page, save `{{#neowiki_value: <property> | page=<restricted page>}}` as a sysop and view it: the value shows.
3. View that page logged out, without purging: the value is absent. Log back in and view it again: the value shows. Each access class keeps its own cached copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDxvurgMTmC6JieEVYRzq8
@alistair3149
alistair3149 force-pushed the 1059-parser-cache-class branch from 385b73f to 1b03dd0 Compare September 3, 2026 01:50
@JeroenDeDauw JeroenDeDauw added security alert('xss'); BlueSpice Capabilities specific to, or particularly relevant for, BlueSpice labels Sep 7, 2026
@JeroenDeDauw
JeroenDeDauw merged commit 648034e into 1059-parse-time-gates Sep 8, 2026
14 checks passed
@JeroenDeDauw
JeroenDeDauw deleted the 1059-parser-cache-class branch September 8, 2026 14:34
JeroenDeDauw pushed a commit that referenced this pull request Sep 9, 2026
* Gate parse-time reads on the user the page is parsed for

For #1059

Every parse-time read now runs as the user the page is parsed for, taken from the parser rather than the request context, and is gated the way the REST endpoints are:

* `{{#cypher_raw}}`, `{{#sparql_raw}}`, `nw.query` and `nw.sparqlQuery` require the `neowiki-query` right. The check lives in the two query services, so every raw-query surface shares it, the REST endpoints included, which no longer check it themselves. Denial renders the function's error box, or raises a `LuaError`.
* `{{#neowiki_value}}` and the `nw` data functions check the `read` permission of the page hosting the Subject, through the existing `PageReadAuthorizer`. Denial is indistinguishable from absent data; a relation whose target sits on an unreadable page shows the target's Subject id instead of a label.
* `nw.getSchema` reads through a lookup built for the user the page is parsed for instead of the request-wide one.
* Parse-time query limits are pinned to the `default` tier: the output is parser-cached, so the tier must not vary by who parsed.

The request context's user was the wrong identity: it is the saver during the canonical parse of an edit and the job runner otherwise, neither of which matches the identity the parser cache files the output under.

**Do not deploy without #1347.** The parser cache still keeps one copy per page. On a wiki where anonymous users cannot read, the save-time parse runs as anonymous, so every edit caches a copy with all parse-time values blank and serves it to every logged-in reader until the next purge; on a public wiki with per-page restrictions the same happens for the restricted values. #1347 keys such output by the reader's permissions. The per-wiki switch to disable the parse-time functions is deferred; running with the parser cache off covers the residual case.

## Manual Browser Check

1. Restrict a page's read permission for anonymous users (for example with the Lockdown extension), and give it a Subject with a text property.
2. On another page, add `{{#neowiki_value: <property> | page=<restricted page>}}` and preview while logged in: the value shows. Log out, purge, and view the page: the value is absent.
3. Set `$wgGroupPermissions['*']['neowiki-query'] = false;`, purge a page holding `{{#cypher_raw: RETURN 1 AS n}}`, and view it logged out: "You do not have permission to run Cypher queries." renders in place of the result.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDxvurgMTmC6JieEVYRzq8

* Key parser-cached output by the parsing user's access class (#1347)

For #1059

Follows-up to #1346

With the parse-time gates in place, the parser cache still served whatever the last parse produced to every reader: a sysop's parse cached restricted values for anonymous readers, and an anonymous parse cached gaps for sysops. Such output is now keyed by the parsing user's access class.

* A cache-varying parser option, `neowikiAccessClass`, registered through `ParserOptionsRegister`. It carries no value; the class enters the key through the `PageRenderingHash` hook, and only for a page whose parse recorded the option. A lazily valued option would instead be loaded for every logged-in edit of every page by core's cache-key comparison, pushing all of them onto the deferred parser-cache path.
* The class itself, from `UserAccessClass`, is the parsing user's effective groups plus the wiki-level `read` and `neowiki-query` decisions, as a readable string such as `*,autoconfirmed,user;read;query`. Group names are encoded, since they reach the class from hooks and the database and could otherwise be named to describe like another set of groups. Every reader gets a class, the anonymous one included, so a page that reads Subjects never reuses an entry cached before this change and no upgrade purge is needed.
* Obtaining the parsing authority (`ParserAuthority::of`) records the option, so every gated read keys its page by access class without a call site having to remember to. `{{#view}}` now resolves its page's Main Subject through the public `PageSubjectsLookup` instead of a parsing authority, so it keeps one entry as before.
* ADR 27's open decision on parse-time semantics resolves to this rule. The alternatives weighed (a fixed anonymous authority, post-cache trimming, a cache-off operating rule) are recorded under Alternatives Considered.

The class is a proxy for the permission hooks: exact wherever page access follows group membership, wrong for hooks that grant per user, which is why such wikis must run with the parser cache off. The installation docs now say so next to the rights. Installs with restricted content need one `refreshLinks` run, noted in the upgrading docs, because MediaWiki rewrites categories and page properties only on an edit.

Not solved here, and now stated in ADR 27's consequences: data derived from the canonical parse (categories, page properties, links tables, the Page node's categories in graph projections) is computed as the anonymous user, so on a wiki where anonymous users cannot read, a category derived from a parse-time read is never set. A designated reader for canonical parses would lift that; it needs a decision.

## Manual Browser Check

1. With the parser cache enabled, restrict a page's read permission for anonymous users (for example with the Lockdown extension) and give it a Subject with a text property.
2. On a second page, save `{{#neowiki_value: <property> | page=<restricted page>}}` as a sysop and view it: the value shows.
3. View that page logged out, without purging: the value is absent. Log back in and view it again: the value shows. Each access class keeps its own cached copy.


Claude-Session: https://claude.ai/code/session_01YDxvurgMTmC6JieEVYRzq8

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
alistair3149 added a commit that referenced this pull request Sep 9, 2026
Fixes #1379

NeoWiki published each page's latest revision everywhere except the page view, so an approval extension such as
ContentStabilization could stabilize what a reader sees on a page but not what the graph stores, the RDF export or
the REST reads served. It already registers a Page Property Provider with us and calls `newPageRebuilder()->rebuild()`
when a stable point changes; it had no way to say which revision that rebuild should read.

Extensions can now register a `RevisionPolicy` through `NeoWikiRegistrar::setRevisionPolicy()`. It answers two
questions:

- `canonicalRevision()` substitutes the revision to publish for the one the caller resolved, or returns `null` when
  the page holds nothing publishable. Callers keep resolving their own revision and pass it through, as SMW's
  `RevisionGuard` does, so each keeps its own read flags — the import path still reads from the primary database.
  It governs the graph projection and the subject-page index, `PageRebuilder::rebuild()`, the RDF export, reads by
  Subject id, and Schemas, Layouts, Mappings and the on-wiki configuration page.
- `revisionIsReadableBy()` answers the one question substitution cannot: whether a viewer who named a revision by id
  may read it. A refused revision answers exactly like an absent one, so the sequential revision ids stay unsweepable
  (#1046).

Registration is a single slot rather than the `add*` list every other extension point uses, because two extensions
cannot both decide this. A second policy is refused with a warning and the first keeps deciding, as a graph database
plugin repeating a name is. With nothing registered every surface reads the latest revision, exactly as before.

## Decisions worth reviewing

**Only `revisionIsReadableBy()` takes an `Authority`.** Everything the substitution governs is shared state that every
reader sees — the graph, the subject-page index, the RDF export, the parser cache — so a viewer-dependent answer there
would let a policy vary the parser cache per user, the problem #1347 closed. ContentStabilization's SMW integration
takes no user at all for the same reason.

**The subject-page index follows the published revision**, not the page's latest, so it and the graph never disagree
about which Subjects a page holds. The cost: a Subject introduced by an unpublished revision is not resolvable by id
until it is published, so on a reviewer's draft view `GetSubjectQuery` gets no page identifiers for it and its
computed display name falls back to an empty page name. `PointInTimeSubjectLookup` knows the page it read that
Subject from and could supply it; that is a follow-up, not a blocker. ADR 32 records the decision.

**Editing surfaces and every write stay on the page's latest revision.** `MediaWikiSubjectRepository` applies the
policy to reads by Subject id only; reads keyed by page and the read-modify-write methods do not, since a write from
a substituted revision would save over the revision it did not read. The Schema editor is unaffected for a different
reason: it loads page source through core's own `/v1/page/` endpoint, not through `PageContentFetcher`.

**Schemas, Layouts, Mappings and wiki config are in scope** — one rule for every page. The consequence is that a wiki
running an approval extension validates and renders against the approved Schema while an admin edits the draft in the
editor. One caveat is documented rather than fixed: `CachingSchemaLookup` keys on the page's latest revision id, so a
Schema whose approval changes with no accompanying page edit keeps serving the previously approved definition until
that page is next edited.

**A policy that publishes nothing skips, it does not withdraw.** `PageRefreshOutcome::SkippedUnpublishableRevision`
leaves whatever is already projected in place. Withdrawing belongs to page deletion, and skipping is what keeps a
draft save on a never-approved page from wiping data. The gap this leaves — a page whose last stable point is removed
keeps its formerly-approved data in the graph — is worth its own issue if we want it closed.

## Not in this change

- **The parse-time accessors.** `{{#neowiki_value}}`, the `nw` data functions and `{{#view}}`'s placeholder still read
  the latest revision while rendering an older revision's wikitext. That one needs no extension point:
  ContentStabilization already sets `ParserOptions::setCurrentRevisionRecordCallback`, and reading through
  `Parser::fetchCurrentRevisionRecordOfTitle()` honours it for any extension that stabilizes. Follow-up PR.
- **Withdrawal semantics**, above.

## Considered, omitted

- A `revisionIsCanonical()` skip predicate mirroring `RevisionGuard::IsApprovedRevision`. A nullable
  `canonicalRevision()` expresses the same thing and one method is easier to document than two.
- Applying the policy inside `MediaWikiSubjectContentRepository`. Its callers are the page view, `Special:NeoJson`
  and the parse-time resolver; the first two must keep reading what MediaWiki told them to read, and the third is the
  follow-up above.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzPP5bUaN1Nos9CwJoLXzW
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

BlueSpice Capabilities specific to, or particularly relevant for, BlueSpice security alert('xss');

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants