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