Merge "Make Special:ChangeContentModel field labels consistently use colons"
[lhc/web/wiklou.git] / includes / Permissions / PermissionManager.php
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 Action;
23 use Exception;
24 use Hooks;
25 use MediaWiki\Config\ServiceOptions;
26 use MediaWiki\Linker\LinkTarget;
27 use MediaWiki\Revision\RevisionLookup;
28 use MediaWiki\Revision\RevisionRecord;
29 use MediaWiki\Session\SessionManager;
30 use MediaWiki\Special\SpecialPageFactory;
31 use MediaWiki\User\UserIdentity;
32 use MessageSpecifier;
33 use NamespaceInfo;
34 use RequestContext;
35 use SpecialPage;
36 use Title;
37 use User;
38 use Wikimedia\ScopedCallback;
39 use WikiPage;
40
41 /**
42 * A service class for checking permissions
43 * To obtain an instance, use MediaWikiServices::getInstance()->getPermissionManager().
44 *
45 * @since 1.33
46 */
47 class PermissionManager {
48
49 /** @var string Does cheap permission checks from replica DBs (usable for GUI creation) */
50 const RIGOR_QUICK = 'quick';
51
52 /** @var string Does cheap and expensive checks possibly from a replica DB */
53 const RIGOR_FULL = 'full';
54
55 /** @var string Does cheap and expensive checks, using the master as needed */
56 const RIGOR_SECURE = 'secure';
57
58 /**
59 * TODO Make this const when HHVM support is dropped (T192166)
60 *
61 * @since 1.34
62 * @var array
63 */
64 public static $constructorOptions = [
65 'WhitelistRead',
66 'WhitelistReadRegexp',
67 'EmailConfirmToEdit',
68 'BlockDisablesLogin',
69 'GroupPermissions',
70 'RevokePermissions',
71 'AvailableRights',
72 'NamespaceProtection',
73 'RestrictionLevels'
74 ];
75
76 /** @var ServiceOptions */
77 private $options;
78
79 /** @var SpecialPageFactory */
80 private $specialPageFactory;
81
82 /** @var RevisionLookup */
83 private $revisionLookup;
84
85 /** @var NamespaceInfo */
86 private $nsInfo;
87
88 /** @var string[] Cached results of getAllRights() */
89 private $allRights = false;
90
91 /** @var string[][] Cached user rights */
92 private $usersRights = null;
93
94 /**
95 * Temporary user rights, valid for the current request only.
96 * @var string[][][] userid => override group => rights
97 */
98 private $temporaryUserRights = [];
99
100 /** @var string[] Cached rights for isEveryoneAllowed */
101 private $cachedRights = [];
102
103 /**
104 * Array of Strings Core rights.
105 * Each of these should have a corresponding message of the form
106 * "right-$right".
107 * @showinitializer
108 */
109 private $coreRights = [
110 'apihighlimits',
111 'applychangetags',
112 'autoconfirmed',
113 'autocreateaccount',
114 'autopatrol',
115 'bigdelete',
116 'block',
117 'blockemail',
118 'bot',
119 'browsearchive',
120 'changetags',
121 'createaccount',
122 'createpage',
123 'createtalk',
124 'delete',
125 'deletechangetags',
126 'deletedhistory',
127 'deletedtext',
128 'deletelogentry',
129 'deleterevision',
130 'edit',
131 'editcontentmodel',
132 'editinterface',
133 'editprotected',
134 'editmyoptions',
135 'editmyprivateinfo',
136 'editmyusercss',
137 'editmyuserjson',
138 'editmyuserjs',
139 'editmyuserjsredirect',
140 'editmywatchlist',
141 'editsemiprotected',
142 'editsitecss',
143 'editsitejson',
144 'editsitejs',
145 'editusercss',
146 'edituserjson',
147 'edituserjs',
148 'hideuser',
149 'import',
150 'importupload',
151 'ipblock-exempt',
152 'managechangetags',
153 'markbotedits',
154 'mergehistory',
155 'minoredit',
156 'move',
157 'movefile',
158 'move-categorypages',
159 'move-rootuserpages',
160 'move-subpages',
161 'nominornewtalk',
162 'noratelimit',
163 'override-export-depth',
164 'pagelang',
165 'patrol',
166 'patrolmarks',
167 'protect',
168 'purge',
169 'read',
170 'reupload',
171 'reupload-own',
172 'reupload-shared',
173 'rollback',
174 'sendemail',
175 'siteadmin',
176 'suppressionlog',
177 'suppressredirect',
178 'suppressrevision',
179 'unblockself',
180 'undelete',
181 'unwatchedpages',
182 'upload',
183 'upload_by_url',
184 'userrights',
185 'userrights-interwiki',
186 'viewmyprivateinfo',
187 'viewmywatchlist',
188 'viewsuppressed',
189 'writeapi',
190 ];
191
192 /**
193 * @param ServiceOptions $options
194 * @param SpecialPageFactory $specialPageFactory
195 * @param RevisionLookup $revisionLookup
196 * @param NamespaceInfo $nsInfo
197 */
198 public function __construct(
199 ServiceOptions $options,
200 SpecialPageFactory $specialPageFactory,
201 RevisionLookup $revisionLookup,
202 NamespaceInfo $nsInfo
203 ) {
204 $options->assertRequiredOptions( self::$constructorOptions );
205 $this->options = $options;
206 $this->specialPageFactory = $specialPageFactory;
207 $this->revisionLookup = $revisionLookup;
208 $this->nsInfo = $nsInfo;
209 }
210
211 /**
212 * Can $user perform $action on a page?
213 *
214 * The method is intended to replace Title::userCan()
215 * The $user parameter need to be superseded by UserIdentity value in future
216 * The $title parameter need to be superseded by PageIdentity value in future
217 *
218 * @see Title::userCan()
219 *
220 * @param string $action
221 * @param User $user
222 * @param LinkTarget $page
223 * @param string $rigor One of PermissionManager::RIGOR_ constants
224 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
225 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
226 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
227 *
228 * @return bool
229 */
230 public function userCan( $action, User $user, LinkTarget $page, $rigor = self::RIGOR_SECURE ) {
231 return !count( $this->getPermissionErrorsInternal( $action, $user, $page, $rigor, true ) );
232 }
233
234 /**
235 * A convenience method for calling PermissionManager::userCan
236 * with PermissionManager::RIGOR_QUICK
237 *
238 * Suitable for use for nonessential UI controls in common cases, but
239 * _not_ for functional access control.
240 * May provide false positives, but should never provide a false negative.
241 *
242 * @see PermissionManager::userCan()
243 *
244 * @param string $action
245 * @param User $user
246 * @param LinkTarget $page
247 * @return bool
248 */
249 public function quickUserCan( $action, User $user, LinkTarget $page ) {
250 return $this->userCan( $action, $user, $page, self::RIGOR_QUICK );
251 }
252
253 /**
254 * Can $user perform $action on a page?
255 *
256 * @todo FIXME: This *does not* check throttles (User::pingLimiter()).
257 *
258 * @param string $action Action that permission needs to be checked for
259 * @param User $user User to check
260 * @param LinkTarget $page
261 * @param string $rigor One of PermissionManager::RIGOR_ constants
262 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
263 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
264 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
265 * @param array $ignoreErrors Array of Strings Set this to a list of message keys
266 * whose corresponding errors may be ignored.
267 *
268 * @return array Array of arrays of the arguments to wfMessage to explain permissions problems.
269 */
270 public function getPermissionErrors(
271 $action,
272 User $user,
273 LinkTarget $page,
274 $rigor = self::RIGOR_SECURE,
275 $ignoreErrors = []
276 ) {
277 $errors = $this->getPermissionErrorsInternal( $action, $user, $page, $rigor );
278
279 // Remove the errors being ignored.
280 foreach ( $errors as $index => $error ) {
281 $errKey = is_array( $error ) ? $error[0] : $error;
282
283 if ( in_array( $errKey, $ignoreErrors ) ) {
284 unset( $errors[$index] );
285 }
286 if ( $errKey instanceof MessageSpecifier && in_array( $errKey->getKey(), $ignoreErrors ) ) {
287 unset( $errors[$index] );
288 }
289 }
290
291 return $errors;
292 }
293
294 /**
295 * Check if user is blocked from editing a particular article. If the user does not
296 * have a block, this will return false.
297 *
298 * @param User $user
299 * @param LinkTarget $page Title to check
300 * @param bool $fromReplica Whether to check the replica DB instead of the master
301 *
302 * @return bool
303 */
304 public function isBlockedFrom( User $user, LinkTarget $page, $fromReplica = false ) {
305 $block = $user->getBlock( $fromReplica );
306 if ( !$block ) {
307 return false;
308 }
309
310 // TODO: remove upon further migration to LinkTarget
311 $title = Title::newFromLinkTarget( $page );
312
313 $blocked = $user->isHidden();
314 if ( !$blocked ) {
315 // Special handling for a user's own talk page. The block is not aware
316 // of the user, so this must be done here.
317 if ( $title->equals( $user->getTalkPage() ) ) {
318 $blocked = $block->appliesToUsertalk( $title );
319 } else {
320 $blocked = $block->appliesToTitle( $title );
321 }
322 }
323
324 // only for the purpose of the hook. We really don't need this here.
325 $allowUsertalk = $user->isAllowUsertalk();
326
327 // Allow extensions to let a blocked user access a particular page
328 Hooks::run( 'UserIsBlockedFrom', [ $user, $title, &$blocked, &$allowUsertalk ] );
329
330 return $blocked;
331 }
332
333 /**
334 * Can $user perform $action on a page? This is an internal function,
335 * with multiple levels of checks depending on performance needs; see $rigor below.
336 * It does not check wfReadOnly().
337 *
338 * @param string $action Action that permission needs to be checked for
339 * @param User $user User to check
340 * @param LinkTarget $page
341 * @param string $rigor One of PermissionManager::RIGOR_ constants
342 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
343 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
344 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
345 * @param bool $short Set this to true to stop after the first permission error.
346 *
347 * @return array Array of arrays of the arguments to wfMessage to explain permissions problems.
348 * @throws Exception
349 */
350 private function getPermissionErrorsInternal(
351 $action,
352 User $user,
353 LinkTarget $page,
354 $rigor = self::RIGOR_SECURE,
355 $short = false
356 ) {
357 if ( !in_array( $rigor, [ self::RIGOR_QUICK, self::RIGOR_FULL, self::RIGOR_SECURE ] ) ) {
358 throw new Exception( "Invalid rigor parameter '$rigor'." );
359 }
360
361 # Read has special handling
362 if ( $action == 'read' ) {
363 $checks = [
364 'checkPermissionHooks',
365 'checkReadPermissions',
366 'checkUserBlock', // for wgBlockDisablesLogin
367 ];
368 # Don't call checkSpecialsAndNSPermissions, checkSiteConfigPermissions
369 # or checkUserConfigPermissions here as it will lead to duplicate
370 # error messages. This is okay to do since anywhere that checks for
371 # create will also check for edit, and those checks are called for edit.
372 } elseif ( $action == 'create' ) {
373 $checks = [
374 'checkQuickPermissions',
375 'checkPermissionHooks',
376 'checkPageRestrictions',
377 'checkCascadingSourcesRestrictions',
378 'checkActionPermissions',
379 'checkUserBlock'
380 ];
381 } else {
382 $checks = [
383 'checkQuickPermissions',
384 'checkPermissionHooks',
385 'checkSpecialsAndNSPermissions',
386 'checkSiteConfigPermissions',
387 'checkUserConfigPermissions',
388 'checkPageRestrictions',
389 'checkCascadingSourcesRestrictions',
390 'checkActionPermissions',
391 'checkUserBlock'
392 ];
393 }
394
395 $errors = [];
396 foreach ( $checks as $method ) {
397 $errors = $this->$method( $action, $user, $errors, $rigor, $short, $page );
398
399 if ( $short && $errors !== [] ) {
400 break;
401 }
402 }
403
404 return $errors;
405 }
406
407 /**
408 * Check various permission hooks
409 *
410 * @param string $action The action to check
411 * @param User $user User to check
412 * @param array $errors List of current errors
413 * @param string $rigor One of PermissionManager::RIGOR_ constants
414 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
415 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
416 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
417 * @param bool $short Short circuit on first error
418 *
419 * @param LinkTarget $page
420 *
421 * @return array List of errors
422 */
423 private function checkPermissionHooks(
424 $action,
425 User $user,
426 $errors,
427 $rigor,
428 $short,
429 LinkTarget $page
430 ) {
431 // TODO: remove when LinkTarget usage will expand further
432 $title = Title::newFromLinkTarget( $page );
433 // Use getUserPermissionsErrors instead
434 $result = '';
435 if ( !Hooks::run( 'userCan', [ &$title, &$user, $action, &$result ] ) ) {
436 return $result ? [] : [ [ 'badaccess-group0' ] ];
437 }
438 // Check getUserPermissionsErrors hook
439 if ( !Hooks::run( 'getUserPermissionsErrors', [ &$title, &$user, $action, &$result ] ) ) {
440 $errors = $this->resultToError( $errors, $result );
441 }
442 // Check getUserPermissionsErrorsExpensive hook
443 if (
444 $rigor !== self::RIGOR_QUICK
445 && !( $short && count( $errors ) > 0 )
446 && !Hooks::run( 'getUserPermissionsErrorsExpensive', [ &$title, &$user, $action, &$result ] )
447 ) {
448 $errors = $this->resultToError( $errors, $result );
449 }
450
451 return $errors;
452 }
453
454 /**
455 * Add the resulting error code to the errors array
456 *
457 * @param array $errors List of current errors
458 * @param array|string|MessageSpecifier|false $result Result of errors
459 *
460 * @return array List of errors
461 */
462 private function resultToError( $errors, $result ) {
463 if ( is_array( $result ) && count( $result ) && !is_array( $result[0] ) ) {
464 // A single array representing an error
465 $errors[] = $result;
466 } elseif ( is_array( $result ) && is_array( $result[0] ) ) {
467 // A nested array representing multiple errors
468 $errors = array_merge( $errors, $result );
469 } elseif ( $result !== '' && is_string( $result ) ) {
470 // A string representing a message-id
471 $errors[] = [ $result ];
472 } elseif ( $result instanceof MessageSpecifier ) {
473 // A message specifier representing an error
474 $errors[] = [ $result ];
475 } elseif ( $result === false ) {
476 // a generic "We don't want them to do that"
477 $errors[] = [ 'badaccess-group0' ];
478 }
479 return $errors;
480 }
481
482 /**
483 * Check that the user is allowed to read this page.
484 *
485 * @param string $action The action to check
486 * @param User $user User to check
487 * @param array $errors List of current errors
488 * @param string $rigor One of PermissionManager::RIGOR_ constants
489 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
490 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
491 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
492 * @param bool $short Short circuit on first error
493 *
494 * @param LinkTarget $page
495 *
496 * @return array List of errors
497 */
498 private function checkReadPermissions(
499 $action,
500 User $user,
501 $errors,
502 $rigor,
503 $short,
504 LinkTarget $page
505 ) {
506 // TODO: remove when LinkTarget usage will expand further
507 $title = Title::newFromLinkTarget( $page );
508
509 $whiteListRead = $this->options->get( 'WhitelistRead' );
510 $whitelisted = false;
511 if ( $this->isEveryoneAllowed( 'read' ) ) {
512 # Shortcut for public wikis, allows skipping quite a bit of code
513 $whitelisted = true;
514 } elseif ( $this->userHasRight( $user, 'read' ) ) {
515 # If the user is allowed to read pages, he is allowed to read all pages
516 $whitelisted = true;
517 } elseif ( $this->isSameSpecialPage( 'Userlogin', $title )
518 || $this->isSameSpecialPage( 'PasswordReset', $title )
519 || $this->isSameSpecialPage( 'Userlogout', $title )
520 ) {
521 # Always grant access to the login page.
522 # Even anons need to be able to log in.
523 $whitelisted = true;
524 } elseif ( is_array( $whiteListRead ) && count( $whiteListRead ) ) {
525 # Time to check the whitelist
526 # Only do these checks is there's something to check against
527 $name = $title->getPrefixedText();
528 $dbName = $title->getPrefixedDBkey();
529
530 // Check for explicit whitelisting with and without underscores
531 if ( in_array( $name, $whiteListRead, true )
532 || in_array( $dbName, $whiteListRead, true ) ) {
533 $whitelisted = true;
534 } elseif ( $title->getNamespace() == NS_MAIN ) {
535 # Old settings might have the title prefixed with
536 # a colon for main-namespace pages
537 if ( in_array( ':' . $name, $whiteListRead ) ) {
538 $whitelisted = true;
539 }
540 } elseif ( $title->isSpecialPage() ) {
541 # If it's a special page, ditch the subpage bit and check again
542 $name = $title->getDBkey();
543 list( $name, /* $subpage */ ) =
544 $this->specialPageFactory->resolveAlias( $name );
545 if ( $name ) {
546 $pure = SpecialPage::getTitleFor( $name )->getPrefixedText();
547 if ( in_array( $pure, $whiteListRead, true ) ) {
548 $whitelisted = true;
549 }
550 }
551 }
552 }
553
554 $whitelistReadRegexp = $this->options->get( 'WhitelistReadRegexp' );
555 if ( !$whitelisted && is_array( $whitelistReadRegexp )
556 && !empty( $whitelistReadRegexp ) ) {
557 $name = $title->getPrefixedText();
558 // Check for regex whitelisting
559 foreach ( $whitelistReadRegexp as $listItem ) {
560 if ( preg_match( $listItem, $name ) ) {
561 $whitelisted = true;
562 break;
563 }
564 }
565 }
566
567 if ( !$whitelisted ) {
568 # If the title is not whitelisted, give extensions a chance to do so...
569 Hooks::run( 'TitleReadWhitelist', [ $title, $user, &$whitelisted ] );
570 if ( !$whitelisted ) {
571 $errors[] = $this->missingPermissionError( $action, $short );
572 }
573 }
574
575 return $errors;
576 }
577
578 /**
579 * Get a description array when the user doesn't have the right to perform
580 * $action (i.e. when User::isAllowed() returns false)
581 *
582 * @param string $action The action to check
583 * @param bool $short Short circuit on first error
584 * @return array Array containing an error message key and any parameters
585 */
586 private function missingPermissionError( $action, $short ) {
587 // We avoid expensive display logic for quickUserCan's and such
588 if ( $short ) {
589 return [ 'badaccess-group0' ];
590 }
591
592 // TODO: it would be a good idea to replace the method below with something else like
593 // maybe callback injection
594 return User::newFatalPermissionDeniedStatus( $action )->getErrorsArray()[0];
595 }
596
597 /**
598 * Returns true if this title resolves to the named special page
599 *
600 * @param string $name The special page name
601 * @param LinkTarget $page
602 *
603 * @return bool
604 */
605 private function isSameSpecialPage( $name, LinkTarget $page ) {
606 if ( $page->getNamespace() == NS_SPECIAL ) {
607 list( $thisName, /* $subpage */ ) =
608 $this->specialPageFactory->resolveAlias( $page->getDBkey() );
609 if ( $name == $thisName ) {
610 return true;
611 }
612 }
613 return false;
614 }
615
616 /**
617 * Check that the user isn't blocked from editing.
618 *
619 * @param string $action The action to check
620 * @param User $user User to check
621 * @param array $errors List of current errors
622 * @param string $rigor One of PermissionManager::RIGOR_ constants
623 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
624 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
625 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
626 * @param bool $short Short circuit on first error
627 *
628 * @param LinkTarget $page
629 *
630 * @return array List of errors
631 */
632 private function checkUserBlock(
633 $action,
634 User $user,
635 $errors,
636 $rigor,
637 $short,
638 LinkTarget $page
639 ) {
640 // Account creation blocks handled at userlogin.
641 // Unblocking handled in SpecialUnblock
642 if ( $rigor === self::RIGOR_QUICK || in_array( $action, [ 'createaccount', 'unblock' ] ) ) {
643 return $errors;
644 }
645
646 // Optimize for a very common case
647 if ( $action === 'read' && !$this->options->get( 'BlockDisablesLogin' ) ) {
648 return $errors;
649 }
650
651 if ( $this->options->get( 'EmailConfirmToEdit' )
652 && !$user->isEmailConfirmed()
653 && $action === 'edit'
654 ) {
655 $errors[] = [ 'confirmedittext' ];
656 }
657
658 $useReplica = ( $rigor !== self::RIGOR_SECURE );
659 $block = $user->getBlock( $useReplica );
660
661 // If the user does not have a block, or the block they do have explicitly
662 // allows the action (like "read" or "upload").
663 if ( !$block || $block->appliesToRight( $action ) === false ) {
664 return $errors;
665 }
666
667 // Determine if the user is blocked from this action on this page.
668 // What gets passed into this method is a user right, not an action name.
669 // There is no way to instantiate an action by restriction. However, this
670 // will get the action where the restriction is the same. This may result
671 // in actions being blocked that shouldn't be.
672 $actionObj = null;
673 if ( Action::exists( $action ) ) {
674 // TODO: this drags a ton of dependencies in, would be good to avoid WikiPage
675 // instantiation and decouple it creating an ActionPermissionChecker interface
676 $wikiPage = WikiPage::factory( Title::newFromLinkTarget( $page, 'clone' ) );
677 // Creating an action will perform several database queries to ensure that
678 // the action has not been overridden by the content type.
679 // FIXME: avoid use of RequestContext since it drags in User and Title dependencies
680 // probably we may use fake context object since it's unlikely that Action uses it
681 // anyway. It would be nice if we could avoid instantiating the Action at all.
682 $actionObj = Action::factory( $action, $wikiPage, RequestContext::getMain() );
683 // Ensure that the retrieved action matches the restriction.
684 if ( $actionObj && $actionObj->getRestriction() !== $action ) {
685 $actionObj = null;
686 }
687 }
688
689 // If no action object is returned, assume that the action requires unblock
690 // which is the default.
691 if ( !$actionObj || $actionObj->requiresUnblock() ) {
692 if ( $this->isBlockedFrom( $user, $page, $useReplica ) ) {
693 // @todo FIXME: Pass the relevant context into this function.
694 $errors[] = $block->getPermissionsError( RequestContext::getMain() );
695 }
696 }
697
698 return $errors;
699 }
700
701 /**
702 * Permissions checks that fail most often, and which are easiest to test.
703 *
704 * @param string $action The action to check
705 * @param User $user User to check
706 * @param array $errors List of current errors
707 * @param string $rigor One of PermissionManager::RIGOR_ constants
708 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
709 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
710 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
711 * @param bool $short Short circuit on first error
712 *
713 * @param LinkTarget $page
714 *
715 * @return array List of errors
716 */
717 private function checkQuickPermissions(
718 $action,
719 User $user,
720 $errors,
721 $rigor,
722 $short,
723 LinkTarget $page
724 ) {
725 // TODO: remove when LinkTarget usage will expand further
726 $title = Title::newFromLinkTarget( $page );
727
728 if ( !Hooks::run( 'TitleQuickPermissions',
729 [ $title, $user, $action, &$errors, ( $rigor !== self::RIGOR_QUICK ), $short ] )
730 ) {
731 return $errors;
732 }
733
734 $isSubPage = $this->nsInfo->hasSubpages( $title->getNamespace() ) ?
735 strpos( $title->getText(), '/' ) !== false : false;
736
737 if ( $action == 'create' ) {
738 if (
739 ( $this->nsInfo->isTalk( $title->getNamespace() ) &&
740 !$this->userHasRight( $user, 'createtalk' ) ) ||
741 ( !$this->nsInfo->isTalk( $title->getNamespace() ) &&
742 !$this->userHasRight( $user, 'createpage' ) )
743 ) {
744 $errors[] = $user->isAnon() ? [ 'nocreatetext' ] : [ 'nocreate-loggedin' ];
745 }
746 } elseif ( $action == 'move' ) {
747 if ( !$this->userHasRight( $user, 'move-rootuserpages' )
748 && $title->getNamespace() == NS_USER && !$isSubPage ) {
749 // Show user page-specific message only if the user can move other pages
750 $errors[] = [ 'cant-move-user-page' ];
751 }
752
753 // Check if user is allowed to move files if it's a file
754 if ( $title->getNamespace() == NS_FILE &&
755 !$this->userHasRight( $user, 'movefile' ) ) {
756 $errors[] = [ 'movenotallowedfile' ];
757 }
758
759 // Check if user is allowed to move category pages if it's a category page
760 if ( $title->getNamespace() == NS_CATEGORY &&
761 !$this->userHasRight( $user, 'move-categorypages' ) ) {
762 $errors[] = [ 'cant-move-category-page' ];
763 }
764
765 if ( !$this->userHasRight( $user, 'move' ) ) {
766 // User can't move anything
767 $userCanMove = $this->groupHasPermission( 'user', 'move' );
768 $autoconfirmedCanMove = $this->groupHasPermission( 'autoconfirmed', 'move' );
769 if ( $user->isAnon() && ( $userCanMove || $autoconfirmedCanMove ) ) {
770 // custom message if logged-in users without any special rights can move
771 $errors[] = [ 'movenologintext' ];
772 } else {
773 $errors[] = [ 'movenotallowed' ];
774 }
775 }
776 } elseif ( $action == 'move-target' ) {
777 if ( !$this->userHasRight( $user, 'move' ) ) {
778 // User can't move anything
779 $errors[] = [ 'movenotallowed' ];
780 } elseif ( !$this->userHasRight( $user, 'move-rootuserpages' )
781 && $title->getNamespace() == NS_USER && !$isSubPage ) {
782 // Show user page-specific message only if the user can move other pages
783 $errors[] = [ 'cant-move-to-user-page' ];
784 } elseif ( !$this->userHasRight( $user, 'move-categorypages' )
785 && $title->getNamespace() == NS_CATEGORY ) {
786 // Show category page-specific message only if the user can move other pages
787 $errors[] = [ 'cant-move-to-category-page' ];
788 }
789 } elseif ( !$this->userHasRight( $user, $action ) ) {
790 $errors[] = $this->missingPermissionError( $action, $short );
791 }
792
793 return $errors;
794 }
795
796 /**
797 * Check against page_restrictions table requirements on this
798 * page. The user must possess all required rights for this
799 * action.
800 *
801 * @param string $action The action to check
802 * @param User $user User to check
803 * @param array $errors List of current errors
804 * @param string $rigor One of PermissionManager::RIGOR_ constants
805 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
806 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
807 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
808 * @param bool $short Short circuit on first error
809 *
810 * @param LinkTarget $page
811 *
812 * @return array List of errors
813 */
814 private function checkPageRestrictions(
815 $action,
816 User $user,
817 $errors,
818 $rigor,
819 $short,
820 LinkTarget $page
821 ) {
822 // TODO: remove & rework upon further use of LinkTarget
823 $title = Title::newFromLinkTarget( $page );
824 foreach ( $title->getRestrictions( $action ) as $right ) {
825 // Backwards compatibility, rewrite sysop -> editprotected
826 if ( $right == 'sysop' ) {
827 $right = 'editprotected';
828 }
829 // Backwards compatibility, rewrite autoconfirmed -> editsemiprotected
830 if ( $right == 'autoconfirmed' ) {
831 $right = 'editsemiprotected';
832 }
833 if ( $right == '' ) {
834 continue;
835 }
836 if ( !$this->userHasRight( $user, $right ) ) {
837 $errors[] = [ 'protectedpagetext', $right, $action ];
838 } elseif ( $title->areRestrictionsCascading() &&
839 !$this->userHasRight( $user, 'protect' ) ) {
840 $errors[] = [ 'protectedpagetext', 'protect', $action ];
841 }
842 }
843
844 return $errors;
845 }
846
847 /**
848 * Check restrictions on cascading pages.
849 *
850 * @param string $action The action to check
851 * @param UserIdentity $user User to check
852 * @param array $errors List of current errors
853 * @param string $rigor One of PermissionManager::RIGOR_ constants
854 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
855 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
856 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
857 * @param bool $short Short circuit on first error
858 *
859 * @param LinkTarget $page
860 *
861 * @return array List of errors
862 */
863 private function checkCascadingSourcesRestrictions(
864 $action,
865 UserIdentity $user,
866 $errors,
867 $rigor,
868 $short,
869 LinkTarget $page
870 ) {
871 // TODO: remove & rework upon further use of LinkTarget
872 $title = Title::newFromLinkTarget( $page );
873 if ( $rigor !== self::RIGOR_QUICK && !$title->isUserConfigPage() ) {
874 # We /could/ use the protection level on the source page, but it's
875 # fairly ugly as we have to establish a precedence hierarchy for pages
876 # included by multiple cascade-protected pages. So just restrict
877 # it to people with 'protect' permission, as they could remove the
878 # protection anyway.
879 list( $cascadingSources, $restrictions ) = $title->getCascadeProtectionSources();
880 # Cascading protection depends on more than this page...
881 # Several cascading protected pages may include this page...
882 # Check each cascading level
883 # This is only for protection restrictions, not for all actions
884 if ( isset( $restrictions[$action] ) ) {
885 foreach ( $restrictions[$action] as $right ) {
886 // Backwards compatibility, rewrite sysop -> editprotected
887 if ( $right == 'sysop' ) {
888 $right = 'editprotected';
889 }
890 // Backwards compatibility, rewrite autoconfirmed -> editsemiprotected
891 if ( $right == 'autoconfirmed' ) {
892 $right = 'editsemiprotected';
893 }
894 if ( $right != '' && !$this->userHasAllRights( $user, 'protect', $right ) ) {
895 $wikiPages = '';
896 /** @var Title $wikiPage */
897 foreach ( $cascadingSources as $wikiPage ) {
898 $wikiPages .= '* [[:' . $wikiPage->getPrefixedText() . "]]\n";
899 }
900 $errors[] = [ 'cascadeprotected', count( $cascadingSources ), $wikiPages, $action ];
901 }
902 }
903 }
904 }
905
906 return $errors;
907 }
908
909 /**
910 * Check action permissions not already checked in checkQuickPermissions
911 *
912 * @param string $action The action to check
913 * @param User $user User to check
914 * @param array $errors List of current errors
915 * @param string $rigor One of PermissionManager::RIGOR_ constants
916 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
917 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
918 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
919 * @param bool $short Short circuit on first error
920 *
921 * @param LinkTarget $page
922 *
923 * @return array List of errors
924 */
925 private function checkActionPermissions(
926 $action,
927 User $user,
928 $errors,
929 $rigor,
930 $short,
931 LinkTarget $page
932 ) {
933 global $wgDeleteRevisionsLimit, $wgLang;
934
935 // TODO: remove & rework upon further use of LinkTarget
936 $title = Title::newFromLinkTarget( $page );
937
938 if ( $action == 'protect' ) {
939 if ( count( $this->getPermissionErrorsInternal( 'edit', $user, $title, $rigor, true ) ) ) {
940 // If they can't edit, they shouldn't protect.
941 $errors[] = [ 'protect-cantedit' ];
942 }
943 } elseif ( $action == 'create' ) {
944 $title_protection = $title->getTitleProtection();
945 if ( $title_protection ) {
946 if ( $title_protection['permission'] == ''
947 || !$this->userHasRight( $user, $title_protection['permission'] )
948 ) {
949 $errors[] = [
950 'titleprotected',
951 // TODO: get rid of the User dependency
952 User::whoIs( $title_protection['user'] ),
953 $title_protection['reason']
954 ];
955 }
956 }
957 } elseif ( $action == 'move' ) {
958 // Check for immobile pages
959 if ( !$this->nsInfo->isMovable( $title->getNamespace() ) ) {
960 // Specific message for this case
961 $errors[] = [ 'immobile-source-namespace', $title->getNsText() ];
962 } elseif ( !$title->isMovable() ) {
963 // Less specific message for rarer cases
964 $errors[] = [ 'immobile-source-page' ];
965 }
966 } elseif ( $action == 'move-target' ) {
967 if ( !$this->nsInfo->isMovable( $title->getNamespace() ) ) {
968 $errors[] = [ 'immobile-target-namespace', $title->getNsText() ];
969 } elseif ( !$title->isMovable() ) {
970 $errors[] = [ 'immobile-target-page' ];
971 }
972 } elseif ( $action == 'delete' ) {
973 $tempErrors = $this->checkPageRestrictions( 'edit', $user, [], $rigor, true, $title );
974 if ( !$tempErrors ) {
975 $tempErrors = $this->checkCascadingSourcesRestrictions( 'edit',
976 $user, $tempErrors, $rigor, true, $title );
977 }
978 if ( $tempErrors ) {
979 // If protection keeps them from editing, they shouldn't be able to delete.
980 $errors[] = [ 'deleteprotected' ];
981 }
982 if ( $rigor !== self::RIGOR_QUICK && $wgDeleteRevisionsLimit
983 && !$this->userCan( 'bigdelete', $user, $title ) && $title->isBigDeletion()
984 ) {
985 $errors[] = [ 'delete-toobig', $wgLang->formatNum( $wgDeleteRevisionsLimit ) ];
986 }
987 } elseif ( $action === 'undelete' ) {
988 if ( count( $this->getPermissionErrorsInternal( 'edit', $user, $title, $rigor, true ) ) ) {
989 // Undeleting implies editing
990 $errors[] = [ 'undelete-cantedit' ];
991 }
992 if ( !$title->exists()
993 && count( $this->getPermissionErrorsInternal( 'create', $user, $title, $rigor, true ) )
994 ) {
995 // Undeleting where nothing currently exists implies creating
996 $errors[] = [ 'undelete-cantcreate' ];
997 }
998 }
999 return $errors;
1000 }
1001
1002 /**
1003 * Check permissions on special pages & namespaces
1004 *
1005 * @param string $action The action to check
1006 * @param UserIdentity $user User to check
1007 * @param array $errors List of current errors
1008 * @param string $rigor One of PermissionManager::RIGOR_ constants
1009 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
1010 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
1011 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
1012 * @param bool $short Short circuit on first error
1013 *
1014 * @param LinkTarget $page
1015 *
1016 * @return array List of errors
1017 */
1018 private function checkSpecialsAndNSPermissions(
1019 $action,
1020 UserIdentity $user,
1021 $errors,
1022 $rigor,
1023 $short,
1024 LinkTarget $page
1025 ) {
1026 // TODO: remove & rework upon further use of LinkTarget
1027 $title = Title::newFromLinkTarget( $page );
1028
1029 # Only 'createaccount' can be performed on special pages,
1030 # which don't actually exist in the DB.
1031 if ( $title->getNamespace() == NS_SPECIAL && $action !== 'createaccount' ) {
1032 $errors[] = [ 'ns-specialprotected' ];
1033 }
1034
1035 # Check $wgNamespaceProtection for restricted namespaces
1036 if ( $this->isNamespaceProtected( $title->getNamespace(), $user ) ) {
1037 $ns = $title->getNamespace() == NS_MAIN ?
1038 wfMessage( 'nstab-main' )->text() : $title->getNsText();
1039 $errors[] = $title->getNamespace() == NS_MEDIAWIKI ?
1040 [ 'protectedinterface', $action ] : [ 'namespaceprotected', $ns, $action ];
1041 }
1042
1043 return $errors;
1044 }
1045
1046 /**
1047 * Check sitewide CSS/JSON/JS permissions
1048 *
1049 * @param string $action The action to check
1050 * @param User $user User to check
1051 * @param array $errors List of current errors
1052 * @param string $rigor One of PermissionManager::RIGOR_ constants
1053 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
1054 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
1055 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
1056 * @param bool $short Short circuit on first error
1057 *
1058 * @param LinkTarget $page
1059 *
1060 * @return array List of errors
1061 */
1062 private function checkSiteConfigPermissions(
1063 $action,
1064 User $user,
1065 $errors,
1066 $rigor,
1067 $short,
1068 LinkTarget $page
1069 ) {
1070 // TODO: remove & rework upon further use of LinkTarget
1071 $title = Title::newFromLinkTarget( $page );
1072
1073 if ( $action != 'patrol' ) {
1074 $error = null;
1075 // Sitewide CSS/JSON/JS changes, like all NS_MEDIAWIKI changes, also require the
1076 // editinterface right. That's implemented as a restriction so no check needed here.
1077 if ( $title->isSiteCssConfigPage() && !$this->userHasRight( $user, 'editsitecss' ) ) {
1078 $error = [ 'sitecssprotected', $action ];
1079 } elseif ( $title->isSiteJsonConfigPage() && !$this->userHasRight( $user, 'editsitejson' ) ) {
1080 $error = [ 'sitejsonprotected', $action ];
1081 } elseif ( $title->isSiteJsConfigPage() && !$this->userHasRight( $user, 'editsitejs' ) ) {
1082 $error = [ 'sitejsprotected', $action ];
1083 } elseif ( $title->isRawHtmlMessage() ) {
1084 // Raw HTML can be used to deploy CSS or JS so require rights for both.
1085 if ( !$this->userHasRight( $user, 'editsitejs' ) ) {
1086 $error = [ 'sitejsprotected', $action ];
1087 } elseif ( !$this->userHasRight( $user, 'editsitecss' ) ) {
1088 $error = [ 'sitecssprotected', $action ];
1089 }
1090 }
1091
1092 if ( $error ) {
1093 if ( $this->userHasRight( $user, 'editinterface' ) ) {
1094 // Most users / site admins will probably find out about the new, more restrictive
1095 // permissions by failing to edit something. Give them more info.
1096 // TODO remove this a few release cycles after 1.32
1097 $error = [ 'interfaceadmin-info', wfMessage( $error[0], $error[1] ) ];
1098 }
1099 $errors[] = $error;
1100 }
1101 }
1102
1103 return $errors;
1104 }
1105
1106 /**
1107 * Check CSS/JSON/JS sub-page permissions
1108 *
1109 * @param string $action The action to check
1110 * @param UserIdentity $user User to check
1111 * @param array $errors List of current errors
1112 * @param string $rigor One of PermissionManager::RIGOR_ constants
1113 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
1114 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
1115 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
1116 * @param bool $short Short circuit on first error
1117 *
1118 * @param LinkTarget $page
1119 *
1120 * @return array List of errors
1121 */
1122 private function checkUserConfigPermissions(
1123 $action,
1124 UserIdentity $user,
1125 $errors,
1126 $rigor,
1127 $short,
1128 LinkTarget $page
1129 ) {
1130 // TODO: remove & rework upon further use of LinkTarget
1131 $title = Title::newFromLinkTarget( $page );
1132
1133 # Protect css/json/js subpages of user pages
1134 # XXX: this might be better using restrictions
1135
1136 if ( $action === 'patrol' ) {
1137 return $errors;
1138 }
1139
1140 if ( preg_match( '/^' . preg_quote( $user->getName(), '/' ) . '\//', $title->getText() ) ) {
1141 // Users need editmyuser* to edit their own CSS/JSON/JS subpages.
1142 if (
1143 $title->isUserCssConfigPage()
1144 && !$this->userHasAnyRight( $user, 'editmyusercss', 'editusercss' )
1145 ) {
1146 $errors[] = [ 'mycustomcssprotected', $action ];
1147 } elseif (
1148 $title->isUserJsonConfigPage()
1149 && !$this->userHasAnyRight( $user, 'editmyuserjson', 'edituserjson' )
1150 ) {
1151 $errors[] = [ 'mycustomjsonprotected', $action ];
1152 } elseif (
1153 $title->isUserJsConfigPage()
1154 && !$this->userHasAnyRight( $user, 'editmyuserjs', 'edituserjs' )
1155 ) {
1156 $errors[] = [ 'mycustomjsprotected', $action ];
1157 } elseif (
1158 $title->isUserJsConfigPage()
1159 && !$this->userHasAnyRight( $user, 'edituserjs', 'editmyuserjsredirect' )
1160 ) {
1161 // T207750 - do not allow users to edit a redirect if they couldn't edit the target
1162 $rev = $this->revisionLookup->getRevisionByTitle( $title );
1163 $content = $rev ? $rev->getContent( 'main', RevisionRecord::RAW ) : null;
1164 $target = $content ? $content->getUltimateRedirectTarget() : null;
1165 if ( $target && (
1166 !$target->inNamespace( NS_USER )
1167 || !preg_match( '/^' . preg_quote( $user->getName(), '/' ) . '\//', $target->getText() )
1168 ) ) {
1169 $errors[] = [ 'mycustomjsredirectprotected', $action ];
1170 }
1171 }
1172 } else {
1173 // Users need editmyuser* to edit their own CSS/JSON/JS subpages, except for
1174 // deletion/suppression which cannot be used for attacks and we want to avoid the
1175 // situation where an unprivileged user can post abusive content on their subpages
1176 // and only very highly privileged users could remove it.
1177 if ( !in_array( $action, [ 'delete', 'deleterevision', 'suppressrevision' ], true ) ) {
1178 if (
1179 $title->isUserCssConfigPage()
1180 && !$this->userHasRight( $user, 'editusercss' )
1181 ) {
1182 $errors[] = [ 'customcssprotected', $action ];
1183 } elseif (
1184 $title->isUserJsonConfigPage()
1185 && !$this->userHasRight( $user, 'edituserjson' )
1186 ) {
1187 $errors[] = [ 'customjsonprotected', $action ];
1188 } elseif (
1189 $title->isUserJsConfigPage()
1190 && !$this->userHasRight( $user, 'edituserjs' )
1191 ) {
1192 $errors[] = [ 'customjsprotected', $action ];
1193 }
1194 }
1195 }
1196
1197 return $errors;
1198 }
1199
1200 /**
1201 * Testing a permission
1202 *
1203 * @since 1.34
1204 *
1205 * @param UserIdentity $user
1206 * @param string $action
1207 *
1208 * @return bool
1209 */
1210 public function userHasRight( UserIdentity $user, $action = '' ) {
1211 if ( $action === '' ) {
1212 return true; // In the spirit of DWIM
1213 }
1214 // Use strict parameter to avoid matching numeric 0 accidentally inserted
1215 // by misconfiguration: 0 == 'foo'
1216 return in_array( $action, $this->getUserPermissions( $user ), true );
1217 }
1218
1219 /**
1220 * Check if user is allowed to make any action
1221 *
1222 * @param UserIdentity $user
1223 * // TODO: HHVM can't create mocks with variable params @param string ...$actions
1224 * @return bool True if user is allowed to perform *any* of the given actions
1225 * @since 1.34
1226 */
1227 public function userHasAnyRight( UserIdentity $user ) {
1228 $actions = array_slice( func_get_args(), 1 );
1229 foreach ( $actions as $action ) {
1230 if ( $this->userHasRight( $user, $action ) ) {
1231 return true;
1232 }
1233 }
1234 return false;
1235 }
1236
1237 /**
1238 * Check if user is allowed to make all actions
1239 *
1240 * @param UserIdentity $user
1241 * // TODO: HHVM can't create mocks with variable params @param string ...$actions
1242 * @return bool True if user is allowed to perform *all* of the given actions
1243 * @since 1.34
1244 */
1245 public function userHasAllRights( UserIdentity $user ) {
1246 $actions = array_slice( func_get_args(), 1 );
1247 foreach ( $actions as $action ) {
1248 if ( !$this->userHasRight( $user, $action ) ) {
1249 return false;
1250 }
1251 }
1252 return true;
1253 }
1254
1255 /**
1256 * Get the permissions this user has.
1257 *
1258 * @since 1.34
1259 *
1260 * @param UserIdentity $user
1261 *
1262 * @return string[] permission names
1263 */
1264 public function getUserPermissions( UserIdentity $user ) {
1265 $user = User::newFromIdentity( $user );
1266 $rightsCacheKey = $this->getRightsCacheKey( $user );
1267 if ( !isset( $this->usersRights[ $rightsCacheKey ] ) ) {
1268 $this->usersRights[ $rightsCacheKey ] = $this->getGroupPermissions(
1269 $user->getEffectiveGroups()
1270 );
1271 Hooks::run( 'UserGetRights', [ $user, &$this->usersRights[ $rightsCacheKey ] ] );
1272
1273 // Deny any rights denied by the user's session, unless this
1274 // endpoint has no sessions.
1275 if ( !defined( 'MW_NO_SESSION' ) ) {
1276 // FIXME: $user->getRequest().. need to be replaced with something else
1277 $allowedRights = $user->getRequest()->getSession()->getAllowedUserRights();
1278 if ( $allowedRights !== null ) {
1279 $this->usersRights[ $rightsCacheKey ] = array_intersect(
1280 $this->usersRights[ $rightsCacheKey ],
1281 $allowedRights
1282 );
1283 }
1284 }
1285
1286 Hooks::run( 'UserGetRightsRemove', [ $user, &$this->usersRights[ $rightsCacheKey ] ] );
1287 // Force reindexation of rights when a hook has unset one of them
1288 $this->usersRights[ $rightsCacheKey ] = array_values(
1289 array_unique( $this->usersRights[ $rightsCacheKey ] )
1290 );
1291
1292 if (
1293 $user->isLoggedIn() &&
1294 $this->options->get( 'BlockDisablesLogin' ) &&
1295 $user->getBlock()
1296 ) {
1297 $anon = new User;
1298 $this->usersRights[ $rightsCacheKey ] = array_intersect(
1299 $this->usersRights[ $rightsCacheKey ],
1300 $this->getUserPermissions( $anon )
1301 );
1302 }
1303 }
1304 $rights = $this->usersRights[ $rightsCacheKey ];
1305 foreach ( $this->temporaryUserRights[ $user->getId() ] ?? [] as $overrides ) {
1306 $rights = array_values( array_unique( array_merge( $rights, $overrides ) ) );
1307 }
1308 return $rights;
1309 }
1310
1311 /**
1312 * Clears users permissions cache, if specific user is provided it tries to clear
1313 * permissions cache only for provided user.
1314 *
1315 * @since 1.34
1316 *
1317 * @param User|null $user
1318 */
1319 public function invalidateUsersRightsCache( $user = null ) {
1320 if ( $user !== null ) {
1321 $rightsCacheKey = $this->getRightsCacheKey( $user );
1322 if ( isset( $this->usersRights[ $rightsCacheKey ] ) ) {
1323 unset( $this->usersRights[ $rightsCacheKey ] );
1324 }
1325 } else {
1326 $this->usersRights = null;
1327 }
1328 }
1329
1330 /**
1331 * Gets a unique key for user rights cache.
1332 * @param UserIdentity $user
1333 * @return string
1334 */
1335 private function getRightsCacheKey( UserIdentity $user ) {
1336 return $user->isRegistered() ? "u:{$user->getId()}" : "anon:{$user->getName()}";
1337 }
1338
1339 /**
1340 * Check, if the given group has the given permission
1341 *
1342 * If you're wanting to check whether all users have a permission, use
1343 * PermissionManager::isEveryoneAllowed() instead. That properly checks if it's revoked
1344 * from anyone.
1345 *
1346 * @since 1.34
1347 *
1348 * @param string $group Group to check
1349 * @param string $role Role to check
1350 *
1351 * @return bool
1352 */
1353 public function groupHasPermission( $group, $role ) {
1354 $groupPermissions = $this->options->get( 'GroupPermissions' );
1355 $revokePermissions = $this->options->get( 'RevokePermissions' );
1356 return isset( $groupPermissions[$group][$role] ) && $groupPermissions[$group][$role] &&
1357 !( isset( $revokePermissions[$group][$role] ) && $revokePermissions[$group][$role] );
1358 }
1359
1360 /**
1361 * Get the permissions associated with a given list of groups
1362 *
1363 * @since 1.34
1364 *
1365 * @param array $groups Array of Strings List of internal group names
1366 * @return array Array of Strings List of permission key names for given groups combined
1367 */
1368 public function getGroupPermissions( $groups ) {
1369 $rights = [];
1370 // grant every granted permission first
1371 foreach ( $groups as $group ) {
1372 if ( isset( $this->options->get( 'GroupPermissions' )[$group] ) ) {
1373 $rights = array_merge( $rights,
1374 // array_filter removes empty items
1375 array_keys( array_filter( $this->options->get( 'GroupPermissions' )[$group] ) ) );
1376 }
1377 }
1378 // now revoke the revoked permissions
1379 foreach ( $groups as $group ) {
1380 if ( isset( $this->options->get( 'RevokePermissions' )[$group] ) ) {
1381 $rights = array_diff( $rights,
1382 array_keys( array_filter( $this->options->get( 'RevokePermissions' )[$group] ) ) );
1383 }
1384 }
1385 return array_unique( $rights );
1386 }
1387
1388 /**
1389 * Get all the groups who have a given permission
1390 *
1391 * @since 1.34
1392 *
1393 * @param string $role Role to check
1394 * @return array Array of Strings List of internal group names with the given permission
1395 */
1396 public function getGroupsWithPermission( $role ) {
1397 $allowedGroups = [];
1398 foreach ( array_keys( $this->options->get( 'GroupPermissions' ) ) as $group ) {
1399 if ( $this->groupHasPermission( $group, $role ) ) {
1400 $allowedGroups[] = $group;
1401 }
1402 }
1403 return $allowedGroups;
1404 }
1405
1406 /**
1407 * Check if all users may be assumed to have the given permission
1408 *
1409 * We generally assume so if the right is granted to '*' and isn't revoked
1410 * on any group. It doesn't attempt to take grants or other extension
1411 * limitations on rights into account in the general case, though, as that
1412 * would require it to always return false and defeat the purpose.
1413 * Specifically, session-based rights restrictions (such as OAuth or bot
1414 * passwords) are applied based on the current session.
1415 *
1416 * @param string $right Right to check
1417 *
1418 * @return bool
1419 * @since 1.34
1420 */
1421 public function isEveryoneAllowed( $right ) {
1422 // Use the cached results, except in unit tests which rely on
1423 // being able change the permission mid-request
1424 if ( isset( $this->cachedRights[$right] ) ) {
1425 return $this->cachedRights[$right];
1426 }
1427
1428 if ( !isset( $this->options->get( 'GroupPermissions' )['*'][$right] )
1429 || !$this->options->get( 'GroupPermissions' )['*'][$right] ) {
1430 $this->cachedRights[$right] = false;
1431 return false;
1432 }
1433
1434 // If it's revoked anywhere, then everyone doesn't have it
1435 foreach ( $this->options->get( 'RevokePermissions' ) as $rights ) {
1436 if ( isset( $rights[$right] ) && $rights[$right] ) {
1437 $this->cachedRights[$right] = false;
1438 return false;
1439 }
1440 }
1441
1442 // Remove any rights that aren't allowed to the global-session user,
1443 // unless there are no sessions for this endpoint.
1444 if ( !defined( 'MW_NO_SESSION' ) ) {
1445
1446 // XXX: think what could be done with the below
1447 $allowedRights = SessionManager::getGlobalSession()->getAllowedUserRights();
1448 if ( $allowedRights !== null && !in_array( $right, $allowedRights, true ) ) {
1449 $this->cachedRights[$right] = false;
1450 return false;
1451 }
1452 }
1453
1454 // Allow extensions to say false
1455 if ( !Hooks::run( 'UserIsEveryoneAllowed', [ $right ] ) ) {
1456 $this->cachedRights[$right] = false;
1457 return false;
1458 }
1459
1460 $this->cachedRights[$right] = true;
1461 return true;
1462 }
1463
1464 /**
1465 * Get a list of all available permissions.
1466 *
1467 * @since 1.34
1468 *
1469 * @return string[] Array of permission names
1470 */
1471 public function getAllPermissions() {
1472 if ( $this->allRights === false ) {
1473 if ( count( $this->options->get( 'AvailableRights' ) ) ) {
1474 $this->allRights = array_unique( array_merge(
1475 $this->coreRights,
1476 $this->options->get( 'AvailableRights' )
1477 ) );
1478 } else {
1479 $this->allRights = $this->coreRights;
1480 }
1481 Hooks::run( 'UserGetAllRights', [ &$this->allRights ] );
1482 }
1483 return $this->allRights;
1484 }
1485
1486 /**
1487 * Determines if $user is unable to edit pages in namespace because it has been protected.
1488 * @param $index
1489 * @param UserIdentity $user
1490 * @return bool
1491 */
1492 private function isNamespaceProtected( $index, UserIdentity $user ) {
1493 $namespaceProtection = $this->options->get( 'NamespaceProtection' );
1494 if ( isset( $namespaceProtection[$index] ) ) {
1495 return !$this->userHasAllRights( $user, ...(array)$namespaceProtection[$index] );
1496 }
1497 return false;
1498 }
1499
1500 /**
1501 * Determine which restriction levels it makes sense to use in a namespace,
1502 * optionally filtered by a user's rights.
1503 *
1504 * @param int $index Index to check
1505 * @param UserIdentity|null $user User to check
1506 * @return array
1507 */
1508 public function getNamespaceRestrictionLevels( $index, UserIdentity $user = null ) {
1509 if ( !isset( $this->options->get( 'NamespaceProtection' )[$index] ) ) {
1510 // All levels are valid if there's no namespace restriction.
1511 // But still filter by user, if necessary
1512 $levels = $this->options->get( 'RestrictionLevels' );
1513 if ( $user ) {
1514 $levels = array_values( array_filter( $levels, function ( $level ) use ( $user ) {
1515 $right = $level;
1516 if ( $right == 'sysop' ) {
1517 $right = 'editprotected'; // BC
1518 }
1519 if ( $right == 'autoconfirmed' ) {
1520 $right = 'editsemiprotected'; // BC
1521 }
1522 return $this->userHasRight( $user, $right );
1523 } ) );
1524 }
1525 return $levels;
1526 }
1527
1528 // $wgNamespaceProtection can require one or more rights to edit the namespace, which
1529 // may be satisfied by membership in multiple groups each giving a subset of those rights.
1530 // A restriction level is redundant if, for any one of the namespace rights, all groups
1531 // giving that right also give the restriction level's right. Or, conversely, a
1532 // restriction level is not redundant if, for every namespace right, there's at least one
1533 // group giving that right without the restriction level's right.
1534 //
1535 // First, for each right, get a list of groups with that right.
1536 $namespaceRightGroups = [];
1537 foreach ( (array)$this->options->get( 'NamespaceProtection' )[$index] as $right ) {
1538 if ( $right == 'sysop' ) {
1539 $right = 'editprotected'; // BC
1540 }
1541 if ( $right == 'autoconfirmed' ) {
1542 $right = 'editsemiprotected'; // BC
1543 }
1544 if ( $right != '' ) {
1545 $namespaceRightGroups[$right] = $this->getGroupsWithPermission( $right );
1546 }
1547 }
1548
1549 // Now, go through the protection levels one by one.
1550 $usableLevels = [ '' ];
1551 foreach ( $this->options->get( 'RestrictionLevels' ) as $level ) {
1552 $right = $level;
1553 if ( $right == 'sysop' ) {
1554 $right = 'editprotected'; // BC
1555 }
1556 if ( $right == 'autoconfirmed' ) {
1557 $right = 'editsemiprotected'; // BC
1558 }
1559
1560 if ( $right != '' &&
1561 !isset( $namespaceRightGroups[$right] ) &&
1562 ( !$user || $this->userHasRight( $user, $right ) )
1563 ) {
1564 // Do any of the namespace rights imply the restriction right? (see explanation above)
1565 foreach ( $namespaceRightGroups as $groups ) {
1566 if ( !array_diff( $groups, $this->getGroupsWithPermission( $right ) ) ) {
1567 // Yes, this one does.
1568 continue 2;
1569 }
1570 }
1571 // No, keep the restriction level
1572 $usableLevels[] = $level;
1573 }
1574 }
1575
1576 return $usableLevels;
1577 }
1578
1579 /**
1580 * Add temporary user rights, only valid for the current scope.
1581 * This is meant for making it possible to programatically trigger certain actions that
1582 * the user wouldn't be able to trigger themselves; e.g. allow users without the bot right
1583 * to make bot-flagged actions through certain special pages.
1584 * Returns a "scope guard" variable; whenever that variable goes out of scope or is consumed
1585 * via ScopedCallback::consume(), the temporary rights are revoked.
1586 *
1587 * @since 1.34
1588 *
1589 * @param UserIdentity $user
1590 * @param string|string[] $rights
1591 * @return ScopedCallback
1592 */
1593 public function addTemporaryUserRights( UserIdentity $user, $rights ) {
1594 $userId = $user->getId();
1595 $nextKey = count( $this->temporaryUserRights[$userId] ?? [] );
1596 $this->temporaryUserRights[$userId][$nextKey] = (array)$rights;
1597 return new ScopedCallback( function () use ( $userId, $nextKey ) {
1598 unset( $this->temporaryUserRights[$userId][$nextKey] );
1599 } );
1600 }
1601
1602 /**
1603 * Overrides user permissions cache
1604 *
1605 * @since 1.34
1606 *
1607 * @param User $user
1608 * @param string[]|string $rights
1609 *
1610 * @throws Exception
1611 */
1612 public function overrideUserRightsForTesting( $user, $rights = [] ) {
1613 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
1614 throw new Exception( __METHOD__ . ' can not be called outside of tests' );
1615 }
1616 $this->usersRights[ $this->getRightsCacheKey( $user ) ] =
1617 is_array( $rights ) ? $rights : [ $rights ];
1618 }
1619
1620 }