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