Key parser-cached output by the parsing user's access class - #1347
Merged
Conversation
alistair3149
force-pushed
the
1059-parse-time-gates
branch
from
September 2, 2026 15:08
580868e to
ceb4b6c
Compare
alistair3149
force-pushed
the
1059-parser-cache-class
branch
from
September 2, 2026 15:08
8faea4e to
4946259
Compare
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
force-pushed
the
1059-parse-time-gates
branch
from
September 2, 2026 20:08
ceb4b6c to
815a282
Compare
alistair3149
force-pushed
the
1059-parser-cache-class
branch
from
September 2, 2026 20:08
4946259 to
05e12c9
Compare
alistair3149
force-pushed
the
1059-parser-cache-class
branch
3 times, most recently
from
September 3, 2026 01:36
125c3c7 to
c33cfc6
Compare
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
force-pushed
the
1059-parse-time-gates
branch
from
September 3, 2026 01:44
815a282 to
3e0b5eb
Compare
alistair3149
force-pushed
the
1059-parser-cache-class
branch
from
September 3, 2026 01:44
c33cfc6 to
1b9a2b5
Compare
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
force-pushed
the
1059-parse-time-gates
branch
from
September 3, 2026 01:46
3e0b5eb to
98c98e5
Compare
alistair3149
force-pushed
the
1059-parser-cache-class
branch
from
September 3, 2026 01:46
1b9a2b5 to
385b73f
Compare
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
force-pushed
the
1059-parser-cache-class
branch
from
September 3, 2026 01:50
385b73f to
1b03dd0
Compare
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>
This was referenced Sep 9, 2026
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
neowikiAccessClass, registered throughParserOptionsRegister. It carries no value; the class enters the key through thePageRenderingHashhook, 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.UserAccessClass, is the parsing user's effective groups plus the wiki-levelreadandneowiki-querydecisions, 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.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 publicPageSubjectsLookupinstead of a parsing authority, so it keeps one entry as before.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
refreshLinksrun, 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
{{#neowiki_value: <property> | page=<restricted page>}}as a sysop and view it: the value shows.