Merge "docs: Fix typo"
[mediawiki.git] / includes / user / UserGroupManager.php
blobed2f1e50e41dc72d4eb8a02a69a88fb554b8c6e2
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.
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.
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
18 * @file
21 namespace MediaWiki\User;
23 use InvalidArgumentException;
24 use JobQueueGroup;
25 use LogicException;
26 use ManualLogEntry;
27 use MediaWiki\Config\ServiceOptions;
28 use MediaWiki\Deferred\DeferredUpdates;
29 use MediaWiki\HookContainer\HookContainer;
30 use MediaWiki\HookContainer\HookRunner;
31 use MediaWiki\MainConfigNames;
32 use MediaWiki\Parser\Sanitizer;
33 use MediaWiki\Permissions\Authority;
34 use MediaWiki\Permissions\GroupPermissionsLookup;
35 use MediaWiki\User\TempUser\TempUserConfig;
36 use MediaWiki\WikiMap\WikiMap;
37 use Psr\Log\LoggerInterface;
38 use UserGroupExpiryJob;
39 use Wikimedia\Assert\Assert;
40 use Wikimedia\IPUtils;
41 use Wikimedia\Rdbms\IConnectionProvider;
42 use Wikimedia\Rdbms\IDBAccessObject;
43 use Wikimedia\Rdbms\ILBFactory;
44 use Wikimedia\Rdbms\IReadableDatabase;
45 use Wikimedia\Rdbms\ReadOnlyMode;
46 use Wikimedia\Rdbms\SelectQueryBuilder;
48 /**
49 * Manage user group memberships.
51 * @since 1.35
52 * @ingroup User
54 class UserGroupManager {
56 /**
57 * @internal For use by ServiceWiring
59 public const CONSTRUCTOR_OPTIONS = [
60 MainConfigNames::AddGroups,
61 MainConfigNames::AutoConfirmAge,
62 MainConfigNames::AutoConfirmCount,
63 MainConfigNames::Autopromote,
64 MainConfigNames::AutopromoteOnce,
65 MainConfigNames::AutopromoteOnceLogInRC,
66 MainConfigNames::AutopromoteOnceRCExcludedGroups,
67 MainConfigNames::EmailAuthentication,
68 MainConfigNames::ImplicitGroups,
69 MainConfigNames::GroupInheritsPermissions,
70 MainConfigNames::GroupPermissions,
71 MainConfigNames::GroupsAddToSelf,
72 MainConfigNames::GroupsRemoveFromSelf,
73 MainConfigNames::RevokePermissions,
74 MainConfigNames::RemoveGroups,
75 MainConfigNames::PrivilegedGroups,
78 /**
79 * Logical operators recognized in $wgAutopromote.
81 * @since 1.42
83 public const VALID_OPS = [ '&', '|', '^', '!' ];
85 private ServiceOptions $options;
86 private IConnectionProvider $dbProvider;
87 private HookContainer $hookContainer;
88 private HookRunner $hookRunner;
89 private ReadOnlyMode $readOnlyMode;
90 private UserEditTracker $userEditTracker;
91 private GroupPermissionsLookup $groupPermissionsLookup;
92 private JobQueueGroup $jobQueueGroup;
93 private LoggerInterface $logger;
94 private TempUserConfig $tempUserConfig;
96 /** @var callable[] */
97 private $clearCacheCallbacks;
99 /** @var string|false */
100 private $wikiId;
102 /** string key for implicit groups cache */
103 private const CACHE_IMPLICIT = 'implicit';
105 /** string key for effective groups cache */
106 private const CACHE_EFFECTIVE = 'effective';
108 /** string key for group memberships cache */
109 private const CACHE_MEMBERSHIP = 'membership';
111 /** string key for former groups cache */
112 private const CACHE_FORMER = 'former';
114 /** string key for former groups cache */
115 private const CACHE_PRIVILEGED = 'privileged';
118 * @var array Service caches, an assoc. array keyed after the user-keys generated
119 * by the getCacheKey method and storing values in the following format:
121 * userKey => [
122 * self::CACHE_IMPLICIT => implicit groups cache
123 * self::CACHE_EFFECTIVE => effective groups cache
124 * self::CACHE_MEMBERSHIP => [ ] // Array of UserGroupMembership objects
125 * self::CACHE_FORMER => former groups cache
126 * self::CACHE_PRIVILEGED => privileged groups cache
129 private $userGroupCache = [];
132 * @var array An assoc. array that stores query flags used to retrieve user groups
133 * from the database and is stored in the following format:
135 * userKey => [
136 * self::CACHE_IMPLICIT => implicit groups query flag
137 * self::CACHE_EFFECTIVE => effective groups query flag
138 * self::CACHE_MEMBERSHIP => membership groups query flag
139 * self::CACHE_FORMER => former groups query flag
140 * self::CACHE_PRIVILEGED => privileged groups query flag
143 private $queryFlagsUsedForCaching = [];
146 * @internal For use preventing an infinite loop when checking APCOND_BLOCKED
147 * @var array An assoc. array mapping the getCacheKey userKey to a bool indicating
148 * an ongoing condition check.
150 private $recursionMap = [];
153 * @param ServiceOptions $options
154 * @param ReadOnlyMode $readOnlyMode
155 * @param ILBFactory $lbFactory
156 * @param HookContainer $hookContainer
157 * @param UserEditTracker $userEditTracker
158 * @param GroupPermissionsLookup $groupPermissionsLookup
159 * @param JobQueueGroup $jobQueueGroup
160 * @param LoggerInterface $logger
161 * @param TempUserConfig $tempUserConfig
162 * @param callable[] $clearCacheCallbacks
163 * @param string|false $wikiId
165 public function __construct(
166 ServiceOptions $options,
167 ReadOnlyMode $readOnlyMode,
168 ILBFactory $lbFactory,
169 HookContainer $hookContainer,
170 UserEditTracker $userEditTracker,
171 GroupPermissionsLookup $groupPermissionsLookup,
172 JobQueueGroup $jobQueueGroup,
173 LoggerInterface $logger,
174 TempUserConfig $tempUserConfig,
175 array $clearCacheCallbacks = [],
176 $wikiId = UserIdentity::LOCAL
178 $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
179 $this->options = $options;
180 $this->dbProvider = $lbFactory;
181 $this->hookContainer = $hookContainer;
182 $this->hookRunner = new HookRunner( $hookContainer );
183 $this->userEditTracker = $userEditTracker;
184 $this->groupPermissionsLookup = $groupPermissionsLookup;
185 $this->jobQueueGroup = $jobQueueGroup;
186 $this->logger = $logger;
187 $this->tempUserConfig = $tempUserConfig;
188 $this->readOnlyMode = $readOnlyMode;
189 $this->clearCacheCallbacks = $clearCacheCallbacks;
190 $this->wikiId = $wikiId;
194 * Return the set of defined explicit groups.
195 * The implicit groups (by default *, 'user' and 'autoconfirmed')
196 * are not included, as they are defined automatically, not in the database.
197 * @return string[] internal group names
199 public function listAllGroups(): array {
200 return array_values( array_unique(
201 array_diff(
202 array_merge(
203 array_keys( $this->options->get( MainConfigNames::GroupPermissions ) ),
204 array_keys( $this->options->get( MainConfigNames::RevokePermissions ) ),
205 array_keys( $this->options->get( MainConfigNames::GroupInheritsPermissions ) )
207 $this->listAllImplicitGroups()
209 ) );
213 * Get a list of all configured implicit groups
214 * @return string[]
216 public function listAllImplicitGroups(): array {
217 return $this->options->get( MainConfigNames::ImplicitGroups );
221 * Creates a new UserGroupMembership instance from $row.
222 * The fields required to build an instance could be
223 * found using getQueryInfo() method.
225 * @param \stdClass $row A database result object
227 * @return UserGroupMembership
229 public function newGroupMembershipFromRow( \stdClass $row ): UserGroupMembership {
230 return new UserGroupMembership(
231 (int)$row->ug_user,
232 $row->ug_group,
233 $row->ug_expiry === null ? null : wfTimestamp(
234 TS_MW,
235 $row->ug_expiry
241 * Load the user groups cache from the provided user groups data
242 * @internal for use by the User object only
243 * @param UserIdentity $user
244 * @param array $userGroups an array of database query results
245 * @param int $queryFlags
247 public function loadGroupMembershipsFromArray(
248 UserIdentity $user,
249 array $userGroups,
250 int $queryFlags = IDBAccessObject::READ_NORMAL
252 $user->assertWiki( $this->wikiId );
253 $membershipGroups = [];
254 reset( $userGroups );
255 foreach ( $userGroups as $row ) {
256 $ugm = $this->newGroupMembershipFromRow( $row );
257 $membershipGroups[ $ugm->getGroup() ] = $ugm;
259 $this->setCache(
260 $this->getCacheKey( $user ),
261 self::CACHE_MEMBERSHIP,
262 $membershipGroups,
263 $queryFlags
268 * Get the list of implicit group memberships this user has.
270 * This includes 'user' if logged in, '*' for all accounts,
271 * and autopromoted groups
273 * @param UserIdentity $user
274 * @param int $queryFlags
275 * @param bool $recache Whether to avoid the cache
276 * @return string[] internal group names
278 public function getUserImplicitGroups(
279 UserIdentity $user,
280 int $queryFlags = IDBAccessObject::READ_NORMAL,
281 bool $recache = false
282 ): array {
283 $user->assertWiki( $this->wikiId );
284 $userKey = $this->getCacheKey( $user );
285 if ( $recache ||
286 !isset( $this->userGroupCache[$userKey][self::CACHE_IMPLICIT] ) ||
287 !$this->canUseCachedValues( $user, self::CACHE_IMPLICIT, $queryFlags )
289 $groups = [ '*' ];
290 if ( $this->tempUserConfig->isTempName( $user->getName() ) ) {
291 $groups[] = 'temp';
292 } elseif ( $user->isRegistered() ) {
293 $groups[] = 'user';
294 $groups = array_unique( array_merge(
295 $groups,
296 $this->getUserAutopromoteGroups( $user )
297 ) );
299 $this->setCache( $userKey, self::CACHE_IMPLICIT, $groups, $queryFlags );
300 if ( $recache ) {
301 // Assure data consistency with rights/groups,
302 // as getUserEffectiveGroups() depends on this function
303 $this->clearUserCacheForKind( $user, self::CACHE_EFFECTIVE );
306 return $this->userGroupCache[$userKey][self::CACHE_IMPLICIT];
310 * Get the list of implicit group memberships the user has.
312 * This includes all explicit groups, plus 'user' if logged in,
313 * '*' for all accounts, and autopromoted groups
315 * @param UserIdentity $user
316 * @param int $queryFlags
317 * @param bool $recache Whether to avoid the cache
318 * @return string[] internal group names
320 public function getUserEffectiveGroups(
321 UserIdentity $user,
322 int $queryFlags = IDBAccessObject::READ_NORMAL,
323 bool $recache = false
324 ): array {
325 $user->assertWiki( $this->wikiId );
326 $userKey = $this->getCacheKey( $user );
327 // Ignore cache if the $recache flag is set, cached values can not be used
328 // or the cache value is missing
329 if ( $recache ||
330 !$this->canUseCachedValues( $user, self::CACHE_EFFECTIVE, $queryFlags ) ||
331 !isset( $this->userGroupCache[$userKey][self::CACHE_EFFECTIVE] )
333 $groups = array_unique( array_merge(
334 $this->getUserGroups( $user, $queryFlags ), // explicit groups
335 $this->getUserImplicitGroups( $user, $queryFlags, $recache ) // implicit groups
336 ) );
337 // TODO: Deprecate passing out user object in the hook by introducing
338 // an alternative hook
339 if ( $this->hookContainer->isRegistered( 'UserEffectiveGroups' ) ) {
340 $userObj = User::newFromIdentity( $user );
341 $userObj->load();
342 // Hook for additional groups
343 $this->hookRunner->onUserEffectiveGroups( $userObj, $groups );
345 // Force reindexation of groups when a hook has unset one of them
346 $effectiveGroups = array_values( array_unique( $groups ) );
347 $this->setCache( $userKey, self::CACHE_EFFECTIVE, $effectiveGroups, $queryFlags );
349 return $this->userGroupCache[$userKey][self::CACHE_EFFECTIVE];
353 * Returns the groups the user has belonged to.
355 * The user may still belong to the returned groups. Compare with
356 * getUserGroups().
358 * The function will not return groups the user had belonged to before MW 1.17
360 * @param UserIdentity $user
361 * @param int $queryFlags
362 * @return string[] Names of the groups the user has belonged to.
364 public function getUserFormerGroups(
365 UserIdentity $user,
366 int $queryFlags = IDBAccessObject::READ_NORMAL
367 ): array {
368 $user->assertWiki( $this->wikiId );
369 $userKey = $this->getCacheKey( $user );
371 if ( $this->canUseCachedValues( $user, self::CACHE_FORMER, $queryFlags ) &&
372 isset( $this->userGroupCache[$userKey][self::CACHE_FORMER] )
374 return $this->userGroupCache[$userKey][self::CACHE_FORMER];
377 if ( !$user->isRegistered() ) {
378 // Anon users don't have groups stored in the database
379 return [];
382 $res = $this->getDBConnectionRefForQueryFlags( $queryFlags )->newSelectQueryBuilder()
383 ->select( 'ufg_group' )
384 ->from( 'user_former_groups' )
385 ->where( [ 'ufg_user' => $user->getId( $this->wikiId ) ] )
386 ->caller( __METHOD__ )
387 ->fetchResultSet();
388 $formerGroups = [];
389 foreach ( $res as $row ) {
390 $formerGroups[] = $row->ufg_group;
392 $this->setCache( $userKey, self::CACHE_FORMER, $formerGroups, $queryFlags );
394 return $this->userGroupCache[$userKey][self::CACHE_FORMER];
398 * Get the groups for the given user based on $wgAutopromote.
400 * @param UserIdentity $user The user to get the groups for
401 * @return string[] Array of groups to promote to.
403 * @see $wgAutopromote
405 public function getUserAutopromoteGroups( UserIdentity $user ): array {
406 $user->assertWiki( $this->wikiId );
407 $promote = [];
408 // TODO: remove the need for the full user object
409 $userObj = User::newFromIdentity( $user );
410 if ( $userObj->isTemp() ) {
411 return [];
413 foreach ( $this->options->get( MainConfigNames::Autopromote ) as $group => $cond ) {
414 if ( $this->recCheckCondition( $cond, $userObj ) ) {
415 $promote[] = $group;
419 $this->hookRunner->onGetAutoPromoteGroups( $userObj, $promote );
420 return $promote;
424 * Get the groups for the given user based on the given criteria.
426 * Does not return groups the user already belongs to or has once belonged.
428 * @param UserIdentity $user The user to get the groups for
429 * @param string $event Key in $wgAutopromoteOnce (each event has groups/criteria)
431 * @return string[] Groups the user should be promoted to.
433 * @see $wgAutopromoteOnce
435 public function getUserAutopromoteOnceGroups(
436 UserIdentity $user,
437 string $event
438 ): array {
439 $user->assertWiki( $this->wikiId );
440 $autopromoteOnce = $this->options->get( MainConfigNames::AutopromoteOnce );
441 $promote = [];
443 if ( isset( $autopromoteOnce[$event] ) && count( $autopromoteOnce[$event] ) ) {
444 // TODO: remove the need for the full user object
445 $userObj = User::newFromIdentity( $user );
446 if ( $userObj->isTemp() ) {
447 return [];
449 $currentGroups = $this->getUserGroups( $user );
450 $formerGroups = $this->getUserFormerGroups( $user );
451 foreach ( $autopromoteOnce[$event] as $group => $cond ) {
452 // Do not check if the user's already a member
453 if ( in_array( $group, $currentGroups ) ) {
454 continue;
456 // Do not autopromote if the user has belonged to the group
457 if ( in_array( $group, $formerGroups ) ) {
458 continue;
460 // Finally - check the conditions
461 if ( $this->recCheckCondition( $cond, $userObj ) ) {
462 $promote[] = $group;
467 return $promote;
471 * Returns the list of privileged groups that $user belongs to.
472 * Privileged groups are ones that can be abused in a dangerous way.
474 * Depending on how extensions extend this method, it might return values
475 * that are not strictly user groups (ACL list names, etc.).
476 * It is meant for logging/auditing, not for passing to methods that expect group names.
478 * @param UserIdentity $user
479 * @param int $queryFlags
480 * @param bool $recache Whether to avoid the cache
481 * @return string[]
482 * @since 1.41 (also backported to 1.39.5 and 1.40.1)
483 * @see $wgPrivilegedGroups
484 * @see https://www.mediawiki.org/wiki/Manual:Hooks/UserGetPrivilegedGroups
486 public function getUserPrivilegedGroups(
487 UserIdentity $user,
488 int $queryFlags = IDBAccessObject::READ_NORMAL,
489 bool $recache = false
490 ): array {
491 $userKey = $this->getCacheKey( $user );
493 if ( !$recache &&
494 $this->canUseCachedValues( $user, self::CACHE_PRIVILEGED, $queryFlags ) &&
495 isset( $this->userGroupCache[$userKey][self::CACHE_PRIVILEGED] )
497 return $this->userGroupCache[$userKey][self::CACHE_PRIVILEGED];
500 if ( !$user->isRegistered() ) {
501 return [];
504 $groups = array_intersect(
505 $this->getUserEffectiveGroups( $user, $queryFlags, $recache ),
506 $this->options->get( 'PrivilegedGroups' )
509 $this->hookRunner->onUserPrivilegedGroups( $user, $groups );
511 $this->setCache(
512 $this->getCacheKey( $user ),
513 self::CACHE_PRIVILEGED,
514 array_values( array_unique( $groups ) ),
515 $queryFlags
518 return $this->userGroupCache[$userKey][self::CACHE_PRIVILEGED];
522 * Recursively check a condition. Conditions are in the form
523 * [ '&' or '|' or '^' or '!', cond1, cond2, ... ]
524 * where cond1, cond2, ... are themselves conditions; *OR*
525 * APCOND_EMAILCONFIRMED, *OR*
526 * [ APCOND_EMAILCONFIRMED ], *OR*
527 * [ APCOND_EDITCOUNT, number of edits ], *OR*
528 * [ APCOND_AGE, seconds since registration ], *OR*
529 * similar constructs defined by extensions.
530 * This function evaluates the former type recursively, and passes off to
531 * checkCondition for evaluation of the latter type.
533 * If you change the logic of this method, please update
534 * ApiQuerySiteinfo::appendAutoPromote(), as it depends on this method.
536 * @param mixed $cond A condition, possibly containing other conditions
537 * @param User $user The user to check the conditions against
539 * @return bool Whether the condition is true
541 private function recCheckCondition( $cond, User $user ): bool {
542 if ( is_array( $cond ) && count( $cond ) >= 2 && in_array( $cond[0], self::VALID_OPS ) ) {
543 // Recursive condition
544 if ( $cond[0] == '&' ) { // AND (all conds pass)
545 foreach ( array_slice( $cond, 1 ) as $subcond ) {
546 if ( !$this->recCheckCondition( $subcond, $user ) ) {
547 return false;
551 return true;
552 } elseif ( $cond[0] == '|' ) { // OR (at least one cond passes)
553 foreach ( array_slice( $cond, 1 ) as $subcond ) {
554 if ( $this->recCheckCondition( $subcond, $user ) ) {
555 return true;
559 return false;
560 } elseif ( $cond[0] == '^' ) { // XOR (exactly one cond passes)
561 if ( count( $cond ) > 3 ) {
562 $this->logger->warning(
563 'recCheckCondition() given XOR ("^") condition on three or more conditions.' .
564 ' Check your $wgAutopromote and $wgAutopromoteOnce settings.'
567 return $this->recCheckCondition( $cond[1], $user )
568 xor $this->recCheckCondition( $cond[2], $user );
569 } elseif ( $cond[0] == '!' ) { // NOT (no conds pass)
570 foreach ( array_slice( $cond, 1 ) as $subcond ) {
571 if ( $this->recCheckCondition( $subcond, $user ) ) {
572 return false;
576 return true;
579 // If we got here, the array presumably does not contain other conditions;
580 // it's not recursive. Pass it off to checkCondition.
581 if ( !is_array( $cond ) ) {
582 $cond = [ $cond ];
585 return $this->checkCondition( $cond, $user );
589 * As recCheckCondition, but *not* recursive. The only valid conditions
590 * are those whose first element is one of APCOND_* defined in Defines.php.
591 * Other types will throw an exception if no extension evaluates them.
593 * @param array $cond A condition, which must not contain other conditions
594 * @param User $user The user to check the condition against
595 * @return bool Whether the condition is true for the user
596 * @throws InvalidArgumentException if autopromote condition was not recognized.
597 * @throws LogicException if APCOND_BLOCKED is checked again before returning a result.
599 private function checkCondition( array $cond, User $user ): bool {
600 if ( count( $cond ) < 1 ) {
601 return false;
604 switch ( $cond[0] ) {
605 case APCOND_EMAILCONFIRMED:
606 if ( Sanitizer::validateEmail( $user->getEmail() ) ) {
607 if ( $this->options->get( MainConfigNames::EmailAuthentication ) ) {
608 return (bool)$user->getEmailAuthenticationTimestamp();
609 } else {
610 return true;
613 return false;
614 case APCOND_EDITCOUNT:
615 $reqEditCount = $cond[1] ?? $this->options->get( MainConfigNames::AutoConfirmCount );
617 // T157718: Avoid edit count lookup if specified edit count is 0 or invalid
618 if ( $reqEditCount <= 0 ) {
619 return true;
621 return (int)$this->userEditTracker->getUserEditCount( $user ) >= $reqEditCount;
622 case APCOND_AGE:
623 $reqAge = $cond[1] ?? $this->options->get( MainConfigNames::AutoConfirmAge );
624 $age = time() - (int)wfTimestampOrNull( TS_UNIX, $user->getRegistration() );
625 return $age >= $reqAge;
626 case APCOND_AGE_FROM_EDIT:
627 $age = time() - (int)wfTimestampOrNull(
628 TS_UNIX, $this->userEditTracker->getFirstEditTimestamp( $user ) );
629 return $age >= $cond[1];
630 case APCOND_INGROUPS:
631 $groups = array_slice( $cond, 1 );
632 return count( array_intersect( $groups, $this->getUserGroups( $user ) ) ) == count( $groups );
633 case APCOND_ISIP:
634 return $cond[1] == $user->getRequest()->getIP();
635 case APCOND_IPINRANGE:
636 return IPUtils::isInRange( $user->getRequest()->getIP(), $cond[1] );
637 case APCOND_BLOCKED:
638 // Because checking for ipblock-exempt leads back to here (thus infinite recursion),
639 // we if we've been here before for this user without having returned a value.
640 // See T270145 and T349608
641 $userKey = $this->getCacheKey( $user );
642 if ( $this->recursionMap[$userKey] ?? false ) {
643 throw new LogicException(
644 "Unexpected recursion! APCOND_BLOCKED is being checked during" .
645 " an existing APCOND_BLOCKED check for \"{$user->getName()}\"!"
648 $this->recursionMap[$userKey] = true;
649 // Setting the second parameter here to true prevents us from getting back here
650 // during standard MediaWiki core behavior
651 $block = $user->getBlock( IDBAccessObject::READ_LATEST, true );
652 $this->recursionMap[$userKey] = false;
654 return $block && $block->isSitewide();
655 case APCOND_ISBOT:
656 return in_array( 'bot', $this->groupPermissionsLookup
657 ->getGroupPermissions( $this->getUserGroups( $user ) ) );
658 default:
659 $result = null;
660 $this->hookRunner->onAutopromoteCondition( $cond[0],
661 // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args
662 array_slice( $cond, 1 ), $user, $result );
663 if ( $result === null ) {
664 throw new InvalidArgumentException(
665 "Unrecognized condition {$cond[0]} for autopromotion!"
669 return (bool)$result;
674 * Add the user to the group if he/she meets given criteria.
676 * Contrary to autopromotion by $wgAutopromote, the group will be
677 * possible to remove manually via Special:UserRights. In such case it
678 * will not be re-added automatically. The user will also not lose the
679 * group if they no longer meet the criteria.
681 * @param UserIdentity $user User to add to the groups
682 * @param string $event Key in $wgAutopromoteOnce (each event has groups/criteria)
684 * @return string[] Array of groups the user has been promoted to.
686 * @see $wgAutopromoteOnce
688 public function addUserToAutopromoteOnceGroups(
689 UserIdentity $user,
690 string $event
691 ): array {
692 $user->assertWiki( $this->wikiId );
693 Assert::precondition(
694 !$this->wikiId || WikiMap::isCurrentWikiDbDomain( $this->wikiId ),
695 __METHOD__ . " is not supported for foreign wikis: {$this->wikiId} used"
698 if (
699 $this->readOnlyMode->isReadOnly( $this->wikiId ) ||
700 !$user->isRegistered() ||
701 $this->tempUserConfig->isTempName( $user->getName() )
703 return [];
706 $toPromote = $this->getUserAutopromoteOnceGroups( $user, $event );
707 if ( $toPromote === [] ) {
708 return [];
711 $userObj = User::newFromIdentity( $user );
712 if ( !$userObj->checkAndSetTouched() ) {
713 return []; // raced out (bug T48834)
716 $oldGroups = $this->getUserGroups( $user ); // previous groups
717 $oldUGMs = $this->getUserGroupMemberships( $user );
718 $this->addUserToMultipleGroups( $user, $toPromote );
719 $newGroups = array_merge( $oldGroups, $toPromote ); // all groups
720 $newUGMs = $this->getUserGroupMemberships( $user );
722 // update groups in external authentication database
723 // TODO: deprecate passing full User object to hook
724 $this->hookRunner->onUserGroupsChanged(
725 $userObj,
726 $toPromote, [],
727 false,
728 false,
729 $oldUGMs,
730 $newUGMs
733 $logEntry = new ManualLogEntry( 'rights', 'autopromote' );
734 $logEntry->setPerformer( $user );
735 $logEntry->setTarget( $userObj->getUserPage() );
736 $logEntry->setParameters( [
737 '4::oldgroups' => $oldGroups,
738 '5::newgroups' => $newGroups,
739 ] );
740 $logid = $logEntry->insert();
742 // Allow excluding autopromotions into select groups from RecentChanges (T377829).
743 $groupsToShowInRC = array_diff(
744 $toPromote,
745 $this->options->get( MainConfigNames::AutopromoteOnceRCExcludedGroups )
748 if ( $this->options->get( MainConfigNames::AutopromoteOnceLogInRC ) && count( $groupsToShowInRC ) ) {
749 $logEntry->publish( $logid );
752 return $toPromote;
756 * Get the list of explicit group memberships this user has.
757 * The implicit * and user groups are not included.
759 * @param UserIdentity $user
760 * @param int $queryFlags
761 * @return string[]
763 public function getUserGroups(
764 UserIdentity $user,
765 int $queryFlags = IDBAccessObject::READ_NORMAL
766 ): array {
767 return array_keys( $this->getUserGroupMemberships( $user, $queryFlags ) );
771 * Loads and returns UserGroupMembership objects for all the groups a user currently
772 * belongs to.
774 * @param UserIdentity $user the user to search for
775 * @param int $queryFlags
776 * @return UserGroupMembership[] Associative array of (group name => UserGroupMembership object)
778 public function getUserGroupMemberships(
779 UserIdentity $user,
780 int $queryFlags = IDBAccessObject::READ_NORMAL
781 ): array {
782 $user->assertWiki( $this->wikiId );
783 $userKey = $this->getCacheKey( $user );
785 if ( $this->canUseCachedValues( $user, self::CACHE_MEMBERSHIP, $queryFlags ) &&
786 isset( $this->userGroupCache[$userKey][self::CACHE_MEMBERSHIP] )
788 /** @suppress PhanTypeMismatchReturn */
789 return $this->userGroupCache[$userKey][self::CACHE_MEMBERSHIP];
792 if ( !$user->isRegistered() ) {
793 // Anon users don't have groups stored in the database
794 return [];
797 $queryBuilder = $this->newQueryBuilder( $this->getDBConnectionRefForQueryFlags( $queryFlags ) );
798 $res = $queryBuilder
799 ->where( [ 'ug_user' => $user->getId( $this->wikiId ) ] )
800 ->caller( __METHOD__ )
801 ->fetchResultSet();
803 $ugms = [];
804 foreach ( $res as $row ) {
805 $ugm = $this->newGroupMembershipFromRow( $row );
806 if ( !$ugm->isExpired() ) {
807 $ugms[$ugm->getGroup()] = $ugm;
810 ksort( $ugms );
812 $this->setCache( $userKey, self::CACHE_MEMBERSHIP, $ugms, $queryFlags );
814 return $ugms;
818 * Add the user to the given group. This takes immediate effect.
819 * If the user is already in the group, the expiry time will be updated to the new
820 * expiry time. (If $expiry is omitted or null, the membership will be altered to
821 * never expire.)
823 * @param UserIdentity $user
824 * @param string $group Name of the group to add
825 * @param string|null $expiry Optional expiry timestamp in any format acceptable to
826 * wfTimestamp(), or null if the group assignment should not expire
827 * @param bool $allowUpdate Whether to perform "upsert" instead of INSERT
829 * @throws InvalidArgumentException
830 * @return bool
832 public function addUserToGroup(
833 UserIdentity $user,
834 string $group,
835 ?string $expiry = null,
836 bool $allowUpdate = false
837 ): bool {
838 $user->assertWiki( $this->wikiId );
839 if ( $this->readOnlyMode->isReadOnly( $this->wikiId ) ) {
840 return false;
843 $isTemp = $this->tempUserConfig->isTempName( $user->getName() );
844 if ( !$user->isRegistered() ) {
845 throw new InvalidArgumentException(
846 'UserGroupManager::addUserToGroup() needs a positive user ID. ' .
847 'Perhaps addUserToGroup() was called before the user was added to the database.'
850 if ( $isTemp ) {
851 throw new InvalidArgumentException(
852 'UserGroupManager::addUserToGroup() cannot be called on a temporary user.'
856 if ( $expiry ) {
857 $expiry = wfTimestamp( TS_MW, $expiry );
860 // TODO: Deprecate passing out user object in the hook by introducing
861 // an alternative hook
862 if ( $this->hookContainer->isRegistered( 'UserAddGroup' ) ) {
863 $userObj = User::newFromIdentity( $user );
864 $userObj->load();
865 if ( !$this->hookRunner->onUserAddGroup( $userObj, $group, $expiry ) ) {
866 return false;
870 $oldUgms = $this->getUserGroupMemberships( $user, IDBAccessObject::READ_LATEST );
871 $dbw = $this->dbProvider->getPrimaryDatabase( $this->wikiId );
873 $dbw->startAtomic( __METHOD__ );
874 $dbw->newInsertQueryBuilder()
875 ->insertInto( 'user_groups' )
876 ->ignore()
877 ->row( [
878 'ug_user' => $user->getId( $this->wikiId ),
879 'ug_group' => $group,
880 'ug_expiry' => $expiry ? $dbw->timestamp( $expiry ) : null,
882 ->caller( __METHOD__ )->execute();
884 $affected = $dbw->affectedRows();
885 if ( !$affected ) {
886 // Conflicting row already exists; it should be overridden if it is either expired
887 // or if $allowUpdate is true and the current row is different than the loaded row.
888 $conds = [
889 'ug_user' => $user->getId( $this->wikiId ),
890 'ug_group' => $group
892 if ( $allowUpdate ) {
893 // Update the current row if its expiry does not match that of the loaded row
894 $conds[] = $expiry
895 ? $dbw->expr( 'ug_expiry', '=', null )
896 ->or( 'ug_expiry', '!=', $dbw->timestamp( $expiry ) )
897 : $dbw->expr( 'ug_expiry', '!=', null );
898 } else {
899 // Update the current row if it is expired
900 $conds[] = $dbw->expr( 'ug_expiry', '<', $dbw->timestamp() );
902 $dbw->newUpdateQueryBuilder()
903 ->update( 'user_groups' )
904 ->set( [ 'ug_expiry' => $expiry ? $dbw->timestamp( $expiry ) : null ] )
905 ->where( $conds )
906 ->caller( __METHOD__ )->execute();
907 $affected = $dbw->affectedRows();
909 $dbw->endAtomic( __METHOD__ );
911 // Purge old, expired memberships from the DB
912 DeferredUpdates::addCallableUpdate( function ( $fname ) {
913 $dbr = $this->dbProvider->getReplicaDatabase( $this->wikiId );
914 $hasExpiredRow = (bool)$dbr->newSelectQueryBuilder()
915 ->select( '1' )
916 ->from( 'user_groups' )
917 ->where( [ $dbr->expr( 'ug_expiry', '<', $dbr->timestamp() ) ] )
918 ->caller( $fname )
919 ->fetchField();
920 if ( $hasExpiredRow ) {
921 $this->jobQueueGroup->push( new UserGroupExpiryJob( [] ) );
923 } );
925 if ( $affected > 0 ) {
926 $oldUgms[$group] = new UserGroupMembership( $user->getId( $this->wikiId ), $group, $expiry );
927 if ( !$oldUgms[$group]->isExpired() ) {
928 $this->setCache(
929 $this->getCacheKey( $user ),
930 self::CACHE_MEMBERSHIP,
931 $oldUgms,
932 IDBAccessObject::READ_LATEST
934 $this->clearUserCacheForKind( $user, self::CACHE_EFFECTIVE );
936 foreach ( $this->clearCacheCallbacks as $callback ) {
937 $callback( $user );
939 return true;
941 return false;
945 * Add the user to the given list of groups.
947 * @since 1.37
949 * @param UserIdentity $user
950 * @param string[] $groups Names of the groups to add
951 * @param string|null $expiry Optional expiry timestamp in any format acceptable to
952 * wfTimestamp(), or null if the group assignment should not expire
953 * @param bool $allowUpdate Whether to perform "upsert" instead of INSERT
955 * @throws InvalidArgumentException
957 public function addUserToMultipleGroups(
958 UserIdentity $user,
959 array $groups,
960 ?string $expiry = null,
961 bool $allowUpdate = false
963 foreach ( $groups as $group ) {
964 $this->addUserToGroup( $user, $group, $expiry, $allowUpdate );
969 * Remove the user from the given group. This takes immediate effect.
971 * @param UserIdentity $user
972 * @param string $group Name of the group to remove
973 * @throws InvalidArgumentException
974 * @return bool
976 public function removeUserFromGroup( UserIdentity $user, string $group ): bool {
977 $user->assertWiki( $this->wikiId );
978 // TODO: Deprecate passing out user object in the hook by introducing
979 // an alternative hook
980 if ( $this->hookContainer->isRegistered( 'UserRemoveGroup' ) ) {
981 $userObj = User::newFromIdentity( $user );
982 $userObj->load();
983 if ( !$this->hookRunner->onUserRemoveGroup( $userObj, $group ) ) {
984 return false;
988 if ( $this->readOnlyMode->isReadOnly( $this->wikiId ) ) {
989 return false;
992 if ( !$user->isRegistered() ) {
993 throw new InvalidArgumentException(
994 'UserGroupManager::removeUserFromGroup() needs a positive user ID. ' .
995 'Perhaps removeUserFromGroup() was called before the user was added to the database.'
999 $oldUgms = $this->getUserGroupMemberships( $user, IDBAccessObject::READ_LATEST );
1000 $oldFormerGroups = $this->getUserFormerGroups( $user, IDBAccessObject::READ_LATEST );
1001 $dbw = $this->dbProvider->getPrimaryDatabase( $this->wikiId );
1002 $dbw->newDeleteQueryBuilder()
1003 ->deleteFrom( 'user_groups' )
1004 ->where( [ 'ug_user' => $user->getId( $this->wikiId ), 'ug_group' => $group ] )
1005 ->caller( __METHOD__ )->execute();
1007 if ( !$dbw->affectedRows() ) {
1008 return false;
1010 // Remember that the user was in this group
1011 $dbw->newInsertQueryBuilder()
1012 ->insertInto( 'user_former_groups' )
1013 ->ignore()
1014 ->row( [ 'ufg_user' => $user->getId( $this->wikiId ), 'ufg_group' => $group ] )
1015 ->caller( __METHOD__ )->execute();
1017 unset( $oldUgms[$group] );
1018 $userKey = $this->getCacheKey( $user );
1019 $this->setCache( $userKey, self::CACHE_MEMBERSHIP, $oldUgms, IDBAccessObject::READ_LATEST );
1020 $oldFormerGroups[] = $group;
1021 $this->setCache( $userKey, self::CACHE_FORMER, $oldFormerGroups, IDBAccessObject::READ_LATEST );
1022 $this->clearUserCacheForKind( $user, self::CACHE_EFFECTIVE );
1023 foreach ( $this->clearCacheCallbacks as $callback ) {
1024 $callback( $user );
1026 return true;
1030 * Return the query builder to build upon and query
1032 * @param IReadableDatabase $db
1033 * @return SelectQueryBuilder
1034 * @internal
1036 public function newQueryBuilder( IReadableDatabase $db ): SelectQueryBuilder {
1037 return $db->newSelectQueryBuilder()
1038 ->select( [
1039 'ug_user',
1040 'ug_group',
1041 'ug_expiry',
1043 ->from( 'user_groups' );
1047 * Purge expired memberships from the user_groups table
1048 * @internal
1049 * @note this could be slow and is intended for use in a background job
1050 * @return int|false false if purging wasn't attempted (e.g. because of
1051 * readonly), the number of rows purged (might be 0) otherwise
1053 public function purgeExpired() {
1054 if ( $this->readOnlyMode->isReadOnly( $this->wikiId ) ) {
1055 return false;
1058 $ticket = $this->dbProvider->getEmptyTransactionTicket( __METHOD__ );
1059 $dbw = $this->dbProvider->getPrimaryDatabase( $this->wikiId );
1061 $lockKey = "{$dbw->getDomainID()}:UserGroupManager:purge"; // per-wiki
1062 $scopedLock = $dbw->getScopedLockAndFlush( $lockKey, __METHOD__, 0 );
1063 if ( !$scopedLock ) {
1064 return false; // already running
1067 $now = time();
1068 $purgedRows = 0;
1069 do {
1070 $dbw->startAtomic( __METHOD__ );
1071 $res = $this->newQueryBuilder( $dbw )
1072 ->where( [ $dbw->expr( 'ug_expiry', '<', $dbw->timestamp( $now ) ) ] )
1073 ->forUpdate()
1074 ->limit( 100 )
1075 ->caller( __METHOD__ )
1076 ->fetchResultSet();
1078 if ( $res->numRows() > 0 ) {
1079 $insertData = []; // array of users/groups to insert to user_former_groups
1080 $deleteCond = []; // array for deleting the rows that are to be moved around
1081 foreach ( $res as $row ) {
1082 $insertData[] = [ 'ufg_user' => $row->ug_user, 'ufg_group' => $row->ug_group ];
1083 $deleteCond[] = $dbw
1084 ->expr( 'ug_user', '=', $row->ug_user )
1085 ->and( 'ug_group', '=', $row->ug_group );
1087 // Delete the rows we're about to move
1088 $dbw->newDeleteQueryBuilder()
1089 ->deleteFrom( 'user_groups' )
1090 ->where( $dbw->orExpr( $deleteCond ) )
1091 ->caller( __METHOD__ )->execute();
1092 // Push the groups to user_former_groups
1093 $dbw->newInsertQueryBuilder()
1094 ->insertInto( 'user_former_groups' )
1095 ->ignore()
1096 ->rows( $insertData )
1097 ->caller( __METHOD__ )->execute();
1098 // Count how many rows were purged
1099 $purgedRows += $res->numRows();
1102 $dbw->endAtomic( __METHOD__ );
1104 $this->dbProvider->commitAndWaitForReplication( __METHOD__, $ticket );
1105 } while ( $res->numRows() > 0 );
1106 return $purgedRows;
1110 * @param array $config
1111 * @param string $group
1112 * @return string[]
1114 private function expandChangeableGroupConfig( array $config, string $group ): array {
1115 if ( empty( $config[$group] ) ) {
1116 return [];
1117 } elseif ( $config[$group] === true ) {
1118 // You get everything
1119 return $this->listAllGroups();
1120 } elseif ( is_array( $config[$group] ) ) {
1121 return $config[$group];
1123 return [];
1127 * Returns an array of the groups that a particular group can add/remove.
1129 * @since 1.37
1130 * @param string $group The group to check for whether it can add/remove
1131 * @return array [
1132 * 'add' => [ addablegroups ],
1133 * 'remove' => [ removablegroups ],
1134 * 'add-self' => [ addablegroups to self ],
1135 * 'remove-self' => [ removable groups from self ] ]
1137 public function getGroupsChangeableByGroup( string $group ): array {
1138 return [
1139 'add' => $this->expandChangeableGroupConfig(
1140 $this->options->get( MainConfigNames::AddGroups ), $group
1142 'remove' => $this->expandChangeableGroupConfig(
1143 $this->options->get( MainConfigNames::RemoveGroups ), $group
1145 'add-self' => $this->expandChangeableGroupConfig(
1146 $this->options->get( MainConfigNames::GroupsAddToSelf ), $group
1148 'remove-self' => $this->expandChangeableGroupConfig(
1149 $this->options->get( MainConfigNames::GroupsRemoveFromSelf ), $group
1155 * Returns an array of groups that this $actor can add and remove.
1157 * @since 1.37
1158 * @param Authority $authority
1159 * @return array [
1160 * 'add' => [ addablegroups ],
1161 * 'remove' => [ removablegroups ],
1162 * 'add-self' => [ addablegroups to self ],
1163 * 'remove-self' => [ removable groups from self ]
1165 * @phan-return array{add:list<string>,remove:list<string>,add-self:list<string>,remove-self:list<string>}
1167 public function getGroupsChangeableBy( Authority $authority ): array {
1168 if ( $authority->isAllowed( 'userrights' ) ) {
1169 // This group gives the right to modify everything (reverse-
1170 // compatibility with old "userrights lets you change
1171 // everything")
1172 // Using array_merge to make the groups reindexed
1173 $all = array_merge( $this->listAllGroups() );
1174 return [
1175 'add' => $all,
1176 'remove' => $all,
1177 'add-self' => [],
1178 'remove-self' => []
1182 // Okay, it's not so simple, we will have to go through the arrays
1183 $groups = [
1184 'add' => [],
1185 'remove' => [],
1186 'add-self' => [],
1187 'remove-self' => []
1189 $actorGroups = $this->getUserEffectiveGroups( $authority->getUser() );
1191 foreach ( $actorGroups as $actorGroup ) {
1192 $groups = array_merge_recursive(
1193 $groups, $this->getGroupsChangeableByGroup( $actorGroup )
1195 $groups['add'] = array_unique( $groups['add'] );
1196 $groups['remove'] = array_unique( $groups['remove'] );
1197 $groups['add-self'] = array_unique( $groups['add-self'] );
1198 $groups['remove-self'] = array_unique( $groups['remove-self'] );
1200 return $groups;
1204 * Cleans cached group memberships for a given user
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] );
1214 * Sets cached group memberships and query flags for a given user
1216 * @param string $userKey
1217 * @param string $cacheKind one of self::CACHE_KIND_* constants
1218 * @param array $groupValue
1219 * @param int $queryFlags
1221 private function setCache(
1222 string $userKey,
1223 string $cacheKind,
1224 array $groupValue,
1225 int $queryFlags
1227 $this->userGroupCache[$userKey][$cacheKind] = $groupValue;
1228 $this->queryFlagsUsedForCaching[$userKey][$cacheKind] = $queryFlags;
1232 * Clears a cached group membership and query key for a given user
1234 * @param UserIdentity $user
1235 * @param string $cacheKind one of self::CACHE_* constants
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] );
1244 * @param int $recency a bit field composed of IDBAccessObject::READ_XXX flags
1245 * @return IReadableDatabase
1247 private function getDBConnectionRefForQueryFlags( int $recency ): IReadableDatabase {
1248 if ( ( IDBAccessObject::READ_LATEST & $recency ) == IDBAccessObject::READ_LATEST ) {
1249 return $this->dbProvider->getPrimaryDatabase( $this->wikiId );
1251 return $this->dbProvider->getReplicaDatabase( $this->wikiId );
1255 * Gets a unique key for various caches.
1256 * @param UserIdentity $user
1257 * @return string
1259 private function getCacheKey( UserIdentity $user ): string {
1260 return $user->isRegistered() ? "u:{$user->getId( $this->wikiId )}" : "anon:{$user->getName()}";
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
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;
1280 if ( $queryFlags >= IDBAccessObject::READ_LOCKING ) {
1281 return false;
1283 $userKey = $this->getCacheKey( $user );
1284 $queryFlagsUsed = $this->queryFlagsUsedForCaching[$userKey][$cacheKind] ?? IDBAccessObject::READ_NONE;
1285 return $queryFlagsUsed >= $queryFlags;