Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 34 additions & 9 deletions docs/adr/027-access-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ Constraints the model rests on:
by the wiki-level `neowiki-query` right; granting that right gives read access to everything the wiki projects into
the store. Exposing a store directly (which ADR 19 allows for SPARQL) is a different surface: see the projection
decision below.
- **Parse-time reads run as the user the page is parsed for, and their output is cached per access class.**
The parser functions and the Lua library read as the user recorded in the parser options: the viewer on a
page view, or the anonymous user for the save-time parse, the job queue, and Parsoid renders. They are gated
like the REST endpoints: page `read` for page-attributable reads, `neowiki-query` for raw queries. Query
limits use the default tier regardless of user. Output that depends on such a read is parser-cached under
the parsing user's access class, derived from the user's effective groups and the wiki-level `read` and
`neowiki-query` grants, so a cached copy is shared only within one class. The class is a proxy for the
permission hooks: exact wherever page access follows group membership, wrong for hooks that grant per
user (the revision-deletion rights included), so wikis with such hooks must run without a parser cache.
`{{#view}}` needs no class: it emits a marker at parse time, and the Subject behind it is fetched per
viewer over REST, under that viewer's permissions.
- **Raw queries will support server-side filter injection.** A deployment can register scoping predicates (such as
restricting to the current wiki) that core applies to every caller-supplied query, so scoping is enforced rather
than left to each caller.
Expand All @@ -64,13 +75,6 @@ Constraints the model rests on:

These decisions remain open at acceptance; each is deferred to the tracking issue named with it.

- **Parse-time read semantics.** Today the parse path is inconsistent
([#1059](https://github.com/ProfessionalWiki/NeoWiki/issues/1059)): Schema lookups are gated per user but their
output is parser-cached user-agnostically; subject accessors (`{{#neowiki_value}}` and the `nw` data accessors)
check only revision-deletion visibility, not page `read`; `{{#cypher_raw}}`, `{{#sparql_raw}}`, `nw.query` and
`nw.sparqlQuery` check nothing. `{{#view}}` is the leak-free pattern: a placeholder rendered at parse time, data
fetched per user over REST. Deferred to [#1059](https://github.com/ProfessionalWiki/NeoWiki/issues/1059): the
parse-path rule and what it means for each surface.
- **Cross-wiki subject display.** Rendering a subject from another wiki goes through REST, not Cypher, so query-side
scoping does not cover it. Deferred to [#1341](https://github.com/ProfessionalWiki/NeoWiki/issues/1341): the
check and the degradation behavior when the schema or subject is not accessible. Relates to
Expand All @@ -87,8 +91,22 @@ These decisions remain open at acceptance; each is deferred to the tracking issu
## Consequences

- Every new surface that exposes NeoWiki data must be classified: page-attributable (per-row gate), raw query
(whole-store semantics), projection/dump (no permission checks), or parse-time (pending above). There is no
unclassified option.
(whole-store semantics), projection/dump (no permission checks), or parse-time (parsing user's authority,
output keyed by access class). There is no unclassified option.
- A page that reads Subjects or runs queries at parse time holds one parser-cache entry per access class
among its viewers. Current-revision views of other pages are unaffected; old-revision views are keyed per
class wiki-wide, because core's revision-output cache keys on every cache-varying option rather than the
ones a page used. Saving such a page parses it twice when the editor is logged in: once canonically, once
for the editor's class, as pages using `{{int:}}` already do for editors with a non-default interface
language.
- Restricting content does not invalidate what is already cached: the class describes the reader, not the
page, so a page that embeds newly restricted data keeps serving it to its class until the page is edited
or purged, or `$wgParserCacheExpireTime` elapses.
- Data derived from the canonical parse is computed as the anonymous user: the categories, page properties and
links tables written on save and by jobs, and the categories and parser properties of the Page node in graph
projections. On a wiki where anonymous users cannot read, a category or page property derived from a
parse-time read is therefore never set. A designated reader for canonical parses would lift this; it is not
decided.
- Restricting a page does not remove its data from stores; it changes what the backend returns.
- The filter-injection extension point must be designed and implemented for farms like BlueSpice Galaxy.
- Dumps and projections contain restricted content (unless it is omitted via a non-permission mechanism such as the
Expand All @@ -100,6 +118,13 @@ These decisions remain open at acceptance; each is deferred to the tracking issu
QLever, and unable to express hook-based MediaWiki permissions. Rejected, consistent with ADR 13.
- **Project ACL state into the graph for pre-query trimming** (user groups, restriction markers): re-implements an
open set of permission hooks as data and goes stale, because permission changes produce no revision to sync on.
- **Evaluate parse-time reads as a fixed anonymous authority**: user-independent output by construction, but
every privileged reader sees less than they may read, and on a private wiki the functions show nothing.
- **Cache the superset and trim per viewer after the cache**, as core does for section edit links: sound for opaque
display fragments, but the cache then holds restricted data for every consumer that bypasses the output pipeline,
and an ungated parse leaks through categories, page properties and Lua control flow, which no HTML trim retracts.
- **Leave the cache alone and require wikis with restricted content to disable it**: no machinery, but nothing
detects a violation, and a privileged first parse silently caches restricted values for everyone.

## Related

Expand Down
4 changes: 3 additions & 1 deletion docs/authoring/parser-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ For definitions of terms like Subject, Schema, and Layout, see the [Glossary](..
Every parser function reads as the user the page is parsed for. Subjects that user cannot read are
treated as absent. `{{#cypher_raw}}` and `{{#sparql_raw}}` need the `neowiki-query` right.
`{{#view}}` only places a marker at parse time; the Subject it shows is fetched per viewer over the
REST API, under that viewer's permissions.
REST API, under that viewer's permissions. Output of the other functions is cached separately for
readers with different groups or rights, so what one reader may see does not reach another through
the parser cache.

## `{{#view}}`

Expand Down
4 changes: 4 additions & 0 deletions docs/operations/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,10 @@ default.
`neowiki-schema-edit`, `neowiki-layout-edit` and `neowiki-mapping-edit` — gate editing in NeoWiki's own namespaces and
are granted to logged-in users.

Parser functions and Lua read as the user the page is parsed for, and their output is parser-cached per combination
of user groups and wiki-level rights. A permission extension that grants page access per user rather than per group
is not followed by that cache key: run such a wiki with the parser cache off (`$wgParserCacheType = CACHE_NONE`).

## On-wiki configuration

A wiki administrator without server access can set part of NeoWiki's configuration on the `MediaWiki:NeoWiki`
Expand Down
4 changes: 4 additions & 0 deletions docs/operations/upgrading.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,5 +55,9 @@ php maintenance/run.php NeoWiki:RebuildGraphDatabases
[Rebuild](maintenance.md#rebuilding-the-graph) after every upgrade: with no release notes there is no way to tell
whether the new version changed the projected shape, and rebuilds are quick at evaluation scale.

If your install predates September 2026 and holds restricted content, run `php maintenance/run.php refreshLinks`
once: categories and page properties that earlier parses derived from Subject data were recorded without a
permission check, and MediaWiki rewrites those tables only on an edit, not on a view.

If your Subjects predate the optional Subject label, run
[clearing default Subject labels](maintenance.md#clearing-default-subject-labels) once, before that rebuild.
2 changes: 2 additions & 0 deletions extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@

"LoadExtensionSchemaUpdates": "ProfessionalWiki\\NeoWiki\\EntryPoints\\NeoWikiHooks::onLoadExtensionSchemaUpdates",

"ParserOptionsRegister": "ProfessionalWiki\\NeoWiki\\EntryPoints\\NeoWikiHooks::onParserOptionsRegister",
"PageRenderingHash": "ProfessionalWiki\\NeoWiki\\EntryPoints\\NeoWikiHooks::onPageRenderingHash",
"ParserFirstCallInit": "ProfessionalWiki\\NeoWiki\\EntryPoints\\NeoWikiHooks::onParserFirstCallInit",

"RevisionFromEditComplete": "ProfessionalWiki\\NeoWiki\\EntryPoints\\NeoWikiHooks::onRevisionFromEditComplete",
Expand Down
21 changes: 18 additions & 3 deletions src/EntryPoints/NeoWikiHooks.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use MediaWiki\Revision\SlotRoleRegistry;
use MediaWiki\Title\ForeignTitle;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWiki\User\UserIdentity;
use MessageLocalizer;
use ProfessionalWiki\NeoWiki\Application\Rdf\RdfPageProjector;
Expand Down Expand Up @@ -251,16 +252,30 @@ private static function reportFailedGraphDatabaseInitialization( DatabaseUpdater
);
}

/**
* @param array<string, mixed> $defaults
* @param array<string, bool> $inCacheKey
* @param array<string, callable> $lazyOptions
*/
public static function onParserOptionsRegister( array &$defaults, array &$inCacheKey, array &$lazyOptions ): void {
ParserAuthority::registerAccessClassOption( $defaults, $inCacheKey );
}

/**
* @param string[] $forOptions
*/
public static function onPageRenderingHash( string &$confstr, User $user, array &$forOptions ): void {
ParserAuthority::appendAccessClassToRenderingHash( $confstr, $user, $forOptions );
}

public static function onParserFirstCallInit( Parser $parser ): void {
NeoWikiExtension::getInstance()->getNeo4jPlugin()?->registerParserFunctions( $parser );
NeoWikiExtension::getInstance()->getFirstSparqlPlugin()?->registerParserFunctions( $parser );

$parser->setFunctionHook(
'view',
static function ( Parser $parser, string ...$args ): string|array {
$parserFunction = new ViewParserFunction(
NeoWikiExtension::getInstance()->newSubjectContentRepository( ParserAuthority::of( $parser ) )
);
$parserFunction = new ViewParserFunction( NeoWikiExtension::getInstance()->newPageSubjectsLookup() );
return $parserFunction->handle( $parser, ...$args );
}
);
Expand Down
44 changes: 44 additions & 0 deletions src/EntryPoints/ParserAuthority.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
use MediaWiki\MediaWikiServices;
use MediaWiki\Parser\Parser;
use MediaWiki\Permissions\Authority;
use MediaWiki\User\UserIdentity;
use ProfessionalWiki\NeoWiki\Infrastructure\UserAccessClass;
use ProfessionalWiki\NeoWiki\NeoWikiExtension;

/**
* The authority every parse-time read runs against: the user the page is being parsed for. That is
Expand All @@ -15,11 +18,52 @@
* context's user is the wrong choice here: it is the saver during the canonical parse of an edit, and
* whoever runs the job queue otherwise, neither of which matches the identity the parser cache files
* the output under.
*
* Obtaining the authority is also what makes the parse's output depend on who that user is, so
* {@see self::of()} records the access-class parser option as used: the parser cache then files the
* output under the user's access class ({@see UserAccessClass}) instead of sharing it across users.
* Pages that never obtain a parsing authority keep one cache entry for everyone.
*
* The option itself carries no value. Its class enters the cache key through the PageRenderingHash
* hook, and only for a page that recorded the option: a lazily valued option would be loaded for
* every logged-in edit of every page by core's cache-key comparison, sending all of them down the
* deferred parser-cache path.
*/
class ParserAuthority {

public const string ACCESS_CLASS_OPTION = 'neowikiAccessClass';

public static function of( Parser $parser ): Authority {
$parser->getOptions()->getOption( self::ACCESS_CLASS_OPTION );

return MediaWikiServices::getInstance()->getUserFactory()->newFromUserIdentity( $parser->getUserIdentity() );
}

/**
* The ParserOptionsRegister hook body.
*
* @param array<string, mixed> $defaults
* @param array<string, bool> $inCacheKey
*/
public static function registerAccessClassOption( array &$defaults, array &$inCacheKey ): void {
$defaults[self::ACCESS_CLASS_OPTION] = null;
$inCacheKey[self::ACCESS_CLASS_OPTION] = true;
}

/**
* The PageRenderingHash hook body: the parsing user's access class, for a page that recorded the
* option. Every reader gets one, so a page that reads Subjects also stops reusing whatever was
* cached for it before NeoWiki began keying by class.
*
* @param string[] $usedOptions
*/
public static function appendAccessClassToRenderingHash( string &$hash, UserIdentity $user, array $usedOptions ): void {
if ( !in_array( self::ACCESS_CLASS_OPTION, $usedOptions, true ) ) {
return;
}

$hash .= '!' . self::ACCESS_CLASS_OPTION . '='
. NeoWikiExtension::getInstance()->newUserAccessClass()->of( $user );
}

}
3 changes: 2 additions & 1 deletion src/EntryPoints/Scribunto/ScribuntoLuaLibrary.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ private function getCypherQueryRunner(): CypherQueryRunner {

/**
* Every read this library performs runs against the user the page is parsed for
* ({@see ParserAuthority}), so a module cannot read more than the reader may.
* ({@see ParserAuthority}), so a module cannot read more than the reader may, and the parse
* is keyed by that user's access class in the parser cache.
*/
private function getParserAuthority(): Authority {
return ParserAuthority::of( $this->getParser() );
Expand Down
21 changes: 14 additions & 7 deletions src/EntryPoints/ViewParserFunction.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,22 @@
namespace ProfessionalWiki\NeoWiki\EntryPoints;

use MediaWiki\Parser\Parser;
use ProfessionalWiki\NeoWiki\Application\SubjectContentRepository;
use ProfessionalWiki\NeoWiki\Application\PageSubjectsLookup;
use ProfessionalWiki\NeoWiki\Domain\Page\PageId;
use ProfessionalWiki\NeoWiki\Presentation\ViewHtmlBuilder;

class ViewParserFunction {

private const string ARG_SUBJECT = 'subject';
private const string ARG_LAYOUT = 'layout';

/**
* Emits a marker that the frontend fills per viewer over the REST API, so the parse-time work is
* the same for every reader: the page's Main Subject id is read publicly, not as a parsing
* authority ({@see ParserAuthority}), and the output stays out of the per-class cache keying.
*/
public function __construct(
private readonly SubjectContentRepository $subjectContentRepository
private readonly PageSubjectsLookup $pageSubjectsLookup
) {
}

Expand Down Expand Up @@ -138,12 +144,13 @@ private function resolveMainSubjectId( Parser $parser ): ?string {
return null;
}

$subject = $this->subjectContentRepository
->getSubjectContentByPageTitle( $title )
?->getPageSubjects()
->getMainSubject();
$pageId = $title->getArticleID();

if ( $pageId === 0 ) {
return null;
}

return $subject?->getId()->text;
return $this->pageSubjectsLookup->getMainSubjectId( new PageId( $pageId ) )?->text;
}

}
2 changes: 1 addition & 1 deletion src/GraphDatabasePlugins/Neo4j/Neo4jPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ public function newLuaQueryRunner( Parser $parser ): CypherQueryRunner {

/**
* Parse-time queries run as the user the page is parsed for, and always at the default tier: the
* output is parser-cached, so neither may depend on who happened to parse.
* output is parser-cached under that user's access class, and may vary by nothing else.
*/
private function newParseTimeQueryService( Parser $parser ): Neo4jQueryService {
return $this->newQueryService( ParserAuthority::of( $parser ) );
Expand Down
2 changes: 1 addition & 1 deletion src/GraphDatabasePlugins/Sparql/SparqlPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ public function newLuaQueryRunner( Parser $parser ): SparqlQueryRunner {

/**
* Parse-time queries run as the user the page is parsed for, and always at the default tier: the
* output is parser-cached, so neither may depend on who happened to parse.
* output is parser-cached under that user's access class, and may vary by nothing else.
*/
private function newParseTimeQueryService( Parser $parser ): SparqlQueryService {
return $this->newQueryService( ParserAuthority::of( $parser ) );
Expand Down
43 changes: 43 additions & 0 deletions src/Infrastructure/UserAccessClass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare( strict_types = 1 );

namespace ProfessionalWiki\NeoWiki\Infrastructure;

use MediaWiki\Permissions\PermissionManager;
use MediaWiki\User\UserGroupManager;
use MediaWiki\User\UserIdentity;

/**
* The access class of a user, for keying parser-cached output that depends on what the parsing user
* may read: the user's effective groups plus the wiki-level `read` and `neowiki-query` decisions, as
* a readable string such as `*,autoconfirmed,user;read;query`.
*
* Group membership is a proxy for per-page read permission, exact wherever that permission is a
* function of groups (private wikis, namespace lockdowns) and wrong for hooks that grant per user.
* The wiki-level rights are in the class because hooks can grant them outside groups.
*
* Every reader has a class, the anonymous one included, so that an entry cached before NeoWiki began
* keying by class is never reused for a page that reads Subjects.
*/
class UserAccessClass {

public function __construct(
private readonly UserGroupManager $userGroupManager,
private readonly PermissionManager $permissionManager,
) {
}

public function of( UserIdentity $user ): string {
$groups = $this->userGroupManager->getUserEffectiveGroups( $user );
sort( $groups );

// Group names come from hooks and the database, so they can hold the separators, and the
// cache key turns spaces into underscores. Without encoding, a user in a group named
// "sysop,user" would describe exactly like a sysop and read that class's cached output.
return implode( ',', array_map( 'rawurlencode', $groups ) )
. ( $this->permissionManager->userHasRight( $user, 'read' ) ? ';read' : '' )
. ( $this->permissionManager->userHasRight( $user, AuthorityBasedRawQueryAuthorizer::RIGHT ) ? ';query' : '' );
}

}
8 changes: 8 additions & 0 deletions src/NeoWikiExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
use ProfessionalWiki\NeoWiki\GraphDatabasePlugins\Sparql\EntryPoints\Lua\SparqlQueryRunner;
use ProfessionalWiki\NeoWiki\Infrastructure\IdGenerator;
use ProfessionalWiki\NeoWiki\Infrastructure\ProductionIdGenerator;
use ProfessionalWiki\NeoWiki\Infrastructure\UserAccessClass;
use ProfessionalWiki\NeoWiki\Persistence\CorePagePropertyProvider;
use ProfessionalWiki\NeoWiki\Application\EditNotice\InterfaceMessageNoticeProvider;
use ProfessionalWiki\NeoWiki\Application\Queries\GetSubjectEditNotices\GetSubjectEditNoticesPresenter;
Expand Down Expand Up @@ -1283,6 +1284,13 @@ public function newSubjectWriteAuthorizer( Authority $authority ): SubjectWriteA
return $this->newAuthorityBasedSubjectAuthorizer( $authority );
}

public function newUserAccessClass(): UserAccessClass {
return new UserAccessClass(
userGroupManager: MediaWikiServices::getInstance()->getUserGroupManager(),
permissionManager: MediaWikiServices::getInstance()->getPermissionManager(),
);
}

public function newPageReadAuthorizer( Authority $authority ): PageReadAuthorizer {
return new AuthorityBasedPageReadAuthorizer(
authority: $authority,
Expand Down
Loading