Skip to content
Draft
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
12 changes: 12 additions & 0 deletions docs/api/rest-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ permissions might be required depending on the wiki configuration.
You can find all endpoints in [the OpenAPI 3.0 description](#full-specification) exposed via the REST API itself.
Demo wiki example: [neowiki.dev/w/rest.php/specs/v0/module/-](https://neowiki.dev/w/rest.php/specs/v0/module/-)

## Permissions

Read endpoints enforce the caller's per-page `read` permission: page protection, restricted namespaces, read
whitelists, and permission extensions all apply. When you may not read a page, its read endpoints respond as if
the data were absent — a `null` value, an empty list, or a `404` — rather than with a `403`. Treat a not-found
response as "not available": it may mean the data does not exist, or that you may not read it.

Subject write endpoints require per-page `edit` permission and answer `403` on denial.

The Cypher query endpoint is gated only by the `neowiki-query` right, with no per-page filtering (see
[Query API](query-api.md)).

## Endpoints

<!-- REST-ENDPOINTS:START — drift-checked against extension.json by
Expand Down
51 changes: 43 additions & 8 deletions src/Application/Queries/GetPageSubjects/GetPageSubjectsQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@
use ProfessionalWiki\NeoWiki\Application\Queries\GetSubject\GetSubjectResponseItem;
use ProfessionalWiki\NeoWiki\Application\SchemaLookup;
use ProfessionalWiki\NeoWiki\Application\SubjectLookup;
use ProfessionalWiki\NeoWiki\Application\SubjectReadAuthorizer;
use ProfessionalWiki\NeoWiki\Application\SubjectRepository;
use ProfessionalWiki\NeoWiki\Domain\Page\PageId;
use ProfessionalWiki\NeoWiki\Domain\Page\PageIdentifiers;
use ProfessionalWiki\NeoWiki\Domain\Page\PageSubjects;
use ProfessionalWiki\NeoWiki\Domain\Schema\SchemaName;
use ProfessionalWiki\NeoWiki\Domain\Subject\StatementList;
use ProfessionalWiki\NeoWiki\Domain\Subject\Subject;
Expand All @@ -24,21 +27,34 @@ public function __construct(
private SchemaLookup $schemaLookup,
private SchemaPresentationSerializer $schemaSerializer,
private PageIdentifiersLookup $pageIdentifiersLookup,
private SubjectReadAuthorizer $readAuthorizer,
) {
}

public function execute( int $pageId, bool $includeSchemas = false, bool $includeReferencedSubjects = false ): void {
$pageSubjects = $this->subjectRepository->getSubjectsByPageId( new PageId( $pageId ) );
$id = new PageId( $pageId );

// A denied page takes exactly the path a page without Subjects takes, so the response
// is byte-identical to absence and cannot be used to probe page readability (#1046).
$pageSubjects = $this->readAuthorizer->authorizeRead( $id )
? $this->subjectRepository->getSubjectsByPageId( $id )
: PageSubjects::newEmpty();

$mainSubject = $pageSubjects->getMainSubject();
$subjectItems = [];

if ( $mainSubject !== null ) {
$subjectItems[$mainSubject->id->text] = $this->buildResponseItem( $mainSubject );
$subjectItems[$mainSubject->id->text] = $this->buildResponseItem(
$mainSubject,
$this->pageIdentifiersLookup->getPageIdOfSubject( $mainSubject->id )
);
}

foreach ( $pageSubjects->getChildSubjects()->asArray() as $childSubject ) {
$subjectItems[$childSubject->id->text] = $this->buildResponseItem( $childSubject );
$subjectItems[$childSubject->id->text] = $this->buildResponseItem(
$childSubject,
$this->pageIdentifiersLookup->getPageIdOfSubject( $childSubject->id )
);
}

$referencedSubjectItems = null;
Expand All @@ -62,9 +78,7 @@ public function execute( int $pageId, bool $includeSchemas = false, bool $includ
);
}

private function buildResponseItem( Subject $subject ): GetSubjectResponseItem {
$pageIdentifiers = $this->pageIdentifiersLookup->getPageIdOfSubject( $subject->id );

private function buildResponseItem( Subject $subject, ?PageIdentifiers $pageIdentifiers ): GetSubjectResponseItem {
return new GetSubjectResponseItem(
id: $subject->id->text,
label: $subject->label->text,
Expand Down Expand Up @@ -92,15 +106,36 @@ private function buildReferencedSubjectItems( array $pageSubjects, array $alread

$referencedSubject = $this->subjectLookup->getSubject( $referencedId );

if ( $referencedSubject !== null ) {
$referenced[$referencedId->text] = $this->buildResponseItem( $referencedSubject );
if ( $referencedSubject === null ) {
continue;
}

$pageIdentifiers = $this->pageIdentifiersLookup->getPageIdOfSubject( $referencedSubject->id );

if ( !$this->pageIsReadable( $pageIdentifiers ) ) {
continue;
}

$referenced[$referencedId->text] = $this->buildResponseItem( $referencedSubject, $pageIdentifiers );
}
}

return $referenced;
}

/**
* A Subject whose page does not resolve in the graph cannot be reached through the
* graph-backed repository at all (the repository returns null before this runs), so the
* null case is unreachable today. It fails closed regardless: if a SubjectLookup that
* bypasses the graph is ever wired into this query, unresolvable pages are omitted (this
* endpoint's normal absence shape) instead of served ungated. GetSubjectQuery's helper
* deliberately differs — its revision branch reaches null for Subjects from a revision
* the handler already authorized, which must stay readable.
*/
private function pageIsReadable( ?PageIdentifiers $pageIdentifiers ): bool {
return $pageIdentifiers !== null && $this->readAuthorizer->authorizeRead( $pageIdentifiers->getId() );
}

/**
* @param array<string, GetSubjectResponseItem> $pageSubjectItems
* @param array<string, GetSubjectResponseItem>|null $referencedSubjectItems
Expand Down
53 changes: 45 additions & 8 deletions src/Application/Queries/GetSubject/GetSubjectQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

use ProfessionalWiki\NeoWiki\Application\PageIdentifiersLookup;
use ProfessionalWiki\NeoWiki\Application\SubjectLookup;
use ProfessionalWiki\NeoWiki\Application\SubjectReadAuthorizer;
use ProfessionalWiki\NeoWiki\Domain\Page\PageIdentifiers;
use ProfessionalWiki\NeoWiki\Domain\Subject\StatementList;
use ProfessionalWiki\NeoWiki\Domain\Subject\Subject;
use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectId;
Expand All @@ -16,6 +18,7 @@ public function __construct(
private GetSubjectPresenter $presenter,
private SubjectLookup $subjectLookup,
private PageIdentifiersLookup $pageIdentifiersLookup,
private SubjectReadAuthorizer $readAuthorizer,
) {
}

Expand All @@ -31,17 +34,35 @@ public function execute(
return;
}

$pageIdentifiers = $this->pageIdentifiersLookup->getPageIdOfSubject( $subject->id );

if ( !$this->pageIsReadable( $pageIdentifiers ) ) {
// Denial takes exactly the absent-Subject path, so harvested Subject ids cannot
// be confirmed to exist on restricted pages (#1046).
$this->presenter->presentSubjectNotFound();
return;
}

$response = [
$subject->getId()->text => $this->createResponse( $subject, $includePageIdentifiers )
$subject->getId()->text => $this->createResponse( $subject, $includePageIdentifiers, $pageIdentifiers )
];

if ( $includeReferencedSubjects ) {
foreach ( $subject->getReferencedSubjects()->asArray() as $id ) {
$referencedSubject = $this->subjectLookup->getSubject( $id );

if ( $referencedSubject !== null ) {
$response[$referencedSubject->getId()->text] = $this->createResponse( $referencedSubject, $includePageIdentifiers );
if ( $referencedSubject === null ) {
continue;
}

$referencedPageIdentifiers = $this->pageIdentifiersLookup->getPageIdOfSubject( $referencedSubject->id );

if ( !$this->pageIsReadable( $referencedPageIdentifiers ) ) {
continue;
}

$response[$referencedSubject->getId()->text] =
$this->createResponse( $referencedSubject, $includePageIdentifiers, $referencedPageIdentifiers );
}
}

Expand All @@ -53,17 +74,33 @@ public function execute(
);
}

private function createResponse( Subject $subject, bool $includePageIdentifiers ): GetSubjectResponseItem {
$pageIdentifiers = $includePageIdentifiers ? $this->pageIdentifiersLookup->getPageIdOfSubject( $subject->id ) : null;
/**
* A Subject whose page does not resolve in the graph can only have come from a revision
* the caller supplied, and GetSubjectApi authorizes read on that revision's page before
* constructing this query. Reads through the graph-backed repository always resolve the
* owning page (the repository returns null otherwise), so null never bypasses the gate
* there. Denying on null would instead hide Subjects from readable old revisions after
* the Subject was later deleted.
*/
private function pageIsReadable( ?PageIdentifiers $pageIdentifiers ): bool {
return $pageIdentifiers === null || $this->readAuthorizer->authorizeRead( $pageIdentifiers->getId() );
}

private function createResponse(
Subject $subject,
bool $includePageIdentifiers,
?PageIdentifiers $pageIdentifiers
): GetSubjectResponseItem {
$includedIdentifiers = $includePageIdentifiers ? $pageIdentifiers : null;

return new GetSubjectResponseItem(
id: $subject->id->text,
label: $subject->label->text,
schemaName: $subject->getSchemaName()->getText(),
statements: $this->arrayifyStatements( $subject->getStatements() ),
pageId: $pageIdentifiers?->getId()->id,
pageTitle: $pageIdentifiers?->getTitle(),
pageNamespaceId: $pageIdentifiers?->getNamespaceId(),
pageId: $includedIdentifiers?->getId()->id,
pageTitle: $includedIdentifiers?->getTitle(),
pageNamespaceId: $includedIdentifiers?->getNamespaceId(),
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@

namespace ProfessionalWiki\NeoWiki\Application\Queries\ValidateSubjectUpdate;

use ProfessionalWiki\NeoWiki\Application\PageIdentifiersLookup;
use ProfessionalWiki\NeoWiki\Application\SchemaLookup;
use ProfessionalWiki\NeoWiki\Application\SelectStatementResolver;
use ProfessionalWiki\NeoWiki\Application\StatementListBuilder;
use ProfessionalWiki\NeoWiki\Application\Subject\Exception\SubjectNotFoundException;
use ProfessionalWiki\NeoWiki\Application\SubjectReadAuthorizer;
use ProfessionalWiki\NeoWiki\Application\SubjectRepository;
use ProfessionalWiki\NeoWiki\Application\Validation\SubjectValidator;
use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectId;
Expand All @@ -22,6 +24,8 @@ public function __construct(
private SubjectValidator $subjectValidator,
private StatementListBuilder $statementListBuilder,
private SelectStatementResolver $selectStatementResolver,
private PageIdentifiersLookup $pageIdentifiersLookup,
private SubjectReadAuthorizer $readAuthorizer,
) {
}

Expand All @@ -31,10 +35,23 @@ public function __construct(
* @return Violation[]
*
* @throws \InvalidArgumentException when the subject id format is invalid.
* @throws SubjectNotFoundException when the subject does not exist.
* @throws SubjectNotFoundException when the subject does not exist or the caller may not read its page.
*/
public function validate( string $subjectId, string $label, array $statements ): array {
$id = new SubjectId( $subjectId );
$pageIdentifiers = $this->pageIdentifiersLookup->getPageIdOfSubject( $id );

if ( $pageIdentifiers === null ) {
// No owning page in the graph means the repository cannot load the Subject either.
throw SubjectNotFoundException::forId( $id );
}

if ( !$this->readAuthorizer->authorizeRead( $pageIdentifiers->getId() ) ) {
// Denial is shaped exactly like absence: this endpoint previously oracled Subject
// existence via its 404, so denied and absent must stay indistinguishable (#1046).
throw SubjectNotFoundException::forId( $id );
}

$subject = $this->subjectRepository->getSubject( $id );

if ( $subject === null ) {
Expand Down
21 changes: 21 additions & 0 deletions src/Application/SubjectReadAuthorizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare( strict_types=1 );

namespace ProfessionalWiki\NeoWiki\Application;

use ProfessionalWiki\NeoWiki\Domain\Page\PageId;

/**
* The binding per-page authorization for reading Subject data. Unlike SubjectPermissionHints this
* is not advisory, and unlike SubjectWriteAuthorizer it is side-effect-free apart from rate-limit
* accounting (no 'read' rate-limit bucket exists in a default MediaWiki install).
*
* Callers present a denial exactly like absent data (the endpoint's existing not-found shape),
* never as an error, so that restricted pages cannot be enumerated through these endpoints.
*/
interface SubjectReadAuthorizer {

public function authorizeRead( PageId $pageId ): bool;

}
3 changes: 3 additions & 0 deletions src/EntryPoints/Content/MappingContentHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ private function rejectDuplicateTarget( Content $content, Title $title, StatusVa
return;
}

// getAllMappings() runs through the per-page read gate, so a conflicting Mapping on a
// page the saving editor cannot read escapes this uniqueness check. Accepted: shadowed
// conflicts surface at projection time, and read-restricted Mapping pages are rare.
$conflict = ( new Mappings( $extension->getMappingLookup()->getAllMappings() ) )
->conflictFor( $mapping->schema, $mapping->target, $mapping->name );

Expand Down
21 changes: 17 additions & 4 deletions src/EntryPoints/REST/ExportPageRdfApi.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,21 +39,34 @@ public function run( int $pageId ): Response {
] );
}

$page = new PageId( $pageId );

// Denial reuses the exact no-data response so unreadable pages are indistinguishable
// from pages without NeoWiki data. The gate lives here rather than in RdfPageLoader
// because maintenance/DumpRdf.php shares the loader and must stay unfiltered.
if ( !$extension->newSubjectReadAuthorizer( $this->getAuthority() )->authorizeRead( $page ) ) {
return $this->noDataResponse( $pageId );
}

$format = $this->resolveFormat();

$document = $extension
->newRdfPageExporterForProjection( $resolution->projection )
->exportByPageId( new PageId( $pageId ), $format );
->exportByPageId( $page, $format );

if ( $document === null ) {
return $this->getResponseFactory()->createHttpError( 404, [
'message' => 'No NeoWiki data found for page: ' . $pageId,
] );
return $this->noDataResponse( $pageId );
}

return $this->rdfResponse( $document, $format );
}

private function noDataResponse( int $pageId ): Response {
return $this->getResponseFactory()->createHttpError( 404, [
'message' => 'No NeoWiki data found for page: ' . $pageId,
] );
}

private function resolveFormat(): RdfFormat {
$requested = $this->getValidatedParams()['format'] ?? null;

Expand Down
2 changes: 1 addition & 1 deletion src/EntryPoints/REST/GetPageSubjectsApi.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public function run( int $pageId ): Response {

$expandOptions = explode( '|', $this->getRequest()->getQueryParams()['expand'] ?? '' );

NeoWikiExtension::getInstance()->newGetPageSubjectsQuery( $presenter )->execute(
NeoWikiExtension::getInstance()->newGetPageSubjectsQuery( $presenter, $this->getAuthority() )->execute(
pageId: $pageId,
includeSchemas: in_array( self::EXPAND_SCHEMAS, $expandOptions, true ),
includeReferencedSubjects: in_array( self::EXPAND_RELATIONS, $expandOptions, true ),
Expand Down
16 changes: 13 additions & 3 deletions src/EntryPoints/REST/GetSubjectApi.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use MediaWiki\Rest\SimpleHandler;
use MediaWiki\Revision\RevisionRecord;
use ProfessionalWiki\NeoWiki\Application\Queries\GetSubject\GetSubjectQuery;
use ProfessionalWiki\NeoWiki\Domain\Page\PageId;
use ProfessionalWiki\NeoWiki\NeoWikiExtension;
use ProfessionalWiki\NeoWiki\Presentation\RestGetSubjectPresenter;
use Wikimedia\ParamValidator\ParamValidator;
Expand Down Expand Up @@ -41,18 +42,27 @@ public function run( string $subjectId ): Response {

private function newGetSubjectQuery( RestGetSubjectPresenter $presenter, ?int $revisionId ): GetSubjectQuery|Response {
if ( $revisionId === null ) {
return NeoWikiExtension::getInstance()->newGetSubjectQuery( $presenter );
return NeoWikiExtension::getInstance()->newGetSubjectQuery( $presenter, $this->getAuthority() );
}

$revision = MediaWikiServices::getInstance()->getRevisionLookup()->getRevisionById( $revisionId );

if ( $revision === null ) {
// A revision on an unreadable page answers exactly like a nonexistent revision:
// revision ids are sequential, so any distinguishable answer is a sweepable
// existence oracle over restricted pages (#1046).
if ( $revision === null || !$this->revisionPageIsReadable( $revision->getPageId() ) ) {
return $this->getResponseFactory()->createHttpError( 404, [
'message' => 'Revision not found: ' . $revisionId,
] );
}

return NeoWikiExtension::getInstance()->newGetSubjectQueryForRevision( $presenter, $revision );
return NeoWikiExtension::getInstance()->newGetSubjectQueryForRevision( $presenter, $revision, $this->getAuthority() );
}

private function revisionPageIsReadable( int $pageId ): bool {
return NeoWikiExtension::getInstance()
->newSubjectReadAuthorizer( $this->getAuthority() )
->authorizeRead( new PageId( $pageId ) );
}

public function getParamSettings(): array {
Expand Down
Loading
Loading