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