Skip to content

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

Open
alistair3149 wants to merge 1 commit into
1059-parse-time-gatesfrom
1059-parser-cache-class
Open

Key parser-cached output by the parsing user's access class#1347
alistair3149 wants to merge 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant