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