Skip to content

Harden Subject-write authorization on the server and align the frontend hints#1003

Merged
alistair3149 merged 5 commits into
masterfrom
harden/subject-write-authorize-write
Jul 10, 2026
Merged

Harden Subject-write authorization on the server and align the frontend hints#1003
alistair3149 merged 5 commits into
masterfrom
harden/subject-write-authorize-write

Conversation

@alistair3149

@alistair3149 alistair3149 commented Jul 7, 2026

Copy link
Copy Markdown
Member

Follows-up #986
Stacked on #1002

Hardens Subject-write authorization on both the server and the frontend. The server change is the substance; the frontend commits (merged in from #1019) align the browser-side permission hints with it.

Server

The Subject write actions (create, replace, delete, set main, set ordering) authorized their writes with Authority::definitelyCan('edit', $title). definitelyCan reads permissions from a replica (RIGOR_FULL) and checks the edit rate limit without counting the hit, so these writes never consume the edit throttle. API- and agent-driven bulk writes therefore bypass rate limiting, and a block or protection applied moments earlier can be missed due to replication lag.

SubjectAuthorizer::authorizeEdit() now performs the write authorization via Authority::authorizeWrite('edit', $title), which uses RIGOR_SECURE (primary reads) and counts the write against the edit rate limit. All five write actions use it. Because every Subject write is an edit of the owning page, the previous per-operation create-main/create-child authorization distinction collapses into this single check.

Special:NeoJson edits Subject content directly rather than through these actions, so its onSubmit write now authorizes via authorizeEdit as well; its execute keeps the side-effect-free canEditSubject for early denial before the form is rendered. No Subject write path is left on definitelyCan.

The can* checks remain for UI hints (page tools, the RedHerb sidebar) and that early Special:NeoJson gate, so ordinary page views neither enforce nor consume the edit rate limit. The now-unused canDeleteSubject is removed. Each write action's authorization test pins it to authorizeEdit: reverting an action to a can* hint fails its test.

SubjectAuthorizer is also split along the line its callers already follow: SubjectPermissionHints (side-effect-free capability queries) and SubjectWriteAuthorizer (the binding, rate-limit-counting write gate). Passing a write action a hint no longer type checks, and a render path can no longer consume rate-limit allowance by accident. AuthorityBasedSubjectAuthorizer implements both. The unused SchemaAuthorizer is removed, since Schema and Layout writes go through MediaWiki's normal edit machinery.

Part of the permission audit tracked in #101.

Frontend

The frontend permission hints disagreed with the server: canDeleteSubject hinted on the delete right and the two creation hints on createpage plus edit, while the server authorizes every Subject write as an edit. The delete right is sysop-only by default, so the delete affordance in the subjects manager was hidden from ordinary editors who are in fact allowed to delete a Subject. All four hints now check edit, mirroring AuthorityBasedSubjectAuthorizer.

Because the frontend has no binding authorization and cannot have any, the SubjectAuthorizer, SchemaAuthorizer and LayoutAuthorizer interfaces (with their RightsBased* implementations, services, factories, and public-API exports) become *PermissionHints. The name is the documentation extension authors read off the public API: a positive answer means "offer the affordance", never "the write will succeed" — the same conflation the server split removes.

The hints remain wiki-global rather than per-page (a documented TODO): a user who may edit globally but not the page at hand is still offered affordances the server then rejects.

Verification

AuthorityBasedSubjectAuthorizerTest pins authorizeEdit to authorizeWrite (and the can* hints to definitelyCan); each write action's authorization test fails if the action is reverted to a can* hint.

Confirmed end-to-end against a running wiki, with the edit rate limit set to one edit per window — two back-to-back Subject writes via the REST API, as the same rate-limited user, on the same page:

# Request Result
1 POST /neowiki/v0/page/{id}/childSubjects 201 Created — consumes the edit allowance
2 identical request, immediately after 403 Forbidden — throttled by the edit rate limit

On the previous definitelyCan path, write #2 would also have returned 201, since that check never counts the throttle.

Frontend: make tsci is green — 99 test files, 1007 tests, with vue-tsc and eslint/stylelint clean. RightsBasedSubjectPermissionHintsTest pins the corrected rights: reverting canDeleteSubject to delete, or the creation hints to createpage, fails its assertions.

Directed by @alistair3149. The server change incorporates the #986 review, a follow-up code-review pass, and end-to-end rate-limit verification; the frontend alignment was merged in from #1019 (directed by @JeroenDeDauw) and re-verified here (make tsci reproduced green: 1007 tests, typecheck, lint).
Context: the NeoWiki codebase and MediaWiki Authority / PermissionManager / UserAuthority / RateLimiter internals.
Written by Claude Code, Opus 4.8 (1M context)

@alistair3149
alistair3149 force-pushed the harden/subject-write-authorize-write branch from bfcc705 to e5a6224 Compare July 7, 2026 21:04
Base automatically changed from fix/neojson-permission-check to master July 9, 2026 00:05
The Subject write actions (create, replace, delete, set main, set ordering)
authorized their writes with `Authority::definitelyCan( 'edit', $title )`.
`definitelyCan` reads permissions from a replica (RIGOR_FULL) and checks the
edit rate limit without counting the hit, so these writes never consume the
edit throttle. API- and agent-driven bulk writes therefore bypass rate
limiting, and a block or protection applied moments earlier can be missed due
to replication lag.

`SubjectAuthorizer::authorizeEdit()` now performs the write authorization via
`Authority::authorizeWrite( 'edit', $title )`, which uses RIGOR_SECURE (primary
reads) and counts the write against the edit rate limit. All five write actions
use it. Because every Subject write is an edit of the owning page, the previous
per-operation create-main/create-child authorization distinction collapses into
this single check.

`Special:NeoJson` edits Subject content directly rather than through these
actions, so its `onSubmit` write now authorizes via `authorizeEdit` as well; its
`execute` keeps the side-effect-free `canEditSubject` for early denial before the
form is rendered. No Subject write path is left on `definitelyCan`.

The `can*` checks remain for UI hints (page tools, the RedHerb sidebar) and that
early `Special:NeoJson` gate, so ordinary page views neither enforce nor consume
the edit rate limit. The now-unused `canDeleteSubject` is removed. Each write
action's authorization test pins it to `authorizeEdit`: a revert to a `can*` hint
fails the test.

Follows-up to #986

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@alistair3149
alistair3149 force-pushed the harden/subject-write-authorize-write branch from e5a6224 to e36458a Compare July 9, 2026 00:33
@alistair3149
alistair3149 marked this pull request as ready for review July 9, 2026 00:35
@alistair3149
alistair3149 requested a review from malberts July 9, 2026 00:35
Comment thread src/Application/SubjectAuthorizer.php Outdated
@JeroenDeDauw

Copy link
Copy Markdown
Member

There is an interface segregation issue now. Interestingly, Fable 5 max didn't catch it when looking from multiple angles, but it did when I asked "isn't it better to have two interfaces?". Got an Opus session running now with the implementation which will be pushed here after running review skills.

The single SubjectAuthorizer interface mixed two contracts: side-effect-free
capability queries used to paint UI affordances, and the binding authorization
that performs a write. That mixing is what let the write actions authorize with
a `can*` hint in the first place. Only a docblock and a verb distinguished the
two, so nothing but review could stop a write path from gating on a hint, or a
render path from consuming rate-limit allowance.

The interface is now split along the line its callers already follow:

* `SubjectPermissionHints` — `canCreateMainSubject`, `canCreateChildSubject`,
  `canEditSubject`. Side-effect-free, advisory, callable from anywhere.
* `SubjectWriteAuthorizer` — `authorize`. Called once per write attempt, at the
  point of writing, and only when a write will actually be attempted.

The five write actions now depend on `SubjectWriteAuthorizer` alone, so passing
them a hint no longer type checks. The hint callers (page tools, the RedHerb
sidebar, the early denial in `Special:NeoJson`) depend on `SubjectPermissionHints`
alone, so a render path cannot consume the edit rate limit even by accident.
`Special:NeoJson` is the one legitimate consumer of both, and takes each where it
belongs. `AuthorityBasedSubjectAuthorizer` implements both interfaces, and is the
only place that names `definitelyCan`, `authorizeWrite`, or the rate limit.

`NeoWikiExtension` exposes `newSubjectPermissionHints()` and
`newSubjectWriteAuthorizer()`. Only the former is documented for extensions:
extensions get pure queries, and the binding gate stays with the write actions.

Rate-limit accounting is a MediaWiki policy effect, not a guarantee of the port:
a `noratelimit` holder consumes nothing, and a wiki can configure the limit away.
So the interface documents that policy effects attach to `authorize()` and to no
other call, and the mechanism is described on the adapter that implements it.

`SpecialNeoJsonTest` gains a test that a POST consumes the poster's edit rate
limit allowance. Its `onSubmit` write authorization previously had no test of its
own: the existing denial test is satisfied by the hint gate in `execute()` alone,
so removing the write authorization, or downgrading it back to the hint, broke
nothing. The new test fails under both.

Also removes `SchemaAuthorizer`, which had no production caller. Schema and
Layout writes go through MediaWiki's normal edit machinery, which authorizes and
rate limits them; Subjects need their own port only because their writes bypass
that path. The now-unused `SucceedingSubjectAuthorizer` and `SpySubjectAuthorizer`
collapse into a single `SpySubjectWriteAuthorizer`.

Follows-up #1003
The frontend permission hints disagreed with the authorization the server applies.
`canDeleteSubject` hinted on the `delete` right, and the two creation hints on
`createpage` plus `edit`, while the server authorizes every Subject write as an
`edit` of the page that holds the Subject.

The `delete` right is granted to sysops by default, so the delete affordance in
the subjects manager was hidden from ordinary editors who are in fact allowed to
delete a Subject. The creation hints were wrong in the same direction: adding a
Subject to an existing page creates no page, so `createpage` should not be
required.

All four hints now check the `edit` right, mirroring `AuthorityBasedSubjectAuthorizer`
on the server. They remain hints: they check the wiki-global right, whereas the
server checks `edit` on the specific page, so a user who may edit globally but not
the page at hand is still offered affordances the server rejects. That gap is
recorded as a TODO rather than fixed here, since closing it means feeding the
server's per-page answer to the frontend.

Follows-up #1003
The frontend has no binding authorization and cannot have any: the browser decides
nothing, the server authorizes every write. Naming these classes `Authorizer`
implied a security control that does not exist here, which is the same conflation
the backend split removed. Extension authors read these names off the public API,
so the name is the documentation.

`SubjectAuthorizer`, `SchemaAuthorizer` and `LayoutAuthorizer` become
`SubjectPermissionHints`, `SchemaPermissionHints` and `LayoutPermissionHints`,
with their `RightsBased*` implementations and the service and factory names
following. `NeoWikiExtension.newSubjectPermissionHints()` now matches the PHP
factory of the same name. Each interface documents that a positive answer means
"offer the affordance", not "the write will succeed".

The backend split is not mirrored: there is no frontend counterpart to
`SubjectWriteAuthorizer`, because there is nothing for it to authorize.

`canDeleteSubject` is kept rather than folded into `canEditSubject`. Both now
resolve to the same right, but the hints name the question the caller asks, which
is also why the backend keeps three hints that all resolve to `canEditPage`.

Follows-up #1003
@alistair3149 alistair3149 changed the title Authorize Subject writes with authorizeWrite instead of definitelyCan Harden Subject-write authorization on the server and align the frontend hints Jul 10, 2026
@alistair3149
alistair3149 merged commit 453435f into master Jul 10, 2026
13 checks passed
@alistair3149
alistair3149 deleted the harden/subject-write-authorize-write branch July 10, 2026 23:58
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.

2 participants