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