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\Revision\RevisionLookup
;
27 use MediaWiki\Revision\RevisionRecord
;
28 use MediaWiki\Session\SessionManager
;
29 use MediaWiki\Special\SpecialPageFactory
;
30 use MediaWiki\User\UserIdentity
;
37 use Wikimedia\ScopedCallback
;
41 * A service class for checking permissions
42 * To obtain an instance, use MediaWikiServices::getInstance()->getPermissionManager().
46 class PermissionManager
{
48 /** @var string Does cheap permission checks from replica DBs (usable for GUI creation) */
49 const RIGOR_QUICK
= 'quick';
51 /** @var string Does cheap and expensive checks possibly from a replica DB */
52 const RIGOR_FULL
= 'full';
54 /** @var string Does cheap and expensive checks, using the master as needed */
55 const RIGOR_SECURE
= 'secure';
57 /** @var SpecialPageFactory */
58 private $specialPageFactory;
60 /** @var RevisionLookup */
61 private $revisionLookup;
63 /** @var string[] List of pages names anonymous user may see */
64 private $whitelistRead;
66 /** @var string[] Whitelists publicly readable titles with regular expressions */
67 private $whitelistReadRegexp;
69 /** @var bool Require users to confirm email address before they can edit */
70 private $emailConfirmToEdit;
72 /** @var bool If set to true, blocked users will no longer be allowed to log in */
73 private $blockDisablesLogin;
75 /** @var NamespaceInfo */
78 /** @var string[][] Access rights for groups and users in these groups */
79 private $groupPermissions;
81 /** @var string[][] Permission keys revoked from users in each group */
82 private $revokePermissions;
84 /** @var string[] A list of available rights, in addition to the ones defined by the core */
85 private $availableRights;
87 /** @var string[] Cached results of getAllRights() */
88 private $allRights = false;
90 /** @var string[][] Cached user rights */
91 private $usersRights = null;
94 * Temporary user rights, valid for the current request only.
95 * @var string[][][] userid => override group => rights
97 private $temporaryUserRights = [];
99 /** @var string[] Cached rights for isEveryoneAllowed */
100 private $cachedRights = [];
103 * Array of Strings Core rights.
104 * Each of these should have a corresponding message of the form
108 private $coreRights = [
138 'editmyuserjsredirect',
157 'move-categorypages',
158 'move-rootuserpages',
162 'override-export-depth',
184 'userrights-interwiki',
192 * @param SpecialPageFactory $specialPageFactory
193 * @param RevisionLookup $revisionLookup
194 * @param string[] $whitelistRead
195 * @param string[] $whitelistReadRegexp
196 * @param bool $emailConfirmToEdit
197 * @param bool $blockDisablesLogin
198 * @param string[][] $groupPermissions
199 * @param string[][] $revokePermissions
200 * @param string[] $availableRights
201 * @param NamespaceInfo $nsInfo
203 public function __construct(
204 SpecialPageFactory
$specialPageFactory,
205 RevisionLookup
$revisionLookup,
207 $whitelistReadRegexp,
213 NamespaceInfo
$nsInfo
215 $this->specialPageFactory
= $specialPageFactory;
216 $this->revisionLookup
= $revisionLookup;
217 $this->whitelistRead
= $whitelistRead;
218 $this->whitelistReadRegexp
= $whitelistReadRegexp;
219 $this->emailConfirmToEdit
= $emailConfirmToEdit;
220 $this->blockDisablesLogin
= $blockDisablesLogin;
221 $this->groupPermissions
= $groupPermissions;
222 $this->revokePermissions
= $revokePermissions;
223 $this->availableRights
= $availableRights;
224 $this->nsInfo
= $nsInfo;
228 * Can $user perform $action on a page?
230 * The method is intended to replace Title::userCan()
231 * The $user parameter need to be superseded by UserIdentity value in future
232 * The $title parameter need to be superseded by PageIdentity value in future
234 * @see Title::userCan()
236 * @param string $action
238 * @param LinkTarget $page
239 * @param string $rigor One of PermissionManager::RIGOR_ constants
240 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
241 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
242 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
246 public function userCan( $action, User
$user, LinkTarget
$page, $rigor = self
::RIGOR_SECURE
) {
247 return !count( $this->getPermissionErrorsInternal( $action, $user, $page, $rigor, true ) );
251 * Can $user perform $action on a page?
253 * @todo FIXME: This *does not* check throttles (User::pingLimiter()).
255 * @param string $action Action that permission needs to be checked for
256 * @param User $user User to check
257 * @param LinkTarget $page
258 * @param string $rigor One of PermissionManager::RIGOR_ constants
259 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
260 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
261 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
262 * @param array $ignoreErrors Array of Strings Set this to a list of message keys
263 * whose corresponding errors may be ignored.
265 * @return array Array of arrays of the arguments to wfMessage to explain permissions problems.
267 public function getPermissionErrors(
271 $rigor = self
::RIGOR_SECURE
,
274 $errors = $this->getPermissionErrorsInternal( $action, $user, $page, $rigor );
276 // Remove the errors being ignored.
277 foreach ( $errors as $index => $error ) {
278 $errKey = is_array( $error ) ?
$error[0] : $error;
280 if ( in_array( $errKey, $ignoreErrors ) ) {
281 unset( $errors[$index] );
283 if ( $errKey instanceof MessageSpecifier
&& in_array( $errKey->getKey(), $ignoreErrors ) ) {
284 unset( $errors[$index] );
292 * Check if user is blocked from editing a particular article. If the user does not
293 * have a block, this will return false.
296 * @param LinkTarget $page Title to check
297 * @param bool $fromReplica Whether to check the replica DB instead of the master
301 public function isBlockedFrom( User
$user, LinkTarget
$page, $fromReplica = false ) {
302 $block = $user->getBlock( $fromReplica );
307 // TODO: remove upon further migration to LinkTarget
308 $title = Title
::newFromLinkTarget( $page );
310 $blocked = $user->isHidden();
312 // Special handling for a user's own talk page. The block is not aware
313 // of the user, so this must be done here.
314 if ( $title->equals( $user->getTalkPage() ) ) {
315 $blocked = $block->appliesToUsertalk( $title );
317 $blocked = $block->appliesToTitle( $title );
321 // only for the purpose of the hook. We really don't need this here.
322 $allowUsertalk = $user->isAllowUsertalk();
324 // Allow extensions to let a blocked user access a particular page
325 Hooks
::run( 'UserIsBlockedFrom', [ $user, $title, &$blocked, &$allowUsertalk ] );
331 * Can $user perform $action on a page? This is an internal function,
332 * with multiple levels of checks depending on performance needs; see $rigor below.
333 * It does not check wfReadOnly().
335 * @param string $action Action that permission needs to be checked for
336 * @param User $user User to check
337 * @param LinkTarget $page
338 * @param string $rigor One of PermissionManager::RIGOR_ constants
339 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
340 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
341 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
342 * @param bool $short Set this to true to stop after the first permission error.
344 * @return array Array of arrays of the arguments to wfMessage to explain permissions problems.
347 private function getPermissionErrorsInternal(
351 $rigor = self
::RIGOR_SECURE
,
354 if ( !in_array( $rigor, [ self
::RIGOR_QUICK
, self
::RIGOR_FULL
, self
::RIGOR_SECURE
] ) ) {
355 throw new Exception( "Invalid rigor parameter '$rigor'." );
358 # Read has special handling
359 if ( $action == 'read' ) {
361 'checkPermissionHooks',
362 'checkReadPermissions',
363 'checkUserBlock', // for wgBlockDisablesLogin
365 # Don't call checkSpecialsAndNSPermissions, checkSiteConfigPermissions
366 # or checkUserConfigPermissions here as it will lead to duplicate
367 # error messages. This is okay to do since anywhere that checks for
368 # create will also check for edit, and those checks are called for edit.
369 } elseif ( $action == 'create' ) {
371 'checkQuickPermissions',
372 'checkPermissionHooks',
373 'checkPageRestrictions',
374 'checkCascadingSourcesRestrictions',
375 'checkActionPermissions',
380 'checkQuickPermissions',
381 'checkPermissionHooks',
382 'checkSpecialsAndNSPermissions',
383 'checkSiteConfigPermissions',
384 'checkUserConfigPermissions',
385 'checkPageRestrictions',
386 'checkCascadingSourcesRestrictions',
387 'checkActionPermissions',
393 foreach ( $checks as $method ) {
394 $errors = $this->$method( $action, $user, $errors, $rigor, $short, $page );
396 if ( $short && $errors !== [] ) {
405 * Check various permission hooks
407 * @param string $action The action to check
408 * @param User $user User to check
409 * @param array $errors List of current errors
410 * @param string $rigor One of PermissionManager::RIGOR_ constants
411 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
412 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
413 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
414 * @param bool $short Short circuit on first error
416 * @param LinkTarget $page
418 * @return array List of errors
420 private function checkPermissionHooks(
428 // TODO: remove when LinkTarget usage will expand further
429 $title = Title
::newFromLinkTarget( $page );
430 // Use getUserPermissionsErrors instead
432 if ( !Hooks
::run( 'userCan', [ &$title, &$user, $action, &$result ] ) ) {
433 return $result ?
[] : [ [ 'badaccess-group0' ] ];
435 // Check getUserPermissionsErrors hook
436 if ( !Hooks
::run( 'getUserPermissionsErrors', [ &$title, &$user, $action, &$result ] ) ) {
437 $errors = $this->resultToError( $errors, $result );
439 // Check getUserPermissionsErrorsExpensive hook
441 $rigor !== self
::RIGOR_QUICK
442 && !( $short && count( $errors ) > 0 )
443 && !Hooks
::run( 'getUserPermissionsErrorsExpensive', [ &$title, &$user, $action, &$result ] )
445 $errors = $this->resultToError( $errors, $result );
452 * Add the resulting error code to the errors array
454 * @param array $errors List of current errors
455 * @param array|string|MessageSpecifier|false $result Result of errors
457 * @return array List of errors
459 private function resultToError( $errors, $result ) {
460 if ( is_array( $result ) && count( $result ) && !is_array( $result[0] ) ) {
461 // A single array representing an error
463 } elseif ( is_array( $result ) && is_array( $result[0] ) ) {
464 // A nested array representing multiple errors
465 $errors = array_merge( $errors, $result );
466 } elseif ( $result !== '' && is_string( $result ) ) {
467 // A string representing a message-id
468 $errors[] = [ $result ];
469 } elseif ( $result instanceof MessageSpecifier
) {
470 // A message specifier representing an error
471 $errors[] = [ $result ];
472 } elseif ( $result === false ) {
473 // a generic "We don't want them to do that"
474 $errors[] = [ 'badaccess-group0' ];
480 * Check that the user is allowed to read this page.
482 * @param string $action The action to check
483 * @param User $user User to check
484 * @param array $errors List of current errors
485 * @param string $rigor One of PermissionManager::RIGOR_ constants
486 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
487 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
488 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
489 * @param bool $short Short circuit on first error
491 * @param LinkTarget $page
493 * @return array List of errors
495 private function checkReadPermissions(
503 // TODO: remove when LinkTarget usage will expand further
504 $title = Title
::newFromLinkTarget( $page );
506 $whitelisted = false;
507 if ( $this->isEveryoneAllowed( 'read' ) ) {
508 # Shortcut for public wikis, allows skipping quite a bit of code
510 } elseif ( $this->userHasRight( $user, 'read' ) ) {
511 # If the user is allowed to read pages, he is allowed to read all pages
513 } elseif ( $this->isSameSpecialPage( 'Userlogin', $title )
514 ||
$this->isSameSpecialPage( 'PasswordReset', $title )
515 ||
$this->isSameSpecialPage( 'Userlogout', $title )
517 # Always grant access to the login page.
518 # Even anons need to be able to log in.
520 } elseif ( is_array( $this->whitelistRead
) && count( $this->whitelistRead
) ) {
521 # Time to check the whitelist
522 # Only do these checks is there's something to check against
523 $name = $title->getPrefixedText();
524 $dbName = $title->getPrefixedDBkey();
526 // Check for explicit whitelisting with and without underscores
527 if ( in_array( $name, $this->whitelistRead
, true )
528 ||
in_array( $dbName, $this->whitelistRead
, true ) ) {
530 } elseif ( $title->getNamespace() == NS_MAIN
) {
531 # Old settings might have the title prefixed with
532 # a colon for main-namespace pages
533 if ( in_array( ':' . $name, $this->whitelistRead
) ) {
536 } elseif ( $title->isSpecialPage() ) {
537 # If it's a special page, ditch the subpage bit and check again
538 $name = $title->getDBkey();
539 list( $name, /* $subpage */ ) =
540 $this->specialPageFactory
->resolveAlias( $name );
542 $pure = SpecialPage
::getTitleFor( $name )->getPrefixedText();
543 if ( in_array( $pure, $this->whitelistRead
, true ) ) {
550 if ( !$whitelisted && is_array( $this->whitelistReadRegexp
)
551 && !empty( $this->whitelistReadRegexp
) ) {
552 $name = $title->getPrefixedText();
553 // Check for regex whitelisting
554 foreach ( $this->whitelistReadRegexp
as $listItem ) {
555 if ( preg_match( $listItem, $name ) ) {
562 if ( !$whitelisted ) {
563 # If the title is not whitelisted, give extensions a chance to do so...
564 Hooks
::run( 'TitleReadWhitelist', [ $title, $user, &$whitelisted ] );
565 if ( !$whitelisted ) {
566 $errors[] = $this->missingPermissionError( $action, $short );
574 * Get a description array when the user doesn't have the right to perform
575 * $action (i.e. when User::isAllowed() returns false)
577 * @param string $action The action to check
578 * @param bool $short Short circuit on first error
579 * @return array Array containing an error message key and any parameters
581 private function missingPermissionError( $action, $short ) {
582 // We avoid expensive display logic for quickUserCan's and such
584 return [ 'badaccess-group0' ];
587 // TODO: it would be a good idea to replace the method below with something else like
588 // maybe callback injection
589 return User
::newFatalPermissionDeniedStatus( $action )->getErrorsArray()[0];
593 * Returns true if this title resolves to the named special page
595 * @param string $name The special page name
596 * @param LinkTarget $page
600 private function isSameSpecialPage( $name, LinkTarget
$page ) {
601 if ( $page->getNamespace() == NS_SPECIAL
) {
602 list( $thisName, /* $subpage */ ) =
603 $this->specialPageFactory
->resolveAlias( $page->getDBkey() );
604 if ( $name == $thisName ) {
612 * Check that the user isn't blocked from editing.
614 * @param string $action The action to check
615 * @param User $user User to check
616 * @param array $errors List of current errors
617 * @param string $rigor One of PermissionManager::RIGOR_ constants
618 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
619 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
620 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
621 * @param bool $short Short circuit on first error
623 * @param LinkTarget $page
625 * @return array List of errors
627 private function checkUserBlock(
635 // Account creation blocks handled at userlogin.
636 // Unblocking handled in SpecialUnblock
637 if ( $rigor === self
::RIGOR_QUICK ||
in_array( $action, [ 'createaccount', 'unblock' ] ) ) {
641 // Optimize for a very common case
642 if ( $action === 'read' && !$this->blockDisablesLogin
) {
646 if ( $this->emailConfirmToEdit
647 && !$user->isEmailConfirmed()
648 && $action === 'edit'
650 $errors[] = [ 'confirmedittext' ];
653 $useReplica = ( $rigor !== self
::RIGOR_SECURE
);
654 $block = $user->getBlock( $useReplica );
656 // If the user does not have a block, or the block they do have explicitly
657 // allows the action (like "read" or "upload").
658 if ( !$block ||
$block->appliesToRight( $action ) === false ) {
662 // Determine if the user is blocked from this action on this page.
663 // What gets passed into this method is a user right, not an action name.
664 // There is no way to instantiate an action by restriction. However, this
665 // will get the action where the restriction is the same. This may result
666 // in actions being blocked that shouldn't be.
668 if ( Action
::exists( $action ) ) {
669 // TODO: this drags a ton of dependencies in, would be good to avoid WikiPage
670 // instantiation and decouple it creating an ActionPermissionChecker interface
671 $wikiPage = WikiPage
::factory( Title
::newFromLinkTarget( $page, 'clone' ) );
672 // Creating an action will perform several database queries to ensure that
673 // the action has not been overridden by the content type.
674 // FIXME: avoid use of RequestContext since it drags in User and Title dependencies
675 // probably we may use fake context object since it's unlikely that Action uses it
676 // anyway. It would be nice if we could avoid instantiating the Action at all.
677 $actionObj = Action
::factory( $action, $wikiPage, RequestContext
::getMain() );
678 // Ensure that the retrieved action matches the restriction.
679 if ( $actionObj && $actionObj->getRestriction() !== $action ) {
684 // If no action object is returned, assume that the action requires unblock
685 // which is the default.
686 if ( !$actionObj ||
$actionObj->requiresUnblock() ) {
687 if ( $this->isBlockedFrom( $user, $page, $useReplica ) ) {
688 // @todo FIXME: Pass the relevant context into this function.
689 $errors[] = $block->getPermissionsError( RequestContext
::getMain() );
697 * Permissions checks that fail most often, and which are easiest to test.
699 * @param string $action The action to check
700 * @param User $user User to check
701 * @param array $errors List of current errors
702 * @param string $rigor One of PermissionManager::RIGOR_ constants
703 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
704 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
705 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
706 * @param bool $short Short circuit on first error
708 * @param LinkTarget $page
710 * @return array List of errors
712 private function checkQuickPermissions(
720 // TODO: remove when LinkTarget usage will expand further
721 $title = Title
::newFromLinkTarget( $page );
723 if ( !Hooks
::run( 'TitleQuickPermissions',
724 [ $title, $user, $action, &$errors, ( $rigor !== self
::RIGOR_QUICK
), $short ] )
729 $isSubPage = $this->nsInfo
->hasSubpages( $title->getNamespace() ) ?
730 strpos( $title->getText(), '/' ) !== false : false;
732 if ( $action == 'create' ) {
734 ( $this->nsInfo
->isTalk( $title->getNamespace() ) &&
735 !$this->userHasRight( $user, 'createtalk' ) ) ||
736 ( !$this->nsInfo
->isTalk( $title->getNamespace() ) &&
737 !$this->userHasRight( $user, 'createpage' ) )
739 $errors[] = $user->isAnon() ?
[ 'nocreatetext' ] : [ 'nocreate-loggedin' ];
741 } elseif ( $action == 'move' ) {
742 if ( !$this->userHasRight( $user, 'move-rootuserpages' )
743 && $title->getNamespace() == NS_USER
&& !$isSubPage ) {
744 // Show user page-specific message only if the user can move other pages
745 $errors[] = [ 'cant-move-user-page' ];
748 // Check if user is allowed to move files if it's a file
749 if ( $title->getNamespace() == NS_FILE
&&
750 !$this->userHasRight( $user, 'movefile' ) ) {
751 $errors[] = [ 'movenotallowedfile' ];
754 // Check if user is allowed to move category pages if it's a category page
755 if ( $title->getNamespace() == NS_CATEGORY
&&
756 !$this->userHasRight( $user, 'move-categorypages' ) ) {
757 $errors[] = [ 'cant-move-category-page' ];
760 if ( !$this->userHasRight( $user, 'move' ) ) {
761 // User can't move anything
762 $userCanMove = $this->groupHasPermission( 'user', 'move' );
763 $autoconfirmedCanMove = $this->groupHasPermission( 'autoconfirmed', 'move' );
764 if ( $user->isAnon() && ( $userCanMove ||
$autoconfirmedCanMove ) ) {
765 // custom message if logged-in users without any special rights can move
766 $errors[] = [ 'movenologintext' ];
768 $errors[] = [ 'movenotallowed' ];
771 } elseif ( $action == 'move-target' ) {
772 if ( !$this->userHasRight( $user, 'move' ) ) {
773 // User can't move anything
774 $errors[] = [ 'movenotallowed' ];
775 } elseif ( !$this->userHasRight( $user, 'move-rootuserpages' )
776 && $title->getNamespace() == NS_USER
&& !$isSubPage ) {
777 // Show user page-specific message only if the user can move other pages
778 $errors[] = [ 'cant-move-to-user-page' ];
779 } elseif ( !$this->userHasRight( $user, 'move-categorypages' )
780 && $title->getNamespace() == NS_CATEGORY
) {
781 // Show category page-specific message only if the user can move other pages
782 $errors[] = [ 'cant-move-to-category-page' ];
784 } elseif ( !$this->userHasRight( $user, $action ) ) {
785 $errors[] = $this->missingPermissionError( $action, $short );
792 * Check against page_restrictions table requirements on this
793 * page. The user must possess all required rights for this
796 * @param string $action The action to check
797 * @param User $user User to check
798 * @param array $errors List of current errors
799 * @param string $rigor One of PermissionManager::RIGOR_ constants
800 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
801 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
802 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
803 * @param bool $short Short circuit on first error
805 * @param LinkTarget $page
807 * @return array List of errors
809 private function checkPageRestrictions(
817 // TODO: remove & rework upon further use of LinkTarget
818 $title = Title
::newFromLinkTarget( $page );
819 foreach ( $title->getRestrictions( $action ) as $right ) {
820 // Backwards compatibility, rewrite sysop -> editprotected
821 if ( $right == 'sysop' ) {
822 $right = 'editprotected';
824 // Backwards compatibility, rewrite autoconfirmed -> editsemiprotected
825 if ( $right == 'autoconfirmed' ) {
826 $right = 'editsemiprotected';
828 if ( $right == '' ) {
831 if ( !$this->userHasRight( $user, $right ) ) {
832 $errors[] = [ 'protectedpagetext', $right, $action ];
833 } elseif ( $title->areRestrictionsCascading() &&
834 !$this->userHasRight( $user, 'protect' ) ) {
835 $errors[] = [ 'protectedpagetext', 'protect', $action ];
843 * Check restrictions on cascading pages.
845 * @param string $action The action to check
846 * @param User $user User to check
847 * @param array $errors List of current errors
848 * @param string $rigor One of PermissionManager::RIGOR_ constants
849 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
850 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
851 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
852 * @param bool $short Short circuit on first error
854 * @param LinkTarget $page
856 * @return array List of errors
858 private function checkCascadingSourcesRestrictions(
866 // TODO: remove & rework upon further use of LinkTarget
867 $title = Title
::newFromLinkTarget( $page );
868 if ( $rigor !== self
::RIGOR_QUICK
&& !$title->isUserConfigPage() ) {
869 # We /could/ use the protection level on the source page, but it's
870 # fairly ugly as we have to establish a precedence hierarchy for pages
871 # included by multiple cascade-protected pages. So just restrict
872 # it to people with 'protect' permission, as they could remove the
874 list( $cascadingSources, $restrictions ) = $title->getCascadeProtectionSources();
875 # Cascading protection depends on more than this page...
876 # Several cascading protected pages may include this page...
877 # Check each cascading level
878 # This is only for protection restrictions, not for all actions
879 if ( isset( $restrictions[$action] ) ) {
880 foreach ( $restrictions[$action] as $right ) {
881 // Backwards compatibility, rewrite sysop -> editprotected
882 if ( $right == 'sysop' ) {
883 $right = 'editprotected';
885 // Backwards compatibility, rewrite autoconfirmed -> editsemiprotected
886 if ( $right == 'autoconfirmed' ) {
887 $right = 'editsemiprotected';
889 if ( $right != '' && !$user->isAllowedAll( 'protect', $right ) ) {
891 /** @var Title $wikiPage */
892 foreach ( $cascadingSources as $wikiPage ) {
893 $wikiPages .= '* [[:' . $wikiPage->getPrefixedText() . "]]\n";
895 $errors[] = [ 'cascadeprotected', count( $cascadingSources ), $wikiPages, $action ];
905 * Check action permissions not already checked in checkQuickPermissions
907 * @param string $action The action to check
908 * @param User $user User to check
909 * @param array $errors List of current errors
910 * @param string $rigor One of PermissionManager::RIGOR_ constants
911 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
912 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
913 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
914 * @param bool $short Short circuit on first error
916 * @param LinkTarget $page
918 * @return array List of errors
920 private function checkActionPermissions(
928 global $wgDeleteRevisionsLimit, $wgLang;
930 // TODO: remove & rework upon further use of LinkTarget
931 $title = Title
::newFromLinkTarget( $page );
933 if ( $action == 'protect' ) {
934 if ( count( $this->getPermissionErrorsInternal( 'edit', $user, $title, $rigor, true ) ) ) {
935 // If they can't edit, they shouldn't protect.
936 $errors[] = [ 'protect-cantedit' ];
938 } elseif ( $action == 'create' ) {
939 $title_protection = $title->getTitleProtection();
940 if ( $title_protection ) {
941 if ( $title_protection['permission'] == ''
942 ||
!$this->userHasRight( $user, $title_protection['permission'] )
946 // TODO: get rid of the User dependency
947 User
::whoIs( $title_protection['user'] ),
948 $title_protection['reason']
952 } elseif ( $action == 'move' ) {
953 // Check for immobile pages
954 if ( !$this->nsInfo
->isMovable( $title->getNamespace() ) ) {
955 // Specific message for this case
956 $errors[] = [ 'immobile-source-namespace', $title->getNsText() ];
957 } elseif ( !$title->isMovable() ) {
958 // Less specific message for rarer cases
959 $errors[] = [ 'immobile-source-page' ];
961 } elseif ( $action == 'move-target' ) {
962 if ( !$this->nsInfo
->isMovable( $title->getNamespace() ) ) {
963 $errors[] = [ 'immobile-target-namespace', $title->getNsText() ];
964 } elseif ( !$title->isMovable() ) {
965 $errors[] = [ 'immobile-target-page' ];
967 } elseif ( $action == 'delete' ) {
968 $tempErrors = $this->checkPageRestrictions( 'edit', $user, [], $rigor, true, $title );
969 if ( !$tempErrors ) {
970 $tempErrors = $this->checkCascadingSourcesRestrictions( 'edit',
971 $user, $tempErrors, $rigor, true, $title );
974 // If protection keeps them from editing, they shouldn't be able to delete.
975 $errors[] = [ 'deleteprotected' ];
977 if ( $rigor !== self
::RIGOR_QUICK
&& $wgDeleteRevisionsLimit
978 && !$this->userCan( 'bigdelete', $user, $title ) && $title->isBigDeletion()
980 $errors[] = [ 'delete-toobig', $wgLang->formatNum( $wgDeleteRevisionsLimit ) ];
982 } elseif ( $action === 'undelete' ) {
983 if ( count( $this->getPermissionErrorsInternal( 'edit', $user, $title, $rigor, true ) ) ) {
984 // Undeleting implies editing
985 $errors[] = [ 'undelete-cantedit' ];
987 if ( !$title->exists()
988 && count( $this->getPermissionErrorsInternal( 'create', $user, $title, $rigor, true ) )
990 // Undeleting where nothing currently exists implies creating
991 $errors[] = [ 'undelete-cantcreate' ];
998 * Check permissions on special pages & namespaces
1000 * @param string $action The action to check
1001 * @param User $user User to check
1002 * @param array $errors List of current errors
1003 * @param string $rigor One of PermissionManager::RIGOR_ constants
1004 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
1005 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
1006 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
1007 * @param bool $short Short circuit on first error
1009 * @param LinkTarget $page
1011 * @return array List of errors
1013 private function checkSpecialsAndNSPermissions(
1021 // TODO: remove & rework upon further use of LinkTarget
1022 $title = Title
::newFromLinkTarget( $page );
1024 # Only 'createaccount' can be performed on special pages,
1025 # which don't actually exist in the DB.
1026 if ( $title->getNamespace() == NS_SPECIAL
&& $action !== 'createaccount' ) {
1027 $errors[] = [ 'ns-specialprotected' ];
1030 # Check $wgNamespaceProtection for restricted namespaces
1031 if ( $title->isNamespaceProtected( $user ) ) {
1032 $ns = $title->getNamespace() == NS_MAIN ?
1033 wfMessage( 'nstab-main' )->text() : $title->getNsText();
1034 $errors[] = $title->getNamespace() == NS_MEDIAWIKI ?
1035 [ 'protectedinterface', $action ] : [ 'namespaceprotected', $ns, $action ];
1042 * Check sitewide CSS/JSON/JS permissions
1044 * @param string $action The action to check
1045 * @param User $user User to check
1046 * @param array $errors List of current errors
1047 * @param string $rigor One of PermissionManager::RIGOR_ constants
1048 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
1049 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
1050 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
1051 * @param bool $short Short circuit on first error
1053 * @param LinkTarget $page
1055 * @return array List of errors
1057 private function checkSiteConfigPermissions(
1065 // TODO: remove & rework upon further use of LinkTarget
1066 $title = Title
::newFromLinkTarget( $page );
1068 if ( $action != 'patrol' ) {
1070 // Sitewide CSS/JSON/JS changes, like all NS_MEDIAWIKI changes, also require the
1071 // editinterface right. That's implemented as a restriction so no check needed here.
1072 if ( $title->isSiteCssConfigPage() && !$this->userHasRight( $user, 'editsitecss' ) ) {
1073 $error = [ 'sitecssprotected', $action ];
1074 } elseif ( $title->isSiteJsonConfigPage() && !$this->userHasRight( $user, 'editsitejson' ) ) {
1075 $error = [ 'sitejsonprotected', $action ];
1076 } elseif ( $title->isSiteJsConfigPage() && !$this->userHasRight( $user, 'editsitejs' ) ) {
1077 $error = [ 'sitejsprotected', $action ];
1078 } elseif ( $title->isRawHtmlMessage() ) {
1079 // Raw HTML can be used to deploy CSS or JS so require rights for both.
1080 if ( !$this->userHasRight( $user, 'editsitejs' ) ) {
1081 $error = [ 'sitejsprotected', $action ];
1082 } elseif ( !$this->userHasRight( $user, 'editsitecss' ) ) {
1083 $error = [ 'sitecssprotected', $action ];
1088 if ( $this->userHasRight( $user, 'editinterface' ) ) {
1089 // Most users / site admins will probably find out about the new, more restrictive
1090 // permissions by failing to edit something. Give them more info.
1091 // TODO remove this a few release cycles after 1.32
1092 $error = [ 'interfaceadmin-info', wfMessage( $error[0], $error[1] ) ];
1102 * Check CSS/JSON/JS sub-page permissions
1104 * @param string $action The action to check
1105 * @param User $user User to check
1106 * @param array $errors List of current errors
1107 * @param string $rigor One of PermissionManager::RIGOR_ constants
1108 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
1109 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
1110 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
1111 * @param bool $short Short circuit on first error
1113 * @param LinkTarget $page
1115 * @return array List of errors
1117 private function checkUserConfigPermissions(
1125 // TODO: remove & rework upon further use of LinkTarget
1126 $title = Title
::newFromLinkTarget( $page );
1128 # Protect css/json/js subpages of user pages
1129 # XXX: this might be better using restrictions
1131 if ( $action === 'patrol' ) {
1135 if ( preg_match( '/^' . preg_quote( $user->getName(), '/' ) . '\//', $title->getText() ) ) {
1136 // Users need editmyuser* to edit their own CSS/JSON/JS subpages.
1138 $title->isUserCssConfigPage()
1139 && !$user->isAllowedAny( 'editmyusercss', 'editusercss' )
1141 $errors[] = [ 'mycustomcssprotected', $action ];
1143 $title->isUserJsonConfigPage()
1144 && !$user->isAllowedAny( 'editmyuserjson', 'edituserjson' )
1146 $errors[] = [ 'mycustomjsonprotected', $action ];
1148 $title->isUserJsConfigPage()
1149 && !$user->isAllowedAny( 'editmyuserjs', 'edituserjs' )
1151 $errors[] = [ 'mycustomjsprotected', $action ];
1153 $title->isUserJsConfigPage()
1154 && !$user->isAllowedAny( 'edituserjs', 'editmyuserjsredirect' )
1156 // T207750 - do not allow users to edit a redirect if they couldn't edit the target
1157 $rev = $this->revisionLookup
->getRevisionByTitle( $title );
1158 $content = $rev ?
$rev->getContent( 'main', RevisionRecord
::RAW
) : null;
1159 $target = $content ?
$content->getUltimateRedirectTarget() : null;
1161 !$target->inNamespace( NS_USER
)
1162 ||
!preg_match( '/^' . preg_quote( $user->getName(), '/' ) . '\//', $target->getText() )
1164 $errors[] = [ 'mycustomjsredirectprotected', $action ];
1168 // Users need editmyuser* to edit their own CSS/JSON/JS subpages, except for
1169 // deletion/suppression which cannot be used for attacks and we want to avoid the
1170 // situation where an unprivileged user can post abusive content on their subpages
1171 // and only very highly privileged users could remove it.
1172 if ( !in_array( $action, [ 'delete', 'deleterevision', 'suppressrevision' ], true ) ) {
1174 $title->isUserCssConfigPage()
1175 && !$this->userHasRight( $user, 'editusercss' )
1177 $errors[] = [ 'customcssprotected', $action ];
1179 $title->isUserJsonConfigPage()
1180 && !$this->userHasRight( $user, 'edituserjson' )
1182 $errors[] = [ 'customjsonprotected', $action ];
1184 $title->isUserJsConfigPage()
1185 && !$this->userHasRight( $user, 'edituserjs' )
1187 $errors[] = [ 'customjsprotected', $action ];
1196 * Testing a permission
1200 * @param UserIdentity $user
1201 * @param string $action
1205 public function userHasRight( UserIdentity
$user, $action = '' ) {
1206 if ( $action === '' ) {
1207 return true; // In the spirit of DWIM
1209 // Use strict parameter to avoid matching numeric 0 accidentally inserted
1210 // by misconfiguration: 0 == 'foo'
1211 return in_array( $action, $this->getUserPermissions( $user ), true );
1215 * Get the permissions this user has.
1219 * @param UserIdentity $user
1221 * @return string[] permission names
1223 public function getUserPermissions( UserIdentity
$user ) {
1224 $user = User
::newFromIdentity( $user );
1225 if ( !isset( $this->usersRights
[ $user->getId() ] ) ) {
1226 $this->usersRights
[ $user->getId() ] = $this->getGroupPermissions(
1227 $user->getEffectiveGroups()
1229 Hooks
::run( 'UserGetRights', [ $user, &$this->usersRights
[ $user->getId() ] ] );
1231 // Deny any rights denied by the user's session, unless this
1232 // endpoint has no sessions.
1233 if ( !defined( 'MW_NO_SESSION' ) ) {
1234 // FIXME: $user->getRequest().. need to be replaced with something else
1235 $allowedRights = $user->getRequest()->getSession()->getAllowedUserRights();
1236 if ( $allowedRights !== null ) {
1237 $this->usersRights
[ $user->getId() ] = array_intersect(
1238 $this->usersRights
[ $user->getId() ],
1244 Hooks
::run( 'UserGetRightsRemove', [ $user, &$this->usersRights
[ $user->getId() ] ] );
1245 // Force reindexation of rights when a hook has unset one of them
1246 $this->usersRights
[ $user->getId() ] = array_values(
1247 array_unique( $this->usersRights
[ $user->getId() ] )
1251 $user->isLoggedIn() &&
1252 $this->blockDisablesLogin
&&
1256 $this->usersRights
[ $user->getId() ] = array_intersect(
1257 $this->usersRights
[ $user->getId() ],
1258 $this->getUserPermissions( $anon )
1262 $rights = $this->usersRights
[ $user->getId() ];
1263 foreach ( $this->temporaryUserRights
[ $user->getId() ] ??
[] as $overrides ) {
1264 $rights = array_values( array_unique( array_merge( $rights, $overrides ) ) );
1270 * Clears users permissions cache, if specific user is provided it tries to clear
1271 * permissions cache only for provided user.
1275 * @param User|null $user
1277 public function invalidateUsersRightsCache( $user = null ) {
1278 if ( $user !== null ) {
1279 if ( isset( $this->usersRights
[ $user->getId() ] ) ) {
1280 unset( $this->usersRights
[$user->getId()] );
1283 $this->usersRights
= null;
1288 * Check, if the given group has the given permission
1290 * If you're wanting to check whether all users have a permission, use
1291 * PermissionManager::isEveryoneAllowed() instead. That properly checks if it's revoked
1296 * @param string $group Group to check
1297 * @param string $role Role to check
1301 public function groupHasPermission( $group, $role ) {
1302 return isset( $this->groupPermissions
[$group][$role] ) &&
1303 $this->groupPermissions
[$group][$role] &&
1304 !( isset( $this->revokePermissions
[$group][$role] ) &&
1305 $this->revokePermissions
[$group][$role] );
1309 * Get the permissions associated with a given list of groups
1313 * @param array $groups Array of Strings List of internal group names
1314 * @return array Array of Strings List of permission key names for given groups combined
1316 public function getGroupPermissions( $groups ) {
1318 // grant every granted permission first
1319 foreach ( $groups as $group ) {
1320 if ( isset( $this->groupPermissions
[$group] ) ) {
1321 $rights = array_merge( $rights,
1322 // array_filter removes empty items
1323 array_keys( array_filter( $this->groupPermissions
[$group] ) ) );
1326 // now revoke the revoked permissions
1327 foreach ( $groups as $group ) {
1328 if ( isset( $this->revokePermissions
[$group] ) ) {
1329 $rights = array_diff( $rights,
1330 array_keys( array_filter( $this->revokePermissions
[$group] ) ) );
1333 return array_unique( $rights );
1337 * Get all the groups who have a given permission
1341 * @param string $role Role to check
1342 * @return array Array of Strings List of internal group names with the given permission
1344 public function getGroupsWithPermission( $role ) {
1345 $allowedGroups = [];
1346 foreach ( array_keys( $this->groupPermissions
) as $group ) {
1347 if ( $this->groupHasPermission( $group, $role ) ) {
1348 $allowedGroups[] = $group;
1351 return $allowedGroups;
1355 * Check if all users may be assumed to have the given permission
1357 * We generally assume so if the right is granted to '*' and isn't revoked
1358 * on any group. It doesn't attempt to take grants or other extension
1359 * limitations on rights into account in the general case, though, as that
1360 * would require it to always return false and defeat the purpose.
1361 * Specifically, session-based rights restrictions (such as OAuth or bot
1362 * passwords) are applied based on the current session.
1364 * @param string $right Right to check
1369 public function isEveryoneAllowed( $right ) {
1370 // Use the cached results, except in unit tests which rely on
1371 // being able change the permission mid-request
1372 if ( isset( $this->cachedRights
[$right] ) ) {
1373 return $this->cachedRights
[$right];
1376 if ( !isset( $this->groupPermissions
['*'][$right] )
1377 ||
!$this->groupPermissions
['*'][$right] ) {
1378 $this->cachedRights
[$right] = false;
1382 // If it's revoked anywhere, then everyone doesn't have it
1383 foreach ( $this->revokePermissions
as $rights ) {
1384 if ( isset( $rights[$right] ) && $rights[$right] ) {
1385 $this->cachedRights
[$right] = false;
1390 // Remove any rights that aren't allowed to the global-session user,
1391 // unless there are no sessions for this endpoint.
1392 if ( !defined( 'MW_NO_SESSION' ) ) {
1394 // XXX: think what could be done with the below
1395 $allowedRights = SessionManager
::getGlobalSession()->getAllowedUserRights();
1396 if ( $allowedRights !== null && !in_array( $right, $allowedRights, true ) ) {
1397 $this->cachedRights
[$right] = false;
1402 // Allow extensions to say false
1403 if ( !Hooks
::run( 'UserIsEveryoneAllowed', [ $right ] ) ) {
1404 $this->cachedRights
[$right] = false;
1408 $this->cachedRights
[$right] = true;
1413 * Get a list of all available permissions.
1417 * @return string[] Array of permission names
1419 public function getAllPermissions() {
1420 if ( $this->allRights
=== false ) {
1421 if ( count( $this->availableRights
) ) {
1422 $this->allRights
= array_unique( array_merge(
1424 $this->availableRights
1427 $this->allRights
= $this->coreRights
;
1429 Hooks
::run( 'UserGetAllRights', [ &$this->allRights
] );
1431 return $this->allRights
;
1435 * Add temporary user rights, only valid for the current scope.
1436 * This is meant for making it possible to programatically trigger certain actions that
1437 * the user wouldn't be able to trigger themselves; e.g. allow users without the bot right
1438 * to make bot-flagged actions through certain special pages.
1439 * Returns a "scope guard" variable; whenever that variable goes out of scope or is consumed
1440 * via ScopedCallback::consume(), the temporary rights are revoked.
1444 * @param UserIdentity $user
1445 * @param string|string[] $rights
1446 * @return ScopedCallback
1448 public function addTemporaryUserRights( UserIdentity
$user, $rights ) {
1449 $userId = $user->getId();
1450 $nextKey = count( $this->temporaryUserRights
[$userId] ??
[] );
1451 $this->temporaryUserRights
[$userId][$nextKey] = (array)$rights;
1452 return new ScopedCallback( function () use ( $userId, $nextKey ) {
1453 unset( $this->temporaryUserRights
[$userId][$nextKey] );
1458 * Overrides user permissions cache
1463 * @param string[]|string $rights
1467 public function overrideUserRightsForTesting( $user, $rights = [] ) {
1468 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
1469 throw new Exception( __METHOD__
. ' can not be called outside of tests' );
1471 $this->usersRights
[ $user->getId() ] = is_array( $rights ) ?
$rights : [ $rights ];