Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.83% covered (warning)
89.83%
530 / 590
52.78% covered (warning)
52.78%
19 / 36
CRAP
0.00% covered (danger)
0.00%
0 / 1
PermissionManager
89.83% covered (warning)
89.83%
530 / 590
52.78% covered (warning)
52.78%
19 / 36
380.06
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 userCan
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 quickUserCan
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPermissionErrors
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 throwPermissionErrors
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 isBlockedFrom
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getPermissionStatus
98.15% covered (success)
98.15%
53 / 54
0.00% covered (danger)
0.00%
0 / 1
12
 checkPermissionHooks
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
9.86
 resultToStatus
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
12.11
 checkReadPermissions
80.49% covered (warning)
80.49%
33 / 41
0.00% covered (danger)
0.00%
0 / 1
26.93
 missingPermissionError
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 newFatalPermissionDeniedStatus
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 isSameSpecialPage
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 checkUserBlock
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
3
 getApplicableBlock
88.33% covered (warning)
88.33%
53 / 60
0.00% covered (danger)
0.00%
0 / 1
25.99
 checkQuickPermissions
96.08% covered (success)
96.08%
49 / 51
0.00% covered (danger)
0.00%
0 / 1
33
 checkPageRestrictions
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
8
 checkCascadingSourcesRestrictions
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
10.04
 checkActionPermissions
88.00% covered (warning)
88.00%
66 / 75
0.00% covered (danger)
0.00%
0 / 1
39.37
 checkSpecialsAndNSPermissions
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
7
 checkSiteConfigPermissions
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
14.52
 checkUserConfigPermissions
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
19
 userHasRight
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 userHasAnyRight
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 userHasAllRights
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 getUserPermissions
74.07% covered (warning)
74.07%
20 / 27
0.00% covered (danger)
0.00%
0 / 1
10.41
 invalidateUsersRightsCache
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getRightsCacheKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isEveryoneAllowed
65.00% covered (warning)
65.00%
13 / 20
0.00% covered (danger)
0.00%
0 / 1
16.19
 getAllPermissions
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 getImplicitRights
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 isNamespaceProtected
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getNamespaceRestrictionLevels
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
18
 userCanEditRawHtmlPage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addTemporaryUserRights
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 overrideUserRightsForTesting
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
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 */
20namespace MediaWiki\Permissions;
21
22use InvalidArgumentException;
23use LogicException;
24use MediaWiki\Actions\ActionFactory;
25use MediaWiki\Block\AbstractBlock;
26use MediaWiki\Block\Block;
27use MediaWiki\Block\BlockErrorFormatter;
28use MediaWiki\Block\BlockManager;
29use MediaWiki\Config\ServiceOptions;
30use MediaWiki\Context\IContextSource;
31use MediaWiki\Context\RequestContext;
32use MediaWiki\HookContainer\HookContainer;
33use MediaWiki\HookContainer\HookRunner;
34use MediaWiki\Linker\LinkTarget;
35use MediaWiki\MainConfigNames;
36use MediaWiki\Message\Message;
37use MediaWiki\Page\PageIdentity;
38use MediaWiki\Page\PageReference;
39use MediaWiki\Page\RedirectLookup;
40use MediaWiki\Request\WebRequest;
41use MediaWiki\Session\SessionManager;
42use MediaWiki\SpecialPage\SpecialPage;
43use MediaWiki\SpecialPage\SpecialPageFactory;
44use MediaWiki\Title\NamespaceInfo;
45use MediaWiki\Title\Title;
46use MediaWiki\Title\TitleFormatter;
47use MediaWiki\User\TempUser\TempUserConfig;
48use MediaWiki\User\User;
49use MediaWiki\User\UserFactory;
50use MediaWiki\User\UserGroupManager;
51use MediaWiki\User\UserGroupMembership;
52use MediaWiki\User\UserIdentity;
53use MediaWiki\User\UserIdentityLookup;
54use PermissionsError;
55use StatusValue;
56use Wikimedia\Message\MessageSpecifier;
57use Wikimedia\ScopedCallback;
58
59/**
60 * A service class for checking permissions
61 * To obtain an instance, use MediaWikiServices::getInstance()->getPermissionManager().
62 *
63 * @since 1.33
64 */
65class PermissionManager {
66
67    /** @var string Does cheap permission checks from replica DBs (usable for GUI creation) */
68    public const RIGOR_QUICK = 'quick';
69
70    /** @var string Does cheap and expensive checks possibly from a replica DB */
71    public const RIGOR_FULL = 'full';
72
73    /** @var string Does cheap and expensive checks, using the primary DB as needed */
74    public const RIGOR_SECURE = 'secure';
75
76    /**
77     * @internal For use by ServiceWiring
78     */
79    public const CONSTRUCTOR_OPTIONS = [
80        MainConfigNames::WhitelistRead,
81        MainConfigNames::WhitelistReadRegexp,
82        MainConfigNames::EmailConfirmToEdit,
83        MainConfigNames::BlockDisablesLogin,
84        MainConfigNames::EnablePartialActionBlocks,
85        MainConfigNames::GroupPermissions,
86        MainConfigNames::RevokePermissions,
87        MainConfigNames::AvailableRights,
88        MainConfigNames::NamespaceProtection,
89        MainConfigNames::RestrictionLevels,
90        MainConfigNames::DeleteRevisionsLimit,
91        MainConfigNames::RateLimits,
92        MainConfigNames::ImplicitRights,
93    ];
94
95    private ServiceOptions $options;
96    private SpecialPageFactory $specialPageFactory;
97    private NamespaceInfo $nsInfo;
98    private GroupPermissionsLookup $groupPermissionsLookup;
99    private UserGroupManager $userGroupManager;
100    private BlockManager $blockManager;
101    private BlockErrorFormatter $blockErrorFormatter;
102    private HookRunner $hookRunner;
103    private UserIdentityLookup $userIdentityLookup;
104    private RedirectLookup $redirectLookup;
105    private RestrictionStore $restrictionStore;
106    private TitleFormatter $titleFormatter;
107    private TempUserConfig $tempUserConfig;
108    private UserFactory $userFactory;
109    private ActionFactory $actionFactory;
110
111    /** @var string[]|null Cached results of getAllPermissions() */
112    private $allRights;
113
114    /** @var string[]|null Cached results of getImplicitRights() */
115    private $implicitRights;
116
117    /** @var string[][] Cached user rights */
118    private $usersRights = [];
119
120    /**
121     * Temporary user rights, valid for the current request only.
122     * @var string[][][] userid => override group => rights
123     */
124    private $temporaryUserRights = [];
125
126    /** @var bool[] Cached rights for isEveryoneAllowed, [ right => allowed ] */
127    private $cachedRights = [];
128
129    /**
130     * Array of core rights.
131     * Each of these should have a corresponding message of the form
132     * "right-$right".
133     * @showinitializer
134     */
135    private const CORE_RIGHTS = [
136        'apihighlimits',
137        'applychangetags',
138        'autoconfirmed',
139        'autocreateaccount',
140        'autopatrol',
141        'bigdelete',
142        'block',
143        'blockemail',
144        'bot',
145        'browsearchive',
146        'changetags',
147        'createaccount',
148        'createpage',
149        'createtalk',
150        'delete',
151        'delete-redirect',
152        'deletechangetags',
153        'deletedhistory',
154        'deletedtext',
155        'deletelogentry',
156        'deleterevision',
157        'edit',
158        'editcontentmodel',
159        'editinterface',
160        'editprotected',
161        'editmyoptions',
162        'editmyprivateinfo',
163        'editmyusercss',
164        'editmyuserjson',
165        'editmyuserjs',
166        'editmyuserjsredirect',
167        'editmywatchlist',
168        'editsemiprotected',
169        'editsitecss',
170        'editsitejson',
171        'editsitejs',
172        'editusercss',
173        'edituserjson',
174        'edituserjs',
175        'hideuser',
176        'import',
177        'importupload',
178        'ipblock-exempt',
179        'managechangetags',
180        'markbotedits',
181        'mergehistory',
182        'minoredit',
183        'move',
184        'movefile',
185        'move-categorypages',
186        'move-rootuserpages',
187        'move-subpages',
188        'nominornewtalk',
189        'noratelimit',
190        'override-export-depth',
191        'pagelang',
192        'patrol',
193        'patrolmarks',
194        'protect',
195        'read',
196        'renameuser',
197        'reupload',
198        'reupload-own',
199        'reupload-shared',
200        'rollback',
201        'sendemail',
202        'siteadmin',
203        'suppressionlog',
204        'suppressredirect',
205        'suppressrevision',
206        'unblockself',
207        'undelete',
208        'unwatchedpages',
209        'upload',
210        'upload_by_url',
211        'userrights',
212        'userrights-interwiki',
213        'viewmyprivateinfo',
214        'viewmywatchlist',
215        'viewsuppressed',
216    ];
217
218    /**
219     * List of implicit rights.
220     * These should not have a corresponding message of the form
221     * "right-$right".
222     * @showinitializer
223     */
224    private const CORE_IMPLICIT_RIGHTS = [
225        'renderfile',
226        'renderfile-nonstandard',
227        'stashedit',
228        'stashbasehtml',
229        'mailpassword',
230        'changeemail',
231        'confirmemail',
232        'linkpurge',
233        'purge',
234    ];
235
236    public function __construct(
237        ServiceOptions $options,
238        SpecialPageFactory $specialPageFactory,
239        NamespaceInfo $nsInfo,
240        GroupPermissionsLookup $groupPermissionsLookup,
241        UserGroupManager $userGroupManager,
242        BlockManager $blockManager,
243        BlockErrorFormatter $blockErrorFormatter,
244        HookContainer $hookContainer,
245        UserIdentityLookup $userIdentityLookup,
246        RedirectLookup $redirectLookup,
247        RestrictionStore $restrictionStore,
248        TitleFormatter $titleFormatter,
249        TempUserConfig $tempUserConfig,
250        UserFactory $userFactory,
251        ActionFactory $actionFactory
252    ) {
253        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
254        $this->options = $options;
255        $this->specialPageFactory = $specialPageFactory;
256        $this->nsInfo = $nsInfo;
257        $this->groupPermissionsLookup = $groupPermissionsLookup;
258        $this->userGroupManager = $userGroupManager;
259        $this->blockManager = $blockManager;
260        $this->blockErrorFormatter = $blockErrorFormatter;
261        $this->hookRunner = new HookRunner( $hookContainer );
262        $this->userIdentityLookup = $userIdentityLookup;
263        $this->redirectLookup = $redirectLookup;
264        $this->restrictionStore = $restrictionStore;
265        $this->titleFormatter = $titleFormatter;
266        $this->tempUserConfig = $tempUserConfig;
267        $this->userFactory = $userFactory;
268        $this->actionFactory = $actionFactory;
269    }
270
271    /**
272     * Can $user perform $action on a page?
273     *
274     * The method replaced Title::userCan()
275     * The $user parameter need to be superseded by UserIdentity value in future
276     * The $title parameter need to be superseded by PageIdentity value in future
277     *
278     * @param string $action
279     * @param User $user
280     * @param LinkTarget $page
281     * @param string $rigor One of PermissionManager::RIGOR_ constants
282     *   - RIGOR_QUICK  : does cheap permission checks from replica DBs (usable for GUI creation)
283     *   - RIGOR_FULL   : does cheap and expensive checks possibly from a replica DB
284     *   - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed
285     *
286     * @return bool
287     */
288    public function userCan( $action, User $user, LinkTarget $page, $rigor = self::RIGOR_SECURE ): bool {
289        return $this->getPermissionStatus( $action, $user, $page, $rigor, true )->isGood();
290    }
291
292    /**
293     * A convenience method for calling PermissionManager::userCan
294     * with PermissionManager::RIGOR_QUICK
295     *
296     * Suitable for use for nonessential UI controls in common cases, but
297     * _not_ for functional access control.
298     * May provide false positives, but should never provide a false negative.
299     *
300     * @see PermissionManager::userCan()
301     *
302     * @param string $action
303     * @param User $user
304     * @param LinkTarget $page
305     * @return bool
306     */
307    public function quickUserCan( $action, User $user, LinkTarget $page ): bool {
308        return $this->userCan( $action, $user, $page, self::RIGOR_QUICK );
309    }
310
311    /**
312     * Can $user perform $action on a page?
313     *
314     * This *does not* check throttles (User::pingLimiter()). If that's desired, use the Authority
315     * interface methods instead.
316     *
317     * @deprecated since 1.43 Use getPermissionStatus() instead.
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 primary DB as needed
326     * @param string[] $ignoreErrors Set this to a list of message keys
327     *   whose corresponding errors may be ignored.
328     *
329     * @return array[] Permission errors.
330     *   Each entry contains valid arguments for wfMessage() / MessageLocalizer::msg().
331     *   The format is *different* from the normal "legacy error array", as used by
332     *   Status::getErrorsArray() or PermissionStatus::toLegacyErrorArray():
333     *   the first element of each entry can be a MessageSpecifier, not just a string.
334     * @phan-return non-empty-array[]
335     */
336    public function getPermissionErrors(
337        $action,
338        User $user,
339        LinkTarget $page,
340        $rigor = self::RIGOR_SECURE,
341        $ignoreErrors = []
342    ): array {
343        $status = $this->getPermissionStatus( $action, $user, $page, $rigor );
344        $result = [];
345
346        // Produce a result in the weird format used by this function
347        foreach ( $status->getErrors() as [ 'message' => $keyOrMsg, 'params' => $params ] ) {
348            $key = $keyOrMsg instanceof MessageSpecifier ? $keyOrMsg->getKey() : $keyOrMsg;
349            // Remove the errors being ignored.
350            if ( !in_array( $key, $ignoreErrors ) ) {
351                $result[] = [ $keyOrMsg, ...$params ];
352            }
353        }
354        return $result;
355    }
356
357    /**
358     * Like {@link getPermissionErrors}, but immediately throw if there are any errors.
359     *
360     * @param string $action Action that permission needs to be checked for
361     * @param User $user User to check
362     * @param LinkTarget $page
363     * @param string $rigor One of PermissionManager::RIGOR_ constants
364     *   - RIGOR_QUICK  : does cheap permission checks from replica DBs (usable for GUI creation)
365     *   - RIGOR_FULL   : does cheap and expensive checks possibly from a replica DB
366     *   - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed
367     * @param string[] $ignoreErrors Set this to a list of message keys
368     *   whose corresponding errors may be ignored.
369     *
370     * @throws PermissionsError
371     */
372    public function throwPermissionErrors(
373        $action,
374        User $user,
375        LinkTarget $page,
376        $rigor = self::RIGOR_SECURE,
377        $ignoreErrors = []
378    ): void {
379        $status = $this->getPermissionStatus(
380            $action, $user, $page, $rigor );
381        if ( $status->hasMessagesExcept( ...$ignoreErrors ) ) {
382            throw new PermissionsError( $action, $status );
383        }
384    }
385
386    /**
387     * Check if user is blocked from editing a particular article. If the user does not
388     * have a block, this will return false.
389     *
390     * @param User $user
391     * @param PageIdentity|LinkTarget $page Title to check
392     * @param bool $fromReplica Whether to check the replica DB instead of the primary DB
393     * @return bool
394     */
395    public function isBlockedFrom( User $user, $page, $fromReplica = false ): bool {
396        return (bool)$this->getApplicableBlock(
397            'edit',
398            $user,
399            $fromReplica ? self::RIGOR_FULL : self::RIGOR_SECURE,
400            $page,
401            $user->getRequest()
402        );
403    }
404
405    /**
406     * Can $user perform $action on a page?
407     *
408     * This *does not* check throttles (User::pingLimiter()). If that's desired, use the Authority
409     * interface methods instead.
410     *
411     * @param string $action Action that permission needs to be checked for
412     * @param User $user User to check
413     * @param LinkTarget $page
414     * @param string $rigor One of PermissionManager::RIGOR_ constants
415     *   - RIGOR_QUICK  : does cheap permission checks from replica DBs (usable for GUI creation)
416     *   - RIGOR_FULL   : does cheap and expensive checks possibly from a replica DB
417     *   - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed
418     * @param bool $short Set this to true to stop after the first permission error.
419     * @return PermissionStatus Permission errors as a status.
420     *   Check `$status->isGood()` to tell if the user can perform the action.
421     *   Use `$status->getMessages()` to display errors if the status is not good.
422     */
423    public function getPermissionStatus(
424        $action,
425        User $user,
426        LinkTarget $page,
427        $rigor = self::RIGOR_SECURE,
428        $short = false
429    ): PermissionStatus {
430        if ( !in_array( $rigor, [ self::RIGOR_QUICK, self::RIGOR_FULL, self::RIGOR_SECURE ] ) ) {
431            throw new InvalidArgumentException( "Invalid rigor parameter '$rigor'." );
432        }
433
434        // With RIGOR_QUICK we can assume automatic account creation will
435        // occur. At a higher rigor level, the caller is required to opt
436        // in by either passing in a temp placeholder user or by actually
437        // creating the account.
438        if ( $rigor === self::RIGOR_QUICK
439            && !$user->isRegistered()
440            && $this->tempUserConfig->isAutoCreateAction( $action )
441        ) {
442            $user = $this->userFactory->newTempPlaceholder();
443        }
444
445        # Read has special handling
446        if ( $action === 'read' ) {
447            $checks = [
448                [ $this, 'checkPermissionHooks' ],
449                [ $this, 'checkReadPermissions' ],
450                [ $this, 'checkUserBlock' ], // for wgBlockDisablesLogin
451            ];
452        } elseif ( $action === 'create' ) {
453            # Don't call checkSpecialsAndNSPermissions, checkSiteConfigPermissions
454            # or checkUserConfigPermissions here as it will lead to duplicate
455            # error messages. This is okay to do since anywhere that checks for
456            # create will also check for edit, and those checks are called for edit.
457            $checks = [
458                [ $this, 'checkQuickPermissions' ],
459                [ $this, 'checkPermissionHooks' ],
460                [ $this, 'checkPageRestrictions' ],
461                [ $this, 'checkCascadingSourcesRestrictions' ],
462                [ $this, 'checkActionPermissions' ],
463                [ $this, 'checkUserBlock' ],
464            ];
465        } else {
466            // Exclude checkUserConfigPermissions on actions that cannot change the
467            // content of the configuration pages.
468            $skipUserConfigActions = [
469                // Allow patrolling per T21818
470                'patrol',
471
472                // Allow admins and oversighters to delete. For user pages we want to avoid the
473                // situation where an unprivileged user can post abusive content on
474                // their subpages and only very highly privileged users could remove it.
475                // See T200176.
476                'delete',
477                'deleterevision',
478                'suppressrevision',
479
480                // Allow admins and oversighters to view deleted content, even if they
481                // cannot restore it. See T202989
482                'deletedhistory',
483                'deletedtext',
484                'viewsuppressed',
485            ];
486
487            $checks = [
488                [ $this, 'checkQuickPermissions' ],
489                [ $this, 'checkPermissionHooks' ],
490                [ $this, 'checkSpecialsAndNSPermissions' ],
491                [ $this, 'checkSiteConfigPermissions' ],
492            ];
493            if ( !in_array( $action, $skipUserConfigActions, true ) ) {
494                $checks[] = [ $this, 'checkUserConfigPermissions' ];
495            }
496            $checks = [
497                ...$checks,
498                [ $this, 'checkPageRestrictions' ],
499                [ $this, 'checkCascadingSourcesRestrictions' ],
500                [ $this, 'checkActionPermissions' ],
501                [ $this, 'checkUserBlock' ]
502            ];
503        }
504
505        $status = PermissionStatus::newEmpty();
506        foreach ( $checks as $method ) {
507            $method( $action, $user, $status, $rigor, $short, $page );
508
509            if ( $short && !$status->isGood() ) {
510                break;
511            }
512        }
513        if ( !$status->isGood() ) {
514            $errors = $status->toLegacyErrorArray();
515            $this->hookRunner->onPermissionErrorAudit( $page, $user, $action, $rigor, $errors );
516        }
517
518        return $status;
519    }
520
521    /**
522     * Check various permission hooks
523     *
524     * @param string $action The action to check
525     * @param User $user User to check
526     * @param PermissionStatus $status Current errors
527     * @param string $rigor One of PermissionManager::RIGOR_ constants
528     *   - RIGOR_QUICK  : does cheap permission checks from replica DBs (usable for GUI creation)
529     *   - RIGOR_FULL   : does cheap and expensive checks possibly from a replica DB
530     *   - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed
531     * @param bool $short Short circuit on first error
532     * @param LinkTarget $page
533     */
534    private function checkPermissionHooks(
535        $action,
536        User $user,
537        PermissionStatus $status,
538        $rigor,
539        $short,
540        LinkTarget $page
541    ): void {
542        // TODO: remove when LinkTarget usage will expand further
543        $title = Title::newFromLinkTarget( $page );
544        // Use getUserPermissionsErrors instead
545        $result = '';
546        if ( !$this->hookRunner->onUserCan( $title, $user, $action, $result ) ) {
547            if ( !$result ) {
548                $status->fatal( 'badaccess-group0' );
549            }
550            return;
551        }
552        // Check getUserPermissionsErrors hook
553        if ( !$this->hookRunner->onGetUserPermissionsErrors( $title, $user, $action, $result ) ) {
554            $this->resultToStatus( $status, $result );
555        }
556        // Check getUserPermissionsErrorsExpensive hook
557        if (
558            $rigor !== self::RIGOR_QUICK
559            && !( $short && !$status->isGood() )
560            && !$this->hookRunner->onGetUserPermissionsErrorsExpensive(
561                $title, $user, $action, $result )
562        ) {
563            $this->resultToStatus( $status, $result );
564        }
565    }
566
567    /**
568     * Add the resulting error code to the errors array
569     *
570     * @param PermissionStatus $status Current errors
571     * @param array|string|MessageSpecifier|false $result Result of errors
572     */
573    private function resultToStatus( PermissionStatus $status, $result ): void {
574        if ( is_array( $result ) && count( $result ) && !is_array( $result[0] ) ) {
575            // A single array representing an error
576            $status->fatal( ...$result );
577        } elseif ( is_array( $result ) && count( $result ) && is_array( $result[0] ) ) {
578            // A nested array representing multiple errors
579            foreach ( $result as $result1 ) {
580                $this->resultToStatus( $status, $result1 );
581            }
582        } elseif ( is_string( $result ) && $result !== '' ) {
583            // A string representing a message-id
584            $status->fatal( $result );
585        } elseif ( $result instanceof MessageSpecifier ) {
586            // A message specifier representing an error
587            $status->fatal( $result );
588        } elseif ( $result === false ) {
589            // a generic "We don't want them to do that"
590            $status->fatal( 'badaccess-group0' );
591        }
592        // If we got here, $results is the empty array or empty string, which mean no errors.
593    }
594
595    /**
596     * Check that the user is allowed to read this page.
597     *
598     * @param string $action The action to check
599     * @param User $user User to check
600     * @param PermissionStatus $status Current errors
601     * @param string $rigor One of PermissionManager::RIGOR_ constants
602     *   - RIGOR_QUICK  : does cheap permission checks from replica DBs (usable for GUI creation)
603     *   - RIGOR_FULL   : does cheap and expensive checks possibly from a replica DB
604     *   - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed
605     * @param bool $short Short circuit on first error
606     * @param LinkTarget $page
607     */
608    private function checkReadPermissions(
609        $action,
610        User $user,
611        PermissionStatus $status,
612        $rigor,
613        $short,
614        LinkTarget $page
615    ): void {
616        // TODO: remove when LinkTarget usage will expand further
617        $title = Title::newFromLinkTarget( $page );
618
619        $whiteListRead = $this->options->get( MainConfigNames::WhitelistRead );
620        $allowed = false;
621        if ( $this->isEveryoneAllowed( 'read' ) ) {
622            // Shortcut for public wikis, allows skipping quite a bit of code
623            $allowed = true;
624        } elseif ( $this->userHasRight( $user, 'read' ) ) {
625            // If the user is allowed to read pages, he is allowed to read all pages
626            $allowed = true;
627        } elseif ( $this->isSameSpecialPage( 'Userlogin', $page )
628            || $this->isSameSpecialPage( 'PasswordReset', $page )
629            || $this->isSameSpecialPage( 'Userlogout', $page )
630        ) {
631            // Always grant access to the login page.
632            // Even anons need to be able to log in.
633            $allowed = true;
634        } elseif ( $this->isSameSpecialPage( 'RunJobs', $page ) ) {
635            // relies on HMAC key signature alone
636            $allowed = true;
637        } elseif ( is_array( $whiteListRead ) && count( $whiteListRead ) ) {
638            // Time to check the whitelist
639            // Only do these checks if there's something to check against
640            $name = $title->getPrefixedText();
641            $dbName = $title->getPrefixedDBkey();
642
643            // Check for explicit whitelisting with and without underscores
644            if ( in_array( $name, $whiteListRead, true )
645                || in_array( $dbName, $whiteListRead, true )
646            ) {
647                $allowed = true;
648            } elseif ( $page->getNamespace() === NS_MAIN ) {
649                // Old settings might have the title prefixed with
650                // a colon for main-namespace pages
651                if ( in_array( ':' . $name, $whiteListRead ) ) {
652                    $allowed = true;
653                }
654            } elseif ( $title->isSpecialPage() ) {
655                // If it's a special page, ditch the subpage bit and check again
656                $name = $title->getDBkey();
657                [ $name, /* $subpage */ ] =
658                    $this->specialPageFactory->resolveAlias( $name );
659                if ( $name ) {
660                    $pure = SpecialPage::getTitleFor( $name )->getPrefixedText();
661                    if ( in_array( $pure, $whiteListRead, true ) ) {
662                        $allowed = true;
663                    }
664                }
665            }
666        }
667
668        $whitelistReadRegexp = $this->options->get( MainConfigNames::WhitelistReadRegexp );
669        if ( !$allowed && is_array( $whitelistReadRegexp )
670            && $whitelistReadRegexp
671        ) {
672            $name = $title->getPrefixedText();
673            // Check for regex whitelisting
674            foreach ( $whitelistReadRegexp as $listItem ) {
675                if ( preg_match( $listItem, $name ) ) {
676                    $allowed = true;
677                    break;
678                }
679            }
680        }
681
682        if ( !$allowed ) {
683            # If the title is not whitelisted, give extensions a chance to do so...
684            $this->hookRunner->onTitleReadWhitelist( $title, $user, $allowed );
685            if ( !$allowed ) {
686                $this->missingPermissionError( $action, $short, $status );
687            }
688        }
689    }
690
691    /**
692     * Add an error to the status when an action isn't allowed to be performed.
693     *
694     * @param string $action The action to check
695     * @param bool $short Short circuit on first error
696     * @param PermissionStatus $status
697     */
698    private function missingPermissionError( string $action, bool $short, PermissionStatus $status ): void {
699        // We avoid expensive display logic for quickUserCan's and such
700        if ( $short ) {
701            $status->fatal( 'badaccess-group0' );
702        }
703
704        // TODO: it would be a good idea to replace the method below with something else like
705        // maybe callback injection
706        $context = RequestContext::getMain();
707        $fatalStatus = $this->newFatalPermissionDeniedStatus( $action, $context );
708        $status->merge( $fatalStatus );
709        $statusPermission = $fatalStatus->getPermission();
710        if ( $statusPermission ) {
711            $status->setPermission( $statusPermission );
712        }
713    }
714
715    /**
716     * Factory function for fatal permission-denied errors
717     *
718     * @internal for use by UserAuthority
719     *
720     * @param string $permission User right required
721     * @param IContextSource $context
722     *
723     * @return PermissionStatus
724     */
725    public function newFatalPermissionDeniedStatus( $permission, IContextSource $context ): StatusValue {
726        $groups = [];
727        foreach ( $this->groupPermissionsLookup->getGroupsWithPermission( $permission ) as $group ) {
728            $groups[] = UserGroupMembership::getLinkWiki( $group, $context );
729        }
730
731        if ( $groups ) {
732            return PermissionStatus::newFatal(
733                'badaccess-groups',
734                Message::listParam( $groups, 'comma' ),
735                count( $groups )
736            );
737        }
738
739        $status = PermissionStatus::newFatal( 'badaccess-group0' );
740        $status->setPermission( $permission );
741        return $status;
742    }
743
744    /**
745     * Whether a title resolves to the named special page.
746     *
747     * @param string $name The special page name
748     * @param LinkTarget $page
749     * @return bool
750     */
751    private function isSameSpecialPage( $name, LinkTarget $page ): bool {
752        if ( $page->getNamespace() === NS_SPECIAL ) {
753            [ $pageName ] = $this->specialPageFactory->resolveAlias( $page->getDBkey() );
754            if ( $name === $pageName ) {
755                return true;
756            }
757        }
758        return false;
759    }
760
761    /**
762     * Check that the user isn't blocked from editing.
763     *
764     * @param string $action The action to check
765     * @param User $user User to check
766     * @param PermissionStatus $status Current errors
767     * @param string $rigor One of PermissionManager::RIGOR_ constants
768     *   - RIGOR_QUICK  : does cheap permission checks from replica DBs (usable for GUI creation)
769     *   - RIGOR_FULL   : does cheap and expensive checks possibly from a replica DB
770     *   - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed
771     * @param bool $short Short circuit on first error
772     * @param LinkTarget $page
773     */
774    private function checkUserBlock(
775        $action,
776        User $user,
777        PermissionStatus $status,
778        $rigor,
779        $short,
780        LinkTarget $page
781    ): void {
782        $block = $this->getApplicableBlock(
783            $action,
784            $user,
785            $rigor,
786            $page,
787            $user->getRequest()
788        );
789
790        if ( $block ) {
791            // @todo FIXME: Pass the relevant context into this function.
792            $context = RequestContext::getMain();
793            $messages = $this->blockErrorFormatter->getMessages(
794                $block,
795                $user,
796                $context->getRequest()->getIP()
797            );
798
799            foreach ( $messages as $message ) {
800                // TODO: We can pass $message directly once getPermissionErrors() is removed.
801                // For now we store the message key as a string here out of overabundance of caution,
802                // because there is a test case verifying that block messages use strings in that format.
803                $status->fatal( $message->getKey(), ...$message->getParams() );
804            }
805        }
806    }
807
808    /**
809     * Return the Block object applicable for the given permission check, if any.
810     *
811     * @internal for use by UserAuthority only
812     *
813     * @param string $action The action to check
814     * @param User $user User to check
815     * @param string $rigor One of PermissionManager::RIGOR_ constants
816     *   - RIGOR_QUICK  : does cheap permission checks from replica DBs (usable for GUI creation)
817     *   - RIGOR_FULL   : does cheap and expensive checks possibly from a replica DB
818     *   - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed
819     * @param LinkTarget|PageReference|null $page
820     * @param WebRequest|null $request The request to get the IP and cookies
821     *   from. If this is null, IP and cookie blocks will not be checked.
822     * @return ?Block
823     */
824    public function getApplicableBlock(
825        string $action,
826        User $user,
827        string $rigor,
828        $page,
829        ?WebRequest $request
830    ): ?Block {
831        // Unblocking handled in SpecialUnblock
832        if ( $rigor === self::RIGOR_QUICK || in_array( $action, [ 'unblock' ] ) ) {
833            return null;
834        }
835
836        // Optimize for a very common case
837        if ( $action === 'read' && !$this->options->get( MainConfigNames::BlockDisablesLogin ) ) {
838            return null;
839        }
840
841        // Implicit rights aren't blockable (T350117, T350202).
842        if ( in_array( $action, $this->getImplicitRights(), true ) ) {
843            return null;
844        }
845
846        $useReplica = $rigor !== self::RIGOR_SECURE;
847        $isExempt = $this->userHasRight( $user, 'ipblock-exempt' );
848        $requestIfNotExempt = $isExempt ? null : $request;
849
850        // Create account blocks are implemented separately due to weird IP exemption rules
851        if ( in_array( $action, [ 'createaccount', 'autocreateaccount' ], true ) ) {
852            return $this->blockManager->getCreateAccountBlock(
853                $user,
854                $requestIfNotExempt,
855                $useReplica
856            );
857        }
858
859        $block = $this->blockManager->getBlock( $user, $requestIfNotExempt, $useReplica );
860        if ( !$block ) {
861            return null;
862        }
863        $userIsHidden = $block->getHideName();
864
865        // Remove elements from the block that explicitly allow the action
866        // (like "read" or "upload").
867        $block = $this->blockManager->filter(
868            $block,
869            static function ( AbstractBlock $originalBlock ) use ( $action ) {
870                // Remove the block if it explicitly allows the action
871                return $originalBlock->appliesToRight( $action ) !== false;
872            }
873        );
874        if ( !$block ) {
875            return null;
876        }
877
878        // Convert the input page to a Title
879        $targetTitle = null;
880        if ( $page ) {
881            $targetTitle = $page instanceof PageReference ?
882                Title::castFromPageReference( $page ) :
883                Title::castFromLinkTarget( $page );
884
885            if ( !$targetTitle->canExist() ) {
886                $targetTitle = null;
887            }
888        }
889
890        // What gets passed into this method is a user right, not an action name.
891        // There is no way to instantiate an action by restriction. However, this
892        // will get the action where the restriction is the same. This may result
893        // in actions being blocked that shouldn't be.
894        $actionInfo = $this->actionFactory->getActionInfo( $action, $targetTitle );
895
896        // Ensure that the retrieved action matches the restriction.
897        if ( $actionInfo && $actionInfo->getRestriction() !== $action ) {
898            $actionInfo = null;
899        }
900
901        // Return null if the action does not require an unblocked user.
902        // If no ActionInfo is returned, assume that the action requires unblock
903        // which is the default.
904        // NOTE: We may get null here even for known actions, if a wiki's main page
905        // is set to a special page, e.g. Special:MyLanguage/Main_Page (T348451, T346036).
906        if ( $actionInfo && !$actionInfo->requiresUnblock() ) {
907            return null;
908        }
909
910        // Remove elements from the block that do not apply to the specific page
911        if ( $targetTitle ) {
912            $targetIsUserTalk = !$userIsHidden && $targetTitle->equals( $user->getTalkPage() );
913            $block = $this->blockManager->filter(
914                $block,
915                static function ( AbstractBlock $originalBlock )
916                use ( $action, $targetTitle, $targetIsUserTalk ) {
917                    if ( $originalBlock->appliesToRight( $action ) ) {
918                        // An action block takes precedence over appliesToTitle().
919                        // Block::appliesToRight('edit') always returns null,
920                        // allowing title-based exemptions to take effect.
921                        return true;
922                    } elseif ( $targetIsUserTalk ) {
923                        // Special handling for a user's own talk page. The block is not aware
924                        // of the user, so this must be done here.
925                        return $originalBlock->appliesToUsertalk( $targetTitle );
926                    } else {
927                        return $originalBlock->appliesToTitle( $targetTitle );
928                    }
929                }
930            );
931        }
932
933        if ( $targetTitle && $block
934            && $block instanceof AbstractBlock // for phan
935        ) {
936            // Allow extensions to let a blocked user access a particular page
937            $allowUsertalk = $block->isUsertalkEditAllowed();
938            $blocked = true;
939            $this->hookRunner->onUserIsBlockedFrom( $user, $targetTitle, $blocked, $allowUsertalk );
940            if ( !$blocked ) {
941                $block = null;
942            }
943        }
944        return $block;
945    }
946
947    /**
948     * Run easy-to-test (or "quick") permissions checks for a given action.
949     *
950     * @param string $action The action to check
951     * @param User $user User to check
952     * @param PermissionStatus $status Current errors
953     * @param string $rigor One of PermissionManager::RIGOR_ constants
954     *   - RIGOR_QUICK  : does cheap permission checks from replica DBs (usable for GUI creation)
955     *   - RIGOR_FULL   : does cheap and expensive checks possibly from a replica DB
956     *   - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed
957     * @param bool $short Short circuit on first error
958     * @param LinkTarget $page
959     */
960    private function checkQuickPermissions(
961        $action,
962        User $user,
963        PermissionStatus $status,
964        $rigor,
965        $short,
966        LinkTarget $page
967    ): void {
968        // TODO: remove when LinkTarget usage will expand further
969        $title = Title::newFromLinkTarget( $page );
970
971        // This method is always called first, so $status is guaranteed to be empty, so we can
972        // just pass an empty $errors array, instead of converting it to the legacy format and back.
973        $errors = [];
974        if ( !$this->hookRunner->onTitleQuickPermissions( $title, $user, $action,
975            $errors, $rigor !== self::RIGOR_QUICK, $short )
976        ) {
977            // $errors is an array of results, not a result, but resultToStatus() handles
978            // arrays of arrays with recursion so this will work
979            $this->resultToStatus( $status, $errors );
980            return;
981        }
982
983        $isSubPage =
984            $this->nsInfo->hasSubpages( $title->getNamespace() ) &&
985            strpos( $title->getText(), '/' ) !== false;
986
987        if ( $action === 'create' ) {
988            if (
989                ( $this->nsInfo->isTalk( $title->getNamespace() ) &&
990                    !$this->userHasRight( $user, 'createtalk' ) ) ||
991                ( !$this->nsInfo->isTalk( $title->getNamespace() ) &&
992                    !$this->userHasRight( $user, 'createpage' ) )
993            ) {
994                $status->fatal( $user->isNamed() ? 'nocreate-loggedin' : 'nocreatetext' );
995            }
996        } elseif ( $action === 'move' ) {
997            if ( !$this->userHasRight( $user, 'move-rootuserpages' )
998                && $title->getNamespace() === NS_USER && !$isSubPage
999            ) {
1000                // Show user page-specific message only if the user can move other pages
1001                $status->fatal( 'cant-move-user-page' );
1002            }
1003
1004            // Check if user is allowed to move files if it's a file
1005            if ( $title->getNamespace() === NS_FILE &&
1006                !$this->userHasRight( $user, 'movefile' )
1007            ) {
1008                $status->fatal( 'movenotallowedfile' );
1009            }
1010
1011            // Check if user is allowed to move category pages if it's a category page
1012            if ( $title->getNamespace() === NS_CATEGORY &&
1013                !$this->userHasRight( $user, 'move-categorypages' )
1014            ) {
1015                $status->fatal( 'cant-move-category-page' );
1016            }
1017
1018            if ( !$this->userHasRight( $user, 'move' ) ) {
1019                // User can't move anything
1020                $userCanMove = $this->groupPermissionsLookup
1021                    ->groupHasPermission( 'user', 'move' );
1022                $autoconfirmedCanMove = $this->groupPermissionsLookup
1023                    ->groupHasPermission( 'autoconfirmed', 'move' );
1024                if ( $user->isAnon()
1025                    && ( $userCanMove || $autoconfirmedCanMove )
1026                ) {
1027                    // custom message if logged-in users without any special rights can move
1028                    $status->fatal( 'movenologintext' );
1029                } elseif ( $user->isTemp() && $autoconfirmedCanMove ) {
1030                    // Temp user may be able to move if they log in as a proper account
1031                    $status->fatal( 'movenologintext' );
1032                } else {
1033                    $status->fatal( 'movenotallowed' );
1034                }
1035            }
1036        } elseif ( $action === 'move-target' ) {
1037            if ( !$this->userHasRight( $user, 'move' ) ) {
1038                // User can't move anything
1039                $status->fatal( 'movenotallowed' );
1040            } elseif ( !$this->userHasRight( $user, 'move-rootuserpages' )
1041                && $title->getNamespace() === NS_USER
1042                && !$isSubPage
1043            ) {
1044                // Show user page-specific message only if the user can move other pages
1045                $status->fatal( 'cant-move-to-user-page' );
1046            } elseif ( !$this->userHasRight( $user, 'move-categorypages' )
1047                && $title->getNamespace() === NS_CATEGORY
1048            ) {
1049                // Show category page-specific message only if the user can move other pages
1050                $status->fatal( 'cant-move-to-category-page' );
1051            }
1052        } elseif ( $action === 'autocreateaccount' ) {
1053            // createaccount implies autocreateaccount
1054            if ( !$this->userHasAnyRight( $user, 'autocreateaccount', 'createaccount' ) ) {
1055                $this->missingPermissionError( $action, $short, $status );
1056            }
1057        } elseif ( !$this->userHasRight( $user, $action ) ) {
1058            $this->missingPermissionError( $action, $short, $status );
1059        }
1060    }
1061
1062    /**
1063     * Check for any page_restrictions table requirements on this page.
1064     *
1065     * If the page has multiple restrictions, the user must have
1066     * all of those rights to perform the action in question.
1067     *
1068     * @param string $action The action to check
1069     * @param UserIdentity $user User to check
1070     * @param PermissionStatus $status Current errors
1071     * @param string $rigor One of PermissionManager::RIGOR_ constants
1072     *   - RIGOR_QUICK  : does cheap permission checks from replica DBs (usable for GUI creation)
1073     *   - RIGOR_FULL   : does cheap and expensive checks possibly from a replica DB
1074     *   - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed
1075     * @param bool $short Short circuit on first error
1076     * @param LinkTarget $page
1077     */
1078    private function checkPageRestrictions(
1079        $action,
1080        UserIdentity $user,
1081        PermissionStatus $status,
1082        $rigor,
1083        $short,
1084        LinkTarget $page
1085    ): void {
1086        // TODO: remove & rework upon further use of LinkTarget
1087        $title = Title::newFromLinkTarget( $page );
1088        foreach ( $this->restrictionStore->getRestrictions( $title, $action ) as $right ) {
1089            // Backwards compatibility, rewrite sysop -> editprotected
1090            if ( $right === 'sysop' ) {
1091                $right = 'editprotected';
1092            }
1093            // Backwards compatibility, rewrite autoconfirmed -> editsemiprotected
1094            if ( $right === 'autoconfirmed' ) {
1095                $right = 'editsemiprotected';
1096            }
1097            if ( $right == '' ) {
1098                continue;
1099            }
1100            if ( !$this->userHasRight( $user, $right ) ) {
1101                $status->fatal( 'protectedpagetext', $right, $action );
1102            } elseif ( $this->restrictionStore->areRestrictionsCascading( $title ) &&
1103                !$this->userHasRight( $user, 'protect' )
1104            ) {
1105                $status->fatal( 'protectedpagetext', 'protect', $action );
1106            }
1107        }
1108    }
1109
1110    /**
1111     * Check restrictions on cascading pages.
1112     *
1113     * @param string $action The action to check
1114     * @param UserIdentity $user User to check
1115     * @param PermissionStatus $status Current errors
1116     * @param string $rigor One of PermissionManager::RIGOR_ constants
1117     *   - RIGOR_QUICK  : does cheap permission checks from replica DBs (usable for GUI creation)
1118     *   - RIGOR_FULL   : does cheap and expensive checks possibly from a replica DB
1119     *   - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed
1120     * @param bool $short Short circuit on first error
1121     * @param LinkTarget $page
1122     */
1123    private function checkCascadingSourcesRestrictions(
1124        $action,
1125        UserIdentity $user,
1126        PermissionStatus $status,
1127        $rigor,
1128        $short,
1129        LinkTarget $page
1130    ): void {
1131        // TODO: remove & rework upon further use of LinkTarget
1132        $title = Title::newFromLinkTarget( $page );
1133        if ( $rigor !== self::RIGOR_QUICK && !$title->isUserConfigPage() ) {
1134            [ $cascadingSources, $restrictions ] = $this->restrictionStore->getCascadeProtectionSources( $title );
1135            // Cascading protection depends on more than this page...
1136            // Several cascading protected pages may include this page...
1137            // Check each cascading level
1138            // This is only for protection restrictions, not for all actions
1139            if ( isset( $restrictions[$action] ) ) {
1140                foreach ( $restrictions[$action] as $right ) {
1141                    // Backwards compatibility, rewrite sysop -> editprotected
1142                    if ( $right === 'sysop' ) {
1143                        $right = 'editprotected';
1144                    }
1145                    // Backwards compatibility, rewrite autoconfirmed -> editsemiprotected
1146                    if ( $right === 'autoconfirmed' ) {
1147                        $right = 'editsemiprotected';
1148                    }
1149                    if ( $right != '' && !$this->userHasAllRights( $user, 'protect', $right ) ) {
1150                        $wikiPages = '';
1151                        foreach ( $cascadingSources as $pageIdentity ) {
1152                            $wikiPages .= '* [[:' . $this->titleFormatter->getPrefixedText( $pageIdentity ) . "]]\n";
1153                        }
1154                        $status->fatal( 'cascadeprotected', count( $cascadingSources ), $wikiPages, $action );
1155                    }
1156                }
1157            }
1158        }
1159    }
1160
1161    /**
1162     * Check action permissions not already checked in checkQuickPermissions
1163     *
1164     * @param string $action The action to check
1165     * @param User $user User to check
1166     * @param PermissionStatus $status Current errors
1167     * @param string $rigor One of PermissionManager::RIGOR_ constants
1168     *   - RIGOR_QUICK  : does cheap permission checks from replica DBs (usable for GUI creation)
1169     *   - RIGOR_FULL   : does cheap and expensive checks possibly from a replica DB
1170     *   - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed
1171     * @param bool $short Short circuit on first error
1172     * @param LinkTarget $page
1173     */
1174    private function checkActionPermissions(
1175        $action,
1176        User $user,
1177        PermissionStatus $status,
1178        $rigor,
1179        $short,
1180        LinkTarget $page
1181    ): void {
1182        // TODO: remove & rework upon further use of LinkTarget
1183        $title = Title::newFromLinkTarget( $page );
1184
1185        if ( $rigor !== self::RIGOR_QUICK && !defined( 'MW_NO_SESSION' ) ) {
1186            $sessionRestrictions = $user->getRequest()->getSession()->getRestrictions();
1187            if ( $sessionRestrictions ) {
1188                $userCan = $sessionRestrictions->userCan( $title );
1189                if ( !$userCan->isOK() ) {
1190                    $status->merge( $userCan );
1191                }
1192            }
1193        }
1194
1195        if ( $action === 'protect' ) {
1196            if ( !$this->getPermissionStatus( 'edit', $user, $title, $rigor, true )->isGood() ) {
1197                // If they can't edit, they shouldn't protect.
1198                $status->fatal( 'protect-cantedit' );
1199            }
1200        } elseif ( $action === 'create' ) {
1201            $createProtection = $this->restrictionStore->getCreateProtection( $title );
1202            if ( $createProtection ) {
1203                if ( $createProtection['permission'] == ''
1204                    || !$this->userHasRight( $user, $createProtection['permission'] )
1205                ) {
1206                    $protectUserIdentity = $this->userIdentityLookup
1207                        ->getUserIdentityByUserId( $createProtection['user'] );
1208                    $status->fatal(
1209                        'titleprotected',
1210                        $protectUserIdentity ? $protectUserIdentity->getName() : '',
1211                        $createProtection['reason']
1212                    );
1213                }
1214            }
1215        } elseif ( $action === 'move' ) {
1216            // Check for immobile pages
1217            if ( !$this->nsInfo->isMovable( $title->getNamespace() ) ) {
1218                // Specific message for this case
1219                $nsText = $title->getNsText();
1220                if ( $nsText === '' ) {
1221                    $nsText = wfMessage( 'blanknamespace' )->text();
1222                }
1223                $status->fatal( 'immobile-source-namespace', $nsText );
1224            } elseif ( !$title->isMovable() ) {
1225                // Less specific message for rarer cases
1226                $status->fatal( 'immobile-source-page' );
1227            }
1228        } elseif ( $action === 'move-target' ) {
1229            if ( !$this->nsInfo->isMovable( $title->getNamespace() ) ) {
1230                $nsText = $title->getNsText();
1231                if ( $nsText === '' ) {
1232                    $nsText = wfMessage( 'blanknamespace' )->text();
1233                }
1234                $status->fatal( 'immobile-target-namespace', $nsText );
1235            } elseif ( !$title->isMovable() ) {
1236                $status->fatal( 'immobile-target-page' );
1237            }
1238        } elseif ( $action === 'delete' || $action === 'delete-redirect' ) {
1239            $tempStatus = PermissionStatus::newEmpty();
1240            $this->checkPageRestrictions( 'edit', $user, $tempStatus, $rigor, true, $title );
1241            if ( $tempStatus->isGood() ) {
1242                $this->checkCascadingSourcesRestrictions( 'edit',
1243                    $user, $tempStatus, $rigor, true, $title );
1244            }
1245            if ( !$tempStatus->isGood() ) {
1246                // If protection keeps them from editing, they shouldn't be able to delete.
1247                $status->fatal( 'deleteprotected' );
1248            }
1249            if ( $rigor !== self::RIGOR_QUICK
1250                && $action === 'delete'
1251                && $this->options->get( MainConfigNames::DeleteRevisionsLimit )
1252                && !$this->userCan( 'bigdelete', $user, $title )
1253                && $title->isBigDeletion()
1254            ) {
1255                // NOTE: This check is deprecated since 1.37, see T288759
1256                $status->fatal(
1257                    'delete-toobig',
1258                    Message::numParam( $this->options->get( MainConfigNames::DeleteRevisionsLimit ) )
1259                );
1260            }
1261        } elseif ( $action === 'undelete' ) {
1262            if ( !$this->getPermissionStatus( 'edit', $user, $title, $rigor, true )->isGood() ) {
1263                // Undeleting implies editing
1264                $status->fatal( 'undelete-cantedit' );
1265            }
1266            if ( !$title->exists()
1267                && !$this->getPermissionStatus( 'create', $user, $title, $rigor, true )->isGood()
1268            ) {
1269                // Undeleting where nothing currently exists implies creating
1270                $status->fatal( 'undelete-cantcreate' );
1271            }
1272        } elseif ( $action === 'edit' ) {
1273            if ( $this->options->get( MainConfigNames::EmailConfirmToEdit )
1274                && !$user->isEmailConfirmed()
1275            ) {
1276                $status->fatal( 'confirmedittext' );
1277            }
1278
1279            if ( !$title->exists() ) {
1280                $status->merge(
1281                    $this->getPermissionStatus(
1282                        'create',
1283                        $user,
1284                        $title,
1285                        $rigor,
1286                        true
1287                    )
1288                );
1289            }
1290        }
1291    }
1292
1293    /**
1294     * Check permissions on special pages & namespaces
1295     *
1296     * @param string $action The action to check
1297     * @param UserIdentity $user User to check
1298     * @param PermissionStatus $status Current errors
1299     * @param string $rigor One of PermissionManager::RIGOR_ constants
1300     *   - RIGOR_QUICK  : does cheap permission checks from replica DBs (usable for GUI creation)
1301     *   - RIGOR_FULL   : does cheap and expensive checks possibly from a replica DB
1302     *   - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed
1303     * @param bool $short Short circuit on first error
1304     * @param LinkTarget $page
1305     */
1306    private function checkSpecialsAndNSPermissions(
1307        $action,
1308        UserIdentity $user,
1309        PermissionStatus $status,
1310        $rigor,
1311        $short,
1312        LinkTarget $page
1313    ): void {
1314        // TODO: remove & rework upon further use of LinkTarget
1315        $title = Title::newFromLinkTarget( $page );
1316
1317        // Only 'createaccount' can be performed on special pages,
1318        // which don't actually exist in the DB.
1319        if ( $title->getNamespace() === NS_SPECIAL
1320            && !in_array( $action, [ 'createaccount', 'autocreateaccount' ], true )
1321        ) {
1322            $status->fatal( 'ns-specialprotected' );
1323        }
1324
1325        // Check $wgNamespaceProtection for restricted namespaces
1326        if ( $this->isNamespaceProtected( $title->getNamespace(), $user )
1327            // Allow admins and oversighters to view deleted content, even if they
1328            // cannot restore it. See T362536.
1329            && !in_array( $action, [ 'deletedhistory', 'deletedtext', 'viewsuppressed' ], true )
1330        ) {
1331            $ns = $title->getNamespace() === NS_MAIN ?
1332                wfMessage( 'nstab-main' )->text() : $title->getNsText();
1333            if ( $title->getNamespace() === NS_MEDIAWIKI ) {
1334                $status->fatal( 'protectedinterface', $action );
1335            } else {
1336                $status->fatal( 'namespaceprotected', $ns, $action );
1337            }
1338        }
1339    }
1340
1341    /**
1342     * Check sitewide CSS/JSON/JS permissions
1343     *
1344     * @param string $action The action to check
1345     * @param UserIdentity $user User to check
1346     * @param PermissionStatus $status Current errors
1347     * @param string $rigor One of PermissionManager::RIGOR_ constants
1348     *   - RIGOR_QUICK  : does cheap permission checks from replica DBs (usable for GUI creation)
1349     *   - RIGOR_FULL   : does cheap and expensive checks possibly from a replica DB
1350     *   - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed
1351     * @param bool $short Short circuit on first error
1352     * @param LinkTarget $page
1353     */
1354    private function checkSiteConfigPermissions(
1355        $action,
1356        UserIdentity $user,
1357        PermissionStatus $status,
1358        $rigor,
1359        $short,
1360        LinkTarget $page
1361    ): void {
1362        // TODO: remove & rework upon further use of LinkTarget
1363        $title = Title::newFromLinkTarget( $page );
1364
1365        if ( $action === 'patrol' ) {
1366            return;
1367        }
1368
1369        if ( in_array( $action, [ 'deletedhistory', 'deletedtext', 'viewsuppressed' ], true ) ) {
1370            // Allow admins and oversighters to view deleted content, even if they
1371            // cannot restore it. See T202989
1372            // Not using the same handling in `getPermissionStatus` as the checks
1373            // for skipping `checkUserConfigPermissions` since normal admins can delete
1374            // user scripts, but not sitewide scripts
1375            return;
1376        }
1377
1378        // Sitewide CSS/JSON/JS/RawHTML changes, like all NS_MEDIAWIKI changes, also require the
1379        // editinterface right. That's implemented as a restriction so no check needed here.
1380        if ( $title->isSiteCssConfigPage() && !$this->userHasRight( $user, 'editsitecss' ) ) {
1381            $status->fatal( 'sitecssprotected', $action );
1382        } elseif ( $title->isSiteJsonConfigPage() && !$this->userHasRight( $user, 'editsitejson' ) ) {
1383            $status->fatal( 'sitejsonprotected', $action );
1384        } elseif ( $title->isSiteJsConfigPage() && !$this->userHasRight( $user, 'editsitejs' ) ) {
1385            $status->fatal( 'sitejsprotected', $action );
1386        }
1387        if ( $title->isRawHtmlMessage() && !$this->userCanEditRawHtmlPage( $user ) ) {
1388            $status->fatal( 'siterawhtmlprotected', $action );
1389        }
1390    }
1391
1392    /**
1393     * Check CSS/JSON/JS subpage permissions
1394     *
1395     * @param string $action The action to check
1396     * @param UserIdentity $user User to check
1397     * @param PermissionStatus $status Current errors
1398     * @param string $rigor One of PermissionManager::RIGOR_ constants
1399     *   - RIGOR_QUICK  : does cheap permission checks from replica DBs (usable for GUI creation)
1400     *   - RIGOR_FULL   : does cheap and expensive checks possibly from a replica DB
1401     *   - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed
1402     * @param bool $short Short circuit on first error
1403     * @param LinkTarget $page
1404     */
1405    private function checkUserConfigPermissions(
1406        $action,
1407        UserIdentity $user,
1408        PermissionStatus $status,
1409        $rigor,
1410        $short,
1411        LinkTarget $page
1412    ): void {
1413        // TODO: remove & rework upon further use of LinkTarget
1414        $title = Title::newFromLinkTarget( $page );
1415
1416        // Protect css/json/js subpages of user pages
1417        // XXX: this might be better using restrictions
1418        if ( preg_match( '/^' . preg_quote( $user->getName(), '/' ) . '\//', $title->getText() ) ) {
1419            // Users need editmyuser* to edit their own CSS/JSON/JS subpages.
1420            if (
1421                $title->isUserCssConfigPage()
1422                && !$this->userHasAnyRight( $user, 'editmyusercss', 'editusercss' )
1423            ) {
1424                $status->fatal( 'mycustomcssprotected', $action );
1425            } elseif (
1426                $title->isUserJsonConfigPage()
1427                && !$this->userHasAnyRight( $user, 'editmyuserjson', 'edituserjson' )
1428            ) {
1429                $status->fatal( 'mycustomjsonprotected', $action );
1430            } elseif (
1431                $title->isUserJsConfigPage()
1432                && !$this->userHasAnyRight( $user, 'editmyuserjs', 'edituserjs' )
1433            ) {
1434                $status->fatal( 'mycustomjsprotected', $action );
1435            } elseif (
1436                $title->isUserJsConfigPage()
1437                && !$this->userHasAnyRight( $user, 'edituserjs', 'editmyuserjsredirect' )
1438            ) {
1439                // T207750 - do not allow users to edit a redirect if they couldn't edit the target
1440                $target = $this->redirectLookup->getRedirectTarget( $title );
1441                if ( $target && (
1442                        !$target->inNamespace( NS_USER )
1443                        || !preg_match( '/^' . preg_quote( $user->getName(), '/' ) . '\//', $target->getText() )
1444                ) ) {
1445                    $status->fatal( 'mycustomjsredirectprotected', $action );
1446                }
1447            }
1448        } else {
1449            // Users need edituser* to edit others' CSS/JSON/JS subpages.
1450            // The checks to exclude deletion/suppression, which cannot be used for
1451            // attacks and should be excluded to avoid the situation where an
1452            // unprivileged user can post abusive content on their subpages
1453            // and only very highly privileged users could remove it,
1454            // are now a part of `getPermissionStatus` and this method isn't called.
1455            if (
1456                $title->isUserCssConfigPage()
1457                && !$this->userHasRight( $user, 'editusercss' )
1458            ) {
1459                $status->fatal( 'customcssprotected', $action );
1460            } elseif (
1461                $title->isUserJsonConfigPage()
1462                && !$this->userHasRight( $user, 'edituserjson' )
1463            ) {
1464                $status->fatal( 'customjsonprotected', $action );
1465            } elseif (
1466                $title->isUserJsConfigPage()
1467                && !$this->userHasRight( $user, 'edituserjs' )
1468            ) {
1469                $status->fatal( 'customjsprotected', $action );
1470            }
1471        }
1472    }
1473
1474    /**
1475     * Whether the user is generally allowed to perform the given action.
1476     *
1477     * @since 1.34
1478     * @param UserIdentity $user
1479     * @param string $action
1480     * @return bool True if allowed
1481     */
1482    public function userHasRight( UserIdentity $user, $action = '' ): bool {
1483        if ( $action === '' ) {
1484            // In the spirit of DWIM
1485            return true;
1486        }
1487        // Use strict parameter to avoid matching numeric 0 accidentally inserted
1488        // by misconfiguration: 0 == 'foo'
1489        return in_array( $action, $this->getImplicitRights(), true )
1490            || in_array( $action, $this->getUserPermissions( $user ), true );
1491    }
1492
1493    /**
1494     * Whether the user is generally allowed to perform at least one of the actions.
1495     *
1496     * @since 1.34
1497     * @param UserIdentity $user
1498     * @param string ...$actions
1499     * @return bool True if user is allowed to perform *any* of the actions
1500     */
1501    public function userHasAnyRight( UserIdentity $user, ...$actions ): bool {
1502        foreach ( $actions as $action ) {
1503            if ( $this->userHasRight( $user, $action ) ) {
1504                return true;
1505            }
1506        }
1507        return false;
1508    }
1509
1510    /**
1511     * Whether the user is allowed to perform all of the given actions.
1512     *
1513     * @since 1.34
1514     * @param UserIdentity $user
1515     * @param string ...$actions
1516     * @return bool True if user is allowed to perform *all* of the given actions
1517     */
1518    public function userHasAllRights( UserIdentity $user, ...$actions ): bool {
1519        foreach ( $actions as $action ) {
1520            if ( !$this->userHasRight( $user, $action ) ) {
1521                return false;
1522            }
1523        }
1524        return true;
1525    }
1526
1527    /**
1528     * Get the permissions this user has.
1529     *
1530     * @since 1.34
1531     * @param UserIdentity $user
1532     * @return string[] permission names
1533     */
1534    public function getUserPermissions( UserIdentity $user ): array {
1535        $rightsCacheKey = $this->getRightsCacheKey( $user );
1536        if ( !isset( $this->usersRights[ $rightsCacheKey ] ) ) {
1537            $userObj = $this->userFactory->newFromUserIdentity( $user );
1538            $rights = $this->groupPermissionsLookup->getGroupPermissions(
1539                $this->userGroupManager->getUserEffectiveGroups( $user )
1540            );
1541            // Hook requires a full User object
1542            $this->hookRunner->onUserGetRights( $userObj, $rights );
1543
1544            // Deny any rights denied by the user's session, unless this
1545            // endpoint has no sessions.
1546            if ( !defined( 'MW_NO_SESSION' ) ) {
1547                // FIXME: $userObj->getRequest().. need to be replaced with something else
1548                $allowedRights = $userObj->getRequest()->getSession()->getAllowedUserRights();
1549                if ( $allowedRights !== null ) {
1550                    $rights = array_intersect( $rights, $allowedRights );
1551                }
1552            }
1553
1554            // Hook requires a full User object
1555            $this->hookRunner->onUserGetRightsRemove( $userObj, $rights );
1556            // Force reindexation of rights when a hook has unset one of them
1557            $rights = array_values( array_unique( $rights ) );
1558
1559            // If BlockDisablesLogin is true, remove rights that anonymous
1560            // users don't have. This has to be done after the hooks so that
1561            // we know whether the user is exempt. (T129738)
1562            if (
1563                $userObj->isRegistered()
1564                && $this->options->get( MainConfigNames::BlockDisablesLogin )
1565            ) {
1566                $isExempt = in_array( 'ipblock-exempt', $rights, true );
1567                if ( $this->blockManager->getBlock(
1568                    $userObj,
1569                    $isExempt ? null : $userObj->getRequest()
1570                ) ) {
1571                    $anon = $this->userFactory->newAnonymous();
1572                    $rights = array_intersect( $rights, $this->getUserPermissions( $anon ) );
1573                }
1574            }
1575
1576            $this->usersRights[ $rightsCacheKey ] = $rights;
1577        } else {
1578            $rights = $this->usersRights[ $rightsCacheKey ];
1579        }
1580        foreach ( $this->temporaryUserRights[ $user->getId() ] ?? [] as $overrides ) {
1581            $rights = array_values( array_unique( array_merge( $rights, $overrides ) ) );
1582        }
1583        return $rights;
1584    }
1585
1586    /**
1587     * Clear the in-process permission cache for one or all users.
1588     *
1589     * @since 1.34
1590     * @param UserIdentity|null $user If a specific user is provided it will clear
1591     *  the permission cache only for that user.
1592     */
1593    public function invalidateUsersRightsCache( $user = null ): void {
1594        if ( $user !== null ) {
1595            $rightsCacheKey = $this->getRightsCacheKey( $user );
1596            unset( $this->usersRights[ $rightsCacheKey ] );
1597        } else {
1598            $this->usersRights = [];
1599        }
1600    }
1601
1602    /**
1603     * Get a unique key for user rights cache.
1604     *
1605     * @param UserIdentity $user
1606     * @return string
1607     */
1608    private function getRightsCacheKey( UserIdentity $user ): string {
1609        return $user->isRegistered() ? "u:{$user->getId()}" : "anon:{$user->getName()}";
1610    }
1611
1612    /**
1613     * Check if all users may be assumed to have the given permission
1614     *
1615     * We generally assume so if the right is granted to '*' and isn't revoked
1616     * on any group. It doesn't attempt to take grants or other extension
1617     * limitations on rights into account in the general case, though, as that
1618     * would require it to always return false and defeat the purpose.
1619     * Specifically, session-based rights restrictions (such as OAuth or bot
1620     * passwords) are applied based on the current session.
1621     *
1622     * @since 1.34
1623     * @param string $right Right to check
1624     * @return bool
1625     */
1626    public function isEveryoneAllowed( $right ): bool {
1627        // Use the cached results, except in unit tests which rely on
1628        // being able change the permission mid-request
1629        if ( isset( $this->cachedRights[$right] ) ) {
1630            return $this->cachedRights[$right];
1631        }
1632
1633        if ( !isset( $this->options->get( MainConfigNames::GroupPermissions )['*'][$right] )
1634            || !$this->options->get( MainConfigNames::GroupPermissions )['*'][$right]
1635        ) {
1636            $this->cachedRights[$right] = false;
1637            return false;
1638        }
1639
1640        // If it's revoked anywhere, then everyone doesn't have it
1641        foreach ( $this->options->get( MainConfigNames::RevokePermissions ) as $rights ) {
1642            if ( isset( $rights[$right] ) && $rights[$right] ) {
1643                $this->cachedRights[$right] = false;
1644                return false;
1645            }
1646        }
1647
1648        // Remove any rights that aren't allowed to the global-session user,
1649        // unless there are no sessions for this endpoint.
1650        if ( !defined( 'MW_NO_SESSION' ) ) {
1651            // XXX: think what could be done with the below
1652            $allowedRights = SessionManager::getGlobalSession()->getAllowedUserRights();
1653            if ( $allowedRights !== null && !in_array( $right, $allowedRights, true ) ) {
1654                $this->cachedRights[$right] = false;
1655                return false;
1656            }
1657        }
1658
1659        // Allow extensions to say false
1660        if ( !$this->hookRunner->onUserIsEveryoneAllowed( $right ) ) {
1661            $this->cachedRights[$right] = false;
1662            return false;
1663        }
1664
1665        $this->cachedRights[$right] = true;
1666        return true;
1667    }
1668
1669    /**
1670     * Get a list of all permissions that can be managed through group permissions.
1671     * This does not include implicit rights which are granted to all users automatically.
1672     *
1673     * @see getImplicitRights()
1674     *
1675     * @since 1.34
1676     * @return string[] Array of permission names
1677     */
1678    public function getAllPermissions(): array {
1679        if ( $this->allRights === null ) {
1680            if ( count( $this->options->get( MainConfigNames::AvailableRights ) ) ) {
1681                $this->allRights = array_unique( array_merge(
1682                    self::CORE_RIGHTS,
1683                    $this->options->get( MainConfigNames::AvailableRights )
1684                ) );
1685            } else {
1686                $this->allRights = self::CORE_RIGHTS;
1687            }
1688            $this->hookRunner->onUserGetAllRights( $this->allRights );
1689        }
1690        return $this->allRights;
1691    }
1692
1693    /**
1694     * Get a list of implicit rights.
1695     *
1696     * Rights in this list should be granted to all users implicitly.
1697     *
1698     * Implicit rights are defined to allow rate limits to be imposed
1699     * on permissions
1700     *
1701     * @since 1.41
1702     * @return string[] Array of permission names
1703     */
1704    public function getImplicitRights(): array {
1705        if ( $this->implicitRights === null ) {
1706            $rights = array_unique( array_merge(
1707                self::CORE_IMPLICIT_RIGHTS,
1708                $this->options->get( MainConfigNames::ImplicitRights )
1709            ) );
1710
1711            $this->implicitRights = array_diff( $rights, $this->getAllPermissions() );
1712        }
1713        return $this->implicitRights;
1714    }
1715
1716    /**
1717     * Determine if $user is unable to edit pages in namespace because it has been protected.
1718     *
1719     * @param int $index
1720     * @param UserIdentity $user
1721     * @return bool
1722     */
1723    private function isNamespaceProtected( $index, UserIdentity $user ): bool {
1724        $namespaceProtection = $this->options->get( MainConfigNames::NamespaceProtection );
1725        if ( isset( $namespaceProtection[$index] ) ) {
1726            return !$this->userHasAllRights( $user, ...(array)$namespaceProtection[$index] );
1727        }
1728        return false;
1729    }
1730
1731    /**
1732     * Determine which restriction levels it makes sense to use in a namespace,
1733     * optionally filtered by a user's rights.
1734     *
1735     * @param int $index Namespace ID (index) to check
1736     * @param UserIdentity|null $user User to check
1737     * @return string[]
1738     */
1739    public function getNamespaceRestrictionLevels( $index, ?UserIdentity $user = null ): array {
1740        if ( !isset( $this->options->get( MainConfigNames::NamespaceProtection )[$index] ) ) {
1741            // All levels are valid if there's no namespace restriction.
1742            // But still filter by user, if necessary
1743            $levels = $this->options->get( MainConfigNames::RestrictionLevels );
1744            if ( $user ) {
1745                $levels = array_values( array_filter( $levels, function ( $level ) use ( $user ) {
1746                    $right = $level;
1747                    if ( $right === 'sysop' ) {
1748                        $right = 'editprotected'; // BC
1749                    }
1750                    if ( $right === 'autoconfirmed' ) {
1751                        $right = 'editsemiprotected'; // BC
1752                    }
1753                    return $this->userHasRight( $user, $right );
1754                } ) );
1755            }
1756            return $levels;
1757        }
1758
1759        // $wgNamespaceProtection can require one or more rights to edit the namespace, which
1760        // may be satisfied by membership in multiple groups each giving a subset of those rights.
1761        // A restriction level is redundant if, for any one of the namespace rights, all groups
1762        // giving that right also give the restriction level's right. Or, conversely, a
1763        // restriction level is not redundant if, for every namespace right, there's at least one
1764        // group giving that right without the restriction level's right.
1765        //
1766        // First, for each right, get a list of groups with that right.
1767        $namespaceRightGroups = [];
1768        foreach ( (array)$this->options->get( MainConfigNames::NamespaceProtection )[$index] as $right ) {
1769            if ( $right === 'sysop' ) {
1770                $right = 'editprotected'; // BC
1771            }
1772            if ( $right === 'autoconfirmed' ) {
1773                $right = 'editsemiprotected'; // BC
1774            }
1775            if ( $right != '' ) {
1776                $namespaceRightGroups[$right] = $this->groupPermissionsLookup->getGroupsWithPermission( $right );
1777            }
1778        }
1779
1780        // Now, go through the protection levels one by one.
1781        $usableLevels = [ '' ];
1782        foreach ( $this->options->get( MainConfigNames::RestrictionLevels ) as $level ) {
1783            $right = $level;
1784            if ( $right === 'sysop' ) {
1785                $right = 'editprotected'; // BC
1786            }
1787            if ( $right === 'autoconfirmed' ) {
1788                $right = 'editsemiprotected'; // BC
1789            }
1790
1791            if ( $right != '' &&
1792                !isset( $namespaceRightGroups[$right] ) &&
1793                ( !$user || $this->userHasRight( $user, $right ) )
1794            ) {
1795                // Do any of the namespace rights imply the restriction right? (see explanation above)
1796                foreach ( $namespaceRightGroups as $groups ) {
1797                    if ( !array_diff( $groups, $this->groupPermissionsLookup->getGroupsWithPermission( $right ) ) ) {
1798                        // Yes, this one does.
1799                        continue 2;
1800                    }
1801                }
1802                // No, keep the restriction level
1803                $usableLevels[] = $level;
1804            }
1805        }
1806
1807        return $usableLevels;
1808    }
1809
1810    /**
1811     * Check if user is allowed to edit sitewide pages that contain raw HTML.
1812     *
1813     * Pages listed in $wgRawHtmlMessages allow raw HTML which can be used to deploy CSS or JS
1814     * code to all users so both rights are required to edit them.
1815     *
1816     * @param UserIdentity $user
1817     * @return bool True if user has both rights
1818     */
1819    private function userCanEditRawHtmlPage( UserIdentity $user ): bool {
1820        return $this->userHasAllRights( $user, 'editsitecss', 'editsitejs' );
1821    }
1822
1823    /**
1824     * Add temporary user rights, only valid for the current function scope.
1825     *
1826     * This is meant for making it possible to programatically trigger certain actions that
1827     * the user wouldn't be able to trigger themselves; e.g. allow users without the bot right
1828     * to make bot-flagged actions through certain special pages.
1829     *
1830     * This returns a "scope guard" variable. Its only purpose is to be stored in a variable
1831     * by the caller, which is automatically closed at the end of the function, at which point
1832     * the rights are revoked again. Alternatively, you can close it earlier by consuming it
1833     * via ScopedCallback::consume().
1834     *
1835     * @since 1.34
1836     * @param UserIdentity $user
1837     * @param string|string[] $rights
1838     * @return ScopedCallback
1839     */
1840    public function addTemporaryUserRights( UserIdentity $user, $rights ) {
1841        $userId = $user->getId();
1842        $nextKey = count( $this->temporaryUserRights[$userId] ?? [] );
1843        $this->temporaryUserRights[$userId][$nextKey] = (array)$rights;
1844        return new ScopedCallback( function () use ( $userId, $nextKey ) {
1845            unset( $this->temporaryUserRights[$userId][$nextKey] );
1846        } );
1847    }
1848
1849    /**
1850     * Override the user permissions cache
1851     *
1852     * @internal For testing only
1853     * @since 1.34
1854     * @param UserIdentity $user
1855     * @param string[]|string $rights
1856     */
1857    public function overrideUserRightsForTesting( $user, $rights = [] ) {
1858        if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
1859            throw new LogicException( __METHOD__ . ' can not be called outside of tests' );
1860        }
1861        $this->usersRights[ $this->getRightsCacheKey( $user ) ] =
1862            is_array( $rights ) ? $rights : [ $rights ];
1863    }
1864
1865}