Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.90% covered (success)
97.90%
140 / 143
85.00% covered (warning)
85.00%
17 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserAuthority
97.90% covered (success)
97.90%
140 / 143
85.00% covered (warning)
85.00%
17 / 20
51
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 setUseLimitCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUser
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isAllowed
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isAllowedAny
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 isAllowedAll
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 probablyCan
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 definitelyCan
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 isDefinitelyAllowed
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 authorizeAction
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 authorizeRead
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 authorizeWrite
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 internalAllowed
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
11
 internalCan
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
9
 limit
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
8
 getBlock
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
5
 getApplicableBlock
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 isRegistered
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isTemp
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isNamed
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20
21namespace MediaWiki\Permissions;
22
23use IDBAccessObject;
24use InvalidArgumentException;
25use MediaWiki\Block\Block;
26use MediaWiki\Block\BlockErrorFormatter;
27use MediaWiki\Context\IContextSource;
28use MediaWiki\Linker\LinkTarget;
29use MediaWiki\Page\PageIdentity;
30use MediaWiki\Request\WebRequest;
31use MediaWiki\Title\TitleValue;
32use MediaWiki\User\User;
33use MediaWiki\User\UserIdentity;
34use Wikimedia\Assert\Assert;
35use Wikimedia\DebugInfo\DebugInfoTrait;
36
37/**
38 * Represents the authority of a given User. For anonymous visitors, this will typically
39 * allow only basic permissions. For logged in users, permissions are generally based on group
40 * membership, but may be adjusted based on things like IP range blocks, OAuth grants, or
41 * rate limits.
42 *
43 * @note This is intended as an intermediate step towards an implementation of Authority that
44 * contains much of the logic currently in PermissionManager, and is based directly on
45 * WebRequest and Session, rather than a User object. However, for now, code that needs an
46 * Authority that reflects the current user and web request should use a User object directly.
47 *
48 * @unstable
49 * @since 1.36
50 */
51class UserAuthority implements Authority {
52
53    use DebugInfoTrait;
54
55    /**
56     * @var PermissionManager
57     * @noVarDump
58     */
59    private $permissionManager;
60
61    /**
62     * @var RateLimiter
63     * @noVarDump
64     */
65    private $rateLimiter;
66
67    /**
68     * @var User
69     * @noVarDump
70     */
71    private $actor;
72
73    /**
74     * Local cache for user block information. False is used to indicate that there is no block,
75     * while null indicates that we don't know and have to check.
76     * @var Block|false|null
77     */
78    private $userBlock = null;
79
80    /**
81     * Cache for the outcomes of rate limit checks.
82     * We cache the outcomes primarily so we don't bump the counter multiple times
83     * per request.
84     * @var array<string,array> Map of actions to [ int, bool ] pairs.
85     *      The first element is the increment performed so far (typically 1).
86     *      The second element is the cached outcome of the check (whether the limit was reached)
87     */
88    private $limitCache = [];
89
90    /**
91     * Whether the limit cache should be used. Generally, the limit cache should be used in web
92     * requests, since we don't want to bump the same limit more than once per request. It
93     * should not be used during testing, so limits can easily be tested without knowledge
94     * about the caching mechanism.
95     *
96     * @var bool
97     */
98    private bool $useLimitCache;
99
100    private WebRequest $request;
101    private IContextSource $uiContext;
102    private BlockErrorFormatter $blockErrorFormatter;
103
104    /**
105     * @param User $user
106     * @param WebRequest $request
107     * @param IContextSource $uiContext
108     * @param PermissionManager $permissionManager
109     * @param RateLimiter $rateLimiter
110     * @param BlockErrorFormatter $blockErrorFormatter
111     */
112    public function __construct(
113        User $user,
114        WebRequest $request,
115        IContextSource $uiContext,
116        PermissionManager $permissionManager,
117        RateLimiter $rateLimiter,
118        BlockErrorFormatter $blockErrorFormatter
119    ) {
120        $this->actor = $user;
121        $this->request = $request;
122        $this->uiContext = $uiContext;
123        $this->permissionManager = $permissionManager;
124        $this->rateLimiter = $rateLimiter;
125        $this->blockErrorFormatter = $blockErrorFormatter;
126        $this->useLimitCache = !defined( 'MW_PHPUNIT_TEST' );
127    }
128
129    /**
130     * @internal
131     * @param bool $useLimitCache
132     */
133    public function setUseLimitCache( bool $useLimitCache ) {
134        $this->useLimitCache = $useLimitCache;
135    }
136
137    /** @inheritDoc */
138    public function getUser(): UserIdentity {
139        return $this->actor;
140    }
141
142    /** @inheritDoc */
143    public function isAllowed( string $permission, PermissionStatus $status = null ): bool {
144        return $this->internalAllowed( $permission, $status, false, null );
145    }
146
147    /** @inheritDoc */
148    public function isAllowedAny( ...$permissions ): bool {
149        if ( !$permissions ) {
150            throw new InvalidArgumentException( 'At least one permission must be specified' );
151        }
152
153        return $this->permissionManager->userHasAnyRight( $this->actor, ...$permissions );
154    }
155
156    /** @inheritDoc */
157    public function isAllowedAll( ...$permissions ): bool {
158        if ( !$permissions ) {
159            throw new InvalidArgumentException( 'At least one permission must be specified' );
160        }
161
162        return $this->permissionManager->userHasAllRights( $this->actor, ...$permissions );
163    }
164
165    /** @inheritDoc */
166    public function probablyCan(
167        string $action,
168        PageIdentity $target,
169        PermissionStatus $status = null
170    ): bool {
171        return $this->internalCan(
172            PermissionManager::RIGOR_QUICK,
173            $action,
174            $target,
175            $status,
176            false // do not check the rate limit
177        );
178    }
179
180    /** @inheritDoc */
181    public function definitelyCan(
182        string $action,
183        PageIdentity $target,
184        PermissionStatus $status = null
185    ): bool {
186        // Note that we do not use RIGOR_SECURE to avoid hitting the primary
187        // database for read operations. RIGOR_FULL performs the same checks,
188        // but is subject to replication lag.
189        return $this->internalCan(
190            PermissionManager::RIGOR_FULL,
191            $action,
192            $target,
193            $status,
194            0 // only check the rate limit, don't count it as a hit
195        );
196    }
197
198    /** @inheritDoc */
199    public function isDefinitelyAllowed( string $action, PermissionStatus $status = null ): bool {
200        $userBlock = $this->getApplicableBlock( PermissionManager::RIGOR_FULL, $action );
201        return $this->internalAllowed( $action, $status, 0, $userBlock );
202    }
203
204    /** @inheritDoc */
205    public function authorizeAction(
206        string $action,
207        PermissionStatus $status = null
208    ): bool {
209        // Any side-effects can be added here.
210
211        $userBlock = $this->getApplicableBlock( PermissionManager::RIGOR_SECURE, $action );
212
213        return $this->internalAllowed(
214            $action,
215            $status,
216            1,
217            $userBlock
218        );
219    }
220
221    /** @inheritDoc */
222    public function authorizeRead(
223        string $action,
224        PageIdentity $target,
225        PermissionStatus $status = null
226    ): bool {
227        // Any side-effects can be added here.
228
229        // Note that we do not use RIGOR_SECURE to avoid hitting the primary
230        // database for read operations. RIGOR_FULL performs the same checks,
231        // but is subject to replication lag.
232        return $this->internalCan(
233            PermissionManager::RIGOR_FULL,
234            $action,
235            $target,
236            $status,
237            1 // count a hit towards the rate limit
238        );
239    }
240
241    /** @inheritDoc */
242    public function authorizeWrite(
243        string $action,
244        PageIdentity $target,
245        PermissionStatus $status = null
246    ): bool {
247        // Any side-effects can be added here.
248
249        // Note that we need to use RIGOR_SECURE here to ensure that we do not
250        // miss a user block or page protection due to replication lag.
251        return $this->internalCan(
252            PermissionManager::RIGOR_SECURE,
253            $action,
254            $target,
255            $status,
256            1 // count a hit towards the rate limit
257        );
258    }
259
260    /**
261     * Check whether the user is allowed to perform the action, taking into account
262     * the user's block status as well as any rate limits.
263     *
264     * @param string $action
265     * @param PermissionStatus|null $status
266     * @param int|false $limitRate False means no check, 0 means check only,
267     *        and 1 means check and increment
268     * @param ?Block $userBlock
269     *
270     * @return bool
271     */
272    private function internalAllowed(
273        string $action,
274        ?PermissionStatus $status,
275        $limitRate,
276        ?Block $userBlock
277    ): bool {
278        if ( $status ) {
279            Assert::precondition(
280                $status->isGood(),
281                'The PermissionStatus passed as $status parameter must still be good'
282            );
283        }
284
285        if ( !$this->permissionManager->userHasRight( $this->actor, $action ) ) {
286            if ( !$status ) {
287                return false;
288            }
289
290            $status->setPermission( $action );
291            $status->merge(
292                $this->permissionManager->newFatalPermissionDeniedStatus(
293                    $action,
294                    $this->uiContext
295                )
296            );
297        }
298
299        if ( $userBlock ) {
300            if ( !$status ) {
301                return false;
302            }
303
304            $messages = $this->blockErrorFormatter->getMessages(
305                $userBlock,
306                $this->actor,
307                $this->request->getIP()
308            );
309
310            $status->setPermission( $action );
311            foreach ( $messages as $message ) {
312                $status->fatal( $message );
313            }
314        }
315
316        // Check and bump the rate limit.
317        if ( $limitRate !== false ) {
318            $isLimited = $this->limit( $action, $limitRate, $status );
319            if ( $isLimited && !$status ) {
320                return false;
321            }
322        }
323
324        return !$status || $status->isOK();
325    }
326
327    /**
328     * @param string $rigor
329     * @param string $action
330     * @param PageIdentity $target
331     * @param ?PermissionStatus $status
332     * @param int|false $limitRate False means no check, 0 means check only,
333     *        a non-zero values means check and increment
334     *
335     * @return bool
336     */
337    private function internalCan(
338        string $rigor,
339        string $action,
340        PageIdentity $target,
341        ?PermissionStatus $status,
342        $limitRate
343    ): bool {
344        // Check and bump the rate limit.
345        if ( $limitRate !== false ) {
346            $isLimited = $this->limit( $action, $limitRate, $status );
347            if ( $isLimited && !$status ) {
348                // bail early if we don't have a status object
349                return false;
350            }
351        }
352
353        if ( !( $target instanceof LinkTarget ) ) {
354            // TODO: PermissionManager should accept PageIdentity!
355            $target = TitleValue::newFromPage( $target );
356        }
357
358        if ( $status ) {
359            $status->setPermission( $action );
360
361            $errors = $this->permissionManager->getPermissionErrors(
362                $action,
363                $this->actor,
364                $target,
365                $rigor
366            );
367
368            foreach ( $errors as $err ) {
369                $status->fatal( wfMessage( ...$err ) );
370
371                // HACK: Detect whether the permission was denied because the user is blocked.
372                //       A similar hack exists in ApiBase::$blockMsgMap.
373                //       When permission checking logic is moved out of PermissionManager,
374                //       we can record the block info directly when first checking the block,
375                //       rather than doing that here.
376                if ( strpos( $err[0], 'blockedtext' ) !== false ) {
377                    $block = $this->getBlock();
378
379                    if ( $block ) {
380                        $status->setBlock( $block );
381                    }
382                }
383            }
384
385            return $status->isOK();
386        } else {
387            // allow PermissionManager to short-circuit
388            return $this->permissionManager->userCan(
389                $action,
390                $this->actor,
391                $target,
392                $rigor
393            );
394        }
395    }
396
397    /**
398     * Check whether a rate limit has been exceeded for the given action.
399     *
400     * @see RateLimiter::limit
401     * @internal For use by User::pingLimiter only.
402     *
403     * @param string $action
404     * @param int $incrBy
405     * @param PermissionStatus|null $status
406     *
407     * @return bool
408     */
409    public function limit( string $action, int $incrBy, ?PermissionStatus $status ): bool {
410        $isLimited = null;
411
412        if ( $this->useLimitCache && isset( $this->limitCache[ $action ] ) ) {
413            // subtract the increment that was already applied earlier
414            $incrRemaining = $incrBy - $this->limitCache[ $action ][ 0 ];
415
416            // if no increment is left to apply, return the cached outcome
417            if ( $incrRemaining < 1 ) {
418                $isLimited = $this->limitCache[ $action ][ 1 ];
419            }
420        } else {
421            $incrRemaining = $incrBy;
422        }
423
424        if ( $isLimited === null ) {
425            // NOTE: Avoid toRateLimitSubject() if possible, for performance
426            if ( $this->rateLimiter->isLimitable( $action ) ) {
427                $isLimited = $this->rateLimiter->limit(
428                    $this->actor->toRateLimitSubject(),
429                    $action,
430                    $incrRemaining
431                );
432            } else {
433                $isLimited = false;
434            }
435
436            // Cache the outcome, so we don't bump the counter twice during the same request.
437            $this->limitCache[ $action ] = [ $incrBy, $isLimited ];
438        }
439
440        if ( $isLimited && $status ) {
441            $status->setRateLimitExceeded();
442        }
443
444        return $isLimited;
445    }
446
447    /** @inheritDoc */
448    public function getBlock( int $freshness = IDBAccessObject::READ_NORMAL ): ?Block {
449        // Cache block info, so we don't have to fetch it again unnecessarily.
450        if ( $this->userBlock === null || $freshness === IDBAccessObject::READ_LATEST ) {
451            $this->userBlock = $this->actor->getBlock( $freshness );
452
453            // if we got null back, remember this as "false"
454            $this->userBlock = $this->userBlock ?: false;
455        }
456
457        // if we remembered "false", return null
458        return $this->userBlock ?: null;
459    }
460
461    private function getApplicableBlock(
462        string $rigor,
463        string $action,
464        ?PageIdentity $target = null
465    ): ?Block {
466        // NOTE: We follow the parameter order of internalCan here.
467        //       It doesn't match the one in PermissionManager.
468        return $this->permissionManager->getApplicableBlock(
469            $action,
470            $this->actor,
471            $rigor,
472            $target,
473            $this->request
474        );
475    }
476
477    public function isRegistered(): bool {
478        return $this->actor->isRegistered();
479    }
480
481    public function isTemp(): bool {
482        return $this->actor->isTemp();
483    }
484
485    public function isNamed(): bool {
486        return $this->actor->isNamed();
487    }
488}