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