Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.06% covered (success)
97.06%
495 / 510
70.00% covered (warning)
70.00%
21 / 30
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserGroupManager
97.06% covered (success)
97.06%
495 / 510
70.00% covered (warning)
70.00%
21 / 30
140
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 listAllGroups
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 listAllImplicitGroups
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 newGroupMembershipFromRow
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 loadGroupMembershipsFromArray
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 getUserImplicitGroups
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
7
 getUserEffectiveGroups
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
5
 getUserFormerGroups
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
5
 getUserAutopromoteGroups
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 getUserAutopromoteOnceGroups
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
8.01
 getUserPrivilegedGroups
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 recCheckCondition
85.19% covered (warning)
85.19%
23 / 27
0.00% covered (danger)
0.00%
0 / 1
16.83
 checkCondition
98.04% covered (success)
98.04%
50 / 51
0.00% covered (danger)
0.00%
0 / 1
18
 addUserToAutopromoteOnceGroups
97.73% covered (success)
97.73%
43 / 44
0.00% covered (danger)
0.00%
0 / 1
9
 getUserGroups
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUserGroupMemberships
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
6
 addUserToGroup
96.05% covered (success)
96.05%
73 / 76
0.00% covered (danger)
0.00%
0 / 1
16
 addUserToMultipleGroups
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 removeUserFromGroup
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
7
 newQueryBuilder
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 purgeExpired
97.44% covered (success)
97.44%
38 / 39
0.00% covered (danger)
0.00%
0 / 1
5
 expandChangeableGroupConfig
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
4.37
 getGroupsChangeableByGroup
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 getGroupsChangeableBy
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
3
 clearCache
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 setCache
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 clearUserCacheForKind
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getDBConnectionRefForQueryFlags
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getCacheKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 canUseCachedValues
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
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\User;
22
23use InvalidArgumentException;
24use LogicException;
25use MediaWiki\Config\ServiceOptions;
26use MediaWiki\Deferred\DeferredUpdates;
27use MediaWiki\HookContainer\HookContainer;
28use MediaWiki\HookContainer\HookRunner;
29use MediaWiki\JobQueue\JobQueueGroup;
30use MediaWiki\Logging\ManualLogEntry;
31use MediaWiki\MainConfigNames;
32use MediaWiki\Parser\Sanitizer;
33use MediaWiki\Permissions\Authority;
34use MediaWiki\Permissions\GroupPermissionsLookup;
35use MediaWiki\User\TempUser\TempUserConfig;
36use MediaWiki\WikiMap\WikiMap;
37use Psr\Log\LoggerInterface;
38use UserGroupExpiryJob;
39use Wikimedia\Assert\Assert;
40use Wikimedia\IPUtils;
41use Wikimedia\Rdbms\IConnectionProvider;
42use Wikimedia\Rdbms\IDBAccessObject;
43use Wikimedia\Rdbms\ILBFactory;
44use Wikimedia\Rdbms\IReadableDatabase;
45use Wikimedia\Rdbms\ReadOnlyMode;
46use Wikimedia\Rdbms\SelectQueryBuilder;
47
48/**
49 * Manage user group memberships.
50 *
51 * @since 1.35
52 * @ingroup User
53 */
54class UserGroupManager {
55
56    /**
57     * @internal For use by ServiceWiring
58     */
59    public const CONSTRUCTOR_OPTIONS = [
60        MainConfigNames::AddGroups,
61        MainConfigNames::AutoConfirmAge,
62        MainConfigNames::AutoConfirmCount,
63        MainConfigNames::Autopromote,
64        MainConfigNames::AutopromoteOnce,
65        MainConfigNames::AutopromoteOnceLogInRC,
66        MainConfigNames::AutopromoteOnceRCExcludedGroups,
67        MainConfigNames::EmailAuthentication,
68        MainConfigNames::ImplicitGroups,
69        MainConfigNames::GroupInheritsPermissions,
70        MainConfigNames::GroupPermissions,
71        MainConfigNames::GroupsAddToSelf,
72        MainConfigNames::GroupsRemoveFromSelf,
73        MainConfigNames::RevokePermissions,
74        MainConfigNames::RemoveGroups,
75        MainConfigNames::PrivilegedGroups,
76    ];
77
78    /**
79     * Logical operators recognized in $wgAutopromote.
80     *
81     * @since 1.42
82     */
83    public const VALID_OPS = [ '&', '|', '^', '!' ];
84
85    private ServiceOptions $options;
86    private IConnectionProvider $dbProvider;
87    private HookContainer $hookContainer;
88    private HookRunner $hookRunner;
89    private ReadOnlyMode $readOnlyMode;
90    private UserEditTracker $userEditTracker;
91    private GroupPermissionsLookup $groupPermissionsLookup;
92    private JobQueueGroup $jobQueueGroup;
93    private LoggerInterface $logger;
94    private TempUserConfig $tempUserConfig;
95
96    /** @var callable[] */
97    private $clearCacheCallbacks;
98
99    /** @var string|false */
100    private $wikiId;
101
102    /** string key for implicit groups cache */
103    private const CACHE_IMPLICIT = 'implicit';
104
105    /** string key for effective groups cache */
106    private const CACHE_EFFECTIVE = 'effective';
107
108    /** string key for group memberships cache */
109    private const CACHE_MEMBERSHIP = 'membership';
110
111    /** string key for former groups cache */
112    private const CACHE_FORMER = 'former';
113
114    /** string key for former groups cache */
115    private const CACHE_PRIVILEGED = 'privileged';
116
117    /**
118     * @var array Service caches, an assoc. array keyed after the user-keys generated
119     * by the getCacheKey method and storing values in the following format:
120     *
121     * userKey => [
122     *   self::CACHE_IMPLICIT => implicit groups cache
123     *   self::CACHE_EFFECTIVE => effective groups cache
124     *   self::CACHE_MEMBERSHIP => [ ] // Array of UserGroupMembership objects
125     *   self::CACHE_FORMER => former groups cache
126     *   self::CACHE_PRIVILEGED => privileged groups cache
127     * ]
128     */
129    private $userGroupCache = [];
130
131    /**
132     * @var array An assoc. array that stores query flags used to retrieve user groups
133     * from the database and is stored in the following format:
134     *
135     * userKey => [
136     *   self::CACHE_IMPLICIT => implicit groups query flag
137     *   self::CACHE_EFFECTIVE => effective groups query flag
138     *   self::CACHE_MEMBERSHIP => membership groups query flag
139     *   self::CACHE_FORMER => former groups query flag
140     *   self::CACHE_PRIVILEGED => privileged groups query flag
141     * ]
142     */
143    private $queryFlagsUsedForCaching = [];
144
145    /**
146     * @internal For use preventing an infinite loop when checking APCOND_BLOCKED
147     * @var array An assoc. array mapping the getCacheKey userKey to a bool indicating
148     * an ongoing condition check.
149     */
150    private $recursionMap = [];
151
152    /**
153     * @param ServiceOptions $options
154     * @param ReadOnlyMode $readOnlyMode
155     * @param ILBFactory $lbFactory
156     * @param HookContainer $hookContainer
157     * @param UserEditTracker $userEditTracker
158     * @param GroupPermissionsLookup $groupPermissionsLookup
159     * @param JobQueueGroup $jobQueueGroup
160     * @param LoggerInterface $logger
161     * @param TempUserConfig $tempUserConfig
162     * @param callable[] $clearCacheCallbacks
163     * @param string|false $wikiId
164     */
165    public function __construct(
166        ServiceOptions $options,
167        ReadOnlyMode $readOnlyMode,
168        ILBFactory $lbFactory,
169        HookContainer $hookContainer,
170        UserEditTracker $userEditTracker,
171        GroupPermissionsLookup $groupPermissionsLookup,
172        JobQueueGroup $jobQueueGroup,
173        LoggerInterface $logger,
174        TempUserConfig $tempUserConfig,
175        array $clearCacheCallbacks = [],
176        $wikiId = UserIdentity::LOCAL
177    ) {
178        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
179        $this->options = $options;
180        $this->dbProvider = $lbFactory;
181        $this->hookContainer = $hookContainer;
182        $this->hookRunner = new HookRunner( $hookContainer );
183        $this->userEditTracker = $userEditTracker;
184        $this->groupPermissionsLookup = $groupPermissionsLookup;
185        $this->jobQueueGroup = $jobQueueGroup;
186        $this->logger = $logger;
187        $this->tempUserConfig = $tempUserConfig;
188        $this->readOnlyMode = $readOnlyMode;
189        $this->clearCacheCallbacks = $clearCacheCallbacks;
190        $this->wikiId = $wikiId;
191    }
192
193    /**
194     * Return the set of defined explicit groups.
195     * The implicit groups (by default *, 'user' and 'autoconfirmed')
196     * are not included, as they are defined automatically, not in the database.
197     * @return string[] internal group names
198     */
199    public function listAllGroups(): array {
200        return array_values( array_unique(
201            array_diff(
202                array_merge(
203                    array_keys( $this->options->get( MainConfigNames::GroupPermissions ) ),
204                    array_keys( $this->options->get( MainConfigNames::RevokePermissions ) ),
205                    array_keys( $this->options->get( MainConfigNames::GroupInheritsPermissions ) )
206                ),
207                $this->listAllImplicitGroups()
208            )
209        ) );
210    }
211
212    /**
213     * Get a list of all configured implicit groups
214     * @return string[]
215     */
216    public function listAllImplicitGroups(): array {
217        return $this->options->get( MainConfigNames::ImplicitGroups );
218    }
219
220    /**
221     * Creates a new UserGroupMembership instance from $row.
222     * The fields required to build an instance could be
223     * found using getQueryInfo() method.
224     *
225     * @param \stdClass $row A database result object
226     *
227     * @return UserGroupMembership
228     */
229    public function newGroupMembershipFromRow( \stdClass $row ): UserGroupMembership {
230        return new UserGroupMembership(
231            (int)$row->ug_user,
232            $row->ug_group,
233            $row->ug_expiry === null ? null : wfTimestamp(
234                TS_MW,
235                $row->ug_expiry
236            )
237        );
238    }
239
240    /**
241     * Load the user groups cache from the provided user groups data
242     * @internal for use by the User object only
243     * @param UserIdentity $user
244     * @param array $userGroups an array of database query results
245     * @param int $queryFlags
246     */
247    public function loadGroupMembershipsFromArray(
248        UserIdentity $user,
249        array $userGroups,
250        int $queryFlags = IDBAccessObject::READ_NORMAL
251    ) {
252        $user->assertWiki( $this->wikiId );
253        $membershipGroups = [];
254        reset( $userGroups );
255        foreach ( $userGroups as $row ) {
256            $ugm = $this->newGroupMembershipFromRow( $row );
257            $membershipGroups[ $ugm->getGroup() ] = $ugm;
258        }
259        $this->setCache(
260            $this->getCacheKey( $user ),
261            self::CACHE_MEMBERSHIP,
262            $membershipGroups,
263            $queryFlags
264        );
265    }
266
267    /**
268     * Get the list of implicit group memberships this user has.
269     *
270     * This includes 'user' if logged in, '*' for all accounts,
271     * and autopromoted groups
272     *
273     * @param UserIdentity $user
274     * @param int $queryFlags
275     * @param bool $recache Whether to avoid the cache
276     * @return string[] internal group names
277     */
278    public function getUserImplicitGroups(
279        UserIdentity $user,
280        int $queryFlags = IDBAccessObject::READ_NORMAL,
281        bool $recache = false
282    ): array {
283        $user->assertWiki( $this->wikiId );
284        $userKey = $this->getCacheKey( $user );
285        if ( $recache ||
286            !isset( $this->userGroupCache[$userKey][self::CACHE_IMPLICIT] ) ||
287            !$this->canUseCachedValues( $user, self::CACHE_IMPLICIT, $queryFlags )
288        ) {
289            $groups = [ '*' ];
290            if ( $this->tempUserConfig->isTempName( $user->getName() ) ) {
291                $groups[] = 'temp';
292            } elseif ( $user->isRegistered() ) {
293                $groups[] = 'user';
294                $groups = array_unique( array_merge(
295                    $groups,
296                    $this->getUserAutopromoteGroups( $user )
297                ) );
298            }
299            $this->setCache( $userKey, self::CACHE_IMPLICIT, $groups, $queryFlags );
300            if ( $recache ) {
301                // Assure data consistency with rights/groups,
302                // as getUserEffectiveGroups() depends on this function
303                $this->clearUserCacheForKind( $user, self::CACHE_EFFECTIVE );
304            }
305        }
306        return $this->userGroupCache[$userKey][self::CACHE_IMPLICIT];
307    }
308
309    /**
310     * Get the list of implicit group memberships the user has.
311     *
312     * This includes all explicit groups, plus 'user' if logged in,
313     * '*' for all accounts, and autopromoted groups
314     *
315     * @param UserIdentity $user
316     * @param int $queryFlags
317     * @param bool $recache Whether to avoid the cache
318     * @return string[] internal group names
319     */
320    public function getUserEffectiveGroups(
321        UserIdentity $user,
322        int $queryFlags = IDBAccessObject::READ_NORMAL,
323        bool $recache = false
324    ): array {
325        $user->assertWiki( $this->wikiId );
326        $userKey = $this->getCacheKey( $user );
327        // Ignore cache if the $recache flag is set, cached values can not be used
328        // or the cache value is missing
329        if ( $recache ||
330            !$this->canUseCachedValues( $user, self::CACHE_EFFECTIVE, $queryFlags ) ||
331            !isset( $this->userGroupCache[$userKey][self::CACHE_EFFECTIVE] )
332        ) {
333            $groups = array_unique( array_merge(
334                $this->getUserGroups( $user, $queryFlags ), // explicit groups
335                $this->getUserImplicitGroups( $user, $queryFlags, $recache ) // implicit groups
336            ) );
337            // TODO: Deprecate passing out user object in the hook by introducing
338            // an alternative hook
339            if ( $this->hookContainer->isRegistered( 'UserEffectiveGroups' ) ) {
340                $userObj = User::newFromIdentity( $user );
341                $userObj->load();
342                // Hook for additional groups
343                $this->hookRunner->onUserEffectiveGroups( $userObj, $groups );
344            }
345            // Force reindexation of groups when a hook has unset one of them
346            $effectiveGroups = array_values( array_unique( $groups ) );
347            $this->setCache( $userKey, self::CACHE_EFFECTIVE, $effectiveGroups, $queryFlags );
348        }
349        return $this->userGroupCache[$userKey][self::CACHE_EFFECTIVE];
350    }
351
352    /**
353     * Returns the groups the user has belonged to.
354     *
355     * The user may still belong to the returned groups. Compare with
356     * getUserGroups().
357     *
358     * The function will not return groups the user had belonged to before MW 1.17
359     *
360     * @param UserIdentity $user
361     * @param int $queryFlags
362     * @return string[] Names of the groups the user has belonged to.
363     */
364    public function getUserFormerGroups(
365        UserIdentity $user,
366        int $queryFlags = IDBAccessObject::READ_NORMAL
367    ): array {
368        $user->assertWiki( $this->wikiId );
369        $userKey = $this->getCacheKey( $user );
370
371        if ( $this->canUseCachedValues( $user, self::CACHE_FORMER, $queryFlags ) &&
372            isset( $this->userGroupCache[$userKey][self::CACHE_FORMER] )
373        ) {
374            return $this->userGroupCache[$userKey][self::CACHE_FORMER];
375        }
376
377        if ( !$user->isRegistered() ) {
378            // Anon users don't have groups stored in the database
379            return [];
380        }
381
382        $res = $this->getDBConnectionRefForQueryFlags( $queryFlags )->newSelectQueryBuilder()
383            ->select( 'ufg_group' )
384            ->from( 'user_former_groups' )
385            ->where( [ 'ufg_user' => $user->getId( $this->wikiId ) ] )
386            ->caller( __METHOD__ )
387            ->fetchResultSet();
388        $formerGroups = [];
389        foreach ( $res as $row ) {
390            $formerGroups[] = $row->ufg_group;
391        }
392        $this->setCache( $userKey, self::CACHE_FORMER, $formerGroups, $queryFlags );
393
394        return $this->userGroupCache[$userKey][self::CACHE_FORMER];
395    }
396
397    /**
398     * Get the groups for the given user based on $wgAutopromote.
399     *
400     * @param UserIdentity $user The user to get the groups for
401     * @return string[] Array of groups to promote to.
402     *
403     * @see $wgAutopromote
404     */
405    public function getUserAutopromoteGroups( UserIdentity $user ): array {
406        $user->assertWiki( $this->wikiId );
407        $promote = [];
408        // TODO: remove the need for the full user object
409        $userObj = User::newFromIdentity( $user );
410        if ( $userObj->isTemp() ) {
411            return [];
412        }
413        foreach ( $this->options->get( MainConfigNames::Autopromote ) as $group => $cond ) {
414            if ( $this->recCheckCondition( $cond, $userObj ) ) {
415                $promote[] = $group;
416            }
417        }
418
419        $this->hookRunner->onGetAutoPromoteGroups( $userObj, $promote );
420        return $promote;
421    }
422
423    /**
424     * Get the groups for the given user based on the given criteria.
425     *
426     * Does not return groups the user already belongs to or has once belonged.
427     *
428     * @param UserIdentity $user The user to get the groups for
429     * @param string $event Key in $wgAutopromoteOnce (each event has groups/criteria)
430     *
431     * @return string[] Groups the user should be promoted to.
432     *
433     * @see $wgAutopromoteOnce
434     */
435    public function getUserAutopromoteOnceGroups(
436        UserIdentity $user,
437        string $event
438    ): array {
439        $user->assertWiki( $this->wikiId );
440        $autopromoteOnce = $this->options->get( MainConfigNames::AutopromoteOnce );
441        $promote = [];
442
443        if ( isset( $autopromoteOnce[$event] ) && count( $autopromoteOnce[$event] ) ) {
444            // TODO: remove the need for the full user object
445            $userObj = User::newFromIdentity( $user );
446            if ( $userObj->isTemp() ) {
447                return [];
448            }
449            $currentGroups = $this->getUserGroups( $user );
450            $formerGroups = $this->getUserFormerGroups( $user );
451            foreach ( $autopromoteOnce[$event] as $group => $cond ) {
452                // Do not check if the user's already a member
453                if ( in_array( $group, $currentGroups ) ) {
454                    continue;
455                }
456                // Do not autopromote if the user has belonged to the group
457                if ( in_array( $group, $formerGroups ) ) {
458                    continue;
459                }
460                // Finally - check the conditions
461                if ( $this->recCheckCondition( $cond, $userObj ) ) {
462                    $promote[] = $group;
463                }
464            }
465        }
466
467        return $promote;
468    }
469
470    /**
471     * Returns the list of privileged groups that $user belongs to.
472     * Privileged groups are ones that can be abused in a dangerous way.
473     *
474     * Depending on how extensions extend this method, it might return values
475     * that are not strictly user groups (ACL list names, etc.).
476     * It is meant for logging/auditing, not for passing to methods that expect group names.
477     *
478     * @param UserIdentity $user
479     * @param int $queryFlags
480     * @param bool $recache Whether to avoid the cache
481     * @return string[]
482     * @since 1.41 (also backported to 1.39.5 and 1.40.1)
483     * @see $wgPrivilegedGroups
484     * @see https://www.mediawiki.org/wiki/Manual:Hooks/UserGetPrivilegedGroups
485     */
486    public function getUserPrivilegedGroups(
487        UserIdentity $user,
488        int $queryFlags = IDBAccessObject::READ_NORMAL,
489        bool $recache = false
490    ): array {
491        $userKey = $this->getCacheKey( $user );
492
493        if ( !$recache &&
494            $this->canUseCachedValues( $user, self::CACHE_PRIVILEGED, $queryFlags ) &&
495            isset( $this->userGroupCache[$userKey][self::CACHE_PRIVILEGED] )
496        ) {
497            return $this->userGroupCache[$userKey][self::CACHE_PRIVILEGED];
498        }
499
500        if ( !$user->isRegistered() ) {
501            return [];
502        }
503
504        $groups = array_intersect(
505            $this->getUserEffectiveGroups( $user, $queryFlags, $recache ),
506            $this->options->get( 'PrivilegedGroups' )
507        );
508
509        $this->hookRunner->onUserPrivilegedGroups( $user, $groups );
510
511        $this->setCache(
512            $this->getCacheKey( $user ),
513            self::CACHE_PRIVILEGED,
514            array_values( array_unique( $groups ) ),
515            $queryFlags
516        );
517
518        return $this->userGroupCache[$userKey][self::CACHE_PRIVILEGED];
519    }
520
521    /**
522     * Recursively check a condition.  Conditions are in the form
523     *   [ '&' or '|' or '^' or '!', cond1, cond2, ... ]
524     * where cond1, cond2, ... are themselves conditions; *OR*
525     *   APCOND_EMAILCONFIRMED, *OR*
526     *   [ APCOND_EMAILCONFIRMED ], *OR*
527     *   [ APCOND_EDITCOUNT, number of edits ], *OR*
528     *   [ APCOND_AGE, seconds since registration ], *OR*
529     *   similar constructs defined by extensions.
530     * This function evaluates the former type recursively, and passes off to
531     * checkCondition for evaluation of the latter type.
532     *
533     * If you change the logic of this method, please update
534     * ApiQuerySiteinfo::appendAutoPromote(), as it depends on this method.
535     *
536     * @param mixed $cond A condition, possibly containing other conditions
537     * @param User $user The user to check the conditions against
538     *
539     * @return bool Whether the condition is true
540     */
541    private function recCheckCondition( $cond, User $user ): bool {
542        if ( is_array( $cond ) && count( $cond ) >= 2 && in_array( $cond[0], self::VALID_OPS ) ) {
543            // Recursive condition
544            if ( $cond[0] == '&' ) { // AND (all conds pass)
545                foreach ( array_slice( $cond, 1 ) as $subcond ) {
546                    if ( !$this->recCheckCondition( $subcond, $user ) ) {
547                        return false;
548                    }
549                }
550
551                return true;
552            } elseif ( $cond[0] == '|' ) { // OR (at least one cond passes)
553                foreach ( array_slice( $cond, 1 ) as $subcond ) {
554                    if ( $this->recCheckCondition( $subcond, $user ) ) {
555                        return true;
556                    }
557                }
558
559                return false;
560            } elseif ( $cond[0] == '^' ) { // XOR (exactly one cond passes)
561                if ( count( $cond ) > 3 ) {
562                    $this->logger->warning(
563                        'recCheckCondition() given XOR ("^") condition on three or more conditions.' .
564                        ' Check your $wgAutopromote and $wgAutopromoteOnce settings.'
565                    );
566                }
567                return $this->recCheckCondition( $cond[1], $user )
568                    xor $this->recCheckCondition( $cond[2], $user );
569            } elseif ( $cond[0] == '!' ) { // NOT (no conds pass)
570                foreach ( array_slice( $cond, 1 ) as $subcond ) {
571                    if ( $this->recCheckCondition( $subcond, $user ) ) {
572                        return false;
573                    }
574                }
575
576                return true;
577            }
578        }
579        // If we got here, the array presumably does not contain other conditions;
580        // it's not recursive. Pass it off to checkCondition.
581        if ( !is_array( $cond ) ) {
582            $cond = [ $cond ];
583        }
584
585        return $this->checkCondition( $cond, $user );
586    }
587
588    /**
589     * As recCheckCondition, but *not* recursive.  The only valid conditions
590     * are those whose first element is one of APCOND_* defined in Defines.php.
591     * Other types will throw an exception if no extension evaluates them.
592     *
593     * @param array $cond A condition, which must not contain other conditions
594     * @param User $user The user to check the condition against
595     * @return bool Whether the condition is true for the user
596     * @throws InvalidArgumentException if autopromote condition was not recognized.
597     * @throws LogicException if APCOND_BLOCKED is checked again before returning a result.
598     */
599    private function checkCondition( array $cond, User $user ): bool {
600        if ( count( $cond ) < 1 ) {
601            return false;
602        }
603
604        switch ( $cond[0] ) {
605            case APCOND_EMAILCONFIRMED:
606                if ( Sanitizer::validateEmail( $user->getEmail() ) ) {
607                    if ( $this->options->get( MainConfigNames::EmailAuthentication ) ) {
608                        return (bool)$user->getEmailAuthenticationTimestamp();
609                    } else {
610                        return true;
611                    }
612                }
613                return false;
614            case APCOND_EDITCOUNT:
615                $reqEditCount = $cond[1] ?? $this->options->get( MainConfigNames::AutoConfirmCount );
616
617                // T157718: Avoid edit count lookup if specified edit count is 0 or invalid
618                if ( $reqEditCount <= 0 ) {
619                    return true;
620                }
621                return (int)$this->userEditTracker->getUserEditCount( $user ) >= $reqEditCount;
622            case APCOND_AGE:
623                $reqAge = $cond[1] ?? $this->options->get( MainConfigNames::AutoConfirmAge );
624                $age = time() - (int)wfTimestampOrNull( TS_UNIX, $user->getRegistration() );
625                return $age >= $reqAge;
626            case APCOND_AGE_FROM_EDIT:
627                $age = time() - (int)wfTimestampOrNull(
628                    TS_UNIX, $this->userEditTracker->getFirstEditTimestamp( $user ) );
629                return $age >= $cond[1];
630            case APCOND_INGROUPS:
631                $groups = array_slice( $cond, 1 );
632                return count( array_intersect( $groups, $this->getUserGroups( $user ) ) ) == count( $groups );
633            case APCOND_ISIP:
634                return $cond[1] == $user->getRequest()->getIP();
635            case APCOND_IPINRANGE:
636                return IPUtils::isInRange( $user->getRequest()->getIP(), $cond[1] );
637            case APCOND_BLOCKED:
638                // Because checking for ipblock-exempt leads back to here (thus infinite recursion),
639                // we if we've been here before for this user without having returned a value.
640                // See T270145 and T349608
641                $userKey = $this->getCacheKey( $user );
642                if ( $this->recursionMap[$userKey] ?? false ) {
643                    throw new LogicException(
644                        "Unexpected recursion! APCOND_BLOCKED is being checked during" .
645                        " an existing APCOND_BLOCKED check for \"{$user->getName()}\"!"
646                    );
647                }
648                $this->recursionMap[$userKey] = true;
649                // Setting the second parameter here to true prevents us from getting back here
650                // during standard MediaWiki core behavior
651                $block = $user->getBlock( IDBAccessObject::READ_LATEST, true );
652                $this->recursionMap[$userKey] = false;
653
654                return $block && $block->isSitewide();
655            case APCOND_ISBOT:
656                return in_array( 'bot', $this->groupPermissionsLookup
657                    ->getGroupPermissions( $this->getUserGroups( $user ) ) );
658            default:
659                $result = null;
660                $this->hookRunner->onAutopromoteCondition( $cond[0],
661                    array_slice( $cond, 1 ), $user, $result );
662                if ( $result === null ) {
663                    throw new InvalidArgumentException(
664                        "Unrecognized condition {$cond[0]} for autopromotion!"
665                    );
666                }
667
668                return (bool)$result;
669        }
670    }
671
672    /**
673     * Add the user to the group if they meet given criteria.
674     *
675     * Contrary to autopromotion by $wgAutopromote, the group will be
676     *   possible to remove manually via Special:UserRights. In such case it
677     *   will not be re-added automatically. The user will also not lose the
678     *   group if they no longer meet the criteria.
679     *
680     * @param UserIdentity $user User to add to the groups
681     * @param string $event Key in $wgAutopromoteOnce (each event has groups/criteria)
682     *
683     * @return string[] Array of groups the user has been promoted to.
684     *
685     * @see $wgAutopromoteOnce
686     */
687    public function addUserToAutopromoteOnceGroups(
688        UserIdentity $user,
689        string $event
690    ): array {
691        $user->assertWiki( $this->wikiId );
692        Assert::precondition(
693            !$this->wikiId || WikiMap::isCurrentWikiDbDomain( $this->wikiId ),
694            __METHOD__ . " is not supported for foreign wikis: {$this->wikiId} used"
695        );
696
697        if (
698            $this->readOnlyMode->isReadOnly( $this->wikiId ) ||
699            !$user->isRegistered() ||
700            $this->tempUserConfig->isTempName( $user->getName() )
701        ) {
702            return [];
703        }
704
705        $toPromote = $this->getUserAutopromoteOnceGroups( $user, $event );
706        if ( $toPromote === [] ) {
707            return [];
708        }
709
710        $userObj = User::newFromIdentity( $user );
711        if ( !$userObj->checkAndSetTouched() ) {
712            return []; // raced out (bug T48834)
713        }
714
715        $oldGroups = $this->getUserGroups( $user ); // previous groups
716        $oldUGMs = $this->getUserGroupMemberships( $user );
717        $this->addUserToMultipleGroups( $user, $toPromote );
718        $newGroups = array_merge( $oldGroups, $toPromote ); // all groups
719        $newUGMs = $this->getUserGroupMemberships( $user );
720
721        // update groups in external authentication database
722        // TODO: deprecate passing full User object to hook
723        $this->hookRunner->onUserGroupsChanged(
724            $userObj,
725            $toPromote,
726            [],
727            false,
728            false,
729            $oldUGMs,
730            $newUGMs
731        );
732
733        $logEntry = new ManualLogEntry( 'rights', 'autopromote' );
734        $logEntry->setPerformer( $user );
735        $logEntry->setTarget( $userObj->getUserPage() );
736        $logEntry->setParameters( [
737            '4::oldgroups' => $oldGroups,
738            '5::newgroups' => $newGroups,
739        ] );
740        $logid = $logEntry->insert();
741
742        // Allow excluding autopromotions into select groups from RecentChanges (T377829).
743        $groupsToShowInRC = array_diff(
744            $toPromote,
745            $this->options->get( MainConfigNames::AutopromoteOnceRCExcludedGroups )
746        );
747
748        if ( $this->options->get( MainConfigNames::AutopromoteOnceLogInRC ) && count( $groupsToShowInRC ) ) {
749            $logEntry->publish( $logid );
750        }
751
752        return $toPromote;
753    }
754
755    /**
756     * Get the list of explicit group memberships this user has.
757     * The implicit * and user groups are not included.
758     *
759     * @param UserIdentity $user
760     * @param int $queryFlags
761     * @return string[]
762     */
763    public function getUserGroups(
764        UserIdentity $user,
765        int $queryFlags = IDBAccessObject::READ_NORMAL
766    ): array {
767        return array_keys( $this->getUserGroupMemberships( $user, $queryFlags ) );
768    }
769
770    /**
771     * Loads and returns UserGroupMembership objects for all the groups a user currently
772     * belongs to.
773     *
774     * @param UserIdentity $user the user to search for
775     * @param int $queryFlags
776     * @return UserGroupMembership[] Associative array of (group name => UserGroupMembership object)
777     */
778    public function getUserGroupMemberships(
779        UserIdentity $user,
780        int $queryFlags = IDBAccessObject::READ_NORMAL
781    ): array {
782        $user->assertWiki( $this->wikiId );
783        $userKey = $this->getCacheKey( $user );
784
785        if ( $this->canUseCachedValues( $user, self::CACHE_MEMBERSHIP, $queryFlags ) &&
786            isset( $this->userGroupCache[$userKey][self::CACHE_MEMBERSHIP] )
787        ) {
788            /** @suppress PhanTypeMismatchReturn */
789            return $this->userGroupCache[$userKey][self::CACHE_MEMBERSHIP];
790        }
791
792        if ( !$user->isRegistered() ) {
793            // Anon users don't have groups stored in the database
794            return [];
795        }
796
797        $queryBuilder = $this->newQueryBuilder( $this->getDBConnectionRefForQueryFlags( $queryFlags ) );
798        $res = $queryBuilder
799            ->where( [ 'ug_user' => $user->getId( $this->wikiId ) ] )
800            ->caller( __METHOD__ )
801            ->fetchResultSet();
802
803        $ugms = [];
804        foreach ( $res as $row ) {
805            $ugm = $this->newGroupMembershipFromRow( $row );
806            if ( !$ugm->isExpired() ) {
807                $ugms[$ugm->getGroup()] = $ugm;
808            }
809        }
810        ksort( $ugms );
811
812        $this->setCache( $userKey, self::CACHE_MEMBERSHIP, $ugms, $queryFlags );
813
814        return $ugms;
815    }
816
817    /**
818     * Add the user to the given group. This takes immediate effect.
819     * If the user is already in the group, the expiry time will be updated to the new
820     * expiry time. (If $expiry is omitted or null, the membership will be altered to
821     * never expire.)
822     *
823     * @param UserIdentity $user
824     * @param string $group Name of the group to add
825     * @param string|null $expiry Optional expiry timestamp in any format acceptable to
826     *   wfTimestamp(), or null if the group assignment should not expire
827     * @param bool $allowUpdate Whether to perform "upsert" instead of INSERT
828     *
829     * @throws InvalidArgumentException
830     * @return bool
831     */
832    public function addUserToGroup(
833        UserIdentity $user,
834        string $group,
835        ?string $expiry = null,
836        bool $allowUpdate = false
837    ): bool {
838        $user->assertWiki( $this->wikiId );
839        if ( $this->readOnlyMode->isReadOnly( $this->wikiId ) ) {
840            return false;
841        }
842
843        $isTemp = $this->tempUserConfig->isTempName( $user->getName() );
844        if ( !$user->isRegistered() ) {
845            throw new InvalidArgumentException(
846                'UserGroupManager::addUserToGroup() needs a positive user ID. ' .
847                'Perhaps addUserToGroup() was called before the user was added to the database.'
848            );
849        }
850        if ( $isTemp ) {
851            throw new InvalidArgumentException(
852                'UserGroupManager::addUserToGroup() cannot be called on a temporary user.'
853            );
854        }
855
856        if ( $expiry ) {
857            $expiry = wfTimestamp( TS_MW, $expiry );
858        }
859
860        // TODO: Deprecate passing out user object in the hook by introducing
861        // an alternative hook
862        if ( $this->hookContainer->isRegistered( 'UserAddGroup' ) ) {
863            $userObj = User::newFromIdentity( $user );
864            $userObj->load();
865            if ( !$this->hookRunner->onUserAddGroup( $userObj, $group, $expiry ) ) {
866                return false;
867            }
868        }
869
870        $oldUgms = $this->getUserGroupMemberships( $user, IDBAccessObject::READ_LATEST );
871        $dbw = $this->dbProvider->getPrimaryDatabase( $this->wikiId );
872
873        $dbw->startAtomic( __METHOD__ );
874        $dbw->newInsertQueryBuilder()
875            ->insertInto( 'user_groups' )
876            ->ignore()
877            ->row( [
878                'ug_user' => $user->getId( $this->wikiId ),
879                'ug_group' => $group,
880                'ug_expiry' => $expiry ? $dbw->timestamp( $expiry ) : null,
881            ] )
882            ->caller( __METHOD__ )->execute();
883
884        $affected = $dbw->affectedRows();
885        if ( !$affected ) {
886            // Conflicting row already exists; it should be overridden if it is either expired
887            // or if $allowUpdate is true and the current row is different than the loaded row.
888            $conds = [
889                'ug_user' => $user->getId( $this->wikiId ),
890                'ug_group' => $group
891            ];
892            if ( $allowUpdate ) {
893                // Update the current row if its expiry does not match that of the loaded row
894                $conds[] = $expiry
895                    ? $dbw->expr( 'ug_expiry', '=', null )
896                        ->or( 'ug_expiry', '!=', $dbw->timestamp( $expiry ) )
897                    : $dbw->expr( 'ug_expiry', '!=', null );
898            } else {
899                // Update the current row if it is expired
900                $conds[] = $dbw->expr( 'ug_expiry', '<', $dbw->timestamp() );
901            }
902            $dbw->newUpdateQueryBuilder()
903                ->update( 'user_groups' )
904                ->set( [ 'ug_expiry' => $expiry ? $dbw->timestamp( $expiry ) : null ] )
905                ->where( $conds )
906                ->caller( __METHOD__ )->execute();
907            $affected = $dbw->affectedRows();
908        }
909        $dbw->endAtomic( __METHOD__ );
910
911        // Purge old, expired memberships from the DB
912        DeferredUpdates::addCallableUpdate( function ( $fname ) {
913            $dbr = $this->dbProvider->getReplicaDatabase( $this->wikiId );
914            $hasExpiredRow = (bool)$dbr->newSelectQueryBuilder()
915                ->select( '1' )
916                ->from( 'user_groups' )
917                ->where( [ $dbr->expr( 'ug_expiry', '<', $dbr->timestamp() ) ] )
918                ->caller( $fname )
919                ->fetchField();
920            if ( $hasExpiredRow ) {
921                $this->jobQueueGroup->push( new UserGroupExpiryJob( [] ) );
922            }
923        } );
924
925        if ( $affected > 0 ) {
926            $oldUgms[$group] = new UserGroupMembership( $user->getId( $this->wikiId ), $group, $expiry );
927            if ( !$oldUgms[$group]->isExpired() ) {
928                $this->setCache(
929                    $this->getCacheKey( $user ),
930                    self::CACHE_MEMBERSHIP,
931                    $oldUgms,
932                    IDBAccessObject::READ_LATEST
933                );
934                $this->clearUserCacheForKind( $user, self::CACHE_EFFECTIVE );
935            }
936            foreach ( $this->clearCacheCallbacks as $callback ) {
937                $callback( $user );
938            }
939            return true;
940        }
941        return false;
942    }
943
944    /**
945     * Add the user to the given list of groups.
946     *
947     * @since 1.37
948     *
949     * @param UserIdentity $user
950     * @param string[] $groups Names of the groups to add
951     * @param string|null $expiry Optional expiry timestamp in any format acceptable to
952     *   wfTimestamp(), or null if the group assignment should not expire
953     * @param bool $allowUpdate Whether to perform "upsert" instead of INSERT
954     *
955     * @throws InvalidArgumentException
956     */
957    public function addUserToMultipleGroups(
958        UserIdentity $user,
959        array $groups,
960        ?string $expiry = null,
961        bool $allowUpdate = false
962    ) {
963        foreach ( $groups as $group ) {
964            $this->addUserToGroup( $user, $group, $expiry, $allowUpdate );
965        }
966    }
967
968    /**
969     * Remove the user from the given group. This takes immediate effect.
970     *
971     * @param UserIdentity $user
972     * @param string $group Name of the group to remove
973     * @throws InvalidArgumentException
974     * @return bool
975     */
976    public function removeUserFromGroup( UserIdentity $user, string $group ): bool {
977        $user->assertWiki( $this->wikiId );
978        // TODO: Deprecate passing out user object in the hook by introducing
979        // an alternative hook
980        if ( $this->hookContainer->isRegistered( 'UserRemoveGroup' ) ) {
981            $userObj = User::newFromIdentity( $user );
982            $userObj->load();
983            if ( !$this->hookRunner->onUserRemoveGroup( $userObj, $group ) ) {
984                return false;
985            }
986        }
987
988        if ( $this->readOnlyMode->isReadOnly( $this->wikiId ) ) {
989            return false;
990        }
991
992        if ( !$user->isRegistered() ) {
993            throw new InvalidArgumentException(
994                'UserGroupManager::removeUserFromGroup() needs a positive user ID. ' .
995                'Perhaps removeUserFromGroup() was called before the user was added to the database.'
996            );
997        }
998
999        $oldUgms = $this->getUserGroupMemberships( $user, IDBAccessObject::READ_LATEST );
1000        $oldFormerGroups = $this->getUserFormerGroups( $user, IDBAccessObject::READ_LATEST );
1001        $dbw = $this->dbProvider->getPrimaryDatabase( $this->wikiId );
1002        $dbw->newDeleteQueryBuilder()
1003            ->deleteFrom( 'user_groups' )
1004            ->where( [ 'ug_user' => $user->getId( $this->wikiId ), 'ug_group' => $group ] )
1005            ->caller( __METHOD__ )->execute();
1006
1007        if ( !$dbw->affectedRows() ) {
1008            return false;
1009        }
1010        // Remember that the user was in this group
1011        $dbw->newInsertQueryBuilder()
1012            ->insertInto( 'user_former_groups' )
1013            ->ignore()
1014            ->row( [ 'ufg_user' => $user->getId( $this->wikiId ), 'ufg_group' => $group ] )
1015            ->caller( __METHOD__ )->execute();
1016
1017        unset( $oldUgms[$group] );
1018        $userKey = $this->getCacheKey( $user );
1019        $this->setCache( $userKey, self::CACHE_MEMBERSHIP, $oldUgms, IDBAccessObject::READ_LATEST );
1020        $oldFormerGroups[] = $group;
1021        $this->setCache( $userKey, self::CACHE_FORMER, $oldFormerGroups, IDBAccessObject::READ_LATEST );
1022        $this->clearUserCacheForKind( $user, self::CACHE_EFFECTIVE );
1023        foreach ( $this->clearCacheCallbacks as $callback ) {
1024            $callback( $user );
1025        }
1026        return true;
1027    }
1028
1029    /**
1030     * Return the query builder to build upon and query
1031     *
1032     * @param IReadableDatabase $db
1033     * @return SelectQueryBuilder
1034     * @internal
1035     */
1036    public function newQueryBuilder( IReadableDatabase $db ): SelectQueryBuilder {
1037        return $db->newSelectQueryBuilder()
1038            ->select( [
1039                'ug_user',
1040                'ug_group',
1041                'ug_expiry',
1042            ] )
1043            ->from( 'user_groups' );
1044    }
1045
1046    /**
1047     * Purge expired memberships from the user_groups table
1048     * @internal
1049     * @note this could be slow and is intended for use in a background job
1050     * @return int|false false if purging wasn't attempted (e.g. because of
1051     *  readonly), the number of rows purged (might be 0) otherwise
1052     */
1053    public function purgeExpired() {
1054        if ( $this->readOnlyMode->isReadOnly( $this->wikiId ) ) {
1055            return false;
1056        }
1057
1058        $ticket = $this->dbProvider->getEmptyTransactionTicket( __METHOD__ );
1059        $dbw = $this->dbProvider->getPrimaryDatabase( $this->wikiId );
1060
1061        $lockKey = "{$dbw->getDomainID()}:UserGroupManager:purge"; // per-wiki
1062        $scopedLock = $dbw->getScopedLockAndFlush( $lockKey, __METHOD__, 0 );
1063        if ( !$scopedLock ) {
1064            return false; // already running
1065        }
1066
1067        $now = time();
1068        $purgedRows = 0;
1069        do {
1070            $dbw->startAtomic( __METHOD__ );
1071            $res = $this->newQueryBuilder( $dbw )
1072                ->where( [ $dbw->expr( 'ug_expiry', '<', $dbw->timestamp( $now ) ) ] )
1073                ->forUpdate()
1074                ->limit( 100 )
1075                ->caller( __METHOD__ )
1076                ->fetchResultSet();
1077
1078            if ( $res->numRows() > 0 ) {
1079                $insertData = []; // array of users/groups to insert to user_former_groups
1080                $deleteCond = []; // array for deleting the rows that are to be moved around
1081                foreach ( $res as $row ) {
1082                    $insertData[] = [ 'ufg_user' => $row->ug_user, 'ufg_group' => $row->ug_group ];
1083                    $deleteCond[] = $dbw
1084                        ->expr( 'ug_user', '=', $row->ug_user )
1085                        ->and( 'ug_group', '=', $row->ug_group );
1086                }
1087                // Delete the rows we're about to move
1088                $dbw->newDeleteQueryBuilder()
1089                    ->deleteFrom( 'user_groups' )
1090                    ->where( $dbw->orExpr( $deleteCond ) )
1091                    ->caller( __METHOD__ )->execute();
1092                // Push the groups to user_former_groups
1093                $dbw->newInsertQueryBuilder()
1094                    ->insertInto( 'user_former_groups' )
1095                    ->ignore()
1096                    ->rows( $insertData )
1097                    ->caller( __METHOD__ )->execute();
1098                // Count how many rows were purged
1099                $purgedRows += $res->numRows();
1100            }
1101
1102            $dbw->endAtomic( __METHOD__ );
1103
1104            $this->dbProvider->commitAndWaitForReplication( __METHOD__, $ticket );
1105        } while ( $res->numRows() > 0 );
1106        return $purgedRows;
1107    }
1108
1109    /**
1110     * @param array $config
1111     * @param string $group
1112     * @return string[]
1113     */
1114    private function expandChangeableGroupConfig( array $config, string $group ): array {
1115        if ( empty( $config[$group] ) ) {
1116            return [];
1117        } elseif ( $config[$group] === true ) {
1118            // You get everything
1119            return $this->listAllGroups();
1120        } elseif ( is_array( $config[$group] ) ) {
1121            return $config[$group];
1122        }
1123        return [];
1124    }
1125
1126    /**
1127     * Returns an array of the groups that a particular group can add/remove.
1128     *
1129     * @since 1.37
1130     * @param string $group The group to check for whether it can add/remove
1131     * @return array [
1132     *     'add' => [ addablegroups ],
1133     *     'remove' => [ removablegroups ],
1134     *     'add-self' => [ addablegroups to self ],
1135     *     'remove-self' => [ removable groups from self ] ]
1136     */
1137    public function getGroupsChangeableByGroup( string $group ): array {
1138        return [
1139            'add' => $this->expandChangeableGroupConfig(
1140                $this->options->get( MainConfigNames::AddGroups ), $group
1141            ),
1142            'remove' => $this->expandChangeableGroupConfig(
1143                $this->options->get( MainConfigNames::RemoveGroups ), $group
1144            ),
1145            'add-self' => $this->expandChangeableGroupConfig(
1146                $this->options->get( MainConfigNames::GroupsAddToSelf ), $group
1147            ),
1148            'remove-self' => $this->expandChangeableGroupConfig(
1149                $this->options->get( MainConfigNames::GroupsRemoveFromSelf ), $group
1150            ),
1151        ];
1152    }
1153
1154    /**
1155     * Returns an array of groups that this $actor can add and remove.
1156     *
1157     * @since 1.37
1158     * @param Authority $authority
1159     * @return array [
1160     *  'add' => [ addablegroups ],
1161     *  'remove' => [ removablegroups ],
1162     *  'add-self' => [ addablegroups to self ],
1163     *  'remove-self' => [ removable groups from self ]
1164     * ]
1165     * @phan-return array{add:list<string>,remove:list<string>,add-self:list<string>,remove-self:list<string>}
1166     */
1167    public function getGroupsChangeableBy( Authority $authority ): array {
1168        if ( $authority->isAllowed( 'userrights' ) ) {
1169            // This group gives the right to modify everything (reverse-
1170            // compatibility with old "userrights lets you change
1171            // everything")
1172            // Using array_merge to make the groups reindexed
1173            $all = array_merge( $this->listAllGroups() );
1174            return [
1175                'add' => $all,
1176                'remove' => $all,
1177                'add-self' => [],
1178                'remove-self' => []
1179            ];
1180        }
1181
1182        // Okay, it's not so simple, we will have to go through the arrays
1183        $groups = [
1184            'add' => [],
1185            'remove' => [],
1186            'add-self' => [],
1187            'remove-self' => []
1188        ];
1189        $actorGroups = $this->getUserEffectiveGroups( $authority->getUser() );
1190
1191        foreach ( $actorGroups as $actorGroup ) {
1192            $groups = array_merge_recursive(
1193                $groups, $this->getGroupsChangeableByGroup( $actorGroup )
1194            );
1195            $groups['add'] = array_unique( $groups['add'] );
1196            $groups['remove'] = array_unique( $groups['remove'] );
1197            $groups['add-self'] = array_unique( $groups['add-self'] );
1198            $groups['remove-self'] = array_unique( $groups['remove-self'] );
1199        }
1200        return $groups;
1201    }
1202
1203    /**
1204     * Cleans cached group memberships for a given user
1205     */
1206    public function clearCache( UserIdentity $user ) {
1207        $user->assertWiki( $this->wikiId );
1208        $userKey = $this->getCacheKey( $user );
1209        unset( $this->userGroupCache[$userKey] );
1210        unset( $this->queryFlagsUsedForCaching[$userKey] );
1211    }
1212
1213    /**
1214     * Sets cached group memberships and query flags for a given user
1215     *
1216     * @param string $userKey
1217     * @param string $cacheKind one of self::CACHE_KIND_* constants
1218     * @param array $groupValue
1219     * @param int $queryFlags
1220     */
1221    private function setCache(
1222        string $userKey,
1223        string $cacheKind,
1224        array $groupValue,
1225        int $queryFlags
1226    ) {
1227        $this->userGroupCache[$userKey][$cacheKind] = $groupValue;
1228        $this->queryFlagsUsedForCaching[$userKey][$cacheKind] = $queryFlags;
1229    }
1230
1231    /**
1232     * Clears a cached group membership and query key for a given user
1233     *
1234     * @param UserIdentity $user
1235     * @param string $cacheKind one of self::CACHE_* constants
1236     */
1237    private function clearUserCacheForKind( UserIdentity $user, string $cacheKind ) {
1238        $userKey = $this->getCacheKey( $user );
1239        unset( $this->userGroupCache[$userKey][$cacheKind] );
1240        unset( $this->queryFlagsUsedForCaching[$userKey][$cacheKind] );
1241    }
1242
1243    /**
1244     * @param int $recency a bit field composed of IDBAccessObject::READ_XXX flags
1245     * @return IReadableDatabase
1246     */
1247    private function getDBConnectionRefForQueryFlags( int $recency ): IReadableDatabase {
1248        if ( ( IDBAccessObject::READ_LATEST & $recency ) == IDBAccessObject::READ_LATEST ) {
1249            return $this->dbProvider->getPrimaryDatabase( $this->wikiId );
1250        }
1251        return $this->dbProvider->getReplicaDatabase( $this->wikiId );
1252    }
1253
1254    /**
1255     * Gets a unique key for various caches.
1256     * @param UserIdentity $user
1257     * @return string
1258     */
1259    private function getCacheKey( UserIdentity $user ): string {
1260        return $user->isRegistered() ? "u:{$user->getId( $this->wikiId )}" : "anon:{$user->getName()}";
1261    }
1262
1263    /**
1264     * Determines if it's ok to use cached options values for a given user and query flags
1265     * @param UserIdentity $user
1266     * @param string $cacheKind one of self::CACHE_* constants
1267     * @param int $queryFlags
1268     * @return bool
1269     */
1270    private function canUseCachedValues(
1271        UserIdentity $user,
1272        string $cacheKind,
1273        int $queryFlags
1274    ): bool {
1275        if ( !$user->isRegistered() ) {
1276            // Anon users don't have groups stored in the database,
1277            // so $queryFlags are ignored.
1278            return true;
1279        }
1280        if ( $queryFlags >= IDBAccessObject::READ_LOCKING ) {
1281            return false;
1282        }
1283        $userKey = $this->getCacheKey( $user );
1284        $queryFlagsUsed = $this->queryFlagsUsedForCaching[$userKey][$cacheKind] ?? IDBAccessObject::READ_NONE;
1285        return $queryFlagsUsed >= $queryFlags;
1286    }
1287}