Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
111 / 111
100.00% covered (success)
100.00%
28 / 28
CRAP
100.00% covered (success)
100.00%
1 / 1
AbuseFilterPermissionManager
100.00% covered (success)
100.00%
111 / 111
100.00% covered (success)
100.00%
28 / 28
69
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 canEdit
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 canEditGlobal
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 canEditFilter
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 canEditFilterWithRestrictedActions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 canViewPrivateFilters
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 canViewSuppressed
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 canSuppress
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 canViewProtectedVariablesInFilter
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getCacheKey
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 canViewProtectedVariables
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 checkCanViewProtectedVariables
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 getUsedProtectedVariables
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getForbiddenVariables
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getProtectedVariables
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 canViewPrivateFiltersLogs
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 canViewAbuseLog
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 canHideAbuseLog
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 canRevertFilterActions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 canViewTemporaryAccountIPs
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 canSeeIPForFilterLog
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 canSeeLogDetailsForFilter
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
8
 canSeeLogDetails
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 canSeePrivateDetails
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 canSeeHiddenLogEntries
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 canUseTestTools
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasRevisionAccess
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
7
 hasRCEntryAccess
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3namespace MediaWiki\Extension\AbuseFilter;
4
5use LogicException;
6use MediaWiki\Extension\AbuseFilter\Filter\AbstractFilter;
7use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner;
8use MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerFactory;
9use MediaWiki\Extension\AbuseFilter\Variables\AbuseFilterProtectedVariablesLookup;
10use MediaWiki\Logging\LogPage;
11use MediaWiki\MediaWikiServices;
12use MediaWiki\Permissions\Authority;
13use MediaWiki\RecentChanges\RCCacheEntry;
14use MediaWiki\RecentChanges\RecentChange;
15use MediaWiki\Registration\ExtensionRegistry;
16use MediaWiki\Revision\RevisionRecord;
17use MediaWiki\User\TempUser\TempUserConfig;
18use Wikimedia\ObjectCache\MapCacheLRU;
19
20/**
21 * This class simplifies the interactions between the AbuseFilter code and Authority, knowing
22 * what rights are required to perform AF-related actions.
23 */
24class AbuseFilterPermissionManager {
25    public const SERVICE_NAME = 'AbuseFilterPermissionManager';
26
27    /**
28     * @var string[] All protected variables
29     */
30    private array $protectedVariables;
31
32    private MapCacheLRU $canViewProtectedVariablesCache;
33
34    public function __construct(
35        private readonly TempUserConfig $tempUserConfig,
36        private readonly ExtensionRegistry $extensionRegistry,
37        AbuseFilterProtectedVariablesLookup $protectedVariablesLookup,
38        private readonly RuleCheckerFactory $ruleCheckerFactory,
39        private readonly AbuseFilterHookRunner $hookRunner
40    ) {
41        $this->protectedVariables = $protectedVariablesLookup->getAllProtectedVariables();
42
43        $this->canViewProtectedVariablesCache = new MapCacheLRU( 10 );
44    }
45
46    public function canEdit( Authority $performer ): bool {
47        $block = $performer->getBlock();
48        return (
49            !( $block && $block->isSitewide() ) &&
50            $performer->isAllowed( 'abusefilter-modify' )
51        );
52    }
53
54    public function canEditGlobal( Authority $performer ): bool {
55        return $performer->isAllowed( 'abusefilter-modify-global' );
56    }
57
58    /**
59     * Whether the user can edit the given filter.
60     *
61     * @param Authority $performer
62     * @param AbstractFilter $filter
63     * @return bool
64     */
65    public function canEditFilter( Authority $performer, AbstractFilter $filter ): bool {
66        // A user with viewsuppressed can view suppressed filters but if they lack
67        // the suppressrevision right then they shouldn't be able to edit it (T414011)
68        if ( $filter->isSuppressed() && !$this->canSuppress( $performer ) ) {
69            return false;
70        }
71
72        return (
73            $this->canEdit( $performer ) &&
74            !( $filter->isGlobal() && !$this->canEditGlobal( $performer ) )
75        );
76    }
77
78    /**
79     * Whether the user can edit a filter with restricted actions enabled.
80     *
81     * @param Authority $performer
82     * @return bool
83     */
84    public function canEditFilterWithRestrictedActions( Authority $performer ): bool {
85        return $performer->isAllowed( 'abusefilter-modify-restricted' );
86    }
87
88    public function canViewPrivateFilters( Authority $performer ): bool {
89        $block = $performer->getBlock();
90        return (
91            !( $block && $block->isSitewide() ) &&
92            $performer->isAllowedAny(
93                'abusefilter-modify',
94                'abusefilter-view-private'
95            )
96        );
97    }
98
99    /**
100     * Can the user view a suppressed filter or log entry?
101     *
102     * @param Authority $performer
103     * @return bool
104     */
105    public function canViewSuppressed( Authority $performer ): bool {
106        $block = $performer->getBlock();
107        return (
108            !( $block && $block->isSitewide() ) &&
109            $performer->isAllowed( 'viewsuppressed' )
110        );
111    }
112
113    /**
114     * Can the user suppress a filter or log entry?
115     *
116     * @param Authority $performer
117     * @return bool
118     */
119    public function canSuppress( Authority $performer ): bool {
120        $block = $performer->getBlock();
121        return (
122            !( $block && $block->isSitewide() ) &&
123            $performer->isAllowed( 'suppressrevision' )
124        );
125    }
126
127    /**
128     * Whether the given user can see all of the protected variables used in the given filter.
129     *
130     * @param Authority $performer
131     * @param AbstractFilter $filter
132     * @return AbuseFilterPermissionStatus
133     * @throws LogicException If the provided $filter is not protected. Check if the filter is protected using
134     *   {@link AbstractFilter::isProtected} before calling this method.
135     */
136    public function canViewProtectedVariablesInFilter(
137        Authority $performer, AbstractFilter $filter
138    ): AbuseFilterPermissionStatus {
139        if ( !$filter->isProtected() ) {
140            throw new LogicException(
141                '::canViewProtectedVariablesInFilter should not be called when the provided $filter is not protected'
142            );
143        }
144        $ruleChecker = $this->ruleCheckerFactory->newRuleChecker();
145        $usedVars = $ruleChecker->getUsedVars( $filter->getRules() );
146        return $this->canViewProtectedVariables( $performer, $usedVars );
147    }
148
149    /**
150     * Returns the cache key used to access the MapCacheLRU instance that
151     * caches the return values of {@link self::canViewProtectedVariables}.
152     *
153     * @param Authority $performer
154     * @param array $variables
155     * @return string
156     */
157    private function getCacheKey( Authority $performer, array $variables ): string {
158        // Sort the $variables array as the order of the variables will not affect
159        // the return value from the cached methods.
160        sort( $variables );
161
162        return $performer->getUser()->getId() . '-' . implode( ',', $variables );
163    }
164
165    /**
166     * Whether the given user can see all of the specified protected variables.
167     *
168     * @param Authority $performer
169     * @param string[] $variables The variables, which do not need to filtered to just protected variables.
170     * @return AbuseFilterPermissionStatus
171     */
172    public function canViewProtectedVariables( Authority $performer, array $variables ): AbuseFilterPermissionStatus {
173        $variables = $this->getUsedProtectedVariables( $variables );
174
175        // Check if we have the result in cache, and return it if we do.
176        $cacheKey = $this->getCacheKey( $performer, $variables );
177        if ( $this->canViewProtectedVariablesCache->has( $cacheKey ) ) {
178            return $this->canViewProtectedVariablesCache->get( $cacheKey );
179        }
180
181        $returnStatus = $this->checkCanViewProtectedVariables( $performer );
182        if ( !$returnStatus->isGood() ) {
183            $this->canViewProtectedVariablesCache->set( $cacheKey, $returnStatus );
184            return $returnStatus;
185        }
186
187        $this->hookRunner->onAbuseFilterCanViewProtectedVariables( $performer, $variables, $returnStatus );
188
189        $this->canViewProtectedVariablesCache->set( $cacheKey, $returnStatus );
190        return $returnStatus;
191    }
192
193    /**
194     * Checks that the user is allowed to see protected variables without
195     * checking variable specific restrictions.
196     *
197     * @param Authority $performer
198     * @return AbuseFilterPermissionStatus
199     */
200    private function checkCanViewProtectedVariables( Authority $performer ): AbuseFilterPermissionStatus {
201        $block = $performer->getBlock();
202        if ( $block && $block->isSitewide() ) {
203            return AbuseFilterPermissionStatus::newBlockedError( $block );
204        }
205
206        if ( !$performer->isAllowed( 'abusefilter-access-protected-vars' ) ) {
207            return AbuseFilterPermissionStatus::newPermissionError( 'abusefilter-access-protected-vars' );
208        }
209
210        return AbuseFilterPermissionStatus::newGood();
211    }
212
213    /**
214     * Return all used protected variables from an array of variables. Ignore user permissions.
215     *
216     * @param string[] $usedVariables
217     * @return string[] The protected variables in $usedVariables, with any duplicates removed.
218     */
219    public function getUsedProtectedVariables( array $usedVariables ): array {
220        return array_intersect( $this->protectedVariables, $usedVariables );
221    }
222
223    /**
224     * Check if the filter uses variables that the user is not allowed to use (i.e., variables that are protected, if
225     * the user can't view protected variables), and return them.
226     *
227     * @param Authority $performer
228     * @param string[] $usedVariables
229     * @return string[]
230     */
231    public function getForbiddenVariables( Authority $performer, array $usedVariables ): array {
232        $usedProtectedVariables = $this->getUsedProtectedVariables( $usedVariables );
233        // All good if protected variables aren't used, or the user can view them.
234        if (
235            count( $usedProtectedVariables ) === 0 ||
236            $this->canViewProtectedVariables( $performer, $usedProtectedVariables )->isGood()
237        ) {
238            return [];
239        }
240        return $usedProtectedVariables;
241    }
242
243    /**
244     * Return an array of protected variables. Convenience method that calls
245     * {@link AbuseFilterProtectedVariablesLookup::getAllProtectedVariables}.
246     *
247     * @return string[]
248     */
249    public function getProtectedVariables() {
250        return $this->protectedVariables;
251    }
252
253    public function canViewPrivateFiltersLogs( Authority $performer ): bool {
254        return $this->canViewPrivateFilters( $performer ) ||
255            $performer->isAllowed( 'abusefilter-log-private' );
256    }
257
258    public function canViewAbuseLog( Authority $performer ): bool {
259        return $performer->isAllowed( 'abusefilter-log' );
260    }
261
262    public function canHideAbuseLog( Authority $performer ): bool {
263        return $performer->isAllowed( 'abusefilter-hide-log' );
264    }
265
266    public function canRevertFilterActions( Authority $performer ): bool {
267        return $performer->isAllowed( 'abusefilter-revert' );
268    }
269
270    /**
271     * Check whether an authority can view temporary account IP addresses, as determined
272     * by the CheckUser extension (if loaded). If they can, this overrides any restrictions
273     * on seeing IP addresses due to not having the necessary AbuseFilter permissions.
274     */
275    private function canViewTemporaryAccountIPs( Authority $performer ): bool {
276        return $this->extensionRegistry->isLoaded( 'CheckUser' ) &&
277            MediaWikiServices::getInstance()->getService( 'CheckUserPermissionManager' )
278                ->canAccessTemporaryAccountIPAddresses( $performer )->isGood();
279    }
280
281    /**
282     * Check whether an authority can see IP addresses for logs of a given filter. This may
283     * differ depending on whether the log entry performer is a temporary user.
284     *
285     * @param Authority $performer
286     * @param AbstractFilter $filter
287     * @param string $userName Name of the performing user for the log entry
288     * @return bool
289     */
290    public function canSeeIPForFilterLog(
291        Authority $performer,
292        AbstractFilter $filter,
293        string $userName
294    ) {
295        if ( $this->canSeeLogDetailsForFilter( $performer, $filter ) ) {
296            return true;
297        }
298
299        if (
300            $this->tempUserConfig->isTempName( $userName ) &&
301            $this->canViewTemporaryAccountIPs( $performer )
302        ) {
303            return true;
304        }
305
306        return false;
307    }
308
309    /**
310     * Checks if a user can see log details associated with a given filter.
311     *
312     * If the filter is protected, you should call {@link self::canViewProtectedVariables} providing the variables
313     * present in the log details.
314     *
315     * @param Authority $performer
316     * @param AbstractFilter $filter
317     * @return bool
318     */
319    public function canSeeLogDetailsForFilter( Authority $performer, AbstractFilter $filter ): bool {
320        if ( !$this->canSeeLogDetails( $performer ) ) {
321            return false;
322        }
323
324        if ( $filter->isSuppressed() && !$this->canViewSuppressed( $performer ) ) {
325            return false;
326        }
327
328        if ( $filter->isHidden() && !$this->canViewPrivateFiltersLogs( $performer ) ) {
329            return false;
330        }
331
332        // Callers are expected to check access to the specific protected variables used in the given
333        // log entries. This is because the variables in the logs may be different to the current filter.
334        // We don't want to prevent access to past logs based on the variables currently in the filter,
335        // to avoid hiding logs which the user should be able to see otherwise.
336        if ( $filter->isProtected() && !$this->canViewProtectedVariables( $performer, [] )->isGood() ) {
337            return false;
338        }
339
340        return true;
341    }
342
343    public function canSeeLogDetails( Authority $performer ): bool {
344        return $performer->isAllowed( 'abusefilter-log-detail' );
345    }
346
347    public function canSeePrivateDetails( Authority $performer ): bool {
348        return $performer->isAllowed( 'abusefilter-privatedetails' );
349    }
350
351    public function canSeeHiddenLogEntries( Authority $performer ): bool {
352        return $performer->isAllowed( 'abusefilter-hidden-log' );
353    }
354
355    public function canUseTestTools( Authority $performer ): bool {
356        // TODO: make independent
357        return $this->canViewPrivateFilters( $performer );
358    }
359
360    /**
361     * Determine whether the current user is allowed to view a revision
362     * at all, given its current visibility restrictions.
363     *
364     * Unlike `RevisionRecord::userCanBitfield`, which checks whether the user
365     * may view a specific deleted aspect of a revision (e.g. text or comment),
366     * this method evaluates whether the revision itself is viewable, considering
367     * all applicable visibility flags together.
368     *
369     * @param int $visibility Current visibility bit field (the `rev_deleted` value)
370     * @param Authority $authority User on whose behalf to check access
371     * @return bool
372     * @todo Consider moving this to core if similar logic is needed elsewhere
373     * @see RevisionRecord::userCanBitfield
374     */
375    public static function hasRevisionAccess( int $visibility, Authority $authority ): bool {
376        if ( !$visibility ) {
377            return true;
378        }
379        if ( $visibility & RevisionRecord::DELETED_RESTRICTED ) {
380            // Suppressed revisions require suppressor rights regardless of other flags
381            return $authority->isAllowedAny( 'suppressrevision', 'viewsuppressed' );
382        }
383        if ( ( $visibility & RevisionRecord::DELETED_TEXT ) && !$authority->isAllowed( 'deletedtext' ) ) {
384            return false;
385        }
386        if ( ( $visibility & ( RevisionRecord::DELETED_COMMENT | RevisionRecord::DELETED_USER ) ) &&
387            !$authority->isAllowed( 'deletedhistory' )
388        ) {
389            return false;
390        }
391        return true;
392    }
393
394    /**
395     * Determine whether the current user is allowed to view a recent change row
396     * at all, given its source and current visibility restrictions.
397     *
398     * Unlike `ChangesList::userCan`, which checks whether the user may view a
399     * specific deleted aspect of a recent change and delegates to
400     * `LogEventsList::userCanBitfield` for log entries or
401     * `RevisionRecord::userCanBitfield` otherwise, this method evaluates whether
402     * the recent change row itself is viewable, considering all applicable
403     * visibility flags together, and delegates to `::hasRevisionAccess` for
404     * non-log entries.
405     *
406     * @param RCCacheEntry|RecentChange $rc
407     * @param Authority $authority User on whose behalf to check
408     * @return bool
409     * @see ChangesList::userCan
410     * @see LogEventsList::userCanBitfield
411     * @see RevisionRecord::userCanBitfield
412     * @see AbuseFilterPermissionManager::hasRevisionAccess
413     */
414    public static function hasRCEntryAccess( $rc, Authority $authority ): bool {
415        $visibility = (int)$rc->getAttribute( 'rc_deleted' );
416        if ( $visibility === 0 ) {
417            return true;
418        }
419        if ( $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_LOG ) {
420            if ( $visibility & LogPage::DELETED_RESTRICTED ) {
421                return $authority->isAllowedAny( 'suppressrevision', 'viewsuppressed' );
422            } else {
423                return $authority->isAllowed( 'deletedhistory' );
424            }
425        }
426        return self::hasRevisionAccess( $visibility, $authority );
427    }
428
429}