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\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
;
38 use Wikimedia\ScopedCallback
;
42 * A service class for checking permissions
43 * To obtain an instance, use MediaWikiServices::getInstance()->getPermissionManager().
47 class PermissionManager
{
49 /** @var string Does cheap permission checks from replica DBs (usable for GUI creation) */
50 const RIGOR_QUICK
= 'quick';
52 /** @var string Does cheap and expensive checks possibly from a replica DB */
53 const RIGOR_FULL
= 'full';
55 /** @var string Does cheap and expensive checks, using the master as needed */
56 const RIGOR_SECURE
= 'secure';
59 * TODO Make this const when HHVM support is dropped (T192166)
64 public static $constructorOptions = [
66 'WhitelistReadRegexp',
74 /** @var ServiceOptions */
77 /** @var SpecialPageFactory */
78 private $specialPageFactory;
80 /** @var RevisionLookup */
81 private $revisionLookup;
83 /** @var NamespaceInfo */
86 /** @var string[] Cached results of getAllRights() */
87 private $allRights = false;
89 /** @var string[][] Cached user rights */
90 private $usersRights = null;
93 * Temporary user rights, valid for the current request only.
94 * @var string[][][] userid => override group => rights
96 private $temporaryUserRights = [];
98 /** @var string[] Cached rights for isEveryoneAllowed */
99 private $cachedRights = [];
102 * Array of Strings Core rights.
103 * Each of these should have a corresponding message of the form
107 private $coreRights = [
137 'editmyuserjsredirect',
156 'move-categorypages',
157 'move-rootuserpages',
161 'override-export-depth',
183 'userrights-interwiki',
191 * @param ServiceOptions $options
192 * @param SpecialPageFactory $specialPageFactory
193 * @param RevisionLookup $revisionLookup
194 * @param NamespaceInfo $nsInfo
196 public function __construct(
197 ServiceOptions
$options,
198 SpecialPageFactory
$specialPageFactory,
199 RevisionLookup
$revisionLookup,
200 NamespaceInfo
$nsInfo
202 $options->assertRequiredOptions( self
::$constructorOptions );
203 $this->options
= $options;
204 $this->specialPageFactory
= $specialPageFactory;
205 $this->revisionLookup
= $revisionLookup;
206 $this->nsInfo
= $nsInfo;
210 * Can $user perform $action on a page?
212 * The method is intended to replace Title::userCan()
213 * The $user parameter need to be superseded by UserIdentity value in future
214 * The $title parameter need to be superseded by PageIdentity value in future
216 * @see Title::userCan()
218 * @param string $action
220 * @param LinkTarget $page
221 * @param string $rigor One of PermissionManager::RIGOR_ constants
222 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
223 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
224 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
228 public function userCan( $action, User
$user, LinkTarget
$page, $rigor = self
::RIGOR_SECURE
) {
229 return !count( $this->getPermissionErrorsInternal( $action, $user, $page, $rigor, true ) );
233 * Can $user perform $action on a page?
235 * @todo FIXME: This *does not* check throttles (User::pingLimiter()).
237 * @param string $action Action that permission needs to be checked for
238 * @param User $user User to check
239 * @param LinkTarget $page
240 * @param string $rigor One of PermissionManager::RIGOR_ constants
241 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
242 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
243 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
244 * @param array $ignoreErrors Array of Strings Set this to a list of message keys
245 * whose corresponding errors may be ignored.
247 * @return array Array of arrays of the arguments to wfMessage to explain permissions problems.
249 public function getPermissionErrors(
253 $rigor = self
::RIGOR_SECURE
,
256 $errors = $this->getPermissionErrorsInternal( $action, $user, $page, $rigor );
258 // Remove the errors being ignored.
259 foreach ( $errors as $index => $error ) {
260 $errKey = is_array( $error ) ?
$error[0] : $error;
262 if ( in_array( $errKey, $ignoreErrors ) ) {
263 unset( $errors[$index] );
265 if ( $errKey instanceof MessageSpecifier
&& in_array( $errKey->getKey(), $ignoreErrors ) ) {
266 unset( $errors[$index] );
274 * Check if user is blocked from editing a particular article
277 * @param LinkTarget $page Title to check
278 * @param bool $fromReplica Whether to check the replica DB instead of the master
282 public function isBlockedFrom( User
$user, LinkTarget
$page, $fromReplica = false ) {
283 $blocked = $user->isHidden();
285 // TODO: remove upon further migration to LinkTarget
286 $title = Title
::newFromLinkTarget( $page );
289 $block = $user->getBlock( $fromReplica );
291 // Special handling for a user's own talk page. The block is not aware
292 // of the user, so this must be done here.
293 if ( $title->equals( $user->getTalkPage() ) ) {
294 $blocked = $block->appliesToUsertalk( $title );
296 $blocked = $block->appliesToTitle( $title );
301 // only for the purpose of the hook. We really don't need this here.
302 $allowUsertalk = $user->isAllowUsertalk();
304 Hooks
::run( 'UserIsBlockedFrom', [ $user, $title, &$blocked, &$allowUsertalk ] );
310 * Can $user perform $action on a page? This is an internal function,
311 * with multiple levels of checks depending on performance needs; see $rigor below.
312 * It does not check wfReadOnly().
314 * @param string $action Action that permission needs to be checked for
315 * @param User $user User to check
316 * @param LinkTarget $page
317 * @param string $rigor One of PermissionManager::RIGOR_ constants
318 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
319 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
320 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
321 * @param bool $short Set this to true to stop after the first permission error.
323 * @return array Array of arrays of the arguments to wfMessage to explain permissions problems.
326 private function getPermissionErrorsInternal(
330 $rigor = self
::RIGOR_SECURE
,
333 if ( !in_array( $rigor, [ self
::RIGOR_QUICK
, self
::RIGOR_FULL
, self
::RIGOR_SECURE
] ) ) {
334 throw new Exception( "Invalid rigor parameter '$rigor'." );
337 # Read has special handling
338 if ( $action == 'read' ) {
340 'checkPermissionHooks',
341 'checkReadPermissions',
342 'checkUserBlock', // for wgBlockDisablesLogin
344 # Don't call checkSpecialsAndNSPermissions, checkSiteConfigPermissions
345 # or checkUserConfigPermissions here as it will lead to duplicate
346 # error messages. This is okay to do since anywhere that checks for
347 # create will also check for edit, and those checks are called for edit.
348 } elseif ( $action == 'create' ) {
350 'checkQuickPermissions',
351 'checkPermissionHooks',
352 'checkPageRestrictions',
353 'checkCascadingSourcesRestrictions',
354 'checkActionPermissions',
359 'checkQuickPermissions',
360 'checkPermissionHooks',
361 'checkSpecialsAndNSPermissions',
362 'checkSiteConfigPermissions',
363 'checkUserConfigPermissions',
364 'checkPageRestrictions',
365 'checkCascadingSourcesRestrictions',
366 'checkActionPermissions',
372 foreach ( $checks as $method ) {
373 $errors = $this->$method( $action, $user, $errors, $rigor, $short, $page );
375 if ( $short && $errors !== [] ) {
384 * Check various permission hooks
386 * @param string $action The action to check
387 * @param User $user User to check
388 * @param array $errors List of current errors
389 * @param string $rigor One of PermissionManager::RIGOR_ constants
390 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
391 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
392 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
393 * @param bool $short Short circuit on first error
395 * @param LinkTarget $page
397 * @return array List of errors
399 private function checkPermissionHooks(
407 // TODO: remove when LinkTarget usage will expand further
408 $title = Title
::newFromLinkTarget( $page );
409 // Use getUserPermissionsErrors instead
411 if ( !Hooks
::run( 'userCan', [ &$title, &$user, $action, &$result ] ) ) {
412 return $result ?
[] : [ [ 'badaccess-group0' ] ];
414 // Check getUserPermissionsErrors hook
415 if ( !Hooks
::run( 'getUserPermissionsErrors', [ &$title, &$user, $action, &$result ] ) ) {
416 $errors = $this->resultToError( $errors, $result );
418 // Check getUserPermissionsErrorsExpensive hook
420 $rigor !== self
::RIGOR_QUICK
421 && !( $short && count( $errors ) > 0 )
422 && !Hooks
::run( 'getUserPermissionsErrorsExpensive', [ &$title, &$user, $action, &$result ] )
424 $errors = $this->resultToError( $errors, $result );
431 * Add the resulting error code to the errors array
433 * @param array $errors List of current errors
434 * @param array|string|MessageSpecifier|false $result Result of errors
436 * @return array List of errors
438 private function resultToError( $errors, $result ) {
439 if ( is_array( $result ) && count( $result ) && !is_array( $result[0] ) ) {
440 // A single array representing an error
442 } elseif ( is_array( $result ) && is_array( $result[0] ) ) {
443 // A nested array representing multiple errors
444 $errors = array_merge( $errors, $result );
445 } elseif ( $result !== '' && is_string( $result ) ) {
446 // A string representing a message-id
447 $errors[] = [ $result ];
448 } elseif ( $result instanceof MessageSpecifier
) {
449 // A message specifier representing an error
450 $errors[] = [ $result ];
451 } elseif ( $result === false ) {
452 // a generic "We don't want them to do that"
453 $errors[] = [ 'badaccess-group0' ];
459 * Check that the user is allowed to read this page.
461 * @param string $action The action to check
462 * @param User $user User to check
463 * @param array $errors List of current errors
464 * @param string $rigor One of PermissionManager::RIGOR_ constants
465 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
466 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
467 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
468 * @param bool $short Short circuit on first error
470 * @param LinkTarget $page
472 * @return array List of errors
474 private function checkReadPermissions(
482 // TODO: remove when LinkTarget usage will expand further
483 $title = Title
::newFromLinkTarget( $page );
485 $whiteListRead = $this->options
->get( 'WhitelistRead' );
486 $whitelisted = false;
487 if ( $this->isEveryoneAllowed( 'read' ) ) {
488 # Shortcut for public wikis, allows skipping quite a bit of code
490 } elseif ( $this->userHasRight( $user, 'read' ) ) {
491 # If the user is allowed to read pages, he is allowed to read all pages
493 } elseif ( $this->isSameSpecialPage( 'Userlogin', $title )
494 ||
$this->isSameSpecialPage( 'PasswordReset', $title )
495 ||
$this->isSameSpecialPage( 'Userlogout', $title )
497 # Always grant access to the login page.
498 # Even anons need to be able to log in.
500 } elseif ( is_array( $whiteListRead ) && count( $whiteListRead ) ) {
501 # Time to check the whitelist
502 # Only do these checks is there's something to check against
503 $name = $title->getPrefixedText();
504 $dbName = $title->getPrefixedDBkey();
506 // Check for explicit whitelisting with and without underscores
507 if ( in_array( $name, $whiteListRead, true )
508 ||
in_array( $dbName, $whiteListRead, true ) ) {
510 } elseif ( $title->getNamespace() == NS_MAIN
) {
511 # Old settings might have the title prefixed with
512 # a colon for main-namespace pages
513 if ( in_array( ':' . $name, $whiteListRead ) ) {
516 } elseif ( $title->isSpecialPage() ) {
517 # If it's a special page, ditch the subpage bit and check again
518 $name = $title->getDBkey();
519 list( $name, /* $subpage */ ) =
520 $this->specialPageFactory
->resolveAlias( $name );
522 $pure = SpecialPage
::getTitleFor( $name )->getPrefixedText();
523 if ( in_array( $pure, $whiteListRead, true ) ) {
530 $whitelistReadRegexp = $this->options
->get( 'WhitelistReadRegexp' );
531 if ( !$whitelisted && is_array( $whitelistReadRegexp )
532 && !empty( $whitelistReadRegexp ) ) {
533 $name = $title->getPrefixedText();
534 // Check for regex whitelisting
535 foreach ( $whitelistReadRegexp as $listItem ) {
536 if ( preg_match( $listItem, $name ) ) {
543 if ( !$whitelisted ) {
544 # If the title is not whitelisted, give extensions a chance to do so...
545 Hooks
::run( 'TitleReadWhitelist', [ $title, $user, &$whitelisted ] );
546 if ( !$whitelisted ) {
547 $errors[] = $this->missingPermissionError( $action, $short );
555 * Get a description array when the user doesn't have the right to perform
556 * $action (i.e. when User::isAllowed() returns false)
558 * @param string $action The action to check
559 * @param bool $short Short circuit on first error
560 * @return array Array containing an error message key and any parameters
562 private function missingPermissionError( $action, $short ) {
563 // We avoid expensive display logic for quickUserCan's and such
565 return [ 'badaccess-group0' ];
568 // TODO: it would be a good idea to replace the method below with something else like
569 // maybe callback injection
570 return User
::newFatalPermissionDeniedStatus( $action )->getErrorsArray()[0];
574 * Returns true if this title resolves to the named special page
576 * @param string $name The special page name
577 * @param LinkTarget $page
581 private function isSameSpecialPage( $name, LinkTarget
$page ) {
582 if ( $page->getNamespace() == NS_SPECIAL
) {
583 list( $thisName, /* $subpage */ ) =
584 $this->specialPageFactory
->resolveAlias( $page->getDBkey() );
585 if ( $name == $thisName ) {
593 * Check that the user isn't blocked from editing.
595 * @param string $action The action to check
596 * @param User $user User to check
597 * @param array $errors List of current errors
598 * @param string $rigor One of PermissionManager::RIGOR_ constants
599 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
600 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
601 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
602 * @param bool $short Short circuit on first error
604 * @param LinkTarget $page
606 * @return array List of errors
608 private function checkUserBlock(
616 // Account creation blocks handled at userlogin.
617 // Unblocking handled in SpecialUnblock
618 if ( $rigor === self
::RIGOR_QUICK ||
in_array( $action, [ 'createaccount', 'unblock' ] ) ) {
622 // Optimize for a very common case
623 if ( $action === 'read' && !$this->options
->get( 'BlockDisablesLogin' ) ) {
627 if ( $this->options
->get( 'EmailConfirmToEdit' )
628 && !$user->isEmailConfirmed()
629 && $action === 'edit'
631 $errors[] = [ 'confirmedittext' ];
634 $useReplica = ( $rigor !== self
::RIGOR_SECURE
);
635 $block = $user->getBlock( $useReplica );
637 // If the user does not have a block, or the block they do have explicitly
638 // allows the action (like "read" or "upload").
639 if ( !$block ||
$block->appliesToRight( $action ) === false ) {
643 // Determine if the user is blocked from this action on this page.
644 // What gets passed into this method is a user right, not an action name.
645 // There is no way to instantiate an action by restriction. However, this
646 // will get the action where the restriction is the same. This may result
647 // in actions being blocked that shouldn't be.
649 if ( Action
::exists( $action ) ) {
650 // TODO: this drags a ton of dependencies in, would be good to avoid WikiPage
651 // instantiation and decouple it creating an ActionPermissionChecker interface
652 $wikiPage = WikiPage
::factory( Title
::newFromLinkTarget( $page, 'clone' ) );
653 // Creating an action will perform several database queries to ensure that
654 // the action has not been overridden by the content type.
655 // FIXME: avoid use of RequestContext since it drags in User and Title dependencies
656 // probably we may use fake context object since it's unlikely that Action uses it
657 // anyway. It would be nice if we could avoid instantiating the Action at all.
658 $actionObj = Action
::factory( $action, $wikiPage, RequestContext
::getMain() );
659 // Ensure that the retrieved action matches the restriction.
660 if ( $actionObj && $actionObj->getRestriction() !== $action ) {
665 // If no action object is returned, assume that the action requires unblock
666 // which is the default.
667 if ( !$actionObj ||
$actionObj->requiresUnblock() ) {
668 if ( $this->isBlockedFrom( $user, $page, $useReplica ) ) {
669 // @todo FIXME: Pass the relevant context into this function.
670 $errors[] = $block->getPermissionsError( RequestContext
::getMain() );
678 * Permissions checks that fail most often, and which are easiest to test.
680 * @param string $action The action to check
681 * @param User $user User to check
682 * @param array $errors List of current errors
683 * @param string $rigor One of PermissionManager::RIGOR_ constants
684 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
685 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
686 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
687 * @param bool $short Short circuit on first error
689 * @param LinkTarget $page
691 * @return array List of errors
693 private function checkQuickPermissions(
701 // TODO: remove when LinkTarget usage will expand further
702 $title = Title
::newFromLinkTarget( $page );
704 if ( !Hooks
::run( 'TitleQuickPermissions',
705 [ $title, $user, $action, &$errors, ( $rigor !== self
::RIGOR_QUICK
), $short ] )
710 $isSubPage = $this->nsInfo
->hasSubpages( $title->getNamespace() ) ?
711 strpos( $title->getText(), '/' ) !== false : false;
713 if ( $action == 'create' ) {
715 ( $this->nsInfo
->isTalk( $title->getNamespace() ) &&
716 !$this->userHasRight( $user, 'createtalk' ) ) ||
717 ( !$this->nsInfo
->isTalk( $title->getNamespace() ) &&
718 !$this->userHasRight( $user, 'createpage' ) )
720 $errors[] = $user->isAnon() ?
[ 'nocreatetext' ] : [ 'nocreate-loggedin' ];
722 } elseif ( $action == 'move' ) {
723 if ( !$this->userHasRight( $user, 'move-rootuserpages' )
724 && $title->getNamespace() == NS_USER
&& !$isSubPage ) {
725 // Show user page-specific message only if the user can move other pages
726 $errors[] = [ 'cant-move-user-page' ];
729 // Check if user is allowed to move files if it's a file
730 if ( $title->getNamespace() == NS_FILE
&&
731 !$this->userHasRight( $user, 'movefile' ) ) {
732 $errors[] = [ 'movenotallowedfile' ];
735 // Check if user is allowed to move category pages if it's a category page
736 if ( $title->getNamespace() == NS_CATEGORY
&&
737 !$this->userHasRight( $user, 'move-categorypages' ) ) {
738 $errors[] = [ 'cant-move-category-page' ];
741 if ( !$this->userHasRight( $user, 'move' ) ) {
742 // User can't move anything
743 $userCanMove = $this->groupHasPermission( 'user', 'move' );
744 $autoconfirmedCanMove = $this->groupHasPermission( 'autoconfirmed', 'move' );
745 if ( $user->isAnon() && ( $userCanMove ||
$autoconfirmedCanMove ) ) {
746 // custom message if logged-in users without any special rights can move
747 $errors[] = [ 'movenologintext' ];
749 $errors[] = [ 'movenotallowed' ];
752 } elseif ( $action == 'move-target' ) {
753 if ( !$this->userHasRight( $user, 'move' ) ) {
754 // User can't move anything
755 $errors[] = [ 'movenotallowed' ];
756 } elseif ( !$this->userHasRight( $user, 'move-rootuserpages' )
757 && $title->getNamespace() == NS_USER
&& !$isSubPage ) {
758 // Show user page-specific message only if the user can move other pages
759 $errors[] = [ 'cant-move-to-user-page' ];
760 } elseif ( !$this->userHasRight( $user, 'move-categorypages' )
761 && $title->getNamespace() == NS_CATEGORY
) {
762 // Show category page-specific message only if the user can move other pages
763 $errors[] = [ 'cant-move-to-category-page' ];
765 } elseif ( !$this->userHasRight( $user, $action ) ) {
766 $errors[] = $this->missingPermissionError( $action, $short );
773 * Check against page_restrictions table requirements on this
774 * page. The user must possess all required rights for this
777 * @param string $action The action to check
778 * @param User $user User to check
779 * @param array $errors List of current errors
780 * @param string $rigor One of PermissionManager::RIGOR_ constants
781 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
782 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
783 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
784 * @param bool $short Short circuit on first error
786 * @param LinkTarget $page
788 * @return array List of errors
790 private function checkPageRestrictions(
798 // TODO: remove & rework upon further use of LinkTarget
799 $title = Title
::newFromLinkTarget( $page );
800 foreach ( $title->getRestrictions( $action ) as $right ) {
801 // Backwards compatibility, rewrite sysop -> editprotected
802 if ( $right == 'sysop' ) {
803 $right = 'editprotected';
805 // Backwards compatibility, rewrite autoconfirmed -> editsemiprotected
806 if ( $right == 'autoconfirmed' ) {
807 $right = 'editsemiprotected';
809 if ( $right == '' ) {
812 if ( !$this->userHasRight( $user, $right ) ) {
813 $errors[] = [ 'protectedpagetext', $right, $action ];
814 } elseif ( $title->areRestrictionsCascading() &&
815 !$this->userHasRight( $user, 'protect' ) ) {
816 $errors[] = [ 'protectedpagetext', 'protect', $action ];
824 * Check restrictions on cascading pages.
826 * @param string $action The action to check
827 * @param User $user User to check
828 * @param array $errors List of current errors
829 * @param string $rigor One of PermissionManager::RIGOR_ constants
830 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
831 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
832 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
833 * @param bool $short Short circuit on first error
835 * @param LinkTarget $page
837 * @return array List of errors
839 private function checkCascadingSourcesRestrictions(
847 // TODO: remove & rework upon further use of LinkTarget
848 $title = Title
::newFromLinkTarget( $page );
849 if ( $rigor !== self
::RIGOR_QUICK
&& !$title->isUserConfigPage() ) {
850 # We /could/ use the protection level on the source page, but it's
851 # fairly ugly as we have to establish a precedence hierarchy for pages
852 # included by multiple cascade-protected pages. So just restrict
853 # it to people with 'protect' permission, as they could remove the
855 list( $cascadingSources, $restrictions ) = $title->getCascadeProtectionSources();
856 # Cascading protection depends on more than this page...
857 # Several cascading protected pages may include this page...
858 # Check each cascading level
859 # This is only for protection restrictions, not for all actions
860 if ( isset( $restrictions[$action] ) ) {
861 foreach ( $restrictions[$action] as $right ) {
862 // Backwards compatibility, rewrite sysop -> editprotected
863 if ( $right == 'sysop' ) {
864 $right = 'editprotected';
866 // Backwards compatibility, rewrite autoconfirmed -> editsemiprotected
867 if ( $right == 'autoconfirmed' ) {
868 $right = 'editsemiprotected';
870 if ( $right != '' && !$user->isAllowedAll( 'protect', $right ) ) {
872 /** @var Title $wikiPage */
873 foreach ( $cascadingSources as $wikiPage ) {
874 $wikiPages .= '* [[:' . $wikiPage->getPrefixedText() . "]]\n";
876 $errors[] = [ 'cascadeprotected', count( $cascadingSources ), $wikiPages, $action ];
886 * Check action permissions not already checked in checkQuickPermissions
888 * @param string $action The action to check
889 * @param User $user User to check
890 * @param array $errors List of current errors
891 * @param string $rigor One of PermissionManager::RIGOR_ constants
892 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
893 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
894 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
895 * @param bool $short Short circuit on first error
897 * @param LinkTarget $page
899 * @return array List of errors
901 private function checkActionPermissions(
909 global $wgDeleteRevisionsLimit, $wgLang;
911 // TODO: remove & rework upon further use of LinkTarget
912 $title = Title
::newFromLinkTarget( $page );
914 if ( $action == 'protect' ) {
915 if ( count( $this->getPermissionErrorsInternal( 'edit', $user, $title, $rigor, true ) ) ) {
916 // If they can't edit, they shouldn't protect.
917 $errors[] = [ 'protect-cantedit' ];
919 } elseif ( $action == 'create' ) {
920 $title_protection = $title->getTitleProtection();
921 if ( $title_protection ) {
922 if ( $title_protection['permission'] == ''
923 ||
!$this->userHasRight( $user, $title_protection['permission'] )
927 // TODO: get rid of the User dependency
928 User
::whoIs( $title_protection['user'] ),
929 $title_protection['reason']
933 } elseif ( $action == 'move' ) {
934 // Check for immobile pages
935 if ( !$this->nsInfo
->isMovable( $title->getNamespace() ) ) {
936 // Specific message for this case
937 $errors[] = [ 'immobile-source-namespace', $title->getNsText() ];
938 } elseif ( !$title->isMovable() ) {
939 // Less specific message for rarer cases
940 $errors[] = [ 'immobile-source-page' ];
942 } elseif ( $action == 'move-target' ) {
943 if ( !$this->nsInfo
->isMovable( $title->getNamespace() ) ) {
944 $errors[] = [ 'immobile-target-namespace', $title->getNsText() ];
945 } elseif ( !$title->isMovable() ) {
946 $errors[] = [ 'immobile-target-page' ];
948 } elseif ( $action == 'delete' ) {
949 $tempErrors = $this->checkPageRestrictions( 'edit', $user, [], $rigor, true, $title );
950 if ( !$tempErrors ) {
951 $tempErrors = $this->checkCascadingSourcesRestrictions( 'edit',
952 $user, $tempErrors, $rigor, true, $title );
955 // If protection keeps them from editing, they shouldn't be able to delete.
956 $errors[] = [ 'deleteprotected' ];
958 if ( $rigor !== self
::RIGOR_QUICK
&& $wgDeleteRevisionsLimit
959 && !$this->userCan( 'bigdelete', $user, $title ) && $title->isBigDeletion()
961 $errors[] = [ 'delete-toobig', $wgLang->formatNum( $wgDeleteRevisionsLimit ) ];
963 } elseif ( $action === 'undelete' ) {
964 if ( count( $this->getPermissionErrorsInternal( 'edit', $user, $title, $rigor, true ) ) ) {
965 // Undeleting implies editing
966 $errors[] = [ 'undelete-cantedit' ];
968 if ( !$title->exists()
969 && count( $this->getPermissionErrorsInternal( 'create', $user, $title, $rigor, true ) )
971 // Undeleting where nothing currently exists implies creating
972 $errors[] = [ 'undelete-cantcreate' ];
979 * Check permissions on special pages & namespaces
981 * @param string $action The action to check
982 * @param User $user User to check
983 * @param array $errors List of current errors
984 * @param string $rigor One of PermissionManager::RIGOR_ constants
985 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
986 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
987 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
988 * @param bool $short Short circuit on first error
990 * @param LinkTarget $page
992 * @return array List of errors
994 private function checkSpecialsAndNSPermissions(
1002 // TODO: remove & rework upon further use of LinkTarget
1003 $title = Title
::newFromLinkTarget( $page );
1005 # Only 'createaccount' can be performed on special pages,
1006 # which don't actually exist in the DB.
1007 if ( $title->getNamespace() == NS_SPECIAL
&& $action !== 'createaccount' ) {
1008 $errors[] = [ 'ns-specialprotected' ];
1011 # Check $wgNamespaceProtection for restricted namespaces
1012 if ( $title->isNamespaceProtected( $user ) ) {
1013 $ns = $title->getNamespace() == NS_MAIN ?
1014 wfMessage( 'nstab-main' )->text() : $title->getNsText();
1015 $errors[] = $title->getNamespace() == NS_MEDIAWIKI ?
1016 [ 'protectedinterface', $action ] : [ 'namespaceprotected', $ns, $action ];
1023 * Check sitewide CSS/JSON/JS permissions
1025 * @param string $action The action to check
1026 * @param User $user User to check
1027 * @param array $errors List of current errors
1028 * @param string $rigor One of PermissionManager::RIGOR_ constants
1029 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
1030 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
1031 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
1032 * @param bool $short Short circuit on first error
1034 * @param LinkTarget $page
1036 * @return array List of errors
1038 private function checkSiteConfigPermissions(
1046 // TODO: remove & rework upon further use of LinkTarget
1047 $title = Title
::newFromLinkTarget( $page );
1049 if ( $action != 'patrol' ) {
1051 // Sitewide CSS/JSON/JS changes, like all NS_MEDIAWIKI changes, also require the
1052 // editinterface right. That's implemented as a restriction so no check needed here.
1053 if ( $title->isSiteCssConfigPage() && !$this->userHasRight( $user, 'editsitecss' ) ) {
1054 $error = [ 'sitecssprotected', $action ];
1055 } elseif ( $title->isSiteJsonConfigPage() && !$this->userHasRight( $user, 'editsitejson' ) ) {
1056 $error = [ 'sitejsonprotected', $action ];
1057 } elseif ( $title->isSiteJsConfigPage() && !$this->userHasRight( $user, 'editsitejs' ) ) {
1058 $error = [ 'sitejsprotected', $action ];
1059 } elseif ( $title->isRawHtmlMessage() ) {
1060 // Raw HTML can be used to deploy CSS or JS so require rights for both.
1061 if ( !$this->userHasRight( $user, 'editsitejs' ) ) {
1062 $error = [ 'sitejsprotected', $action ];
1063 } elseif ( !$this->userHasRight( $user, 'editsitecss' ) ) {
1064 $error = [ 'sitecssprotected', $action ];
1069 if ( $this->userHasRight( $user, 'editinterface' ) ) {
1070 // Most users / site admins will probably find out about the new, more restrictive
1071 // permissions by failing to edit something. Give them more info.
1072 // TODO remove this a few release cycles after 1.32
1073 $error = [ 'interfaceadmin-info', wfMessage( $error[0], $error[1] ) ];
1083 * Check CSS/JSON/JS sub-page permissions
1085 * @param string $action The action to check
1086 * @param User $user User to check
1087 * @param array $errors List of current errors
1088 * @param string $rigor One of PermissionManager::RIGOR_ constants
1089 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
1090 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
1091 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
1092 * @param bool $short Short circuit on first error
1094 * @param LinkTarget $page
1096 * @return array List of errors
1098 private function checkUserConfigPermissions(
1106 // TODO: remove & rework upon further use of LinkTarget
1107 $title = Title
::newFromLinkTarget( $page );
1109 # Protect css/json/js subpages of user pages
1110 # XXX: this might be better using restrictions
1112 if ( $action === 'patrol' ) {
1116 if ( preg_match( '/^' . preg_quote( $user->getName(), '/' ) . '\//', $title->getText() ) ) {
1117 // Users need editmyuser* to edit their own CSS/JSON/JS subpages.
1119 $title->isUserCssConfigPage()
1120 && !$user->isAllowedAny( 'editmyusercss', 'editusercss' )
1122 $errors[] = [ 'mycustomcssprotected', $action ];
1124 $title->isUserJsonConfigPage()
1125 && !$user->isAllowedAny( 'editmyuserjson', 'edituserjson' )
1127 $errors[] = [ 'mycustomjsonprotected', $action ];
1129 $title->isUserJsConfigPage()
1130 && !$user->isAllowedAny( 'editmyuserjs', 'edituserjs' )
1132 $errors[] = [ 'mycustomjsprotected', $action ];
1134 $title->isUserJsConfigPage()
1135 && !$user->isAllowedAny( 'edituserjs', 'editmyuserjsredirect' )
1137 // T207750 - do not allow users to edit a redirect if they couldn't edit the target
1138 $rev = $this->revisionLookup
->getRevisionByTitle( $title );
1139 $content = $rev ?
$rev->getContent( 'main', RevisionRecord
::RAW
) : null;
1140 $target = $content ?
$content->getUltimateRedirectTarget() : null;
1142 !$target->inNamespace( NS_USER
)
1143 ||
!preg_match( '/^' . preg_quote( $user->getName(), '/' ) . '\//', $target->getText() )
1145 $errors[] = [ 'mycustomjsredirectprotected', $action ];
1149 // Users need editmyuser* to edit their own CSS/JSON/JS subpages, except for
1150 // deletion/suppression which cannot be used for attacks and we want to avoid the
1151 // situation where an unprivileged user can post abusive content on their subpages
1152 // and only very highly privileged users could remove it.
1153 if ( !in_array( $action, [ 'delete', 'deleterevision', 'suppressrevision' ], true ) ) {
1155 $title->isUserCssConfigPage()
1156 && !$this->userHasRight( $user, 'editusercss' )
1158 $errors[] = [ 'customcssprotected', $action ];
1160 $title->isUserJsonConfigPage()
1161 && !$this->userHasRight( $user, 'edituserjson' )
1163 $errors[] = [ 'customjsonprotected', $action ];
1165 $title->isUserJsConfigPage()
1166 && !$this->userHasRight( $user, 'edituserjs' )
1168 $errors[] = [ 'customjsprotected', $action ];
1177 * Testing a permission
1181 * @param UserIdentity $user
1182 * @param string $action
1186 public function userHasRight( UserIdentity
$user, $action = '' ) {
1187 if ( $action === '' ) {
1188 return true; // In the spirit of DWIM
1190 // Use strict parameter to avoid matching numeric 0 accidentally inserted
1191 // by misconfiguration: 0 == 'foo'
1192 return in_array( $action, $this->getUserPermissions( $user ), true );
1196 * Get the permissions this user has.
1200 * @param UserIdentity $user
1202 * @return string[] permission names
1204 public function getUserPermissions( UserIdentity
$user ) {
1205 $user = User
::newFromIdentity( $user );
1206 if ( !isset( $this->usersRights
[ $user->getId() ] ) ) {
1207 $this->usersRights
[ $user->getId() ] = $this->getGroupPermissions(
1208 $user->getEffectiveGroups()
1210 Hooks
::run( 'UserGetRights', [ $user, &$this->usersRights
[ $user->getId() ] ] );
1212 // Deny any rights denied by the user's session, unless this
1213 // endpoint has no sessions.
1214 if ( !defined( 'MW_NO_SESSION' ) ) {
1215 // FIXME: $user->getRequest().. need to be replaced with something else
1216 $allowedRights = $user->getRequest()->getSession()->getAllowedUserRights();
1217 if ( $allowedRights !== null ) {
1218 $this->usersRights
[ $user->getId() ] = array_intersect(
1219 $this->usersRights
[ $user->getId() ],
1225 Hooks
::run( 'UserGetRightsRemove', [ $user, &$this->usersRights
[ $user->getId() ] ] );
1226 // Force reindexation of rights when a hook has unset one of them
1227 $this->usersRights
[ $user->getId() ] = array_values(
1228 array_unique( $this->usersRights
[ $user->getId() ] )
1232 $user->isLoggedIn() &&
1233 $this->options
->get( 'BlockDisablesLogin' ) &&
1237 $this->usersRights
[ $user->getId() ] = array_intersect(
1238 $this->usersRights
[ $user->getId() ],
1239 $this->getUserPermissions( $anon )
1243 $rights = $this->usersRights
[ $user->getId() ];
1244 foreach ( $this->temporaryUserRights
[ $user->getId() ] ??
[] as $overrides ) {
1245 $rights = array_values( array_unique( array_merge( $rights, $overrides ) ) );
1251 * Clears users permissions cache, if specific user is provided it tries to clear
1252 * permissions cache only for provided user.
1256 * @param User|null $user
1258 public function invalidateUsersRightsCache( $user = null ) {
1259 if ( $user !== null ) {
1260 if ( isset( $this->usersRights
[ $user->getId() ] ) ) {
1261 unset( $this->usersRights
[$user->getId()] );
1264 $this->usersRights
= null;
1269 * Check, if the given group has the given permission
1271 * If you're wanting to check whether all users have a permission, use
1272 * PermissionManager::isEveryoneAllowed() instead. That properly checks if it's revoked
1277 * @param string $group Group to check
1278 * @param string $role Role to check
1282 public function groupHasPermission( $group, $role ) {
1283 $groupPermissions = $this->options
->get( 'GroupPermissions' );
1284 $revokePermissions = $this->options
->get( 'RevokePermissions' );
1285 return isset( $groupPermissions[$group][$role] ) && $groupPermissions[$group][$role] &&
1286 !( isset( $revokePermissions[$group][$role] ) && $revokePermissions[$group][$role] );
1290 * Get the permissions associated with a given list of groups
1294 * @param array $groups Array of Strings List of internal group names
1295 * @return array Array of Strings List of permission key names for given groups combined
1297 public function getGroupPermissions( $groups ) {
1299 // grant every granted permission first
1300 foreach ( $groups as $group ) {
1301 if ( isset( $this->options
->get( 'GroupPermissions' )[$group] ) ) {
1302 $rights = array_merge( $rights,
1303 // array_filter removes empty items
1304 array_keys( array_filter( $this->options
->get( 'GroupPermissions' )[$group] ) ) );
1307 // now revoke the revoked permissions
1308 foreach ( $groups as $group ) {
1309 if ( isset( $this->options
->get( 'RevokePermissions' )[$group] ) ) {
1310 $rights = array_diff( $rights,
1311 array_keys( array_filter( $this->options
->get( 'RevokePermissions' )[$group] ) ) );
1314 return array_unique( $rights );
1318 * Get all the groups who have a given permission
1322 * @param string $role Role to check
1323 * @return array Array of Strings List of internal group names with the given permission
1325 public function getGroupsWithPermission( $role ) {
1326 $allowedGroups = [];
1327 foreach ( array_keys( $this->options
->get( 'GroupPermissions' ) ) as $group ) {
1328 if ( $this->groupHasPermission( $group, $role ) ) {
1329 $allowedGroups[] = $group;
1332 return $allowedGroups;
1336 * Check if all users may be assumed to have the given permission
1338 * We generally assume so if the right is granted to '*' and isn't revoked
1339 * on any group. It doesn't attempt to take grants or other extension
1340 * limitations on rights into account in the general case, though, as that
1341 * would require it to always return false and defeat the purpose.
1342 * Specifically, session-based rights restrictions (such as OAuth or bot
1343 * passwords) are applied based on the current session.
1345 * @param string $right Right to check
1350 public function isEveryoneAllowed( $right ) {
1351 // Use the cached results, except in unit tests which rely on
1352 // being able change the permission mid-request
1353 if ( isset( $this->cachedRights
[$right] ) ) {
1354 return $this->cachedRights
[$right];
1357 if ( !isset( $this->options
->get( 'GroupPermissions' )['*'][$right] )
1358 ||
!$this->options
->get( 'GroupPermissions' )['*'][$right] ) {
1359 $this->cachedRights
[$right] = false;
1363 // If it's revoked anywhere, then everyone doesn't have it
1364 foreach ( $this->options
->get( 'RevokePermissions' ) as $rights ) {
1365 if ( isset( $rights[$right] ) && $rights[$right] ) {
1366 $this->cachedRights
[$right] = false;
1371 // Remove any rights that aren't allowed to the global-session user,
1372 // unless there are no sessions for this endpoint.
1373 if ( !defined( 'MW_NO_SESSION' ) ) {
1375 // XXX: think what could be done with the below
1376 $allowedRights = SessionManager
::getGlobalSession()->getAllowedUserRights();
1377 if ( $allowedRights !== null && !in_array( $right, $allowedRights, true ) ) {
1378 $this->cachedRights
[$right] = false;
1383 // Allow extensions to say false
1384 if ( !Hooks
::run( 'UserIsEveryoneAllowed', [ $right ] ) ) {
1385 $this->cachedRights
[$right] = false;
1389 $this->cachedRights
[$right] = true;
1394 * Get a list of all available permissions.
1398 * @return string[] Array of permission names
1400 public function getAllPermissions() {
1401 if ( $this->allRights
=== false ) {
1402 if ( count( $this->options
->get( 'AvailableRights' ) ) ) {
1403 $this->allRights
= array_unique( array_merge(
1405 $this->options
->get( 'AvailableRights' )
1408 $this->allRights
= $this->coreRights
;
1410 Hooks
::run( 'UserGetAllRights', [ &$this->allRights
] );
1412 return $this->allRights
;
1416 * Add temporary user rights, only valid for the current scope.
1417 * This is meant for making it possible to programatically trigger certain actions that
1418 * the user wouldn't be able to trigger themselves; e.g. allow users without the bot right
1419 * to make bot-flagged actions through certain special pages.
1420 * Returns a "scope guard" variable; whenever that variable goes out of scope or is consumed
1421 * via ScopedCallback::consume(), the temporary rights are revoked.
1425 * @param UserIdentity $user
1426 * @param string|string[] $rights
1427 * @return ScopedCallback
1429 public function addTemporaryUserRights( UserIdentity
$user, $rights ) {
1430 $userId = $user->getId();
1431 $nextKey = count( $this->temporaryUserRights
[$userId] ??
[] );
1432 $this->temporaryUserRights
[$userId][$nextKey] = (array)$rights;
1433 return new ScopedCallback( function () use ( $userId, $nextKey ) {
1434 unset( $this->temporaryUserRights
[$userId][$nextKey] );
1439 * Overrides user permissions cache
1444 * @param string[]|string $rights
1448 public function overrideUserRightsForTesting( $user, $rights = [] ) {
1449 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
1450 throw new Exception( __METHOD__
. ' can not be called outside of tests' );
1452 $this->usersRights
[ $user->getId() ] = is_array( $rights ) ?
$rights : [ $rights ];