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.
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.
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
20 namespace MediaWiki\Permissions
;
25 use MediaWiki\Linker\LinkTarget
;
26 use MediaWiki\Session\SessionManager
;
27 use MediaWiki\Special\SpecialPageFactory
;
28 use MediaWiki\User\UserIdentity
;
35 use Wikimedia\ScopedCallback
;
39 * A service class for checking permissions
40 * To obtain an instance, use MediaWikiServices::getInstance()->getPermissionManager().
44 class PermissionManager
{
46 /** @var string Does cheap permission checks from replica DBs (usable for GUI creation) */
47 const RIGOR_QUICK
= 'quick';
49 /** @var string Does cheap and expensive checks possibly from a replica DB */
50 const RIGOR_FULL
= 'full';
52 /** @var string Does cheap and expensive checks, using the master as needed */
53 const RIGOR_SECURE
= 'secure';
55 /** @var SpecialPageFactory */
56 private $specialPageFactory;
58 /** @var string[] List of pages names anonymous user may see */
59 private $whitelistRead;
61 /** @var string[] Whitelists publicly readable titles with regular expressions */
62 private $whitelistReadRegexp;
64 /** @var bool Require users to confirm email address before they can edit */
65 private $emailConfirmToEdit;
67 /** @var bool If set to true, blocked users will no longer be allowed to log in */
68 private $blockDisablesLogin;
70 /** @var NamespaceInfo */
73 /** @var string[][] Access rights for groups and users in these groups */
74 private $groupPermissions;
76 /** @var string[][] Permission keys revoked from users in each group */
77 private $revokePermissions;
79 /** @var string[] A list of available rights, in addition to the ones defined by the core */
80 private $availableRights;
82 /** @var string[] Cached results of getAllRights() */
83 private $allRights = false;
85 /** @var string[][] Cached user rights */
86 private $usersRights = null;
89 * Temporary user rights, valid for the current request only.
90 * @var string[][][] userid => override group => rights
92 private $temporaryUserRights = [];
94 /** @var string[] Cached rights for isEveryoneAllowed */
95 private $cachedRights = [];
98 * Array of Strings Core rights.
99 * Each of these should have a corresponding message of the form
103 private $coreRights = [
151 'move-categorypages',
152 'move-rootuserpages',
156 'override-export-depth',
178 'userrights-interwiki',
186 * @param SpecialPageFactory $specialPageFactory
187 * @param string[] $whitelistRead
188 * @param string[] $whitelistReadRegexp
189 * @param bool $emailConfirmToEdit
190 * @param bool $blockDisablesLogin
191 * @param string[][] $groupPermissions
192 * @param string[][] $revokePermissions
193 * @param string[] $availableRights
194 * @param NamespaceInfo $nsInfo
196 public function __construct(
197 SpecialPageFactory
$specialPageFactory,
199 $whitelistReadRegexp,
205 NamespaceInfo
$nsInfo
207 $this->specialPageFactory
= $specialPageFactory;
208 $this->whitelistRead
= $whitelistRead;
209 $this->whitelistReadRegexp
= $whitelistReadRegexp;
210 $this->emailConfirmToEdit
= $emailConfirmToEdit;
211 $this->blockDisablesLogin
= $blockDisablesLogin;
212 $this->groupPermissions
= $groupPermissions;
213 $this->revokePermissions
= $revokePermissions;
214 $this->availableRights
= $availableRights;
215 $this->nsInfo
= $nsInfo;
219 * Can $user perform $action on a page?
221 * The method is intended to replace Title::userCan()
222 * The $user parameter need to be superseded by UserIdentity value in future
223 * The $title parameter need to be superseded by PageIdentity value in future
225 * @see Title::userCan()
227 * @param string $action
229 * @param LinkTarget $page
230 * @param string $rigor One of PermissionManager::RIGOR_ constants
231 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
232 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
233 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
237 public function userCan( $action, User
$user, LinkTarget
$page, $rigor = self
::RIGOR_SECURE
) {
238 return !count( $this->getPermissionErrorsInternal( $action, $user, $page, $rigor, true ) );
242 * Can $user perform $action on a page?
244 * @todo FIXME: This *does not* check throttles (User::pingLimiter()).
246 * @param string $action Action that permission needs to be checked for
247 * @param User $user User to check
248 * @param LinkTarget $page
249 * @param string $rigor One of PermissionManager::RIGOR_ constants
250 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
251 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
252 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
253 * @param array $ignoreErrors Array of Strings Set this to a list of message keys
254 * whose corresponding errors may be ignored.
256 * @return array Array of arrays of the arguments to wfMessage to explain permissions problems.
258 public function getPermissionErrors(
262 $rigor = self
::RIGOR_SECURE
,
265 $errors = $this->getPermissionErrorsInternal( $action, $user, $page, $rigor );
267 // Remove the errors being ignored.
268 foreach ( $errors as $index => $error ) {
269 $errKey = is_array( $error ) ?
$error[0] : $error;
271 if ( in_array( $errKey, $ignoreErrors ) ) {
272 unset( $errors[$index] );
274 if ( $errKey instanceof MessageSpecifier
&& in_array( $errKey->getKey(), $ignoreErrors ) ) {
275 unset( $errors[$index] );
283 * Check if user is blocked from editing a particular article
286 * @param LinkTarget $page Title to check
287 * @param bool $fromReplica Whether to check the replica DB instead of the master
291 public function isBlockedFrom( User
$user, LinkTarget
$page, $fromReplica = false ) {
292 $blocked = $user->isHidden();
294 // TODO: remove upon further migration to LinkTarget
295 $page = Title
::newFromLinkTarget( $page );
298 $block = $user->getBlock( $fromReplica );
300 // Special handling for a user's own talk page. The block is not aware
301 // of the user, so this must be done here.
302 if ( $page->equals( $user->getTalkPage() ) ) {
303 $blocked = $block->appliesToUsertalk( $page );
305 $blocked = $block->appliesToTitle( $page );
310 // only for the purpose of the hook. We really don't need this here.
311 $allowUsertalk = $user->isAllowUsertalk();
313 Hooks
::run( 'UserIsBlockedFrom', [ $user, $page, &$blocked, &$allowUsertalk ] );
319 * Can $user perform $action on a page? This is an internal function,
320 * with multiple levels of checks depending on performance needs; see $rigor below.
321 * It does not check wfReadOnly().
323 * @param string $action Action that permission needs to be checked for
324 * @param User $user User to check
325 * @param LinkTarget $page
326 * @param string $rigor One of PermissionManager::RIGOR_ constants
327 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
328 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
329 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
330 * @param bool $short Set this to true to stop after the first permission error.
332 * @return array Array of arrays of the arguments to wfMessage to explain permissions problems.
335 private function getPermissionErrorsInternal(
339 $rigor = self
::RIGOR_SECURE
,
342 if ( !in_array( $rigor, [ self
::RIGOR_QUICK
, self
::RIGOR_FULL
, self
::RIGOR_SECURE
] ) ) {
343 throw new Exception( "Invalid rigor parameter '$rigor'." );
346 # Read has special handling
347 if ( $action == 'read' ) {
349 'checkPermissionHooks',
350 'checkReadPermissions',
351 'checkUserBlock', // for wgBlockDisablesLogin
353 # Don't call checkSpecialsAndNSPermissions, checkSiteConfigPermissions
354 # or checkUserConfigPermissions here as it will lead to duplicate
355 # error messages. This is okay to do since anywhere that checks for
356 # create will also check for edit, and those checks are called for edit.
357 } elseif ( $action == 'create' ) {
359 'checkQuickPermissions',
360 'checkPermissionHooks',
361 'checkPageRestrictions',
362 'checkCascadingSourcesRestrictions',
363 'checkActionPermissions',
368 'checkQuickPermissions',
369 'checkPermissionHooks',
370 'checkSpecialsAndNSPermissions',
371 'checkSiteConfigPermissions',
372 'checkUserConfigPermissions',
373 'checkPageRestrictions',
374 'checkCascadingSourcesRestrictions',
375 'checkActionPermissions',
381 foreach ( $checks as $method ) {
382 $errors = $this->$method( $action, $user, $errors, $rigor, $short, $page );
384 if ( $short && $errors !== [] ) {
393 * Check various permission hooks
395 * @param string $action The action to check
396 * @param User $user User to check
397 * @param array $errors List of current errors
398 * @param string $rigor One of PermissionManager::RIGOR_ constants
399 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
400 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
401 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
402 * @param bool $short Short circuit on first error
404 * @param LinkTarget $page
406 * @return array List of errors
408 private function checkPermissionHooks(
416 // TODO: remove when LinkTarget usage will expand further
417 $page = Title
::newFromLinkTarget( $page );
418 // Use getUserPermissionsErrors instead
420 if ( !Hooks
::run( 'userCan', [ &$page, &$user, $action, &$result ] ) ) {
421 return $result ?
[] : [ [ 'badaccess-group0' ] ];
423 // Check getUserPermissionsErrors hook
424 if ( !Hooks
::run( 'getUserPermissionsErrors', [ &$page, &$user, $action, &$result ] ) ) {
425 $errors = $this->resultToError( $errors, $result );
427 // Check getUserPermissionsErrorsExpensive hook
429 $rigor !== self
::RIGOR_QUICK
430 && !( $short && count( $errors ) > 0 )
431 && !Hooks
::run( 'getUserPermissionsErrorsExpensive', [ &$page, &$user, $action, &$result ] )
433 $errors = $this->resultToError( $errors, $result );
440 * Add the resulting error code to the errors array
442 * @param array $errors List of current errors
443 * @param array|string|MessageSpecifier|false $result Result of errors
445 * @return array List of errors
447 private function resultToError( $errors, $result ) {
448 if ( is_array( $result ) && count( $result ) && !is_array( $result[0] ) ) {
449 // A single array representing an error
451 } elseif ( is_array( $result ) && is_array( $result[0] ) ) {
452 // A nested array representing multiple errors
453 $errors = array_merge( $errors, $result );
454 } elseif ( $result !== '' && is_string( $result ) ) {
455 // A string representing a message-id
456 $errors[] = [ $result ];
457 } elseif ( $result instanceof MessageSpecifier
) {
458 // A message specifier representing an error
459 $errors[] = [ $result ];
460 } elseif ( $result === false ) {
461 // a generic "We don't want them to do that"
462 $errors[] = [ 'badaccess-group0' ];
468 * Check that the user is allowed to read this page.
470 * @param string $action The action to check
471 * @param User $user User to check
472 * @param array $errors List of current errors
473 * @param string $rigor One of PermissionManager::RIGOR_ constants
474 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
475 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
476 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
477 * @param bool $short Short circuit on first error
479 * @param LinkTarget $page
481 * @return array List of errors
483 private function checkReadPermissions(
491 // TODO: remove when LinkTarget usage will expand further
492 $page = Title
::newFromLinkTarget( $page );
494 $whitelisted = false;
495 if ( User
::isEveryoneAllowed( 'read' ) ) {
496 # Shortcut for public wikis, allows skipping quite a bit of code
498 } elseif ( $user->isAllowed( 'read' ) ) {
499 # If the user is allowed to read pages, he is allowed to read all pages
501 } elseif ( $this->isSameSpecialPage( 'Userlogin', $page )
502 ||
$this->isSameSpecialPage( 'PasswordReset', $page )
503 ||
$this->isSameSpecialPage( 'Userlogout', $page )
505 # Always grant access to the login page.
506 # Even anons need to be able to log in.
508 } elseif ( is_array( $this->whitelistRead
) && count( $this->whitelistRead
) ) {
509 # Time to check the whitelist
510 # Only do these checks is there's something to check against
511 $name = $page->getPrefixedText();
512 $dbName = $page->getPrefixedDBkey();
514 // Check for explicit whitelisting with and without underscores
515 if ( in_array( $name, $this->whitelistRead
, true )
516 ||
in_array( $dbName, $this->whitelistRead
, true ) ) {
518 } elseif ( $page->getNamespace() == NS_MAIN
) {
519 # Old settings might have the title prefixed with
520 # a colon for main-namespace pages
521 if ( in_array( ':' . $name, $this->whitelistRead
) ) {
524 } elseif ( $page->isSpecialPage() ) {
525 # If it's a special page, ditch the subpage bit and check again
526 $name = $page->getDBkey();
527 list( $name, /* $subpage */ ) =
528 $this->specialPageFactory
->resolveAlias( $name );
530 $pure = SpecialPage
::getTitleFor( $name )->getPrefixedText();
531 if ( in_array( $pure, $this->whitelistRead
, true ) ) {
538 if ( !$whitelisted && is_array( $this->whitelistReadRegexp
)
539 && !empty( $this->whitelistReadRegexp
) ) {
540 $name = $page->getPrefixedText();
541 // Check for regex whitelisting
542 foreach ( $this->whitelistReadRegexp
as $listItem ) {
543 if ( preg_match( $listItem, $name ) ) {
550 if ( !$whitelisted ) {
551 # If the title is not whitelisted, give extensions a chance to do so...
552 Hooks
::run( 'TitleReadWhitelist', [ $page, $user, &$whitelisted ] );
553 if ( !$whitelisted ) {
554 $errors[] = $this->missingPermissionError( $action, $short );
562 * Get a description array when the user doesn't have the right to perform
563 * $action (i.e. when User::isAllowed() returns false)
565 * @param string $action The action to check
566 * @param bool $short Short circuit on first error
567 * @return array Array containing an error message key and any parameters
569 private function missingPermissionError( $action, $short ) {
570 // We avoid expensive display logic for quickUserCan's and such
572 return [ 'badaccess-group0' ];
575 // TODO: it would be a good idea to replace the method below with something else like
576 // maybe callback injection
577 return User
::newFatalPermissionDeniedStatus( $action )->getErrorsArray()[0];
581 * Returns true if this title resolves to the named special page
583 * @param string $name The special page name
584 * @param LinkTarget $page
588 private function isSameSpecialPage( $name, LinkTarget
$page ) {
589 if ( $page->getNamespace() == NS_SPECIAL
) {
590 list( $thisName, /* $subpage */ ) =
591 $this->specialPageFactory
->resolveAlias( $page->getDBkey() );
592 if ( $name == $thisName ) {
600 * Check that the user isn't blocked from editing.
602 * @param string $action The action to check
603 * @param User $user User to check
604 * @param array $errors List of current errors
605 * @param string $rigor One of PermissionManager::RIGOR_ constants
606 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
607 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
608 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
609 * @param bool $short Short circuit on first error
611 * @param LinkTarget $page
613 * @return array List of errors
615 private function checkUserBlock(
623 // Account creation blocks handled at userlogin.
624 // Unblocking handled in SpecialUnblock
625 if ( $rigor === self
::RIGOR_QUICK ||
in_array( $action, [ 'createaccount', 'unblock' ] ) ) {
629 // Optimize for a very common case
630 if ( $action === 'read' && !$this->blockDisablesLogin
) {
634 if ( $this->emailConfirmToEdit
635 && !$user->isEmailConfirmed()
636 && $action === 'edit'
638 $errors[] = [ 'confirmedittext' ];
641 $useReplica = ( $rigor !== self
::RIGOR_SECURE
);
642 $block = $user->getBlock( $useReplica );
644 // If the user does not have a block, or the block they do have explicitly
645 // allows the action (like "read" or "upload").
646 if ( !$block ||
$block->appliesToRight( $action ) === false ) {
650 // Determine if the user is blocked from this action on this page.
651 // What gets passed into this method is a user right, not an action name.
652 // There is no way to instantiate an action by restriction. However, this
653 // will get the action where the restriction is the same. This may result
654 // in actions being blocked that shouldn't be.
656 if ( Action
::exists( $action ) ) {
657 // TODO: this drags a ton of dependencies in, would be good to avoid WikiPage
658 // instantiation and decouple it creating an ActionPermissionChecker interface
659 $wikiPage = WikiPage
::factory( Title
::newFromLinkTarget( $page, 'clone' ) );
660 // Creating an action will perform several database queries to ensure that
661 // the action has not been overridden by the content type.
662 // FIXME: avoid use of RequestContext since it drags in User and Title dependencies
663 // probably we may use fake context object since it's unlikely that Action uses it
664 // anyway. It would be nice if we could avoid instantiating the Action at all.
665 $actionObj = Action
::factory( $action, $wikiPage, RequestContext
::getMain() );
666 // Ensure that the retrieved action matches the restriction.
667 if ( $actionObj && $actionObj->getRestriction() !== $action ) {
672 // If no action object is returned, assume that the action requires unblock
673 // which is the default.
674 if ( !$actionObj ||
$actionObj->requiresUnblock() ) {
675 if ( $this->isBlockedFrom( $user, $page, $useReplica ) ) {
676 // @todo FIXME: Pass the relevant context into this function.
677 $errors[] = $block->getPermissionsError( RequestContext
::getMain() );
685 * Permissions checks that fail most often, and which are easiest to test.
687 * @param string $action The action to check
688 * @param User $user User to check
689 * @param array $errors List of current errors
690 * @param string $rigor One of PermissionManager::RIGOR_ constants
691 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
692 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
693 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
694 * @param bool $short Short circuit on first error
696 * @param LinkTarget $page
698 * @return array List of errors
700 private function checkQuickPermissions(
708 // TODO: remove when LinkTarget usage will expand further
709 $page = Title
::newFromLinkTarget( $page );
711 if ( !Hooks
::run( 'TitleQuickPermissions',
712 [ $page, $user, $action, &$errors, ( $rigor !== self
::RIGOR_QUICK
), $short ] )
717 $isSubPage = $this->nsInfo
->hasSubpages( $page->getNamespace() ) ?
718 strpos( $page->getText(), '/' ) !== false : false;
720 if ( $action == 'create' ) {
722 ( $this->nsInfo
->isTalk( $page->getNamespace() ) &&
723 !$user->isAllowed( 'createtalk' ) ) ||
724 ( !$this->nsInfo
->isTalk( $page->getNamespace() ) &&
725 !$user->isAllowed( 'createpage' ) )
727 $errors[] = $user->isAnon() ?
[ 'nocreatetext' ] : [ 'nocreate-loggedin' ];
729 } elseif ( $action == 'move' ) {
730 if ( !$user->isAllowed( 'move-rootuserpages' )
731 && $page->getNamespace() == NS_USER
&& !$isSubPage ) {
732 // Show user page-specific message only if the user can move other pages
733 $errors[] = [ 'cant-move-user-page' ];
736 // Check if user is allowed to move files if it's a file
737 if ( $page->getNamespace() == NS_FILE
&& !$user->isAllowed( 'movefile' ) ) {
738 $errors[] = [ 'movenotallowedfile' ];
741 // Check if user is allowed to move category pages if it's a category page
742 if ( $page->getNamespace() == NS_CATEGORY
&& !$user->isAllowed( 'move-categorypages' ) ) {
743 $errors[] = [ 'cant-move-category-page' ];
746 if ( !$user->isAllowed( 'move' ) ) {
747 // User can't move anything
748 $userCanMove = User
::groupHasPermission( 'user', 'move' );
749 $autoconfirmedCanMove = User
::groupHasPermission( 'autoconfirmed', 'move' );
750 if ( $user->isAnon() && ( $userCanMove ||
$autoconfirmedCanMove ) ) {
751 // custom message if logged-in users without any special rights can move
752 $errors[] = [ 'movenologintext' ];
754 $errors[] = [ 'movenotallowed' ];
757 } elseif ( $action == 'move-target' ) {
758 if ( !$user->isAllowed( 'move' ) ) {
759 // User can't move anything
760 $errors[] = [ 'movenotallowed' ];
761 } elseif ( !$user->isAllowed( 'move-rootuserpages' )
762 && $page->getNamespace() == NS_USER
&& !$isSubPage ) {
763 // Show user page-specific message only if the user can move other pages
764 $errors[] = [ 'cant-move-to-user-page' ];
765 } elseif ( !$user->isAllowed( 'move-categorypages' )
766 && $page->getNamespace() == NS_CATEGORY
) {
767 // Show category page-specific message only if the user can move other pages
768 $errors[] = [ 'cant-move-to-category-page' ];
770 } elseif ( !$user->isAllowed( $action ) ) {
771 $errors[] = $this->missingPermissionError( $action, $short );
778 * Check against page_restrictions table requirements on this
779 * page. The user must possess all required rights for this
782 * @param string $action The action to check
783 * @param User $user User to check
784 * @param array $errors List of current errors
785 * @param string $rigor One of PermissionManager::RIGOR_ constants
786 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
787 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
788 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
789 * @param bool $short Short circuit on first error
791 * @param LinkTarget $page
793 * @return array List of errors
795 private function checkPageRestrictions(
803 // TODO: remove & rework upon further use of LinkTarget
804 $page = Title
::newFromLinkTarget( $page );
805 foreach ( $page->getRestrictions( $action ) as $right ) {
806 // Backwards compatibility, rewrite sysop -> editprotected
807 if ( $right == 'sysop' ) {
808 $right = 'editprotected';
810 // Backwards compatibility, rewrite autoconfirmed -> editsemiprotected
811 if ( $right == 'autoconfirmed' ) {
812 $right = 'editsemiprotected';
814 if ( $right == '' ) {
817 if ( !$user->isAllowed( $right ) ) {
818 $errors[] = [ 'protectedpagetext', $right, $action ];
819 } elseif ( $page->areRestrictionsCascading() && !$user->isAllowed( 'protect' ) ) {
820 $errors[] = [ 'protectedpagetext', 'protect', $action ];
828 * Check restrictions on cascading pages.
830 * @param string $action The action to check
831 * @param User $user User to check
832 * @param array $errors List of current errors
833 * @param string $rigor One of PermissionManager::RIGOR_ constants
834 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
835 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
836 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
837 * @param bool $short Short circuit on first error
839 * @param LinkTarget $page
841 * @return array List of errors
843 private function checkCascadingSourcesRestrictions(
851 // TODO: remove & rework upon further use of LinkTarget
852 $page = Title
::newFromLinkTarget( $page );
853 if ( $rigor !== self
::RIGOR_QUICK
&& !$page->isUserConfigPage() ) {
854 # We /could/ use the protection level on the source page, but it's
855 # fairly ugly as we have to establish a precedence hierarchy for pages
856 # included by multiple cascade-protected pages. So just restrict
857 # it to people with 'protect' permission, as they could remove the
859 list( $cascadingSources, $restrictions ) = $page->getCascadeProtectionSources();
860 # Cascading protection depends on more than this page...
861 # Several cascading protected pages may include this page...
862 # Check each cascading level
863 # This is only for protection restrictions, not for all actions
864 if ( isset( $restrictions[$action] ) ) {
865 foreach ( $restrictions[$action] as $right ) {
866 // Backwards compatibility, rewrite sysop -> editprotected
867 if ( $right == 'sysop' ) {
868 $right = 'editprotected';
870 // Backwards compatibility, rewrite autoconfirmed -> editsemiprotected
871 if ( $right == 'autoconfirmed' ) {
872 $right = 'editsemiprotected';
874 if ( $right != '' && !$user->isAllowedAll( 'protect', $right ) ) {
876 /** @var Title $wikiPage */
877 foreach ( $cascadingSources as $wikiPage ) {
878 $wikiPages .= '* [[:' . $wikiPage->getPrefixedText() . "]]\n";
880 $errors[] = [ 'cascadeprotected', count( $cascadingSources ), $wikiPages, $action ];
890 * Check action permissions not already checked in checkQuickPermissions
892 * @param string $action The action to check
893 * @param User $user User to check
894 * @param array $errors List of current errors
895 * @param string $rigor One of PermissionManager::RIGOR_ constants
896 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
897 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
898 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
899 * @param bool $short Short circuit on first error
901 * @param LinkTarget $page
903 * @return array List of errors
905 private function checkActionPermissions(
913 global $wgDeleteRevisionsLimit, $wgLang;
915 // TODO: remove & rework upon further use of LinkTarget
916 $page = Title
::newFromLinkTarget( $page );
918 if ( $action == 'protect' ) {
919 if ( count( $this->getPermissionErrorsInternal( 'edit', $user, $page, $rigor, true ) ) ) {
920 // If they can't edit, they shouldn't protect.
921 $errors[] = [ 'protect-cantedit' ];
923 } elseif ( $action == 'create' ) {
924 $title_protection = $page->getTitleProtection();
925 if ( $title_protection ) {
926 if ( $title_protection['permission'] == ''
927 ||
!$user->isAllowed( $title_protection['permission'] )
931 // TODO: get rid of the User dependency
932 User
::whoIs( $title_protection['user'] ),
933 $title_protection['reason']
937 } elseif ( $action == 'move' ) {
938 // Check for immobile pages
939 if ( !$this->nsInfo
->isMovable( $page->getNamespace() ) ) {
940 // Specific message for this case
941 $errors[] = [ 'immobile-source-namespace', $page->getNsText() ];
942 } elseif ( !$page->isMovable() ) {
943 // Less specific message for rarer cases
944 $errors[] = [ 'immobile-source-page' ];
946 } elseif ( $action == 'move-target' ) {
947 if ( !$this->nsInfo
->isMovable( $page->getNamespace() ) ) {
948 $errors[] = [ 'immobile-target-namespace', $page->getNsText() ];
949 } elseif ( !$page->isMovable() ) {
950 $errors[] = [ 'immobile-target-page' ];
952 } elseif ( $action == 'delete' ) {
953 $tempErrors = $this->checkPageRestrictions( 'edit', $user, [], $rigor, true, $page );
954 if ( !$tempErrors ) {
955 $tempErrors = $this->checkCascadingSourcesRestrictions( 'edit',
956 $user, $tempErrors, $rigor, true, $page );
959 // If protection keeps them from editing, they shouldn't be able to delete.
960 $errors[] = [ 'deleteprotected' ];
962 if ( $rigor !== self
::RIGOR_QUICK
&& $wgDeleteRevisionsLimit
963 && !$this->userCan( 'bigdelete', $user, $page ) && $page->isBigDeletion()
965 $errors[] = [ 'delete-toobig', $wgLang->formatNum( $wgDeleteRevisionsLimit ) ];
967 } elseif ( $action === 'undelete' ) {
968 if ( count( $this->getPermissionErrorsInternal( 'edit', $user, $page, $rigor, true ) ) ) {
969 // Undeleting implies editing
970 $errors[] = [ 'undelete-cantedit' ];
972 if ( !$page->exists()
973 && count( $this->getPermissionErrorsInternal( 'create', $user, $page, $rigor, true ) )
975 // Undeleting where nothing currently exists implies creating
976 $errors[] = [ 'undelete-cantcreate' ];
983 * Check permissions on special pages & namespaces
985 * @param string $action The action to check
986 * @param User $user User to check
987 * @param array $errors List of current errors
988 * @param string $rigor One of PermissionManager::RIGOR_ constants
989 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
990 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
991 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
992 * @param bool $short Short circuit on first error
994 * @param LinkTarget $page
996 * @return array List of errors
998 private function checkSpecialsAndNSPermissions(
1006 // TODO: remove & rework upon further use of LinkTarget
1007 $page = Title
::newFromLinkTarget( $page );
1009 # Only 'createaccount' can be performed on special pages,
1010 # which don't actually exist in the DB.
1011 if ( $page->getNamespace() == NS_SPECIAL
&& $action !== 'createaccount' ) {
1012 $errors[] = [ 'ns-specialprotected' ];
1015 # Check $wgNamespaceProtection for restricted namespaces
1016 if ( $page->isNamespaceProtected( $user ) ) {
1017 $ns = $page->getNamespace() == NS_MAIN ?
1018 wfMessage( 'nstab-main' )->text() : $page->getNsText();
1019 $errors[] = $page->getNamespace() == NS_MEDIAWIKI ?
1020 [ 'protectedinterface', $action ] : [ 'namespaceprotected', $ns, $action ];
1027 * Check sitewide CSS/JSON/JS permissions
1029 * @param string $action The action to check
1030 * @param User $user User to check
1031 * @param array $errors List of current errors
1032 * @param string $rigor One of PermissionManager::RIGOR_ constants
1033 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
1034 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
1035 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
1036 * @param bool $short Short circuit on first error
1038 * @param LinkTarget $page
1040 * @return array List of errors
1042 private function checkSiteConfigPermissions(
1050 // TODO: remove & rework upon further use of LinkTarget
1051 $page = Title
::newFromLinkTarget( $page );
1053 if ( $action != 'patrol' ) {
1055 // Sitewide CSS/JSON/JS changes, like all NS_MEDIAWIKI changes, also require the
1056 // editinterface right. That's implemented as a restriction so no check needed here.
1057 if ( $page->isSiteCssConfigPage() && !$user->isAllowed( 'editsitecss' ) ) {
1058 $error = [ 'sitecssprotected', $action ];
1059 } elseif ( $page->isSiteJsonConfigPage() && !$user->isAllowed( 'editsitejson' ) ) {
1060 $error = [ 'sitejsonprotected', $action ];
1061 } elseif ( $page->isSiteJsConfigPage() && !$user->isAllowed( 'editsitejs' ) ) {
1062 $error = [ 'sitejsprotected', $action ];
1063 } elseif ( $page->isRawHtmlMessage() ) {
1064 // Raw HTML can be used to deploy CSS or JS so require rights for both.
1065 if ( !$user->isAllowed( 'editsitejs' ) ) {
1066 $error = [ 'sitejsprotected', $action ];
1067 } elseif ( !$user->isAllowed( 'editsitecss' ) ) {
1068 $error = [ 'sitecssprotected', $action ];
1073 if ( $user->isAllowed( 'editinterface' ) ) {
1074 // Most users / site admins will probably find out about the new, more restrictive
1075 // permissions by failing to edit something. Give them more info.
1076 // TODO remove this a few release cycles after 1.32
1077 $error = [ 'interfaceadmin-info', wfMessage( $error[0], $error[1] ) ];
1087 * Check CSS/JSON/JS sub-page permissions
1089 * @param string $action The action to check
1090 * @param User $user User to check
1091 * @param array $errors List of current errors
1092 * @param string $rigor One of PermissionManager::RIGOR_ constants
1093 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
1094 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
1095 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
1096 * @param bool $short Short circuit on first error
1098 * @param LinkTarget $page
1100 * @return array List of errors
1102 private function checkUserConfigPermissions(
1110 // TODO: remove & rework upon further use of LinkTarget
1111 $page = Title
::newFromLinkTarget( $page );
1113 # Protect css/json/js subpages of user pages
1114 # XXX: this might be better using restrictions
1116 if ( $action === 'patrol' ) {
1120 if ( preg_match( '/^' . preg_quote( $user->getName(), '/' ) . '\//', $page->getText() ) ) {
1121 // Users need editmyuser* to edit their own CSS/JSON/JS subpages.
1123 $page->isUserCssConfigPage()
1124 && !$user->isAllowedAny( 'editmyusercss', 'editusercss' )
1126 $errors[] = [ 'mycustomcssprotected', $action ];
1128 $page->isUserJsonConfigPage()
1129 && !$user->isAllowedAny( 'editmyuserjson', 'edituserjson' )
1131 $errors[] = [ 'mycustomjsonprotected', $action ];
1133 $page->isUserJsConfigPage()
1134 && !$user->isAllowedAny( 'editmyuserjs', 'edituserjs' )
1136 $errors[] = [ 'mycustomjsprotected', $action ];
1139 // Users need editmyuser* to edit their own CSS/JSON/JS subpages, except for
1140 // deletion/suppression which cannot be used for attacks and we want to avoid the
1141 // situation where an unprivileged user can post abusive content on their subpages
1142 // and only very highly privileged users could remove it.
1143 if ( !in_array( $action, [ 'delete', 'deleterevision', 'suppressrevision' ], true ) ) {
1145 $page->isUserCssConfigPage()
1146 && !$user->isAllowed( 'editusercss' )
1148 $errors[] = [ 'customcssprotected', $action ];
1150 $page->isUserJsonConfigPage()
1151 && !$user->isAllowed( 'edituserjson' )
1153 $errors[] = [ 'customjsonprotected', $action ];
1155 $page->isUserJsConfigPage()
1156 && !$user->isAllowed( 'edituserjs' )
1158 $errors[] = [ 'customjsprotected', $action ];
1167 * Testing a permission
1171 * @param UserIdentity $user
1172 * @param string $action
1176 public function userHasRight( UserIdentity
$user, $action = '' ) {
1177 if ( $action === '' ) {
1178 return true; // In the spirit of DWIM
1180 // Use strict parameter to avoid matching numeric 0 accidentally inserted
1181 // by misconfiguration: 0 == 'foo'
1182 return in_array( $action, $this->getUserPermissions( $user ), true );
1186 * Get the permissions this user has.
1190 * @param UserIdentity $user
1192 * @return string[] permission names
1194 public function getUserPermissions( UserIdentity
$user ) {
1195 $user = User
::newFromIdentity( $user );
1196 if ( !isset( $this->usersRights
[ $user->getId() ] ) ) {
1197 $this->usersRights
[ $user->getId() ] = $this->getGroupPermissions(
1198 $user->getEffectiveGroups()
1200 Hooks
::run( 'UserGetRights', [ $user, &$this->usersRights
[ $user->getId() ] ] );
1202 // Deny any rights denied by the user's session, unless this
1203 // endpoint has no sessions.
1204 if ( !defined( 'MW_NO_SESSION' ) ) {
1205 // FIXME: $user->getRequest().. need to be replaced with something else
1206 $allowedRights = $user->getRequest()->getSession()->getAllowedUserRights();
1207 if ( $allowedRights !== null ) {
1208 $this->usersRights
[ $user->getId() ] = array_intersect(
1209 $this->usersRights
[ $user->getId() ],
1215 Hooks
::run( 'UserGetRightsRemove', [ $user, &$this->usersRights
[ $user->getId() ] ] );
1216 // Force reindexation of rights when a hook has unset one of them
1217 $this->usersRights
[ $user->getId() ] = array_values(
1218 array_unique( $this->usersRights
[ $user->getId() ] )
1222 $user->isLoggedIn() &&
1223 $this->blockDisablesLogin
&&
1227 $this->usersRights
[ $user->getId() ] = array_intersect(
1228 $this->usersRights
[ $user->getId() ],
1229 $this->getUserPermissions( $anon )
1233 $rights = $this->usersRights
[ $user->getId() ];
1234 foreach ( $this->temporaryUserRights
[ $user->getId() ] ??
[] as $overrides ) {
1235 $rights = array_values( array_unique( array_merge( $rights, $overrides ) ) );
1241 * Clears users permissions cache, if specific user is provided it tries to clear
1242 * permissions cache only for provided user.
1246 * @param User|null $user
1248 public function invalidateUsersRightsCache( $user = null ) {
1249 if ( $user !== null ) {
1250 if ( isset( $this->usersRights
[ $user->getId() ] ) ) {
1251 unset( $this->usersRights
[$user->getId()] );
1254 $this->usersRights
= null;
1259 * Check, if the given group has the given permission
1261 * If you're wanting to check whether all users have a permission, use
1262 * PermissionManager::isEveryoneAllowed() instead. That properly checks if it's revoked
1267 * @param string $group Group to check
1268 * @param string $role Role to check
1272 public function groupHasPermission( $group, $role ) {
1273 return isset( $this->groupPermissions
[$group][$role] ) &&
1274 $this->groupPermissions
[$group][$role] &&
1275 !( isset( $this->revokePermissions
[$group][$role] ) &&
1276 $this->revokePermissions
[$group][$role] );
1280 * Get the permissions associated with a given list of groups
1284 * @param array $groups Array of Strings List of internal group names
1285 * @return array Array of Strings List of permission key names for given groups combined
1287 public function getGroupPermissions( $groups ) {
1289 // grant every granted permission first
1290 foreach ( $groups as $group ) {
1291 if ( isset( $this->groupPermissions
[$group] ) ) {
1292 $rights = array_merge( $rights,
1293 // array_filter removes empty items
1294 array_keys( array_filter( $this->groupPermissions
[$group] ) ) );
1297 // now revoke the revoked permissions
1298 foreach ( $groups as $group ) {
1299 if ( isset( $this->revokePermissions
[$group] ) ) {
1300 $rights = array_diff( $rights,
1301 array_keys( array_filter( $this->revokePermissions
[$group] ) ) );
1304 return array_unique( $rights );
1308 * Get all the groups who have a given permission
1312 * @param string $role Role to check
1313 * @return array Array of Strings List of internal group names with the given permission
1315 public function getGroupsWithPermission( $role ) {
1316 $allowedGroups = [];
1317 foreach ( array_keys( $this->groupPermissions
) as $group ) {
1318 if ( $this->groupHasPermission( $group, $role ) ) {
1319 $allowedGroups[] = $group;
1322 return $allowedGroups;
1326 * Check if all users may be assumed to have the given permission
1328 * We generally assume so if the right is granted to '*' and isn't revoked
1329 * on any group. It doesn't attempt to take grants or other extension
1330 * limitations on rights into account in the general case, though, as that
1331 * would require it to always return false and defeat the purpose.
1332 * Specifically, session-based rights restrictions (such as OAuth or bot
1333 * passwords) are applied based on the current session.
1335 * @param string $right Right to check
1340 public function isEveryoneAllowed( $right ) {
1341 // Use the cached results, except in unit tests which rely on
1342 // being able change the permission mid-request
1343 if ( isset( $this->cachedRights
[$right] ) ) {
1344 return $this->cachedRights
[$right];
1347 if ( !isset( $this->groupPermissions
['*'][$right] )
1348 ||
!$this->groupPermissions
['*'][$right] ) {
1349 $this->cachedRights
[$right] = false;
1353 // If it's revoked anywhere, then everyone doesn't have it
1354 foreach ( $this->revokePermissions
as $rights ) {
1355 if ( isset( $rights[$right] ) && $rights[$right] ) {
1356 $this->cachedRights
[$right] = false;
1361 // Remove any rights that aren't allowed to the global-session user,
1362 // unless there are no sessions for this endpoint.
1363 if ( !defined( 'MW_NO_SESSION' ) ) {
1365 // XXX: think what could be done with the below
1366 $allowedRights = SessionManager
::getGlobalSession()->getAllowedUserRights();
1367 if ( $allowedRights !== null && !in_array( $right, $allowedRights, true ) ) {
1368 $this->cachedRights
[$right] = false;
1373 // Allow extensions to say false
1374 if ( !Hooks
::run( 'UserIsEveryoneAllowed', [ $right ] ) ) {
1375 $this->cachedRights
[$right] = false;
1379 $this->cachedRights
[$right] = true;
1384 * Get a list of all available permissions.
1388 * @return string[] Array of permission names
1390 public function getAllPermissions() {
1391 if ( $this->allRights
=== false ) {
1392 if ( count( $this->availableRights
) ) {
1393 $this->allRights
= array_unique( array_merge(
1395 $this->availableRights
1398 $this->allRights
= $this->coreRights
;
1400 Hooks
::run( 'UserGetAllRights', [ &$this->allRights
] );
1402 return $this->allRights
;
1406 * Add temporary user rights, only valid for the current scope.
1407 * This is meant for making it possible to programatically trigger certain actions that
1408 * the user wouldn't be able to trigger themselves; e.g. allow users without the bot right
1409 * to make bot-flagged actions through certain special pages.
1410 * Returns a "scope guard" variable; whenever that variable goes out of scope or is consumed
1411 * via ScopedCallback::consume(), the temporary rights are revoked.
1415 * @param UserIdentity $user
1416 * @param string|string[] $rights
1417 * @return ScopedCallback
1419 public function addTemporaryUserRights( UserIdentity
$user, $rights ) {
1420 $userId = $user->getId();
1421 $nextKey = count( $this->temporaryUserRights
[$userId] ??
[] );
1422 $this->temporaryUserRights
[$userId][$nextKey] = (array)$rights;
1423 return new ScopedCallback( function () use ( $userId, $nextKey ) {
1424 unset( $this->temporaryUserRights
[$userId][$nextKey] );
1429 * Overrides user permissions cache
1434 * @param string[]|string $rights
1438 public function overrideUserRightsForTesting( $user, $rights = [] ) {
1439 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
1440 throw new Exception( __METHOD__
. ' can not be called outside of tests' );
1442 $this->usersRights
[ $user->getId() ] = is_array( $rights ) ?
$rights : [ $rights ];