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. If the user does not
275 * have a block, this will return false.
278 * @param LinkTarget $page Title to check
279 * @param bool $fromReplica Whether to check the replica DB instead of the master
283 public function isBlockedFrom( User
$user, LinkTarget
$page, $fromReplica = false ) {
284 $block = $user->getBlock( $fromReplica );
289 // TODO: remove upon further migration to LinkTarget
290 $title = Title
::newFromLinkTarget( $page );
292 $blocked = $user->isHidden();
294 // Special handling for a user's own talk page. The block is not aware
295 // of the user, so this must be done here.
296 if ( $title->equals( $user->getTalkPage() ) ) {
297 $blocked = $block->appliesToUsertalk( $title );
299 $blocked = $block->appliesToTitle( $title );
303 // only for the purpose of the hook. We really don't need this here.
304 $allowUsertalk = $user->isAllowUsertalk();
306 // Allow extensions to let a blocked user access a particular page
307 Hooks
::run( 'UserIsBlockedFrom', [ $user, $title, &$blocked, &$allowUsertalk ] );
313 * Can $user perform $action on a page? This is an internal function,
314 * with multiple levels of checks depending on performance needs; see $rigor below.
315 * It does not check wfReadOnly().
317 * @param string $action Action that permission needs to be checked for
318 * @param User $user User to check
319 * @param LinkTarget $page
320 * @param string $rigor One of PermissionManager::RIGOR_ constants
321 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
322 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
323 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
324 * @param bool $short Set this to true to stop after the first permission error.
326 * @return array Array of arrays of the arguments to wfMessage to explain permissions problems.
329 private function getPermissionErrorsInternal(
333 $rigor = self
::RIGOR_SECURE
,
336 if ( !in_array( $rigor, [ self
::RIGOR_QUICK
, self
::RIGOR_FULL
, self
::RIGOR_SECURE
] ) ) {
337 throw new Exception( "Invalid rigor parameter '$rigor'." );
340 # Read has special handling
341 if ( $action == 'read' ) {
343 'checkPermissionHooks',
344 'checkReadPermissions',
345 'checkUserBlock', // for wgBlockDisablesLogin
347 # Don't call checkSpecialsAndNSPermissions, checkSiteConfigPermissions
348 # or checkUserConfigPermissions here as it will lead to duplicate
349 # error messages. This is okay to do since anywhere that checks for
350 # create will also check for edit, and those checks are called for edit.
351 } elseif ( $action == 'create' ) {
353 'checkQuickPermissions',
354 'checkPermissionHooks',
355 'checkPageRestrictions',
356 'checkCascadingSourcesRestrictions',
357 'checkActionPermissions',
362 'checkQuickPermissions',
363 'checkPermissionHooks',
364 'checkSpecialsAndNSPermissions',
365 'checkSiteConfigPermissions',
366 'checkUserConfigPermissions',
367 'checkPageRestrictions',
368 'checkCascadingSourcesRestrictions',
369 'checkActionPermissions',
375 foreach ( $checks as $method ) {
376 $errors = $this->$method( $action, $user, $errors, $rigor, $short, $page );
378 if ( $short && $errors !== [] ) {
387 * Check various permission hooks
389 * @param string $action The action to check
390 * @param User $user User to check
391 * @param array $errors List of current errors
392 * @param string $rigor One of PermissionManager::RIGOR_ constants
393 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
394 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
395 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
396 * @param bool $short Short circuit on first error
398 * @param LinkTarget $page
400 * @return array List of errors
402 private function checkPermissionHooks(
410 // TODO: remove when LinkTarget usage will expand further
411 $title = Title
::newFromLinkTarget( $page );
412 // Use getUserPermissionsErrors instead
414 if ( !Hooks
::run( 'userCan', [ &$title, &$user, $action, &$result ] ) ) {
415 return $result ?
[] : [ [ 'badaccess-group0' ] ];
417 // Check getUserPermissionsErrors hook
418 if ( !Hooks
::run( 'getUserPermissionsErrors', [ &$title, &$user, $action, &$result ] ) ) {
419 $errors = $this->resultToError( $errors, $result );
421 // Check getUserPermissionsErrorsExpensive hook
423 $rigor !== self
::RIGOR_QUICK
424 && !( $short && count( $errors ) > 0 )
425 && !Hooks
::run( 'getUserPermissionsErrorsExpensive', [ &$title, &$user, $action, &$result ] )
427 $errors = $this->resultToError( $errors, $result );
434 * Add the resulting error code to the errors array
436 * @param array $errors List of current errors
437 * @param array|string|MessageSpecifier|false $result Result of errors
439 * @return array List of errors
441 private function resultToError( $errors, $result ) {
442 if ( is_array( $result ) && count( $result ) && !is_array( $result[0] ) ) {
443 // A single array representing an error
445 } elseif ( is_array( $result ) && is_array( $result[0] ) ) {
446 // A nested array representing multiple errors
447 $errors = array_merge( $errors, $result );
448 } elseif ( $result !== '' && is_string( $result ) ) {
449 // A string representing a message-id
450 $errors[] = [ $result ];
451 } elseif ( $result instanceof MessageSpecifier
) {
452 // A message specifier representing an error
453 $errors[] = [ $result ];
454 } elseif ( $result === false ) {
455 // a generic "We don't want them to do that"
456 $errors[] = [ 'badaccess-group0' ];
462 * Check that the user is allowed to read this page.
464 * @param string $action The action to check
465 * @param User $user User to check
466 * @param array $errors List of current errors
467 * @param string $rigor One of PermissionManager::RIGOR_ constants
468 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
469 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
470 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
471 * @param bool $short Short circuit on first error
473 * @param LinkTarget $page
475 * @return array List of errors
477 private function checkReadPermissions(
485 // TODO: remove when LinkTarget usage will expand further
486 $title = Title
::newFromLinkTarget( $page );
488 $whiteListRead = $this->options
->get( 'WhitelistRead' );
489 $whitelisted = false;
490 if ( $this->isEveryoneAllowed( 'read' ) ) {
491 # Shortcut for public wikis, allows skipping quite a bit of code
493 } elseif ( $this->userHasRight( $user, 'read' ) ) {
494 # If the user is allowed to read pages, he is allowed to read all pages
496 } elseif ( $this->isSameSpecialPage( 'Userlogin', $title )
497 ||
$this->isSameSpecialPage( 'PasswordReset', $title )
498 ||
$this->isSameSpecialPage( 'Userlogout', $title )
500 # Always grant access to the login page.
501 # Even anons need to be able to log in.
503 } elseif ( is_array( $whiteListRead ) && count( $whiteListRead ) ) {
504 # Time to check the whitelist
505 # Only do these checks is there's something to check against
506 $name = $title->getPrefixedText();
507 $dbName = $title->getPrefixedDBkey();
509 // Check for explicit whitelisting with and without underscores
510 if ( in_array( $name, $whiteListRead, true )
511 ||
in_array( $dbName, $whiteListRead, true ) ) {
513 } elseif ( $title->getNamespace() == NS_MAIN
) {
514 # Old settings might have the title prefixed with
515 # a colon for main-namespace pages
516 if ( in_array( ':' . $name, $whiteListRead ) ) {
519 } elseif ( $title->isSpecialPage() ) {
520 # If it's a special page, ditch the subpage bit and check again
521 $name = $title->getDBkey();
522 list( $name, /* $subpage */ ) =
523 $this->specialPageFactory
->resolveAlias( $name );
525 $pure = SpecialPage
::getTitleFor( $name )->getPrefixedText();
526 if ( in_array( $pure, $whiteListRead, true ) ) {
533 $whitelistReadRegexp = $this->options
->get( 'WhitelistReadRegexp' );
534 if ( !$whitelisted && is_array( $whitelistReadRegexp )
535 && !empty( $whitelistReadRegexp ) ) {
536 $name = $title->getPrefixedText();
537 // Check for regex whitelisting
538 foreach ( $whitelistReadRegexp as $listItem ) {
539 if ( preg_match( $listItem, $name ) ) {
546 if ( !$whitelisted ) {
547 # If the title is not whitelisted, give extensions a chance to do so...
548 Hooks
::run( 'TitleReadWhitelist', [ $title, $user, &$whitelisted ] );
549 if ( !$whitelisted ) {
550 $errors[] = $this->missingPermissionError( $action, $short );
558 * Get a description array when the user doesn't have the right to perform
559 * $action (i.e. when User::isAllowed() returns false)
561 * @param string $action The action to check
562 * @param bool $short Short circuit on first error
563 * @return array Array containing an error message key and any parameters
565 private function missingPermissionError( $action, $short ) {
566 // We avoid expensive display logic for quickUserCan's and such
568 return [ 'badaccess-group0' ];
571 // TODO: it would be a good idea to replace the method below with something else like
572 // maybe callback injection
573 return User
::newFatalPermissionDeniedStatus( $action )->getErrorsArray()[0];
577 * Returns true if this title resolves to the named special page
579 * @param string $name The special page name
580 * @param LinkTarget $page
584 private function isSameSpecialPage( $name, LinkTarget
$page ) {
585 if ( $page->getNamespace() == NS_SPECIAL
) {
586 list( $thisName, /* $subpage */ ) =
587 $this->specialPageFactory
->resolveAlias( $page->getDBkey() );
588 if ( $name == $thisName ) {
596 * Check that the user isn't blocked from editing.
598 * @param string $action The action to check
599 * @param User $user User to check
600 * @param array $errors List of current errors
601 * @param string $rigor One of PermissionManager::RIGOR_ constants
602 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
603 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
604 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
605 * @param bool $short Short circuit on first error
607 * @param LinkTarget $page
609 * @return array List of errors
611 private function checkUserBlock(
619 // Account creation blocks handled at userlogin.
620 // Unblocking handled in SpecialUnblock
621 if ( $rigor === self
::RIGOR_QUICK ||
in_array( $action, [ 'createaccount', 'unblock' ] ) ) {
625 // Optimize for a very common case
626 if ( $action === 'read' && !$this->options
->get( 'BlockDisablesLogin' ) ) {
630 if ( $this->options
->get( 'EmailConfirmToEdit' )
631 && !$user->isEmailConfirmed()
632 && $action === 'edit'
634 $errors[] = [ 'confirmedittext' ];
637 $useReplica = ( $rigor !== self
::RIGOR_SECURE
);
638 $block = $user->getBlock( $useReplica );
640 // If the user does not have a block, or the block they do have explicitly
641 // allows the action (like "read" or "upload").
642 if ( !$block ||
$block->appliesToRight( $action ) === false ) {
646 // Determine if the user is blocked from this action on this page.
647 // What gets passed into this method is a user right, not an action name.
648 // There is no way to instantiate an action by restriction. However, this
649 // will get the action where the restriction is the same. This may result
650 // in actions being blocked that shouldn't be.
652 if ( Action
::exists( $action ) ) {
653 // TODO: this drags a ton of dependencies in, would be good to avoid WikiPage
654 // instantiation and decouple it creating an ActionPermissionChecker interface
655 $wikiPage = WikiPage
::factory( Title
::newFromLinkTarget( $page, 'clone' ) );
656 // Creating an action will perform several database queries to ensure that
657 // the action has not been overridden by the content type.
658 // FIXME: avoid use of RequestContext since it drags in User and Title dependencies
659 // probably we may use fake context object since it's unlikely that Action uses it
660 // anyway. It would be nice if we could avoid instantiating the Action at all.
661 $actionObj = Action
::factory( $action, $wikiPage, RequestContext
::getMain() );
662 // Ensure that the retrieved action matches the restriction.
663 if ( $actionObj && $actionObj->getRestriction() !== $action ) {
668 // If no action object is returned, assume that the action requires unblock
669 // which is the default.
670 if ( !$actionObj ||
$actionObj->requiresUnblock() ) {
671 if ( $this->isBlockedFrom( $user, $page, $useReplica ) ) {
672 // @todo FIXME: Pass the relevant context into this function.
673 $errors[] = $block->getPermissionsError( RequestContext
::getMain() );
681 * Permissions checks that fail most often, and which are easiest to test.
683 * @param string $action The action to check
684 * @param User $user User to check
685 * @param array $errors List of current errors
686 * @param string $rigor One of PermissionManager::RIGOR_ constants
687 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
688 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
689 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
690 * @param bool $short Short circuit on first error
692 * @param LinkTarget $page
694 * @return array List of errors
696 private function checkQuickPermissions(
704 // TODO: remove when LinkTarget usage will expand further
705 $title = Title
::newFromLinkTarget( $page );
707 if ( !Hooks
::run( 'TitleQuickPermissions',
708 [ $title, $user, $action, &$errors, ( $rigor !== self
::RIGOR_QUICK
), $short ] )
713 $isSubPage = $this->nsInfo
->hasSubpages( $title->getNamespace() ) ?
714 strpos( $title->getText(), '/' ) !== false : false;
716 if ( $action == 'create' ) {
718 ( $this->nsInfo
->isTalk( $title->getNamespace() ) &&
719 !$this->userHasRight( $user, 'createtalk' ) ) ||
720 ( !$this->nsInfo
->isTalk( $title->getNamespace() ) &&
721 !$this->userHasRight( $user, 'createpage' ) )
723 $errors[] = $user->isAnon() ?
[ 'nocreatetext' ] : [ 'nocreate-loggedin' ];
725 } elseif ( $action == 'move' ) {
726 if ( !$this->userHasRight( $user, 'move-rootuserpages' )
727 && $title->getNamespace() == NS_USER
&& !$isSubPage ) {
728 // Show user page-specific message only if the user can move other pages
729 $errors[] = [ 'cant-move-user-page' ];
732 // Check if user is allowed to move files if it's a file
733 if ( $title->getNamespace() == NS_FILE
&&
734 !$this->userHasRight( $user, 'movefile' ) ) {
735 $errors[] = [ 'movenotallowedfile' ];
738 // Check if user is allowed to move category pages if it's a category page
739 if ( $title->getNamespace() == NS_CATEGORY
&&
740 !$this->userHasRight( $user, 'move-categorypages' ) ) {
741 $errors[] = [ 'cant-move-category-page' ];
744 if ( !$this->userHasRight( $user, 'move' ) ) {
745 // User can't move anything
746 $userCanMove = $this->groupHasPermission( 'user', 'move' );
747 $autoconfirmedCanMove = $this->groupHasPermission( 'autoconfirmed', 'move' );
748 if ( $user->isAnon() && ( $userCanMove ||
$autoconfirmedCanMove ) ) {
749 // custom message if logged-in users without any special rights can move
750 $errors[] = [ 'movenologintext' ];
752 $errors[] = [ 'movenotallowed' ];
755 } elseif ( $action == 'move-target' ) {
756 if ( !$this->userHasRight( $user, 'move' ) ) {
757 // User can't move anything
758 $errors[] = [ 'movenotallowed' ];
759 } elseif ( !$this->userHasRight( $user, 'move-rootuserpages' )
760 && $title->getNamespace() == NS_USER
&& !$isSubPage ) {
761 // Show user page-specific message only if the user can move other pages
762 $errors[] = [ 'cant-move-to-user-page' ];
763 } elseif ( !$this->userHasRight( $user, 'move-categorypages' )
764 && $title->getNamespace() == NS_CATEGORY
) {
765 // Show category page-specific message only if the user can move other pages
766 $errors[] = [ 'cant-move-to-category-page' ];
768 } elseif ( !$this->userHasRight( $user, $action ) ) {
769 $errors[] = $this->missingPermissionError( $action, $short );
776 * Check against page_restrictions table requirements on this
777 * page. The user must possess all required rights for this
780 * @param string $action The action to check
781 * @param User $user User to check
782 * @param array $errors List of current errors
783 * @param string $rigor One of PermissionManager::RIGOR_ constants
784 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
785 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
786 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
787 * @param bool $short Short circuit on first error
789 * @param LinkTarget $page
791 * @return array List of errors
793 private function checkPageRestrictions(
801 // TODO: remove & rework upon further use of LinkTarget
802 $title = Title
::newFromLinkTarget( $page );
803 foreach ( $title->getRestrictions( $action ) as $right ) {
804 // Backwards compatibility, rewrite sysop -> editprotected
805 if ( $right == 'sysop' ) {
806 $right = 'editprotected';
808 // Backwards compatibility, rewrite autoconfirmed -> editsemiprotected
809 if ( $right == 'autoconfirmed' ) {
810 $right = 'editsemiprotected';
812 if ( $right == '' ) {
815 if ( !$this->userHasRight( $user, $right ) ) {
816 $errors[] = [ 'protectedpagetext', $right, $action ];
817 } elseif ( $title->areRestrictionsCascading() &&
818 !$this->userHasRight( $user, 'protect' ) ) {
819 $errors[] = [ 'protectedpagetext', 'protect', $action ];
827 * Check restrictions on cascading pages.
829 * @param string $action The action to check
830 * @param User $user User to check
831 * @param array $errors List of current errors
832 * @param string $rigor One of PermissionManager::RIGOR_ constants
833 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
834 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
835 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
836 * @param bool $short Short circuit on first error
838 * @param LinkTarget $page
840 * @return array List of errors
842 private function checkCascadingSourcesRestrictions(
850 // TODO: remove & rework upon further use of LinkTarget
851 $title = Title
::newFromLinkTarget( $page );
852 if ( $rigor !== self
::RIGOR_QUICK
&& !$title->isUserConfigPage() ) {
853 # We /could/ use the protection level on the source page, but it's
854 # fairly ugly as we have to establish a precedence hierarchy for pages
855 # included by multiple cascade-protected pages. So just restrict
856 # it to people with 'protect' permission, as they could remove the
858 list( $cascadingSources, $restrictions ) = $title->getCascadeProtectionSources();
859 # Cascading protection depends on more than this page...
860 # Several cascading protected pages may include this page...
861 # Check each cascading level
862 # This is only for protection restrictions, not for all actions
863 if ( isset( $restrictions[$action] ) ) {
864 foreach ( $restrictions[$action] as $right ) {
865 // Backwards compatibility, rewrite sysop -> editprotected
866 if ( $right == 'sysop' ) {
867 $right = 'editprotected';
869 // Backwards compatibility, rewrite autoconfirmed -> editsemiprotected
870 if ( $right == 'autoconfirmed' ) {
871 $right = 'editsemiprotected';
873 if ( $right != '' && !$user->isAllowedAll( 'protect', $right ) ) {
875 /** @var Title $wikiPage */
876 foreach ( $cascadingSources as $wikiPage ) {
877 $wikiPages .= '* [[:' . $wikiPage->getPrefixedText() . "]]\n";
879 $errors[] = [ 'cascadeprotected', count( $cascadingSources ), $wikiPages, $action ];
889 * Check action permissions not already checked in checkQuickPermissions
891 * @param string $action The action to check
892 * @param User $user User to check
893 * @param array $errors List of current errors
894 * @param string $rigor One of PermissionManager::RIGOR_ constants
895 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
896 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
897 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
898 * @param bool $short Short circuit on first error
900 * @param LinkTarget $page
902 * @return array List of errors
904 private function checkActionPermissions(
912 global $wgDeleteRevisionsLimit, $wgLang;
914 // TODO: remove & rework upon further use of LinkTarget
915 $title = Title
::newFromLinkTarget( $page );
917 if ( $action == 'protect' ) {
918 if ( count( $this->getPermissionErrorsInternal( 'edit', $user, $title, $rigor, true ) ) ) {
919 // If they can't edit, they shouldn't protect.
920 $errors[] = [ 'protect-cantedit' ];
922 } elseif ( $action == 'create' ) {
923 $title_protection = $title->getTitleProtection();
924 if ( $title_protection ) {
925 if ( $title_protection['permission'] == ''
926 ||
!$this->userHasRight( $user, $title_protection['permission'] )
930 // TODO: get rid of the User dependency
931 User
::whoIs( $title_protection['user'] ),
932 $title_protection['reason']
936 } elseif ( $action == 'move' ) {
937 // Check for immobile pages
938 if ( !$this->nsInfo
->isMovable( $title->getNamespace() ) ) {
939 // Specific message for this case
940 $errors[] = [ 'immobile-source-namespace', $title->getNsText() ];
941 } elseif ( !$title->isMovable() ) {
942 // Less specific message for rarer cases
943 $errors[] = [ 'immobile-source-page' ];
945 } elseif ( $action == 'move-target' ) {
946 if ( !$this->nsInfo
->isMovable( $title->getNamespace() ) ) {
947 $errors[] = [ 'immobile-target-namespace', $title->getNsText() ];
948 } elseif ( !$title->isMovable() ) {
949 $errors[] = [ 'immobile-target-page' ];
951 } elseif ( $action == 'delete' ) {
952 $tempErrors = $this->checkPageRestrictions( 'edit', $user, [], $rigor, true, $title );
953 if ( !$tempErrors ) {
954 $tempErrors = $this->checkCascadingSourcesRestrictions( 'edit',
955 $user, $tempErrors, $rigor, true, $title );
958 // If protection keeps them from editing, they shouldn't be able to delete.
959 $errors[] = [ 'deleteprotected' ];
961 if ( $rigor !== self
::RIGOR_QUICK
&& $wgDeleteRevisionsLimit
962 && !$this->userCan( 'bigdelete', $user, $title ) && $title->isBigDeletion()
964 $errors[] = [ 'delete-toobig', $wgLang->formatNum( $wgDeleteRevisionsLimit ) ];
966 } elseif ( $action === 'undelete' ) {
967 if ( count( $this->getPermissionErrorsInternal( 'edit', $user, $title, $rigor, true ) ) ) {
968 // Undeleting implies editing
969 $errors[] = [ 'undelete-cantedit' ];
971 if ( !$title->exists()
972 && count( $this->getPermissionErrorsInternal( 'create', $user, $title, $rigor, true ) )
974 // Undeleting where nothing currently exists implies creating
975 $errors[] = [ 'undelete-cantcreate' ];
982 * Check permissions on special pages & namespaces
984 * @param string $action The action to check
985 * @param User $user User to check
986 * @param array $errors List of current errors
987 * @param string $rigor One of PermissionManager::RIGOR_ constants
988 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
989 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
990 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
991 * @param bool $short Short circuit on first error
993 * @param LinkTarget $page
995 * @return array List of errors
997 private function checkSpecialsAndNSPermissions(
1005 // TODO: remove & rework upon further use of LinkTarget
1006 $title = Title
::newFromLinkTarget( $page );
1008 # Only 'createaccount' can be performed on special pages,
1009 # which don't actually exist in the DB.
1010 if ( $title->getNamespace() == NS_SPECIAL
&& $action !== 'createaccount' ) {
1011 $errors[] = [ 'ns-specialprotected' ];
1014 # Check $wgNamespaceProtection for restricted namespaces
1015 if ( $title->isNamespaceProtected( $user ) ) {
1016 $ns = $title->getNamespace() == NS_MAIN ?
1017 wfMessage( 'nstab-main' )->text() : $title->getNsText();
1018 $errors[] = $title->getNamespace() == NS_MEDIAWIKI ?
1019 [ 'protectedinterface', $action ] : [ 'namespaceprotected', $ns, $action ];
1026 * Check sitewide CSS/JSON/JS permissions
1028 * @param string $action The action to check
1029 * @param User $user User to check
1030 * @param array $errors List of current errors
1031 * @param string $rigor One of PermissionManager::RIGOR_ constants
1032 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
1033 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
1034 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
1035 * @param bool $short Short circuit on first error
1037 * @param LinkTarget $page
1039 * @return array List of errors
1041 private function checkSiteConfigPermissions(
1049 // TODO: remove & rework upon further use of LinkTarget
1050 $title = Title
::newFromLinkTarget( $page );
1052 if ( $action != 'patrol' ) {
1054 // Sitewide CSS/JSON/JS changes, like all NS_MEDIAWIKI changes, also require the
1055 // editinterface right. That's implemented as a restriction so no check needed here.
1056 if ( $title->isSiteCssConfigPage() && !$this->userHasRight( $user, 'editsitecss' ) ) {
1057 $error = [ 'sitecssprotected', $action ];
1058 } elseif ( $title->isSiteJsonConfigPage() && !$this->userHasRight( $user, 'editsitejson' ) ) {
1059 $error = [ 'sitejsonprotected', $action ];
1060 } elseif ( $title->isSiteJsConfigPage() && !$this->userHasRight( $user, 'editsitejs' ) ) {
1061 $error = [ 'sitejsprotected', $action ];
1062 } elseif ( $title->isRawHtmlMessage() ) {
1063 // Raw HTML can be used to deploy CSS or JS so require rights for both.
1064 if ( !$this->userHasRight( $user, 'editsitejs' ) ) {
1065 $error = [ 'sitejsprotected', $action ];
1066 } elseif ( !$this->userHasRight( $user, 'editsitecss' ) ) {
1067 $error = [ 'sitecssprotected', $action ];
1072 if ( $this->userHasRight( $user, 'editinterface' ) ) {
1073 // Most users / site admins will probably find out about the new, more restrictive
1074 // permissions by failing to edit something. Give them more info.
1075 // TODO remove this a few release cycles after 1.32
1076 $error = [ 'interfaceadmin-info', wfMessage( $error[0], $error[1] ) ];
1086 * Check CSS/JSON/JS sub-page permissions
1088 * @param string $action The action to check
1089 * @param User $user User to check
1090 * @param array $errors List of current errors
1091 * @param string $rigor One of PermissionManager::RIGOR_ constants
1092 * - RIGOR_QUICK : does cheap permission checks from replica DBs (usable for GUI creation)
1093 * - RIGOR_FULL : does cheap and expensive checks possibly from a replica DB
1094 * - RIGOR_SECURE : does cheap and expensive checks, using the master as needed
1095 * @param bool $short Short circuit on first error
1097 * @param LinkTarget $page
1099 * @return array List of errors
1101 private function checkUserConfigPermissions(
1109 // TODO: remove & rework upon further use of LinkTarget
1110 $title = Title
::newFromLinkTarget( $page );
1112 # Protect css/json/js subpages of user pages
1113 # XXX: this might be better using restrictions
1115 if ( $action === 'patrol' ) {
1119 if ( preg_match( '/^' . preg_quote( $user->getName(), '/' ) . '\//', $title->getText() ) ) {
1120 // Users need editmyuser* to edit their own CSS/JSON/JS subpages.
1122 $title->isUserCssConfigPage()
1123 && !$user->isAllowedAny( 'editmyusercss', 'editusercss' )
1125 $errors[] = [ 'mycustomcssprotected', $action ];
1127 $title->isUserJsonConfigPage()
1128 && !$user->isAllowedAny( 'editmyuserjson', 'edituserjson' )
1130 $errors[] = [ 'mycustomjsonprotected', $action ];
1132 $title->isUserJsConfigPage()
1133 && !$user->isAllowedAny( 'editmyuserjs', 'edituserjs' )
1135 $errors[] = [ 'mycustomjsprotected', $action ];
1137 $title->isUserJsConfigPage()
1138 && !$user->isAllowedAny( 'edituserjs', 'editmyuserjsredirect' )
1140 // T207750 - do not allow users to edit a redirect if they couldn't edit the target
1141 $rev = $this->revisionLookup
->getRevisionByTitle( $title );
1142 $content = $rev ?
$rev->getContent( 'main', RevisionRecord
::RAW
) : null;
1143 $target = $content ?
$content->getUltimateRedirectTarget() : null;
1145 !$target->inNamespace( NS_USER
)
1146 ||
!preg_match( '/^' . preg_quote( $user->getName(), '/' ) . '\//', $target->getText() )
1148 $errors[] = [ 'mycustomjsredirectprotected', $action ];
1152 // Users need editmyuser* to edit their own CSS/JSON/JS subpages, except for
1153 // deletion/suppression which cannot be used for attacks and we want to avoid the
1154 // situation where an unprivileged user can post abusive content on their subpages
1155 // and only very highly privileged users could remove it.
1156 if ( !in_array( $action, [ 'delete', 'deleterevision', 'suppressrevision' ], true ) ) {
1158 $title->isUserCssConfigPage()
1159 && !$this->userHasRight( $user, 'editusercss' )
1161 $errors[] = [ 'customcssprotected', $action ];
1163 $title->isUserJsonConfigPage()
1164 && !$this->userHasRight( $user, 'edituserjson' )
1166 $errors[] = [ 'customjsonprotected', $action ];
1168 $title->isUserJsConfigPage()
1169 && !$this->userHasRight( $user, 'edituserjs' )
1171 $errors[] = [ 'customjsprotected', $action ];
1180 * Testing a permission
1184 * @param UserIdentity $user
1185 * @param string $action
1189 public function userHasRight( UserIdentity
$user, $action = '' ) {
1190 if ( $action === '' ) {
1191 return true; // In the spirit of DWIM
1193 // Use strict parameter to avoid matching numeric 0 accidentally inserted
1194 // by misconfiguration: 0 == 'foo'
1195 return in_array( $action, $this->getUserPermissions( $user ), true );
1199 * Get the permissions this user has.
1203 * @param UserIdentity $user
1205 * @return string[] permission names
1207 public function getUserPermissions( UserIdentity
$user ) {
1208 $user = User
::newFromIdentity( $user );
1209 if ( !isset( $this->usersRights
[ $user->getId() ] ) ) {
1210 $this->usersRights
[ $user->getId() ] = $this->getGroupPermissions(
1211 $user->getEffectiveGroups()
1213 Hooks
::run( 'UserGetRights', [ $user, &$this->usersRights
[ $user->getId() ] ] );
1215 // Deny any rights denied by the user's session, unless this
1216 // endpoint has no sessions.
1217 if ( !defined( 'MW_NO_SESSION' ) ) {
1218 // FIXME: $user->getRequest().. need to be replaced with something else
1219 $allowedRights = $user->getRequest()->getSession()->getAllowedUserRights();
1220 if ( $allowedRights !== null ) {
1221 $this->usersRights
[ $user->getId() ] = array_intersect(
1222 $this->usersRights
[ $user->getId() ],
1228 Hooks
::run( 'UserGetRightsRemove', [ $user, &$this->usersRights
[ $user->getId() ] ] );
1229 // Force reindexation of rights when a hook has unset one of them
1230 $this->usersRights
[ $user->getId() ] = array_values(
1231 array_unique( $this->usersRights
[ $user->getId() ] )
1235 $user->isLoggedIn() &&
1236 $this->options
->get( 'BlockDisablesLogin' ) &&
1240 $this->usersRights
[ $user->getId() ] = array_intersect(
1241 $this->usersRights
[ $user->getId() ],
1242 $this->getUserPermissions( $anon )
1246 $rights = $this->usersRights
[ $user->getId() ];
1247 foreach ( $this->temporaryUserRights
[ $user->getId() ] ??
[] as $overrides ) {
1248 $rights = array_values( array_unique( array_merge( $rights, $overrides ) ) );
1254 * Clears users permissions cache, if specific user is provided it tries to clear
1255 * permissions cache only for provided user.
1259 * @param User|null $user
1261 public function invalidateUsersRightsCache( $user = null ) {
1262 if ( $user !== null ) {
1263 if ( isset( $this->usersRights
[ $user->getId() ] ) ) {
1264 unset( $this->usersRights
[$user->getId()] );
1267 $this->usersRights
= null;
1272 * Check, if the given group has the given permission
1274 * If you're wanting to check whether all users have a permission, use
1275 * PermissionManager::isEveryoneAllowed() instead. That properly checks if it's revoked
1280 * @param string $group Group to check
1281 * @param string $role Role to check
1285 public function groupHasPermission( $group, $role ) {
1286 $groupPermissions = $this->options
->get( 'GroupPermissions' );
1287 $revokePermissions = $this->options
->get( 'RevokePermissions' );
1288 return isset( $groupPermissions[$group][$role] ) && $groupPermissions[$group][$role] &&
1289 !( isset( $revokePermissions[$group][$role] ) && $revokePermissions[$group][$role] );
1293 * Get the permissions associated with a given list of groups
1297 * @param array $groups Array of Strings List of internal group names
1298 * @return array Array of Strings List of permission key names for given groups combined
1300 public function getGroupPermissions( $groups ) {
1302 // grant every granted permission first
1303 foreach ( $groups as $group ) {
1304 if ( isset( $this->options
->get( 'GroupPermissions' )[$group] ) ) {
1305 $rights = array_merge( $rights,
1306 // array_filter removes empty items
1307 array_keys( array_filter( $this->options
->get( 'GroupPermissions' )[$group] ) ) );
1310 // now revoke the revoked permissions
1311 foreach ( $groups as $group ) {
1312 if ( isset( $this->options
->get( 'RevokePermissions' )[$group] ) ) {
1313 $rights = array_diff( $rights,
1314 array_keys( array_filter( $this->options
->get( 'RevokePermissions' )[$group] ) ) );
1317 return array_unique( $rights );
1321 * Get all the groups who have a given permission
1325 * @param string $role Role to check
1326 * @return array Array of Strings List of internal group names with the given permission
1328 public function getGroupsWithPermission( $role ) {
1329 $allowedGroups = [];
1330 foreach ( array_keys( $this->options
->get( 'GroupPermissions' ) ) as $group ) {
1331 if ( $this->groupHasPermission( $group, $role ) ) {
1332 $allowedGroups[] = $group;
1335 return $allowedGroups;
1339 * Check if all users may be assumed to have the given permission
1341 * We generally assume so if the right is granted to '*' and isn't revoked
1342 * on any group. It doesn't attempt to take grants or other extension
1343 * limitations on rights into account in the general case, though, as that
1344 * would require it to always return false and defeat the purpose.
1345 * Specifically, session-based rights restrictions (such as OAuth or bot
1346 * passwords) are applied based on the current session.
1348 * @param string $right Right to check
1353 public function isEveryoneAllowed( $right ) {
1354 // Use the cached results, except in unit tests which rely on
1355 // being able change the permission mid-request
1356 if ( isset( $this->cachedRights
[$right] ) ) {
1357 return $this->cachedRights
[$right];
1360 if ( !isset( $this->options
->get( 'GroupPermissions' )['*'][$right] )
1361 ||
!$this->options
->get( 'GroupPermissions' )['*'][$right] ) {
1362 $this->cachedRights
[$right] = false;
1366 // If it's revoked anywhere, then everyone doesn't have it
1367 foreach ( $this->options
->get( 'RevokePermissions' ) as $rights ) {
1368 if ( isset( $rights[$right] ) && $rights[$right] ) {
1369 $this->cachedRights
[$right] = false;
1374 // Remove any rights that aren't allowed to the global-session user,
1375 // unless there are no sessions for this endpoint.
1376 if ( !defined( 'MW_NO_SESSION' ) ) {
1378 // XXX: think what could be done with the below
1379 $allowedRights = SessionManager
::getGlobalSession()->getAllowedUserRights();
1380 if ( $allowedRights !== null && !in_array( $right, $allowedRights, true ) ) {
1381 $this->cachedRights
[$right] = false;
1386 // Allow extensions to say false
1387 if ( !Hooks
::run( 'UserIsEveryoneAllowed', [ $right ] ) ) {
1388 $this->cachedRights
[$right] = false;
1392 $this->cachedRights
[$right] = true;
1397 * Get a list of all available permissions.
1401 * @return string[] Array of permission names
1403 public function getAllPermissions() {
1404 if ( $this->allRights
=== false ) {
1405 if ( count( $this->options
->get( 'AvailableRights' ) ) ) {
1406 $this->allRights
= array_unique( array_merge(
1408 $this->options
->get( 'AvailableRights' )
1411 $this->allRights
= $this->coreRights
;
1413 Hooks
::run( 'UserGetAllRights', [ &$this->allRights
] );
1415 return $this->allRights
;
1419 * Add temporary user rights, only valid for the current scope.
1420 * This is meant for making it possible to programatically trigger certain actions that
1421 * the user wouldn't be able to trigger themselves; e.g. allow users without the bot right
1422 * to make bot-flagged actions through certain special pages.
1423 * Returns a "scope guard" variable; whenever that variable goes out of scope or is consumed
1424 * via ScopedCallback::consume(), the temporary rights are revoked.
1428 * @param UserIdentity $user
1429 * @param string|string[] $rights
1430 * @return ScopedCallback
1432 public function addTemporaryUserRights( UserIdentity
$user, $rights ) {
1433 $userId = $user->getId();
1434 $nextKey = count( $this->temporaryUserRights
[$userId] ??
[] );
1435 $this->temporaryUserRights
[$userId][$nextKey] = (array)$rights;
1436 return new ScopedCallback( function () use ( $userId, $nextKey ) {
1437 unset( $this->temporaryUserRights
[$userId][$nextKey] );
1442 * Overrides user permissions cache
1447 * @param string[]|string $rights
1451 public function overrideUserRightsForTesting( $user, $rights = [] ) {
1452 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
1453 throw new Exception( __METHOD__
. ' can not be called outside of tests' );
1455 $this->usersRights
[ $user->getId() ] = is_array( $rights ) ?
$rights : [ $rights ];