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