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