Restore the documented status-code contract of DELETE /subject/{id} - #1380
Merged
Conversation
alistair3149
force-pushed
the
fix/1312-delete-subject-read-gate
branch
from
September 9, 2026 00:13
1426c48 to
650f2fc
Compare
This was referenced Sep 9, 2026
alistair3149
force-pushed
the
fix/1312-delete-subject-read-gate
branch
from
September 9, 2026 00:23
650f2fc to
b46b02b
Compare
alistair3149
force-pushed
the
fix/1312-delete-subject-read-gate
branch
from
September 9, 2026 00:36
b46b02b to
dcc34e6
Compare
alistair3149
force-pushed
the
fix/1312-delete-subject-read-gate
branch
3 times, most recently
from
September 9, 2026 01:46
a31f842 to
60adeea
Compare
Fixes #1312 `DELETE /neowiki/v0/subject/{subjectId}` broke the status-code contract that [`docs/api/rest-api.md`](https://github.com/ProfessionalWiki/NeoWiki/blob/master/docs/api/rest-api.md) documents and [ADR 027](https://github.com/ProfessionalWiki/NeoWiki/blob/master/docs/adr/027-access-control.md) governs, in four ways. ## The read gate The action resolved the Subject to its page and authorized the write against that page, but never checked the caller may read it — unlike `ReplaceSubjectAction` and `UpdateStatementAction`. Two consequences: a Subject on an unreadable page answered `403` where the contract promises `404`, so `403` versus `404` told a caller which Subject ids exist; and a caller holding `edit` but not `read` could delete Subjects there. The action now takes a `PageReadAuthorizer` and answers the not-found path before reaching the write check — the shape `UpdateStatementAction::getPageOfSubjectToEdit()` already uses. A page the caller can read still answers `403` when they cannot edit it. ## Three further breaches of the same contract **A malformed id answered `500`.** `new SubjectId( 'notavalidid' )` throws `InvalidArgumentException`, a `LogicException`, which no catch arm matched. It now answers `400`, as every sibling Subject-keyed handler does. **Internal failures answered `403` carrying their own message.** The handler caught every `RuntimeException` and reported it as a permission denial, so a `DBError` raised inside the authorizers surfaced as `403 {"message": "<internal error text>"}` — and the read gate added here runs the `getUserPermissionsErrors` hook, which is arbitrary ACL-extension code. The delete denial now raises the typed `SubjectEditNotAuthorizedException`, keeping its own message, and an internal error is a `500`. **A delete that removed nothing answered `200`.** `SubjectRepository::deleteSubject()` returned `void` and the implementation discarded the write's outcome. It now returns `PageContentSavingStatus`, as `savePageSubjects()` already does, and the action answers not-found unless a revision was created. The repository asks the slot whether it still holds the Subject rather than inferring removal from the save: `mutatePageSubjects` re-serializes the slot whatever the mutation did, so a slot written by anything other than the serializer — an import, or `Special:NeoJson` — yields new bytes and a real revision even when nothing was removed. ## Open for review A write the wiki refuses — read-only mode, an edit-filter abort — now answers `404 Subject not found` while the Subject still exists. `CreateSubjectAction`, `SetMainSubjectAction`, `SetSubjectsOrderingAction` and `MoveSubjectAction` all map `ERROR` to a not-found presentation, so the shape is the established one, and it replaces a false `200`. Giving `ERROR` its own answer is the alternative, and the one that would keep the documented `404` causes exact. Left as it is here because the choice belongs to all five endpoints at once, not to this one. ## Considered, omitted Sharing the resolve-then-read-gate half with `Application\Rdf\SubjectHostingPageResolver`. Adopting it here alone would leave the three Subject-id-keyed write actions in three different shapes, and moving it out of the `Rdf` namespace reaches past this fix. Worth doing across all of them at once. ## Not fixed here [#1383](#1383) — `Special:NeoJson` reads and writes Subject content with no read gate at all, the same threat model on a path that is not a REST route. It is unregistered unless `$wgNeoWikiEnableDevelopmentUI` is set, which defaults to false. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AfaXavUCWB237b7NmErXjg
alistair3149
force-pushed
the
fix/1312-delete-subject-read-gate
branch
from
September 9, 2026 01:48
60adeea to
17b951c
Compare
alistair3149
marked this pull request as ready for review
September 9, 2026 01:56
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.
Fixes #1312
DELETE /neowiki/v0/subject/{subjectId}broke the status-code contract thatdocs/api/rest-api.mddocuments and ADR 027 governs, in four ways.The read gate
The action resolved the Subject to its page and authorized the write against that page, but never
checked the caller may read it — unlike
ReplaceSubjectActionandUpdateStatementAction. Twoconsequences: a Subject on an unreadable page answered
403where the contract promises404, so403versus404told a caller which Subject ids exist; and a caller holdingeditbut notreadcould delete Subjects there.
The action now takes a
PageReadAuthorizerand answers the not-found path before reaching the writecheck — the shape
UpdateStatementAction::getPageOfSubjectToEdit()already uses. A page the callercan read still answers
403when they cannot edit it.Three further breaches of the same contract
A malformed id answered
500.new SubjectId( 'notavalidid' )throwsInvalidArgumentException, aLogicException, which no catch arm matched. It now answers400, asevery sibling Subject-keyed handler does.
Internal failures answered
403carrying their own message. The handler caught everyRuntimeExceptionand reported it as a permission denial, so aDBErrorraised inside theauthorizers surfaced as
403 {"message": "<internal error text>"}— and the read gate added hereruns the
getUserPermissionsErrorshook, which is arbitrary ACL-extension code. The delete denialnow raises the typed
SubjectEditNotAuthorizedException, keeping its own message, and an internalerror is a
500.A delete that removed nothing answered
200.SubjectRepository::deleteSubject()returnedvoidand the implementation discarded the write's outcome. It now returnsPageContentSavingStatus, assavePageSubjects()already does, and the action answers not-foundunless a revision was created. The repository asks the slot whether it still holds the Subject
rather than inferring removal from the save:
mutatePageSubjectsre-serializes the slot whateverthe mutation did, so a slot written by anything other than the serializer — an import, or
Special:NeoJson— yields new bytes and a real revision even when nothing was removed.Open for review
A write the wiki refuses — read-only mode, an edit-filter abort — now answers
404 Subject not foundwhile the Subject still exists.CreateSubjectAction,SetMainSubjectAction,SetSubjectsOrderingActionandMoveSubjectActionall mapERRORto a not-found presentation, sothe shape is the established one, and it replaces a false
200. GivingERRORits own answer isthe alternative, and the one that would keep the documented
404causes exact. Left as it is herebecause the choice belongs to all five endpoints at once, not to this one.
Considered, omitted
Sharing the resolve-then-read-gate half with
Application\Rdf\SubjectHostingPageResolver. Adoptingit here alone would leave the three Subject-id-keyed write actions in three different shapes, and
moving it out of the
Rdfnamespace reaches past this fix. Worth doing across all of them at once.Not fixed here
#1383 —
Special:NeoJsonreads andwrites Subject content with no read gate at all, the same threat model on a path that is not a REST
route. It is unregistered unless
$wgNeoWikiEnableDevelopmentUIis set, which defaults to false.