Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
89.83% |
530 / 590 |
|
52.78% |
19 / 36 |
CRAP | |
0.00% |
0 / 1 |
PermissionManager | |
89.83% |
530 / 590 |
|
52.78% |
19 / 36 |
380.06 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
1 | |||
userCan | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
quickUserCan | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getPermissionErrors | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 | |||
throwPermissionErrors | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
isBlockedFrom | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
getPermissionStatus | |
98.15% |
53 / 54 |
|
0.00% |
0 / 1 |
12 | |||
checkPermissionHooks | |
69.23% |
9 / 13 |
|
0.00% |
0 / 1 |
9.86 | |||
resultToStatus | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
12.11 | |||
checkReadPermissions | |
80.49% |
33 / 41 |
|
0.00% |
0 / 1 |
26.93 | |||
missingPermissionError | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
3 | |||
newFatalPermissionDeniedStatus | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
3 | |||
isSameSpecialPage | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
3.07 | |||
checkUserBlock | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
3 | |||
getApplicableBlock | |
88.33% |
53 / 60 |
|
0.00% |
0 / 1 |
25.99 | |||
checkQuickPermissions | |
96.08% |
49 / 51 |
|
0.00% |
0 / 1 |
33 | |||
checkPageRestrictions | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
8 | |||
checkCascadingSourcesRestrictions | |
92.86% |
13 / 14 |
|
0.00% |
0 / 1 |
10.04 | |||
checkActionPermissions | |
88.00% |
66 / 75 |
|
0.00% |
0 / 1 |
39.37 | |||
checkSpecialsAndNSPermissions | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
7 | |||
checkSiteConfigPermissions | |
69.23% |
9 / 13 |
|
0.00% |
0 / 1 |
14.52 | |||
checkUserConfigPermissions | |
100.00% |
27 / 27 |
|
100.00% |
1 / 1 |
19 | |||
userHasRight | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
userHasAnyRight | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
userHasAllRights | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
3.14 | |||
getUserPermissions | |
74.07% |
20 / 27 |
|
0.00% |
0 / 1 |
10.41 | |||
invalidateUsersRightsCache | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
getRightsCacheKey | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
isEveryoneAllowed | |
65.00% |
13 / 20 |
|
0.00% |
0 / 1 |
16.19 | |||
getAllPermissions | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
3 | |||
getImplicitRights | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
isNamespaceProtected | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
getNamespaceRestrictionLevels | |
100.00% |
35 / 35 |
|
100.00% |
1 / 1 |
18 | |||
userCanEditRawHtmlPage | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
addTemporaryUserRights | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
overrideUserRightsForTesting | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
3.14 |
1 | <?php |
2 | /** |
3 | * This program is free software; you can redistribute it and/or modify |
4 | * it under the terms of the GNU General Public License as published by |
5 | * the Free Software Foundation; either version 2 of the License, or |
6 | * (at your option) any later version. |
7 | * |
8 | * This program is distributed in the hope that it will be useful, |
9 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
10 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
11 | * GNU General Public License for more details. |
12 | * |
13 | * You should have received a copy of the GNU General Public License along |
14 | * with this program; if not, write to the Free Software Foundation, Inc., |
15 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
16 | * http://www.gnu.org/copyleft/gpl.html |
17 | * |
18 | * @file |
19 | */ |
20 | namespace MediaWiki\Permissions; |
21 | |
22 | use InvalidArgumentException; |
23 | use LogicException; |
24 | use MediaWiki\Actions\ActionFactory; |
25 | use MediaWiki\Block\AbstractBlock; |
26 | use MediaWiki\Block\Block; |
27 | use MediaWiki\Block\BlockErrorFormatter; |
28 | use MediaWiki\Block\BlockManager; |
29 | use MediaWiki\Config\ServiceOptions; |
30 | use MediaWiki\Context\IContextSource; |
31 | use MediaWiki\Context\RequestContext; |
32 | use MediaWiki\HookContainer\HookContainer; |
33 | use MediaWiki\HookContainer\HookRunner; |
34 | use MediaWiki\Linker\LinkTarget; |
35 | use MediaWiki\MainConfigNames; |
36 | use MediaWiki\Message\Message; |
37 | use MediaWiki\Page\PageIdentity; |
38 | use MediaWiki\Page\PageReference; |
39 | use MediaWiki\Page\RedirectLookup; |
40 | use MediaWiki\Request\WebRequest; |
41 | use MediaWiki\Session\SessionManager; |
42 | use MediaWiki\SpecialPage\SpecialPage; |
43 | use MediaWiki\SpecialPage\SpecialPageFactory; |
44 | use MediaWiki\Title\NamespaceInfo; |
45 | use MediaWiki\Title\Title; |
46 | use MediaWiki\Title\TitleFormatter; |
47 | use MediaWiki\User\TempUser\TempUserConfig; |
48 | use MediaWiki\User\User; |
49 | use MediaWiki\User\UserFactory; |
50 | use MediaWiki\User\UserGroupManager; |
51 | use MediaWiki\User\UserGroupMembership; |
52 | use MediaWiki\User\UserIdentity; |
53 | use MediaWiki\User\UserIdentityLookup; |
54 | use PermissionsError; |
55 | use StatusValue; |
56 | use Wikimedia\Message\MessageSpecifier; |
57 | use Wikimedia\ScopedCallback; |
58 | |
59 | /** |
60 | * A service class for checking permissions |
61 | * To obtain an instance, use MediaWikiServices::getInstance()->getPermissionManager(). |
62 | * |
63 | * @since 1.33 |
64 | */ |
65 | class PermissionManager { |
66 | |
67 | /** @var string Does cheap permission checks from replica DBs (usable for GUI creation) */ |
68 | public const RIGOR_QUICK = 'quick'; |
69 | |
70 | /** @var string Does cheap and expensive checks possibly from a replica DB */ |
71 | public const RIGOR_FULL = 'full'; |
72 | |
73 | /** @var string Does cheap and expensive checks, using the primary DB as needed */ |
74 | public const RIGOR_SECURE = 'secure'; |
75 | |
76 | /** |
77 | * @internal For use by ServiceWiring |
78 | */ |
79 | public const CONSTRUCTOR_OPTIONS = [ |
80 | MainConfigNames::WhitelistRead, |
81 | MainConfigNames::WhitelistReadRegexp, |
82 | MainConfigNames::EmailConfirmToEdit, |
83 | MainConfigNames::BlockDisablesLogin, |
84 | MainConfigNames::EnablePartialActionBlocks, |
85 | MainConfigNames::GroupPermissions, |
86 | MainConfigNames::RevokePermissions, |
87 | MainConfigNames::AvailableRights, |
88 | MainConfigNames::NamespaceProtection, |
89 | MainConfigNames::RestrictionLevels, |
90 | MainConfigNames::DeleteRevisionsLimit, |
91 | MainConfigNames::RateLimits, |
92 | MainConfigNames::ImplicitRights, |
93 | ]; |
94 | |
95 | private ServiceOptions $options; |
96 | private SpecialPageFactory $specialPageFactory; |
97 | private NamespaceInfo $nsInfo; |
98 | private GroupPermissionsLookup $groupPermissionsLookup; |
99 | private UserGroupManager $userGroupManager; |
100 | private BlockManager $blockManager; |
101 | private BlockErrorFormatter $blockErrorFormatter; |
102 | private HookRunner $hookRunner; |
103 | private UserIdentityLookup $userIdentityLookup; |
104 | private RedirectLookup $redirectLookup; |
105 | private RestrictionStore $restrictionStore; |
106 | private TitleFormatter $titleFormatter; |
107 | private TempUserConfig $tempUserConfig; |
108 | private UserFactory $userFactory; |
109 | private ActionFactory $actionFactory; |
110 | |
111 | /** @var string[]|null Cached results of getAllPermissions() */ |
112 | private $allRights; |
113 | |
114 | /** @var string[]|null Cached results of getImplicitRights() */ |
115 | private $implicitRights; |
116 | |
117 | /** @var string[][] Cached user rights */ |
118 | private $usersRights = []; |
119 | |
120 | /** |
121 | * Temporary user rights, valid for the current request only. |
122 | * @var string[][][] userid => override group => rights |
123 | */ |
124 | private $temporaryUserRights = []; |
125 | |
126 | /** @var bool[] Cached rights for isEveryoneAllowed, [ right => allowed ] */ |
127 | private $cachedRights = []; |
128 | |
129 | /** |
130 | * Array of core rights. |
131 | * Each of these should have a corresponding message of the form |
132 | * "right-$right". |
133 | * @showinitializer |
134 | */ |
135 | private const CORE_RIGHTS = [ |
136 | 'apihighlimits', |
137 | 'applychangetags', |
138 | 'autoconfirmed', |
139 | 'autocreateaccount', |
140 | 'autopatrol', |
141 | 'bigdelete', |
142 | 'block', |
143 | 'blockemail', |
144 | 'bot', |
145 | 'browsearchive', |
146 | 'changetags', |
147 | 'createaccount', |
148 | 'createpage', |
149 | 'createtalk', |
150 | 'delete', |
151 | 'delete-redirect', |
152 | 'deletechangetags', |
153 | 'deletedhistory', |
154 | 'deletedtext', |
155 | 'deletelogentry', |
156 | 'deleterevision', |
157 | 'edit', |
158 | 'editcontentmodel', |
159 | 'editinterface', |
160 | 'editprotected', |
161 | 'editmyoptions', |
162 | 'editmyprivateinfo', |
163 | 'editmyusercss', |
164 | 'editmyuserjson', |
165 | 'editmyuserjs', |
166 | 'editmyuserjsredirect', |
167 | 'editmywatchlist', |
168 | 'editsemiprotected', |
169 | 'editsitecss', |
170 | 'editsitejson', |
171 | 'editsitejs', |
172 | 'editusercss', |
173 | 'edituserjson', |
174 | 'edituserjs', |
175 | 'hideuser', |
176 | 'import', |
177 | 'importupload', |
178 | 'ipblock-exempt', |
179 | 'managechangetags', |
180 | 'markbotedits', |
181 | 'mergehistory', |
182 | 'minoredit', |
183 | 'move', |
184 | 'movefile', |
185 | 'move-categorypages', |
186 | 'move-rootuserpages', |
187 | 'move-subpages', |
188 | 'nominornewtalk', |
189 | 'noratelimit', |
190 | 'override-export-depth', |
191 | 'pagelang', |
192 | 'patrol', |
193 | 'patrolmarks', |
194 | 'protect', |
195 | 'read', |
196 | 'renameuser', |
197 | 'reupload', |
198 | 'reupload-own', |
199 | 'reupload-shared', |
200 | 'rollback', |
201 | 'sendemail', |
202 | 'siteadmin', |
203 | 'suppressionlog', |
204 | 'suppressredirect', |
205 | 'suppressrevision', |
206 | 'unblockself', |
207 | 'undelete', |
208 | 'unwatchedpages', |
209 | 'upload', |
210 | 'upload_by_url', |
211 | 'userrights', |
212 | 'userrights-interwiki', |
213 | 'viewmyprivateinfo', |
214 | 'viewmywatchlist', |
215 | 'viewsuppressed', |
216 | ]; |
217 | |
218 | /** |
219 | * List of implicit rights. |
220 | * These should not have a corresponding message of the form |
221 | * "right-$right". |
222 | * @showinitializer |
223 | */ |
224 | private const CORE_IMPLICIT_RIGHTS = [ |
225 | 'renderfile', |
226 | 'renderfile-nonstandard', |
227 | 'stashedit', |
228 | 'stashbasehtml', |
229 | 'mailpassword', |
230 | 'changeemail', |
231 | 'confirmemail', |
232 | 'linkpurge', |
233 | 'purge', |
234 | ]; |
235 | |
236 | public function __construct( |
237 | ServiceOptions $options, |
238 | SpecialPageFactory $specialPageFactory, |
239 | NamespaceInfo $nsInfo, |
240 | GroupPermissionsLookup $groupPermissionsLookup, |
241 | UserGroupManager $userGroupManager, |
242 | BlockManager $blockManager, |
243 | BlockErrorFormatter $blockErrorFormatter, |
244 | HookContainer $hookContainer, |
245 | UserIdentityLookup $userIdentityLookup, |
246 | RedirectLookup $redirectLookup, |
247 | RestrictionStore $restrictionStore, |
248 | TitleFormatter $titleFormatter, |
249 | TempUserConfig $tempUserConfig, |
250 | UserFactory $userFactory, |
251 | ActionFactory $actionFactory |
252 | ) { |
253 | $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); |
254 | $this->options = $options; |
255 | $this->specialPageFactory = $specialPageFactory; |
256 | $this->nsInfo = $nsInfo; |
257 | $this->groupPermissionsLookup = $groupPermissionsLookup; |
258 | $this->userGroupManager = $userGroupManager; |
259 | $this->blockManager = $blockManager; |
260 | $this->blockErrorFormatter = $blockErrorFormatter; |
261 | $this->hookRunner = new HookRunner( $hookContainer ); |
262 | $this->userIdentityLookup = $userIdentityLookup; |
263 | $this->redirectLookup = $redirectLookup; |
264 | $this->restrictionStore = $restrictionStore; |
265 | $this->titleFormatter = $titleFormatter; |
266 | $this->tempUserConfig = $tempUserConfig; |
267 | $this->userFactory = $userFactory; |
268 | $this->actionFactory = $actionFactory; |
269 | } |
270 | |
271 | /** |
272 | * Can $user perform $action on a page? |
273 | * |
274 | * The method replaced Title::userCan() |
275 | * The $user parameter need to be superseded by UserIdentity value in future |
276 | * The $title parameter need to be superseded by PageIdentity value in future |
277 | * |
278 | * @param string $action |
279 | * @param User $user |
280 | * @param LinkTarget $page |
281 | * @param string $rigor One of PermissionManager::RIGOR_ constants |
282 | * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation) |
283 | * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB |
284 | * - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed |
285 | * |
286 | * @return bool |
287 | */ |
288 | public function userCan( $action, User $user, LinkTarget $page, $rigor = self::RIGOR_SECURE ): bool { |
289 | return $this->getPermissionStatus( $action, $user, $page, $rigor, true )->isGood(); |
290 | } |
291 | |
292 | /** |
293 | * A convenience method for calling PermissionManager::userCan |
294 | * with PermissionManager::RIGOR_QUICK |
295 | * |
296 | * Suitable for use for nonessential UI controls in common cases, but |
297 | * _not_ for functional access control. |
298 | * May provide false positives, but should never provide a false negative. |
299 | * |
300 | * @see PermissionManager::userCan() |
301 | * |
302 | * @param string $action |
303 | * @param User $user |
304 | * @param LinkTarget $page |
305 | * @return bool |
306 | */ |
307 | public function quickUserCan( $action, User $user, LinkTarget $page ): bool { |
308 | return $this->userCan( $action, $user, $page, self::RIGOR_QUICK ); |
309 | } |
310 | |
311 | /** |
312 | * Can $user perform $action on a page? |
313 | * |
314 | * This *does not* check throttles (User::pingLimiter()). If that's desired, use the Authority |
315 | * interface methods instead. |
316 | * |
317 | * @deprecated since 1.43 Use getPermissionStatus() instead. |
318 | * |
319 | * @param string $action Action that permission needs to be checked for |
320 | * @param User $user User to check |
321 | * @param LinkTarget $page |
322 | * @param string $rigor One of PermissionManager::RIGOR_ constants |
323 | * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation) |
324 | * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB |
325 | * - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed |
326 | * @param string[] $ignoreErrors Set this to a list of message keys |
327 | * whose corresponding errors may be ignored. |
328 | * |
329 | * @return array[] Permission errors. |
330 | * Each entry contains valid arguments for wfMessage() / MessageLocalizer::msg(). |
331 | * The format is *different* from the normal "legacy error array", as used by |
332 | * Status::getErrorsArray() or PermissionStatus::toLegacyErrorArray(): |
333 | * the first element of each entry can be a MessageSpecifier, not just a string. |
334 | * @phan-return non-empty-array[] |
335 | */ |
336 | public function getPermissionErrors( |
337 | $action, |
338 | User $user, |
339 | LinkTarget $page, |
340 | $rigor = self::RIGOR_SECURE, |
341 | $ignoreErrors = [] |
342 | ): array { |
343 | $status = $this->getPermissionStatus( $action, $user, $page, $rigor ); |
344 | $result = []; |
345 | |
346 | // Produce a result in the weird format used by this function |
347 | foreach ( $status->getErrors() as [ 'message' => $keyOrMsg, 'params' => $params ] ) { |
348 | $key = $keyOrMsg instanceof MessageSpecifier ? $keyOrMsg->getKey() : $keyOrMsg; |
349 | // Remove the errors being ignored. |
350 | if ( !in_array( $key, $ignoreErrors ) ) { |
351 | $result[] = [ $keyOrMsg, ...$params ]; |
352 | } |
353 | } |
354 | return $result; |
355 | } |
356 | |
357 | /** |
358 | * Like {@link getPermissionErrors}, but immediately throw if there are any errors. |
359 | * |
360 | * @param string $action Action that permission needs to be checked for |
361 | * @param User $user User to check |
362 | * @param LinkTarget $page |
363 | * @param string $rigor One of PermissionManager::RIGOR_ constants |
364 | * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation) |
365 | * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB |
366 | * - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed |
367 | * @param string[] $ignoreErrors Set this to a list of message keys |
368 | * whose corresponding errors may be ignored. |
369 | * |
370 | * @throws PermissionsError |
371 | */ |
372 | public function throwPermissionErrors( |
373 | $action, |
374 | User $user, |
375 | LinkTarget $page, |
376 | $rigor = self::RIGOR_SECURE, |
377 | $ignoreErrors = [] |
378 | ): void { |
379 | $status = $this->getPermissionStatus( |
380 | $action, $user, $page, $rigor ); |
381 | if ( $status->hasMessagesExcept( ...$ignoreErrors ) ) { |
382 | throw new PermissionsError( $action, $status ); |
383 | } |
384 | } |
385 | |
386 | /** |
387 | * Check if user is blocked from editing a particular article. If the user does not |
388 | * have a block, this will return false. |
389 | * |
390 | * @param User $user |
391 | * @param PageIdentity|LinkTarget $page Title to check |
392 | * @param bool $fromReplica Whether to check the replica DB instead of the primary DB |
393 | * @return bool |
394 | */ |
395 | public function isBlockedFrom( User $user, $page, $fromReplica = false ): bool { |
396 | return (bool)$this->getApplicableBlock( |
397 | 'edit', |
398 | $user, |
399 | $fromReplica ? self::RIGOR_FULL : self::RIGOR_SECURE, |
400 | $page, |
401 | $user->getRequest() |
402 | ); |
403 | } |
404 | |
405 | /** |
406 | * Can $user perform $action on a page? |
407 | * |
408 | * This *does not* check throttles (User::pingLimiter()). If that's desired, use the Authority |
409 | * interface methods instead. |
410 | * |
411 | * @param string $action Action that permission needs to be checked for |
412 | * @param User $user User to check |
413 | * @param LinkTarget $page |
414 | * @param string $rigor One of PermissionManager::RIGOR_ constants |
415 | * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation) |
416 | * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB |
417 | * - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed |
418 | * @param bool $short Set this to true to stop after the first permission error. |
419 | * @return PermissionStatus Permission errors as a status. |
420 | * Check `$status->isGood()` to tell if the user can perform the action. |
421 | * Use `$status->getMessages()` to display errors if the status is not good. |
422 | */ |
423 | public function getPermissionStatus( |
424 | $action, |
425 | User $user, |
426 | LinkTarget $page, |
427 | $rigor = self::RIGOR_SECURE, |
428 | $short = false |
429 | ): PermissionStatus { |
430 | if ( !in_array( $rigor, [ self::RIGOR_QUICK, self::RIGOR_FULL, self::RIGOR_SECURE ] ) ) { |
431 | throw new InvalidArgumentException( "Invalid rigor parameter '$rigor'." ); |
432 | } |
433 | |
434 | // With RIGOR_QUICK we can assume automatic account creation will |
435 | // occur. At a higher rigor level, the caller is required to opt |
436 | // in by either passing in a temp placeholder user or by actually |
437 | // creating the account. |
438 | if ( $rigor === self::RIGOR_QUICK |
439 | && !$user->isRegistered() |
440 | && $this->tempUserConfig->isAutoCreateAction( $action ) |
441 | ) { |
442 | $user = $this->userFactory->newTempPlaceholder(); |
443 | } |
444 | |
445 | # Read has special handling |
446 | if ( $action === 'read' ) { |
447 | $checks = [ |
448 | [ $this, 'checkPermissionHooks' ], |
449 | [ $this, 'checkReadPermissions' ], |
450 | [ $this, 'checkUserBlock' ], // for wgBlockDisablesLogin |
451 | ]; |
452 | } elseif ( $action === 'create' ) { |
453 | # Don't call checkSpecialsAndNSPermissions, checkSiteConfigPermissions |
454 | # or checkUserConfigPermissions here as it will lead to duplicate |
455 | # error messages. This is okay to do since anywhere that checks for |
456 | # create will also check for edit, and those checks are called for edit. |
457 | $checks = [ |
458 | [ $this, 'checkQuickPermissions' ], |
459 | [ $this, 'checkPermissionHooks' ], |
460 | [ $this, 'checkPageRestrictions' ], |
461 | [ $this, 'checkCascadingSourcesRestrictions' ], |
462 | [ $this, 'checkActionPermissions' ], |
463 | [ $this, 'checkUserBlock' ], |
464 | ]; |
465 | } else { |
466 | // Exclude checkUserConfigPermissions on actions that cannot change the |
467 | // content of the configuration pages. |
468 | $skipUserConfigActions = [ |
469 | // Allow patrolling per T21818 |
470 | 'patrol', |
471 | |
472 | // Allow admins and oversighters to delete. For user pages we want to avoid the |
473 | // situation where an unprivileged user can post abusive content on |
474 | // their subpages and only very highly privileged users could remove it. |
475 | // See T200176. |
476 | 'delete', |
477 | 'deleterevision', |
478 | 'suppressrevision', |
479 | |
480 | // Allow admins and oversighters to view deleted content, even if they |
481 | // cannot restore it. See T202989 |
482 | 'deletedhistory', |
483 | 'deletedtext', |
484 | 'viewsuppressed', |
485 | ]; |
486 | |
487 | $checks = [ |
488 | [ $this, 'checkQuickPermissions' ], |
489 | [ $this, 'checkPermissionHooks' ], |
490 | [ $this, 'checkSpecialsAndNSPermissions' ], |
491 | [ $this, 'checkSiteConfigPermissions' ], |
492 | ]; |
493 | if ( !in_array( $action, $skipUserConfigActions, true ) ) { |
494 | $checks[] = [ $this, 'checkUserConfigPermissions' ]; |
495 | } |
496 | $checks = [ |
497 | ...$checks, |
498 | [ $this, 'checkPageRestrictions' ], |
499 | [ $this, 'checkCascadingSourcesRestrictions' ], |
500 | [ $this, 'checkActionPermissions' ], |
501 | [ $this, 'checkUserBlock' ] |
502 | ]; |
503 | } |
504 | |
505 | $status = PermissionStatus::newEmpty(); |
506 | foreach ( $checks as $method ) { |
507 | $method( $action, $user, $status, $rigor, $short, $page ); |
508 | |
509 | if ( $short && !$status->isGood() ) { |
510 | break; |
511 | } |
512 | } |
513 | if ( !$status->isGood() ) { |
514 | $errors = $status->toLegacyErrorArray(); |
515 | $this->hookRunner->onPermissionErrorAudit( $page, $user, $action, $rigor, $errors ); |
516 | } |
517 | |
518 | return $status; |
519 | } |
520 | |
521 | /** |
522 | * Check various permission hooks |
523 | * |
524 | * @param string $action The action to check |
525 | * @param User $user User to check |
526 | * @param PermissionStatus $status Current errors |
527 | * @param string $rigor One of PermissionManager::RIGOR_ constants |
528 | * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation) |
529 | * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB |
530 | * - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed |
531 | * @param bool $short Short circuit on first error |
532 | * @param LinkTarget $page |
533 | */ |
534 | private function checkPermissionHooks( |
535 | $action, |
536 | User $user, |
537 | PermissionStatus $status, |
538 | $rigor, |
539 | $short, |
540 | LinkTarget $page |
541 | ): void { |
542 | // TODO: remove when LinkTarget usage will expand further |
543 | $title = Title::newFromLinkTarget( $page ); |
544 | // Use getUserPermissionsErrors instead |
545 | $result = ''; |
546 | if ( !$this->hookRunner->onUserCan( $title, $user, $action, $result ) ) { |
547 | if ( !$result ) { |
548 | $status->fatal( 'badaccess-group0' ); |
549 | } |
550 | return; |
551 | } |
552 | // Check getUserPermissionsErrors hook |
553 | if ( !$this->hookRunner->onGetUserPermissionsErrors( $title, $user, $action, $result ) ) { |
554 | $this->resultToStatus( $status, $result ); |
555 | } |
556 | // Check getUserPermissionsErrorsExpensive hook |
557 | if ( |
558 | $rigor !== self::RIGOR_QUICK |
559 | && !( $short && !$status->isGood() ) |
560 | && !$this->hookRunner->onGetUserPermissionsErrorsExpensive( |
561 | $title, $user, $action, $result ) |
562 | ) { |
563 | $this->resultToStatus( $status, $result ); |
564 | } |
565 | } |
566 | |
567 | /** |
568 | * Add the resulting error code to the errors array |
569 | * |
570 | * @param PermissionStatus $status Current errors |
571 | * @param array|string|MessageSpecifier|false $result Result of errors |
572 | */ |
573 | private function resultToStatus( PermissionStatus $status, $result ): void { |
574 | if ( is_array( $result ) && count( $result ) && !is_array( $result[0] ) ) { |
575 | // A single array representing an error |
576 | $status->fatal( ...$result ); |
577 | } elseif ( is_array( $result ) && count( $result ) && is_array( $result[0] ) ) { |
578 | // A nested array representing multiple errors |
579 | foreach ( $result as $result1 ) { |
580 | $this->resultToStatus( $status, $result1 ); |
581 | } |
582 | } elseif ( is_string( $result ) && $result !== '' ) { |
583 | // A string representing a message-id |
584 | $status->fatal( $result ); |
585 | } elseif ( $result instanceof MessageSpecifier ) { |
586 | // A message specifier representing an error |
587 | $status->fatal( $result ); |
588 | } elseif ( $result === false ) { |
589 | // a generic "We don't want them to do that" |
590 | $status->fatal( 'badaccess-group0' ); |
591 | } |
592 | // If we got here, $results is the empty array or empty string, which mean no errors. |
593 | } |
594 | |
595 | /** |
596 | * Check that the user is allowed to read this page. |
597 | * |
598 | * @param string $action The action to check |
599 | * @param User $user User to check |
600 | * @param PermissionStatus $status Current errors |
601 | * @param string $rigor One of PermissionManager::RIGOR_ constants |
602 | * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation) |
603 | * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB |
604 | * - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed |
605 | * @param bool $short Short circuit on first error |
606 | * @param LinkTarget $page |
607 | */ |
608 | private function checkReadPermissions( |
609 | $action, |
610 | User $user, |
611 | PermissionStatus $status, |
612 | $rigor, |
613 | $short, |
614 | LinkTarget $page |
615 | ): void { |
616 | // TODO: remove when LinkTarget usage will expand further |
617 | $title = Title::newFromLinkTarget( $page ); |
618 | |
619 | $whiteListRead = $this->options->get( MainConfigNames::WhitelistRead ); |
620 | $allowed = false; |
621 | if ( $this->isEveryoneAllowed( 'read' ) ) { |
622 | // Shortcut for public wikis, allows skipping quite a bit of code |
623 | $allowed = true; |
624 | } elseif ( $this->userHasRight( $user, 'read' ) ) { |
625 | // If the user is allowed to read pages, he is allowed to read all pages |
626 | $allowed = true; |
627 | } elseif ( $this->isSameSpecialPage( 'Userlogin', $page ) |
628 | || $this->isSameSpecialPage( 'PasswordReset', $page ) |
629 | || $this->isSameSpecialPage( 'Userlogout', $page ) |
630 | ) { |
631 | // Always grant access to the login page. |
632 | // Even anons need to be able to log in. |
633 | $allowed = true; |
634 | } elseif ( $this->isSameSpecialPage( 'RunJobs', $page ) ) { |
635 | // relies on HMAC key signature alone |
636 | $allowed = true; |
637 | } elseif ( is_array( $whiteListRead ) && count( $whiteListRead ) ) { |
638 | // Time to check the whitelist |
639 | // Only do these checks if there's something to check against |
640 | $name = $title->getPrefixedText(); |
641 | $dbName = $title->getPrefixedDBkey(); |
642 | |
643 | // Check for explicit whitelisting with and without underscores |
644 | if ( in_array( $name, $whiteListRead, true ) |
645 | || in_array( $dbName, $whiteListRead, true ) |
646 | ) { |
647 | $allowed = true; |
648 | } elseif ( $page->getNamespace() === NS_MAIN ) { |
649 | // Old settings might have the title prefixed with |
650 | // a colon for main-namespace pages |
651 | if ( in_array( ':' . $name, $whiteListRead ) ) { |
652 | $allowed = true; |
653 | } |
654 | } elseif ( $title->isSpecialPage() ) { |
655 | // If it's a special page, ditch the subpage bit and check again |
656 | $name = $title->getDBkey(); |
657 | [ $name, /* $subpage */ ] = |
658 | $this->specialPageFactory->resolveAlias( $name ); |
659 | if ( $name ) { |
660 | $pure = SpecialPage::getTitleFor( $name )->getPrefixedText(); |
661 | if ( in_array( $pure, $whiteListRead, true ) ) { |
662 | $allowed = true; |
663 | } |
664 | } |
665 | } |
666 | } |
667 | |
668 | $whitelistReadRegexp = $this->options->get( MainConfigNames::WhitelistReadRegexp ); |
669 | if ( !$allowed && is_array( $whitelistReadRegexp ) |
670 | && $whitelistReadRegexp |
671 | ) { |
672 | $name = $title->getPrefixedText(); |
673 | // Check for regex whitelisting |
674 | foreach ( $whitelistReadRegexp as $listItem ) { |
675 | if ( preg_match( $listItem, $name ) ) { |
676 | $allowed = true; |
677 | break; |
678 | } |
679 | } |
680 | } |
681 | |
682 | if ( !$allowed ) { |
683 | # If the title is not whitelisted, give extensions a chance to do so... |
684 | $this->hookRunner->onTitleReadWhitelist( $title, $user, $allowed ); |
685 | if ( !$allowed ) { |
686 | $this->missingPermissionError( $action, $short, $status ); |
687 | } |
688 | } |
689 | } |
690 | |
691 | /** |
692 | * Add an error to the status when an action isn't allowed to be performed. |
693 | * |
694 | * @param string $action The action to check |
695 | * @param bool $short Short circuit on first error |
696 | * @param PermissionStatus $status |
697 | */ |
698 | private function missingPermissionError( string $action, bool $short, PermissionStatus $status ): void { |
699 | // We avoid expensive display logic for quickUserCan's and such |
700 | if ( $short ) { |
701 | $status->fatal( 'badaccess-group0' ); |
702 | } |
703 | |
704 | // TODO: it would be a good idea to replace the method below with something else like |
705 | // maybe callback injection |
706 | $context = RequestContext::getMain(); |
707 | $fatalStatus = $this->newFatalPermissionDeniedStatus( $action, $context ); |
708 | $status->merge( $fatalStatus ); |
709 | $statusPermission = $fatalStatus->getPermission(); |
710 | if ( $statusPermission ) { |
711 | $status->setPermission( $statusPermission ); |
712 | } |
713 | } |
714 | |
715 | /** |
716 | * Factory function for fatal permission-denied errors |
717 | * |
718 | * @internal for use by UserAuthority |
719 | * |
720 | * @param string $permission User right required |
721 | * @param IContextSource $context |
722 | * |
723 | * @return PermissionStatus |
724 | */ |
725 | public function newFatalPermissionDeniedStatus( $permission, IContextSource $context ): StatusValue { |
726 | $groups = []; |
727 | foreach ( $this->groupPermissionsLookup->getGroupsWithPermission( $permission ) as $group ) { |
728 | $groups[] = UserGroupMembership::getLinkWiki( $group, $context ); |
729 | } |
730 | |
731 | if ( $groups ) { |
732 | return PermissionStatus::newFatal( |
733 | 'badaccess-groups', |
734 | Message::listParam( $groups, 'comma' ), |
735 | count( $groups ) |
736 | ); |
737 | } |
738 | |
739 | $status = PermissionStatus::newFatal( 'badaccess-group0' ); |
740 | $status->setPermission( $permission ); |
741 | return $status; |
742 | } |
743 | |
744 | /** |
745 | * Whether a title resolves to the named special page. |
746 | * |
747 | * @param string $name The special page name |
748 | * @param LinkTarget $page |
749 | * @return bool |
750 | */ |
751 | private function isSameSpecialPage( $name, LinkTarget $page ): bool { |
752 | if ( $page->getNamespace() === NS_SPECIAL ) { |
753 | [ $pageName ] = $this->specialPageFactory->resolveAlias( $page->getDBkey() ); |
754 | if ( $name === $pageName ) { |
755 | return true; |
756 | } |
757 | } |
758 | return false; |
759 | } |
760 | |
761 | /** |
762 | * Check that the user isn't blocked from editing. |
763 | * |
764 | * @param string $action The action to check |
765 | * @param User $user User to check |
766 | * @param PermissionStatus $status Current errors |
767 | * @param string $rigor One of PermissionManager::RIGOR_ constants |
768 | * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation) |
769 | * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB |
770 | * - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed |
771 | * @param bool $short Short circuit on first error |
772 | * @param LinkTarget $page |
773 | */ |
774 | private function checkUserBlock( |
775 | $action, |
776 | User $user, |
777 | PermissionStatus $status, |
778 | $rigor, |
779 | $short, |
780 | LinkTarget $page |
781 | ): void { |
782 | $block = $this->getApplicableBlock( |
783 | $action, |
784 | $user, |
785 | $rigor, |
786 | $page, |
787 | $user->getRequest() |
788 | ); |
789 | |
790 | if ( $block ) { |
791 | // @todo FIXME: Pass the relevant context into this function. |
792 | $context = RequestContext::getMain(); |
793 | $messages = $this->blockErrorFormatter->getMessages( |
794 | $block, |
795 | $user, |
796 | $context->getRequest()->getIP() |
797 | ); |
798 | |
799 | foreach ( $messages as $message ) { |
800 | // TODO: We can pass $message directly once getPermissionErrors() is removed. |
801 | // For now we store the message key as a string here out of overabundance of caution, |
802 | // because there is a test case verifying that block messages use strings in that format. |
803 | $status->fatal( $message->getKey(), ...$message->getParams() ); |
804 | } |
805 | } |
806 | } |
807 | |
808 | /** |
809 | * Return the Block object applicable for the given permission check, if any. |
810 | * |
811 | * @internal for use by UserAuthority only |
812 | * |
813 | * @param string $action The action to check |
814 | * @param User $user User to check |
815 | * @param string $rigor One of PermissionManager::RIGOR_ constants |
816 | * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation) |
817 | * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB |
818 | * - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed |
819 | * @param LinkTarget|PageReference|null $page |
820 | * @param WebRequest|null $request The request to get the IP and cookies |
821 | * from. If this is null, IP and cookie blocks will not be checked. |
822 | * @return ?Block |
823 | */ |
824 | public function getApplicableBlock( |
825 | string $action, |
826 | User $user, |
827 | string $rigor, |
828 | $page, |
829 | ?WebRequest $request |
830 | ): ?Block { |
831 | // Unblocking handled in SpecialUnblock |
832 | if ( $rigor === self::RIGOR_QUICK || in_array( $action, [ 'unblock' ] ) ) { |
833 | return null; |
834 | } |
835 | |
836 | // Optimize for a very common case |
837 | if ( $action === 'read' && !$this->options->get( MainConfigNames::BlockDisablesLogin ) ) { |
838 | return null; |
839 | } |
840 | |
841 | // Implicit rights aren't blockable (T350117, T350202). |
842 | if ( in_array( $action, $this->getImplicitRights(), true ) ) { |
843 | return null; |
844 | } |
845 | |
846 | $useReplica = $rigor !== self::RIGOR_SECURE; |
847 | $isExempt = $this->userHasRight( $user, 'ipblock-exempt' ); |
848 | $requestIfNotExempt = $isExempt ? null : $request; |
849 | |
850 | // Create account blocks are implemented separately due to weird IP exemption rules |
851 | if ( in_array( $action, [ 'createaccount', 'autocreateaccount' ], true ) ) { |
852 | return $this->blockManager->getCreateAccountBlock( |
853 | $user, |
854 | $requestIfNotExempt, |
855 | $useReplica |
856 | ); |
857 | } |
858 | |
859 | $block = $this->blockManager->getBlock( $user, $requestIfNotExempt, $useReplica ); |
860 | if ( !$block ) { |
861 | return null; |
862 | } |
863 | $userIsHidden = $block->getHideName(); |
864 | |
865 | // Remove elements from the block that explicitly allow the action |
866 | // (like "read" or "upload"). |
867 | $block = $this->blockManager->filter( |
868 | $block, |
869 | static function ( AbstractBlock $originalBlock ) use ( $action ) { |
870 | // Remove the block if it explicitly allows the action |
871 | return $originalBlock->appliesToRight( $action ) !== false; |
872 | } |
873 | ); |
874 | if ( !$block ) { |
875 | return null; |
876 | } |
877 | |
878 | // Convert the input page to a Title |
879 | $targetTitle = null; |
880 | if ( $page ) { |
881 | $targetTitle = $page instanceof PageReference ? |
882 | Title::castFromPageReference( $page ) : |
883 | Title::castFromLinkTarget( $page ); |
884 | |
885 | if ( !$targetTitle->canExist() ) { |
886 | $targetTitle = null; |
887 | } |
888 | } |
889 | |
890 | // What gets passed into this method is a user right, not an action name. |
891 | // There is no way to instantiate an action by restriction. However, this |
892 | // will get the action where the restriction is the same. This may result |
893 | // in actions being blocked that shouldn't be. |
894 | $actionInfo = $this->actionFactory->getActionInfo( $action, $targetTitle ); |
895 | |
896 | // Ensure that the retrieved action matches the restriction. |
897 | if ( $actionInfo && $actionInfo->getRestriction() !== $action ) { |
898 | $actionInfo = null; |
899 | } |
900 | |
901 | // Return null if the action does not require an unblocked user. |
902 | // If no ActionInfo is returned, assume that the action requires unblock |
903 | // which is the default. |
904 | // NOTE: We may get null here even for known actions, if a wiki's main page |
905 | // is set to a special page, e.g. Special:MyLanguage/Main_Page (T348451, T346036). |
906 | if ( $actionInfo && !$actionInfo->requiresUnblock() ) { |
907 | return null; |
908 | } |
909 | |
910 | // Remove elements from the block that do not apply to the specific page |
911 | if ( $targetTitle ) { |
912 | $targetIsUserTalk = !$userIsHidden && $targetTitle->equals( $user->getTalkPage() ); |
913 | $block = $this->blockManager->filter( |
914 | $block, |
915 | static function ( AbstractBlock $originalBlock ) |
916 | use ( $action, $targetTitle, $targetIsUserTalk ) { |
917 | if ( $originalBlock->appliesToRight( $action ) ) { |
918 | // An action block takes precedence over appliesToTitle(). |
919 | // Block::appliesToRight('edit') always returns null, |
920 | // allowing title-based exemptions to take effect. |
921 | return true; |
922 | } elseif ( $targetIsUserTalk ) { |
923 | // Special handling for a user's own talk page. The block is not aware |
924 | // of the user, so this must be done here. |
925 | return $originalBlock->appliesToUsertalk( $targetTitle ); |
926 | } else { |
927 | return $originalBlock->appliesToTitle( $targetTitle ); |
928 | } |
929 | } |
930 | ); |
931 | } |
932 | |
933 | if ( $targetTitle && $block |
934 | && $block instanceof AbstractBlock // for phan |
935 | ) { |
936 | // Allow extensions to let a blocked user access a particular page |
937 | $allowUsertalk = $block->isUsertalkEditAllowed(); |
938 | $blocked = true; |
939 | $this->hookRunner->onUserIsBlockedFrom( $user, $targetTitle, $blocked, $allowUsertalk ); |
940 | if ( !$blocked ) { |
941 | $block = null; |
942 | } |
943 | } |
944 | return $block; |
945 | } |
946 | |
947 | /** |
948 | * Run easy-to-test (or "quick") permissions checks for a given action. |
949 | * |
950 | * @param string $action The action to check |
951 | * @param User $user User to check |
952 | * @param PermissionStatus $status Current errors |
953 | * @param string $rigor One of PermissionManager::RIGOR_ constants |
954 | * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation) |
955 | * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB |
956 | * - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed |
957 | * @param bool $short Short circuit on first error |
958 | * @param LinkTarget $page |
959 | */ |
960 | private function checkQuickPermissions( |
961 | $action, |
962 | User $user, |
963 | PermissionStatus $status, |
964 | $rigor, |
965 | $short, |
966 | LinkTarget $page |
967 | ): void { |
968 | // TODO: remove when LinkTarget usage will expand further |
969 | $title = Title::newFromLinkTarget( $page ); |
970 | |
971 | // This method is always called first, so $status is guaranteed to be empty, so we can |
972 | // just pass an empty $errors array, instead of converting it to the legacy format and back. |
973 | $errors = []; |
974 | if ( !$this->hookRunner->onTitleQuickPermissions( $title, $user, $action, |
975 | $errors, $rigor !== self::RIGOR_QUICK, $short ) |
976 | ) { |
977 | // $errors is an array of results, not a result, but resultToStatus() handles |
978 | // arrays of arrays with recursion so this will work |
979 | $this->resultToStatus( $status, $errors ); |
980 | return; |
981 | } |
982 | |
983 | $isSubPage = |
984 | $this->nsInfo->hasSubpages( $title->getNamespace() ) && |
985 | strpos( $title->getText(), '/' ) !== false; |
986 | |
987 | if ( $action === 'create' ) { |
988 | if ( |
989 | ( $this->nsInfo->isTalk( $title->getNamespace() ) && |
990 | !$this->userHasRight( $user, 'createtalk' ) ) || |
991 | ( !$this->nsInfo->isTalk( $title->getNamespace() ) && |
992 | !$this->userHasRight( $user, 'createpage' ) ) |
993 | ) { |
994 | $status->fatal( $user->isNamed() ? 'nocreate-loggedin' : 'nocreatetext' ); |
995 | } |
996 | } elseif ( $action === 'move' ) { |
997 | if ( !$this->userHasRight( $user, 'move-rootuserpages' ) |
998 | && $title->getNamespace() === NS_USER && !$isSubPage |
999 | ) { |
1000 | // Show user page-specific message only if the user can move other pages |
1001 | $status->fatal( 'cant-move-user-page' ); |
1002 | } |
1003 | |
1004 | // Check if user is allowed to move files if it's a file |
1005 | if ( $title->getNamespace() === NS_FILE && |
1006 | !$this->userHasRight( $user, 'movefile' ) |
1007 | ) { |
1008 | $status->fatal( 'movenotallowedfile' ); |
1009 | } |
1010 | |
1011 | // Check if user is allowed to move category pages if it's a category page |
1012 | if ( $title->getNamespace() === NS_CATEGORY && |
1013 | !$this->userHasRight( $user, 'move-categorypages' ) |
1014 | ) { |
1015 | $status->fatal( 'cant-move-category-page' ); |
1016 | } |
1017 | |
1018 | if ( !$this->userHasRight( $user, 'move' ) ) { |
1019 | // User can't move anything |
1020 | $userCanMove = $this->groupPermissionsLookup |
1021 | ->groupHasPermission( 'user', 'move' ); |
1022 | $autoconfirmedCanMove = $this->groupPermissionsLookup |
1023 | ->groupHasPermission( 'autoconfirmed', 'move' ); |
1024 | if ( $user->isAnon() |
1025 | && ( $userCanMove || $autoconfirmedCanMove ) |
1026 | ) { |
1027 | // custom message if logged-in users without any special rights can move |
1028 | $status->fatal( 'movenologintext' ); |
1029 | } elseif ( $user->isTemp() && $autoconfirmedCanMove ) { |
1030 | // Temp user may be able to move if they log in as a proper account |
1031 | $status->fatal( 'movenologintext' ); |
1032 | } else { |
1033 | $status->fatal( 'movenotallowed' ); |
1034 | } |
1035 | } |
1036 | } elseif ( $action === 'move-target' ) { |
1037 | if ( !$this->userHasRight( $user, 'move' ) ) { |
1038 | // User can't move anything |
1039 | $status->fatal( 'movenotallowed' ); |
1040 | } elseif ( !$this->userHasRight( $user, 'move-rootuserpages' ) |
1041 | && $title->getNamespace() === NS_USER |
1042 | && !$isSubPage |
1043 | ) { |
1044 | // Show user page-specific message only if the user can move other pages |
1045 | $status->fatal( 'cant-move-to-user-page' ); |
1046 | } elseif ( !$this->userHasRight( $user, 'move-categorypages' ) |
1047 | && $title->getNamespace() === NS_CATEGORY |
1048 | ) { |
1049 | // Show category page-specific message only if the user can move other pages |
1050 | $status->fatal( 'cant-move-to-category-page' ); |
1051 | } |
1052 | } elseif ( $action === 'autocreateaccount' ) { |
1053 | // createaccount implies autocreateaccount |
1054 | if ( !$this->userHasAnyRight( $user, 'autocreateaccount', 'createaccount' ) ) { |
1055 | $this->missingPermissionError( $action, $short, $status ); |
1056 | } |
1057 | } elseif ( !$this->userHasRight( $user, $action ) ) { |
1058 | $this->missingPermissionError( $action, $short, $status ); |
1059 | } |
1060 | } |
1061 | |
1062 | /** |
1063 | * Check for any page_restrictions table requirements on this page. |
1064 | * |
1065 | * If the page has multiple restrictions, the user must have |
1066 | * all of those rights to perform the action in question. |
1067 | * |
1068 | * @param string $action The action to check |
1069 | * @param UserIdentity $user User to check |
1070 | * @param PermissionStatus $status Current errors |
1071 | * @param string $rigor One of PermissionManager::RIGOR_ constants |
1072 | * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation) |
1073 | * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB |
1074 | * - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed |
1075 | * @param bool $short Short circuit on first error |
1076 | * @param LinkTarget $page |
1077 | */ |
1078 | private function checkPageRestrictions( |
1079 | $action, |
1080 | UserIdentity $user, |
1081 | PermissionStatus $status, |
1082 | $rigor, |
1083 | $short, |
1084 | LinkTarget $page |
1085 | ): void { |
1086 | // TODO: remove & rework upon further use of LinkTarget |
1087 | $title = Title::newFromLinkTarget( $page ); |
1088 | foreach ( $this->restrictionStore->getRestrictions( $title, $action ) as $right ) { |
1089 | // Backwards compatibility, rewrite sysop -> editprotected |
1090 | if ( $right === 'sysop' ) { |
1091 | $right = 'editprotected'; |
1092 | } |
1093 | // Backwards compatibility, rewrite autoconfirmed -> editsemiprotected |
1094 | if ( $right === 'autoconfirmed' ) { |
1095 | $right = 'editsemiprotected'; |
1096 | } |
1097 | if ( $right == '' ) { |
1098 | continue; |
1099 | } |
1100 | if ( !$this->userHasRight( $user, $right ) ) { |
1101 | $status->fatal( 'protectedpagetext', $right, $action ); |
1102 | } elseif ( $this->restrictionStore->areRestrictionsCascading( $title ) && |
1103 | !$this->userHasRight( $user, 'protect' ) |
1104 | ) { |
1105 | $status->fatal( 'protectedpagetext', 'protect', $action ); |
1106 | } |
1107 | } |
1108 | } |
1109 | |
1110 | /** |
1111 | * Check restrictions on cascading pages. |
1112 | * |
1113 | * @param string $action The action to check |
1114 | * @param UserIdentity $user User to check |
1115 | * @param PermissionStatus $status Current errors |
1116 | * @param string $rigor One of PermissionManager::RIGOR_ constants |
1117 | * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation) |
1118 | * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB |
1119 | * - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed |
1120 | * @param bool $short Short circuit on first error |
1121 | * @param LinkTarget $page |
1122 | */ |
1123 | private function checkCascadingSourcesRestrictions( |
1124 | $action, |
1125 | UserIdentity $user, |
1126 | PermissionStatus $status, |
1127 | $rigor, |
1128 | $short, |
1129 | LinkTarget $page |
1130 | ): void { |
1131 | // TODO: remove & rework upon further use of LinkTarget |
1132 | $title = Title::newFromLinkTarget( $page ); |
1133 | if ( $rigor !== self::RIGOR_QUICK && !$title->isUserConfigPage() ) { |
1134 | [ $cascadingSources, $restrictions ] = $this->restrictionStore->getCascadeProtectionSources( $title ); |
1135 | // Cascading protection depends on more than this page... |
1136 | // Several cascading protected pages may include this page... |
1137 | // Check each cascading level |
1138 | // This is only for protection restrictions, not for all actions |
1139 | if ( isset( $restrictions[$action] ) ) { |
1140 | foreach ( $restrictions[$action] as $right ) { |
1141 | // Backwards compatibility, rewrite sysop -> editprotected |
1142 | if ( $right === 'sysop' ) { |
1143 | $right = 'editprotected'; |
1144 | } |
1145 | // Backwards compatibility, rewrite autoconfirmed -> editsemiprotected |
1146 | if ( $right === 'autoconfirmed' ) { |
1147 | $right = 'editsemiprotected'; |
1148 | } |
1149 | if ( $right != '' && !$this->userHasAllRights( $user, 'protect', $right ) ) { |
1150 | $wikiPages = ''; |
1151 | foreach ( $cascadingSources as $pageIdentity ) { |
1152 | $wikiPages .= '* [[:' . $this->titleFormatter->getPrefixedText( $pageIdentity ) . "]]\n"; |
1153 | } |
1154 | $status->fatal( 'cascadeprotected', count( $cascadingSources ), $wikiPages, $action ); |
1155 | } |
1156 | } |
1157 | } |
1158 | } |
1159 | } |
1160 | |
1161 | /** |
1162 | * Check action permissions not already checked in checkQuickPermissions |
1163 | * |
1164 | * @param string $action The action to check |
1165 | * @param User $user User to check |
1166 | * @param PermissionStatus $status Current errors |
1167 | * @param string $rigor One of PermissionManager::RIGOR_ constants |
1168 | * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation) |
1169 | * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB |
1170 | * - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed |
1171 | * @param bool $short Short circuit on first error |
1172 | * @param LinkTarget $page |
1173 | */ |
1174 | private function checkActionPermissions( |
1175 | $action, |
1176 | User $user, |
1177 | PermissionStatus $status, |
1178 | $rigor, |
1179 | $short, |
1180 | LinkTarget $page |
1181 | ): void { |
1182 | // TODO: remove & rework upon further use of LinkTarget |
1183 | $title = Title::newFromLinkTarget( $page ); |
1184 | |
1185 | if ( $rigor !== self::RIGOR_QUICK && !defined( 'MW_NO_SESSION' ) ) { |
1186 | $sessionRestrictions = $user->getRequest()->getSession()->getRestrictions(); |
1187 | if ( $sessionRestrictions ) { |
1188 | $userCan = $sessionRestrictions->userCan( $title ); |
1189 | if ( !$userCan->isOK() ) { |
1190 | $status->merge( $userCan ); |
1191 | } |
1192 | } |
1193 | } |
1194 | |
1195 | if ( $action === 'protect' ) { |
1196 | if ( !$this->getPermissionStatus( 'edit', $user, $title, $rigor, true )->isGood() ) { |
1197 | // If they can't edit, they shouldn't protect. |
1198 | $status->fatal( 'protect-cantedit' ); |
1199 | } |
1200 | } elseif ( $action === 'create' ) { |
1201 | $createProtection = $this->restrictionStore->getCreateProtection( $title ); |
1202 | if ( $createProtection ) { |
1203 | if ( $createProtection['permission'] == '' |
1204 | || !$this->userHasRight( $user, $createProtection['permission'] ) |
1205 | ) { |
1206 | $protectUserIdentity = $this->userIdentityLookup |
1207 | ->getUserIdentityByUserId( $createProtection['user'] ); |
1208 | $status->fatal( |
1209 | 'titleprotected', |
1210 | $protectUserIdentity ? $protectUserIdentity->getName() : '', |
1211 | $createProtection['reason'] |
1212 | ); |
1213 | } |
1214 | } |
1215 | } elseif ( $action === 'move' ) { |
1216 | // Check for immobile pages |
1217 | if ( !$this->nsInfo->isMovable( $title->getNamespace() ) ) { |
1218 | // Specific message for this case |
1219 | $nsText = $title->getNsText(); |
1220 | if ( $nsText === '' ) { |
1221 | $nsText = wfMessage( 'blanknamespace' )->text(); |
1222 | } |
1223 | $status->fatal( 'immobile-source-namespace', $nsText ); |
1224 | } elseif ( !$title->isMovable() ) { |
1225 | // Less specific message for rarer cases |
1226 | $status->fatal( 'immobile-source-page' ); |
1227 | } |
1228 | } elseif ( $action === 'move-target' ) { |
1229 | if ( !$this->nsInfo->isMovable( $title->getNamespace() ) ) { |
1230 | $nsText = $title->getNsText(); |
1231 | if ( $nsText === '' ) { |
1232 | $nsText = wfMessage( 'blanknamespace' )->text(); |
1233 | } |
1234 | $status->fatal( 'immobile-target-namespace', $nsText ); |
1235 | } elseif ( !$title->isMovable() ) { |
1236 | $status->fatal( 'immobile-target-page' ); |
1237 | } |
1238 | } elseif ( $action === 'delete' || $action === 'delete-redirect' ) { |
1239 | $tempStatus = PermissionStatus::newEmpty(); |
1240 | $this->checkPageRestrictions( 'edit', $user, $tempStatus, $rigor, true, $title ); |
1241 | if ( $tempStatus->isGood() ) { |
1242 | $this->checkCascadingSourcesRestrictions( 'edit', |
1243 | $user, $tempStatus, $rigor, true, $title ); |
1244 | } |
1245 | if ( !$tempStatus->isGood() ) { |
1246 | // If protection keeps them from editing, they shouldn't be able to delete. |
1247 | $status->fatal( 'deleteprotected' ); |
1248 | } |
1249 | if ( $rigor !== self::RIGOR_QUICK |
1250 | && $action === 'delete' |
1251 | && $this->options->get( MainConfigNames::DeleteRevisionsLimit ) |
1252 | && !$this->userCan( 'bigdelete', $user, $title ) |
1253 | && $title->isBigDeletion() |
1254 | ) { |
1255 | // NOTE: This check is deprecated since 1.37, see T288759 |
1256 | $status->fatal( |
1257 | 'delete-toobig', |
1258 | Message::numParam( $this->options->get( MainConfigNames::DeleteRevisionsLimit ) ) |
1259 | ); |
1260 | } |
1261 | } elseif ( $action === 'undelete' ) { |
1262 | if ( !$this->getPermissionStatus( 'edit', $user, $title, $rigor, true )->isGood() ) { |
1263 | // Undeleting implies editing |
1264 | $status->fatal( 'undelete-cantedit' ); |
1265 | } |
1266 | if ( !$title->exists() |
1267 | && !$this->getPermissionStatus( 'create', $user, $title, $rigor, true )->isGood() |
1268 | ) { |
1269 | // Undeleting where nothing currently exists implies creating |
1270 | $status->fatal( 'undelete-cantcreate' ); |
1271 | } |
1272 | } elseif ( $action === 'edit' ) { |
1273 | if ( $this->options->get( MainConfigNames::EmailConfirmToEdit ) |
1274 | && !$user->isEmailConfirmed() |
1275 | ) { |
1276 | $status->fatal( 'confirmedittext' ); |
1277 | } |
1278 | |
1279 | if ( !$title->exists() ) { |
1280 | $status->merge( |
1281 | $this->getPermissionStatus( |
1282 | 'create', |
1283 | $user, |
1284 | $title, |
1285 | $rigor, |
1286 | true |
1287 | ) |
1288 | ); |
1289 | } |
1290 | } |
1291 | } |
1292 | |
1293 | /** |
1294 | * Check permissions on special pages & namespaces |
1295 | * |
1296 | * @param string $action The action to check |
1297 | * @param UserIdentity $user User to check |
1298 | * @param PermissionStatus $status Current errors |
1299 | * @param string $rigor One of PermissionManager::RIGOR_ constants |
1300 | * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation) |
1301 | * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB |
1302 | * - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed |
1303 | * @param bool $short Short circuit on first error |
1304 | * @param LinkTarget $page |
1305 | */ |
1306 | private function checkSpecialsAndNSPermissions( |
1307 | $action, |
1308 | UserIdentity $user, |
1309 | PermissionStatus $status, |
1310 | $rigor, |
1311 | $short, |
1312 | LinkTarget $page |
1313 | ): void { |
1314 | // TODO: remove & rework upon further use of LinkTarget |
1315 | $title = Title::newFromLinkTarget( $page ); |
1316 | |
1317 | // Only 'createaccount' can be performed on special pages, |
1318 | // which don't actually exist in the DB. |
1319 | if ( $title->getNamespace() === NS_SPECIAL |
1320 | && !in_array( $action, [ 'createaccount', 'autocreateaccount' ], true ) |
1321 | ) { |
1322 | $status->fatal( 'ns-specialprotected' ); |
1323 | } |
1324 | |
1325 | // Check $wgNamespaceProtection for restricted namespaces |
1326 | if ( $this->isNamespaceProtected( $title->getNamespace(), $user ) |
1327 | // Allow admins and oversighters to view deleted content, even if they |
1328 | // cannot restore it. See T362536. |
1329 | && !in_array( $action, [ 'deletedhistory', 'deletedtext', 'viewsuppressed' ], true ) |
1330 | ) { |
1331 | $ns = $title->getNamespace() === NS_MAIN ? |
1332 | wfMessage( 'nstab-main' )->text() : $title->getNsText(); |
1333 | if ( $title->getNamespace() === NS_MEDIAWIKI ) { |
1334 | $status->fatal( 'protectedinterface', $action ); |
1335 | } else { |
1336 | $status->fatal( 'namespaceprotected', $ns, $action ); |
1337 | } |
1338 | } |
1339 | } |
1340 | |
1341 | /** |
1342 | * Check sitewide CSS/JSON/JS permissions |
1343 | * |
1344 | * @param string $action The action to check |
1345 | * @param UserIdentity $user User to check |
1346 | * @param PermissionStatus $status Current errors |
1347 | * @param string $rigor One of PermissionManager::RIGOR_ constants |
1348 | * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation) |
1349 | * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB |
1350 | * - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed |
1351 | * @param bool $short Short circuit on first error |
1352 | * @param LinkTarget $page |
1353 | */ |
1354 | private function checkSiteConfigPermissions( |
1355 | $action, |
1356 | UserIdentity $user, |
1357 | PermissionStatus $status, |
1358 | $rigor, |
1359 | $short, |
1360 | LinkTarget $page |
1361 | ): void { |
1362 | // TODO: remove & rework upon further use of LinkTarget |
1363 | $title = Title::newFromLinkTarget( $page ); |
1364 | |
1365 | if ( $action === 'patrol' ) { |
1366 | return; |
1367 | } |
1368 | |
1369 | if ( in_array( $action, [ 'deletedhistory', 'deletedtext', 'viewsuppressed' ], true ) ) { |
1370 | // Allow admins and oversighters to view deleted content, even if they |
1371 | // cannot restore it. See T202989 |
1372 | // Not using the same handling in `getPermissionStatus` as the checks |
1373 | // for skipping `checkUserConfigPermissions` since normal admins can delete |
1374 | // user scripts, but not sitewide scripts |
1375 | return; |
1376 | } |
1377 | |
1378 | // Sitewide CSS/JSON/JS/RawHTML changes, like all NS_MEDIAWIKI changes, also require the |
1379 | // editinterface right. That's implemented as a restriction so no check needed here. |
1380 | if ( $title->isSiteCssConfigPage() && !$this->userHasRight( $user, 'editsitecss' ) ) { |
1381 | $status->fatal( 'sitecssprotected', $action ); |
1382 | } elseif ( $title->isSiteJsonConfigPage() && !$this->userHasRight( $user, 'editsitejson' ) ) { |
1383 | $status->fatal( 'sitejsonprotected', $action ); |
1384 | } elseif ( $title->isSiteJsConfigPage() && !$this->userHasRight( $user, 'editsitejs' ) ) { |
1385 | $status->fatal( 'sitejsprotected', $action ); |
1386 | } |
1387 | if ( $title->isRawHtmlMessage() && !$this->userCanEditRawHtmlPage( $user ) ) { |
1388 | $status->fatal( 'siterawhtmlprotected', $action ); |
1389 | } |
1390 | } |
1391 | |
1392 | /** |
1393 | * Check CSS/JSON/JS subpage permissions |
1394 | * |
1395 | * @param string $action The action to check |
1396 | * @param UserIdentity $user User to check |
1397 | * @param PermissionStatus $status Current errors |
1398 | * @param string $rigor One of PermissionManager::RIGOR_ constants |
1399 | * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation) |
1400 | * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB |
1401 | * - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed |
1402 | * @param bool $short Short circuit on first error |
1403 | * @param LinkTarget $page |
1404 | */ |
1405 | private function checkUserConfigPermissions( |
1406 | $action, |
1407 | UserIdentity $user, |
1408 | PermissionStatus $status, |
1409 | $rigor, |
1410 | $short, |
1411 | LinkTarget $page |
1412 | ): void { |
1413 | // TODO: remove & rework upon further use of LinkTarget |
1414 | $title = Title::newFromLinkTarget( $page ); |
1415 | |
1416 | // Protect css/json/js subpages of user pages |
1417 | // XXX: this might be better using restrictions |
1418 | if ( preg_match( '/^' . preg_quote( $user->getName(), '/' ) . '\//', $title->getText() ) ) { |
1419 | // Users need editmyuser* to edit their own CSS/JSON/JS subpages. |
1420 | if ( |
1421 | $title->isUserCssConfigPage() |
1422 | && !$this->userHasAnyRight( $user, 'editmyusercss', 'editusercss' ) |
1423 | ) { |
1424 | $status->fatal( 'mycustomcssprotected', $action ); |
1425 | } elseif ( |
1426 | $title->isUserJsonConfigPage() |
1427 | && !$this->userHasAnyRight( $user, 'editmyuserjson', 'edituserjson' ) |
1428 | ) { |
1429 | $status->fatal( 'mycustomjsonprotected', $action ); |
1430 | } elseif ( |
1431 | $title->isUserJsConfigPage() |
1432 | && !$this->userHasAnyRight( $user, 'editmyuserjs', 'edituserjs' ) |
1433 | ) { |
1434 | $status->fatal( 'mycustomjsprotected', $action ); |
1435 | } elseif ( |
1436 | $title->isUserJsConfigPage() |
1437 | && !$this->userHasAnyRight( $user, 'edituserjs', 'editmyuserjsredirect' ) |
1438 | ) { |
1439 | // T207750 - do not allow users to edit a redirect if they couldn't edit the target |
1440 | $target = $this->redirectLookup->getRedirectTarget( $title ); |
1441 | if ( $target && ( |
1442 | !$target->inNamespace( NS_USER ) |
1443 | || !preg_match( '/^' . preg_quote( $user->getName(), '/' ) . '\//', $target->getText() ) |
1444 | ) ) { |
1445 | $status->fatal( 'mycustomjsredirectprotected', $action ); |
1446 | } |
1447 | } |
1448 | } else { |
1449 | // Users need edituser* to edit others' CSS/JSON/JS subpages. |
1450 | // The checks to exclude deletion/suppression, which cannot be used for |
1451 | // attacks and should be excluded to avoid the situation where an |
1452 | // unprivileged user can post abusive content on their subpages |
1453 | // and only very highly privileged users could remove it, |
1454 | // are now a part of `getPermissionStatus` and this method isn't called. |
1455 | if ( |
1456 | $title->isUserCssConfigPage() |
1457 | && !$this->userHasRight( $user, 'editusercss' ) |
1458 | ) { |
1459 | $status->fatal( 'customcssprotected', $action ); |
1460 | } elseif ( |
1461 | $title->isUserJsonConfigPage() |
1462 | && !$this->userHasRight( $user, 'edituserjson' ) |
1463 | ) { |
1464 | $status->fatal( 'customjsonprotected', $action ); |
1465 | } elseif ( |
1466 | $title->isUserJsConfigPage() |
1467 | && !$this->userHasRight( $user, 'edituserjs' ) |
1468 | ) { |
1469 | $status->fatal( 'customjsprotected', $action ); |
1470 | } |
1471 | } |
1472 | } |
1473 | |
1474 | /** |
1475 | * Whether the user is generally allowed to perform the given action. |
1476 | * |
1477 | * @since 1.34 |
1478 | * @param UserIdentity $user |
1479 | * @param string $action |
1480 | * @return bool True if allowed |
1481 | */ |
1482 | public function userHasRight( UserIdentity $user, $action = '' ): bool { |
1483 | if ( $action === '' ) { |
1484 | // In the spirit of DWIM |
1485 | return true; |
1486 | } |
1487 | // Use strict parameter to avoid matching numeric 0 accidentally inserted |
1488 | // by misconfiguration: 0 == 'foo' |
1489 | return in_array( $action, $this->getImplicitRights(), true ) |
1490 | || in_array( $action, $this->getUserPermissions( $user ), true ); |
1491 | } |
1492 | |
1493 | /** |
1494 | * Whether the user is generally allowed to perform at least one of the actions. |
1495 | * |
1496 | * @since 1.34 |
1497 | * @param UserIdentity $user |
1498 | * @param string ...$actions |
1499 | * @return bool True if user is allowed to perform *any* of the actions |
1500 | */ |
1501 | public function userHasAnyRight( UserIdentity $user, ...$actions ): bool { |
1502 | foreach ( $actions as $action ) { |
1503 | if ( $this->userHasRight( $user, $action ) ) { |
1504 | return true; |
1505 | } |
1506 | } |
1507 | return false; |
1508 | } |
1509 | |
1510 | /** |
1511 | * Whether the user is allowed to perform all of the given actions. |
1512 | * |
1513 | * @since 1.34 |
1514 | * @param UserIdentity $user |
1515 | * @param string ...$actions |
1516 | * @return bool True if user is allowed to perform *all* of the given actions |
1517 | */ |
1518 | public function userHasAllRights( UserIdentity $user, ...$actions ): bool { |
1519 | foreach ( $actions as $action ) { |
1520 | if ( !$this->userHasRight( $user, $action ) ) { |
1521 | return false; |
1522 | } |
1523 | } |
1524 | return true; |
1525 | } |
1526 | |
1527 | /** |
1528 | * Get the permissions this user has. |
1529 | * |
1530 | * @since 1.34 |
1531 | * @param UserIdentity $user |
1532 | * @return string[] permission names |
1533 | */ |
1534 | public function getUserPermissions( UserIdentity $user ): array { |
1535 | $rightsCacheKey = $this->getRightsCacheKey( $user ); |
1536 | if ( !isset( $this->usersRights[ $rightsCacheKey ] ) ) { |
1537 | $userObj = $this->userFactory->newFromUserIdentity( $user ); |
1538 | $rights = $this->groupPermissionsLookup->getGroupPermissions( |
1539 | $this->userGroupManager->getUserEffectiveGroups( $user ) |
1540 | ); |
1541 | // Hook requires a full User object |
1542 | $this->hookRunner->onUserGetRights( $userObj, $rights ); |
1543 | |
1544 | // Deny any rights denied by the user's session, unless this |
1545 | // endpoint has no sessions. |
1546 | if ( !defined( 'MW_NO_SESSION' ) ) { |
1547 | // FIXME: $userObj->getRequest().. need to be replaced with something else |
1548 | $allowedRights = $userObj->getRequest()->getSession()->getAllowedUserRights(); |
1549 | if ( $allowedRights !== null ) { |
1550 | $rights = array_intersect( $rights, $allowedRights ); |
1551 | } |
1552 | } |
1553 | |
1554 | // Hook requires a full User object |
1555 | $this->hookRunner->onUserGetRightsRemove( $userObj, $rights ); |
1556 | // Force reindexation of rights when a hook has unset one of them |
1557 | $rights = array_values( array_unique( $rights ) ); |
1558 | |
1559 | // If BlockDisablesLogin is true, remove rights that anonymous |
1560 | // users don't have. This has to be done after the hooks so that |
1561 | // we know whether the user is exempt. (T129738) |
1562 | if ( |
1563 | $userObj->isRegistered() |
1564 | && $this->options->get( MainConfigNames::BlockDisablesLogin ) |
1565 | ) { |
1566 | $isExempt = in_array( 'ipblock-exempt', $rights, true ); |
1567 | if ( $this->blockManager->getBlock( |
1568 | $userObj, |
1569 | $isExempt ? null : $userObj->getRequest() |
1570 | ) ) { |
1571 | $anon = $this->userFactory->newAnonymous(); |
1572 | $rights = array_intersect( $rights, $this->getUserPermissions( $anon ) ); |
1573 | } |
1574 | } |
1575 | |
1576 | $this->usersRights[ $rightsCacheKey ] = $rights; |
1577 | } else { |
1578 | $rights = $this->usersRights[ $rightsCacheKey ]; |
1579 | } |
1580 | foreach ( $this->temporaryUserRights[ $user->getId() ] ?? [] as $overrides ) { |
1581 | $rights = array_values( array_unique( array_merge( $rights, $overrides ) ) ); |
1582 | } |
1583 | return $rights; |
1584 | } |
1585 | |
1586 | /** |
1587 | * Clear the in-process permission cache for one or all users. |
1588 | * |
1589 | * @since 1.34 |
1590 | * @param UserIdentity|null $user If a specific user is provided it will clear |
1591 | * the permission cache only for that user. |
1592 | */ |
1593 | public function invalidateUsersRightsCache( $user = null ): void { |
1594 | if ( $user !== null ) { |
1595 | $rightsCacheKey = $this->getRightsCacheKey( $user ); |
1596 | unset( $this->usersRights[ $rightsCacheKey ] ); |
1597 | } else { |
1598 | $this->usersRights = []; |
1599 | } |
1600 | } |
1601 | |
1602 | /** |
1603 | * Get a unique key for user rights cache. |
1604 | * |
1605 | * @param UserIdentity $user |
1606 | * @return string |
1607 | */ |
1608 | private function getRightsCacheKey( UserIdentity $user ): string { |
1609 | return $user->isRegistered() ? "u:{$user->getId()}" : "anon:{$user->getName()}"; |
1610 | } |
1611 | |
1612 | /** |
1613 | * Check if all users may be assumed to have the given permission |
1614 | * |
1615 | * We generally assume so if the right is granted to '*' and isn't revoked |
1616 | * on any group. It doesn't attempt to take grants or other extension |
1617 | * limitations on rights into account in the general case, though, as that |
1618 | * would require it to always return false and defeat the purpose. |
1619 | * Specifically, session-based rights restrictions (such as OAuth or bot |
1620 | * passwords) are applied based on the current session. |
1621 | * |
1622 | * @since 1.34 |
1623 | * @param string $right Right to check |
1624 | * @return bool |
1625 | */ |
1626 | public function isEveryoneAllowed( $right ): bool { |
1627 | // Use the cached results, except in unit tests which rely on |
1628 | // being able change the permission mid-request |
1629 | if ( isset( $this->cachedRights[$right] ) ) { |
1630 | return $this->cachedRights[$right]; |
1631 | } |
1632 | |
1633 | if ( !isset( $this->options->get( MainConfigNames::GroupPermissions )['*'][$right] ) |
1634 | || !$this->options->get( MainConfigNames::GroupPermissions )['*'][$right] |
1635 | ) { |
1636 | $this->cachedRights[$right] = false; |
1637 | return false; |
1638 | } |
1639 | |
1640 | // If it's revoked anywhere, then everyone doesn't have it |
1641 | foreach ( $this->options->get( MainConfigNames::RevokePermissions ) as $rights ) { |
1642 | if ( isset( $rights[$right] ) && $rights[$right] ) { |
1643 | $this->cachedRights[$right] = false; |
1644 | return false; |
1645 | } |
1646 | } |
1647 | |
1648 | // Remove any rights that aren't allowed to the global-session user, |
1649 | // unless there are no sessions for this endpoint. |
1650 | if ( !defined( 'MW_NO_SESSION' ) ) { |
1651 | // XXX: think what could be done with the below |
1652 | $allowedRights = SessionManager::getGlobalSession()->getAllowedUserRights(); |
1653 | if ( $allowedRights !== null && !in_array( $right, $allowedRights, true ) ) { |
1654 | $this->cachedRights[$right] = false; |
1655 | return false; |
1656 | } |
1657 | } |
1658 | |
1659 | // Allow extensions to say false |
1660 | if ( !$this->hookRunner->onUserIsEveryoneAllowed( $right ) ) { |
1661 | $this->cachedRights[$right] = false; |
1662 | return false; |
1663 | } |
1664 | |
1665 | $this->cachedRights[$right] = true; |
1666 | return true; |
1667 | } |
1668 | |
1669 | /** |
1670 | * Get a list of all permissions that can be managed through group permissions. |
1671 | * This does not include implicit rights which are granted to all users automatically. |
1672 | * |
1673 | * @see getImplicitRights() |
1674 | * |
1675 | * @since 1.34 |
1676 | * @return string[] Array of permission names |
1677 | */ |
1678 | public function getAllPermissions(): array { |
1679 | if ( $this->allRights === null ) { |
1680 | if ( count( $this->options->get( MainConfigNames::AvailableRights ) ) ) { |
1681 | $this->allRights = array_unique( array_merge( |
1682 | self::CORE_RIGHTS, |
1683 | $this->options->get( MainConfigNames::AvailableRights ) |
1684 | ) ); |
1685 | } else { |
1686 | $this->allRights = self::CORE_RIGHTS; |
1687 | } |
1688 | $this->hookRunner->onUserGetAllRights( $this->allRights ); |
1689 | } |
1690 | return $this->allRights; |
1691 | } |
1692 | |
1693 | /** |
1694 | * Get a list of implicit rights. |
1695 | * |
1696 | * Rights in this list should be granted to all users implicitly. |
1697 | * |
1698 | * Implicit rights are defined to allow rate limits to be imposed |
1699 | * on permissions |
1700 | * |
1701 | * @since 1.41 |
1702 | * @return string[] Array of permission names |
1703 | */ |
1704 | public function getImplicitRights(): array { |
1705 | if ( $this->implicitRights === null ) { |
1706 | $rights = array_unique( array_merge( |
1707 | self::CORE_IMPLICIT_RIGHTS, |
1708 | $this->options->get( MainConfigNames::ImplicitRights ) |
1709 | ) ); |
1710 | |
1711 | $this->implicitRights = array_diff( $rights, $this->getAllPermissions() ); |
1712 | } |
1713 | return $this->implicitRights; |
1714 | } |
1715 | |
1716 | /** |
1717 | * Determine if $user is unable to edit pages in namespace because it has been protected. |
1718 | * |
1719 | * @param int $index |
1720 | * @param UserIdentity $user |
1721 | * @return bool |
1722 | */ |
1723 | private function isNamespaceProtected( $index, UserIdentity $user ): bool { |
1724 | $namespaceProtection = $this->options->get( MainConfigNames::NamespaceProtection ); |
1725 | if ( isset( $namespaceProtection[$index] ) ) { |
1726 | return !$this->userHasAllRights( $user, ...(array)$namespaceProtection[$index] ); |
1727 | } |
1728 | return false; |
1729 | } |
1730 | |
1731 | /** |
1732 | * Determine which restriction levels it makes sense to use in a namespace, |
1733 | * optionally filtered by a user's rights. |
1734 | * |
1735 | * @param int $index Namespace ID (index) to check |
1736 | * @param UserIdentity|null $user User to check |
1737 | * @return string[] |
1738 | */ |
1739 | public function getNamespaceRestrictionLevels( $index, ?UserIdentity $user = null ): array { |
1740 | if ( !isset( $this->options->get( MainConfigNames::NamespaceProtection )[$index] ) ) { |
1741 | // All levels are valid if there's no namespace restriction. |
1742 | // But still filter by user, if necessary |
1743 | $levels = $this->options->get( MainConfigNames::RestrictionLevels ); |
1744 | if ( $user ) { |
1745 | $levels = array_values( array_filter( $levels, function ( $level ) use ( $user ) { |
1746 | $right = $level; |
1747 | if ( $right === 'sysop' ) { |
1748 | $right = 'editprotected'; // BC |
1749 | } |
1750 | if ( $right === 'autoconfirmed' ) { |
1751 | $right = 'editsemiprotected'; // BC |
1752 | } |
1753 | return $this->userHasRight( $user, $right ); |
1754 | } ) ); |
1755 | } |
1756 | return $levels; |
1757 | } |
1758 | |
1759 | // $wgNamespaceProtection can require one or more rights to edit the namespace, which |
1760 | // may be satisfied by membership in multiple groups each giving a subset of those rights. |
1761 | // A restriction level is redundant if, for any one of the namespace rights, all groups |
1762 | // giving that right also give the restriction level's right. Or, conversely, a |
1763 | // restriction level is not redundant if, for every namespace right, there's at least one |
1764 | // group giving that right without the restriction level's right. |
1765 | // |
1766 | // First, for each right, get a list of groups with that right. |
1767 | $namespaceRightGroups = []; |
1768 | foreach ( (array)$this->options->get( MainConfigNames::NamespaceProtection )[$index] as $right ) { |
1769 | if ( $right === 'sysop' ) { |
1770 | $right = 'editprotected'; // BC |
1771 | } |
1772 | if ( $right === 'autoconfirmed' ) { |
1773 | $right = 'editsemiprotected'; // BC |
1774 | } |
1775 | if ( $right != '' ) { |
1776 | $namespaceRightGroups[$right] = $this->groupPermissionsLookup->getGroupsWithPermission( $right ); |
1777 | } |
1778 | } |
1779 | |
1780 | // Now, go through the protection levels one by one. |
1781 | $usableLevels = [ '' ]; |
1782 | foreach ( $this->options->get( MainConfigNames::RestrictionLevels ) as $level ) { |
1783 | $right = $level; |
1784 | if ( $right === 'sysop' ) { |
1785 | $right = 'editprotected'; // BC |
1786 | } |
1787 | if ( $right === 'autoconfirmed' ) { |
1788 | $right = 'editsemiprotected'; // BC |
1789 | } |
1790 | |
1791 | if ( $right != '' && |
1792 | !isset( $namespaceRightGroups[$right] ) && |
1793 | ( !$user || $this->userHasRight( $user, $right ) ) |
1794 | ) { |
1795 | // Do any of the namespace rights imply the restriction right? (see explanation above) |
1796 | foreach ( $namespaceRightGroups as $groups ) { |
1797 | if ( !array_diff( $groups, $this->groupPermissionsLookup->getGroupsWithPermission( $right ) ) ) { |
1798 | // Yes, this one does. |
1799 | continue 2; |
1800 | } |
1801 | } |
1802 | // No, keep the restriction level |
1803 | $usableLevels[] = $level; |
1804 | } |
1805 | } |
1806 | |
1807 | return $usableLevels; |
1808 | } |
1809 | |
1810 | /** |
1811 | * Check if user is allowed to edit sitewide pages that contain raw HTML. |
1812 | * |
1813 | * Pages listed in $wgRawHtmlMessages allow raw HTML which can be used to deploy CSS or JS |
1814 | * code to all users so both rights are required to edit them. |
1815 | * |
1816 | * @param UserIdentity $user |
1817 | * @return bool True if user has both rights |
1818 | */ |
1819 | private function userCanEditRawHtmlPage( UserIdentity $user ): bool { |
1820 | return $this->userHasAllRights( $user, 'editsitecss', 'editsitejs' ); |
1821 | } |
1822 | |
1823 | /** |
1824 | * Add temporary user rights, only valid for the current function scope. |
1825 | * |
1826 | * This is meant for making it possible to programatically trigger certain actions that |
1827 | * the user wouldn't be able to trigger themselves; e.g. allow users without the bot right |
1828 | * to make bot-flagged actions through certain special pages. |
1829 | * |
1830 | * This returns a "scope guard" variable. Its only purpose is to be stored in a variable |
1831 | * by the caller, which is automatically closed at the end of the function, at which point |
1832 | * the rights are revoked again. Alternatively, you can close it earlier by consuming it |
1833 | * via ScopedCallback::consume(). |
1834 | * |
1835 | * @since 1.34 |
1836 | * @param UserIdentity $user |
1837 | * @param string|string[] $rights |
1838 | * @return ScopedCallback |
1839 | */ |
1840 | public function addTemporaryUserRights( UserIdentity $user, $rights ) { |
1841 | $userId = $user->getId(); |
1842 | $nextKey = count( $this->temporaryUserRights[$userId] ?? [] ); |
1843 | $this->temporaryUserRights[$userId][$nextKey] = (array)$rights; |
1844 | return new ScopedCallback( function () use ( $userId, $nextKey ) { |
1845 | unset( $this->temporaryUserRights[$userId][$nextKey] ); |
1846 | } ); |
1847 | } |
1848 | |
1849 | /** |
1850 | * Override the user permissions cache |
1851 | * |
1852 | * @internal For testing only |
1853 | * @since 1.34 |
1854 | * @param UserIdentity $user |
1855 | * @param string[]|string $rights |
1856 | */ |
1857 | public function overrideUserRightsForTesting( $user, $rights = [] ) { |
1858 | if ( !defined( 'MW_PHPUNIT_TEST' ) ) { |
1859 | throw new LogicException( __METHOD__ . ' can not be called outside of tests' ); |
1860 | } |
1861 | $this->usersRights[ $this->getRightsCacheKey( $user ) ] = |
1862 | is_array( $rights ) ? $rights : [ $rights ]; |
1863 | } |
1864 | |
1865 | } |