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