| <?php |
| /** |
| * @license GPL-2.0-or-later |
| * @file |
| */ |
| |
| namespace MediaWiki\User; |
| |
| use InvalidArgumentException; |
| use MediaWiki\Config\ServiceOptions; |
| use MediaWiki\Deferred\DeferredUpdates; |
| use MediaWiki\HookContainer\HookContainer; |
| use MediaWiki\HookContainer\HookRunner; |
| use MediaWiki\JobQueue\JobQueueGroup; |
| use MediaWiki\Logging\ManualLogEntry; |
| use MediaWiki\MainConfigNames; |
| use MediaWiki\Permissions\Authority; |
| use MediaWiki\User\TempUser\TempUserConfig; |
| use MediaWiki\WikiMap\WikiMap; |
| use Wikimedia\Assert\Assert; |
| use Wikimedia\Rdbms\IConnectionProvider; |
| use Wikimedia\Rdbms\IDBAccessObject; |
| use Wikimedia\Rdbms\IReadableDatabase; |
| use Wikimedia\Rdbms\ReadOnlyMode; |
| use Wikimedia\Rdbms\SelectQueryBuilder; |
| use Wikimedia\Timestamp\TimestampFormat as TS; |
| |
| /** |
| * Manage user group memberships. |
| * |
| * @since 1.35 |
| * @ingroup User |
| */ |
| class UserGroupManager { |
| |
| /** |
| * @internal For use by ServiceWiring |
| */ |
| public const CONSTRUCTOR_OPTIONS = [ |
| MainConfigNames::AddGroups, |
| MainConfigNames::Autopromote, |
| MainConfigNames::AutopromoteOnce, |
| MainConfigNames::AutopromoteOnceLogInRC, |
| MainConfigNames::AutopromoteOnceRCExcludedGroups, |
| MainConfigNames::ImplicitGroups, |
| MainConfigNames::GroupInheritsPermissions, |
| MainConfigNames::GroupPermissions, |
| MainConfigNames::GroupsAddToSelf, |
| MainConfigNames::GroupsRemoveFromSelf, |
| MainConfigNames::RevokePermissions, |
| MainConfigNames::RemoveGroups, |
| MainConfigNames::PrivilegedGroups, |
| ]; |
| |
| /** |
| * Logical operators recognized in $wgAutopromote. |
| * |
| * @since 1.42 |
| * @deprecated since 1.45; use UserRequirementsConditionChecker::VALID_OPS instead |
| */ |
| public const VALID_OPS = UserRequirementsConditionChecker::VALID_OPS; |
| |
| private readonly HookRunner $hookRunner; |
| |
| /** string key for implicit groups cache */ |
| private const CACHE_IMPLICIT = 'implicit'; |
| |
| /** string key for effective groups cache */ |
| private const CACHE_EFFECTIVE = 'effective'; |
| |
| /** string key for group memberships cache */ |
| private const CACHE_MEMBERSHIP = 'membership'; |
| |
| /** string key for former groups cache */ |
| private const CACHE_FORMER = 'former'; |
| |
| /** string key for former groups cache */ |
| private const CACHE_PRIVILEGED = 'privileged'; |
| |
| /** |
| * @var array Service caches, an assoc. array keyed after the user-keys generated |
| * by the getCacheKey method and storing values in the following format: |
| * |
| * userKey => [ |
| * self::CACHE_IMPLICIT => implicit groups cache |
| * self::CACHE_EFFECTIVE => effective groups cache |
| * self::CACHE_MEMBERSHIP => [ ] // Array of UserGroupMembership objects |
| * self::CACHE_FORMER => former groups cache |
| * self::CACHE_PRIVILEGED => privileged groups cache |
| * ] |
| */ |
| private $userGroupCache = []; |
| |
| /** |
| * @var array An assoc. array that stores query flags used to retrieve user groups |
| * from the database and is stored in the following format: |
| * |
| * userKey => [ |
| * self::CACHE_IMPLICIT => implicit groups query flag |
| * self::CACHE_EFFECTIVE => effective groups query flag |
| * self::CACHE_MEMBERSHIP => membership groups query flag |
| * self::CACHE_FORMER => former groups query flag |
| * self::CACHE_PRIVILEGED => privileged groups query flag |
| * ] |
| */ |
| private array $queryFlagsUsedForCaching = []; |
| |
| private bool $expiryPurgeJobQueued = false; |
| |
| /** |
| * @param ServiceOptions $options |
| * @param ReadOnlyMode $readOnlyMode |
| * @param IConnectionProvider $connectionProvider |
| * @param HookContainer $hookContainer |
| * @param JobQueueGroup $jobQueueGroup |
| * @param TempUserConfig $tempUserConfig |
| * @param UserFactory $userFactory |
| * @param UserRequirementsConditionCheckerFactory $userRequirementsConditionCheckerFactory |
| * @param RestrictedUserGroupConfigReader $restrictedUserGroupConfigReader |
| * @param callable[] $clearCacheCallbacks |
| * @param string|false $wikiId |
| */ |
| public function __construct( |
| private readonly ServiceOptions $options, |
| private readonly ReadOnlyMode $readOnlyMode, |
| private readonly IConnectionProvider $connectionProvider, |
| private readonly HookContainer $hookContainer, |
| private readonly JobQueueGroup $jobQueueGroup, |
| private readonly TempUserConfig $tempUserConfig, |
| private readonly UserFactory $userFactory, |
| private readonly UserRequirementsConditionCheckerFactory $userRequirementsConditionCheckerFactory, |
| private readonly RestrictedUserGroupConfigReader $restrictedUserGroupConfigReader, |
| private readonly array $clearCacheCallbacks = [], |
| private readonly string|false $wikiId = UserIdentity::LOCAL |
| ) { |
| $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); |
| $this->hookRunner = new HookRunner( $hookContainer ); |
| } |
| |
| /** |
| * Return the set of defined explicit groups. |
| * The implicit groups (by default *, 'user' and 'autoconfirmed') |
| * are not included, as they are defined automatically, not in the database. |
| * |
| * @return string[] internal group names |
| */ |
| public function listAllGroups(): array { |
| return array_values( |
| array_diff( |
| array_keys( array_merge( |
| $this->options->get( MainConfigNames::GroupPermissions ), |
| $this->options->get( MainConfigNames::RevokePermissions ), |
| $this->options->get( MainConfigNames::GroupInheritsPermissions ), |
| ) ), |
| $this->listAllImplicitGroups() |
| ) |
| ); |
| } |
| |
| /** |
| * Get a list of all configured implicit groups |
| * |
| * @return string[] |
| */ |
| public function listAllImplicitGroups(): array { |
| return $this->options->get( MainConfigNames::ImplicitGroups ); |
| } |
| |
| /** |
| * Creates a new UserGroupMembership instance from $row. |
| * The fields required to build an instance could be |
| * found using getQueryInfo() method. |
| * |
| * @param \stdClass $row A database result object |
| * |
| * @return UserGroupMembership |
| */ |
| public function newGroupMembershipFromRow( \stdClass $row ): UserGroupMembership { |
| return new UserGroupMembership( |
| (int)$row->ug_user, |
| $row->ug_group, |
| $row->ug_expiry === null ? null : wfTimestamp( |
| TS::MW, |
| $row->ug_expiry |
| ) |
| ); |
| } |
| |
| /** |
| * Load the user groups cache from the provided user groups data |
| * @internal for use by the User object only |
| * @param UserIdentity $user |
| * @param array $userGroups an array of database query results |
| * @param int $queryFlags |
| */ |
| public function loadGroupMembershipsFromArray( |
| UserIdentity $user, |
| array $userGroups, |
| int $queryFlags = IDBAccessObject::READ_NORMAL |
| ) { |
| $user->assertWiki( $this->wikiId ); |
| $membershipGroups = []; |
| reset( $userGroups ); |
| foreach ( $userGroups as $row ) { |
| $ugm = $this->newGroupMembershipFromRow( $row ); |
| $membershipGroups[ $ugm->getGroup() ] = $ugm; |
| } |
| $this->setCache( |
| $this->getCacheKey( $user ), |
| self::CACHE_MEMBERSHIP, |
| $membershipGroups, |
| $queryFlags |
| ); |
| } |
| |
| /** |
| * Get the list of implicit group memberships this user has. |
| * |
| * This includes 'user' if logged in, '*' for all accounts, |
| * and any autopromote groups. |
| * |
| * @param UserIdentity $user |
| * @param int $queryFlags |
| * @param bool $recache Whether to avoid the cache |
| * @return string[] internal group names |
| */ |
| public function getUserImplicitGroups( |
| UserIdentity $user, |
| int $queryFlags = IDBAccessObject::READ_NORMAL, |
| bool $recache = false |
| ): array { |
| $user->assertWiki( $this->wikiId ); |
| $userKey = $this->getCacheKey( $user ); |
| if ( $recache || |
| !isset( $this->userGroupCache[$userKey][self::CACHE_IMPLICIT] ) || |
| !$this->canUseCachedValues( $user, self::CACHE_IMPLICIT, $queryFlags ) |
| ) { |
| $groups = [ '*' ]; |
| if ( $this->tempUserConfig->isTempName( $user->getName() ) ) { |
| $groups[] = 'temp'; |
| } elseif ( $user->isRegistered() ) { |
| $groups[] = 'user'; |
| $groups = array_unique( array_merge( |
| $groups, |
| $this->getUserAutopromoteGroups( $user ) |
| ) ); |
| } |
| $this->setCache( $userKey, self::CACHE_IMPLICIT, $groups, $queryFlags ); |
| if ( $recache ) { |
| // Assure data consistency with rights/groups, |
| // as getUserEffectiveGroups() depends on this function |
| $this->clearUserCacheForKind( $user, self::CACHE_EFFECTIVE ); |
| } |
| } |
| return $this->userGroupCache[$userKey][self::CACHE_IMPLICIT]; |
| } |
| |
| /** |
| * Get the list of implicit group memberships the user has. |
| * |
| * This includes all explicit groups, plus 'user' if logged in, |
| * '*' for all accounts, and autopromoted groups, but it doesn't |
| * include disabled groups. |
| * |
| * @param UserIdentity $user |
| * @param int $queryFlags |
| * @param bool $recache Whether to avoid the cache |
| * @return string[] internal group names |
| */ |
| public function getUserEffectiveGroups( |
| UserIdentity $user, |
| int $queryFlags = IDBAccessObject::READ_NORMAL, |
| bool $recache = false |
| ): array { |
| $user->assertWiki( $this->wikiId ); |
| $userKey = $this->getCacheKey( $user ); |
| // Ignore cache if the $recache flag is set, cached values can not be used |
| // or the cache value is missing |
| if ( |
| $recache || |
| !isset( $this->userGroupCache[$userKey][self::CACHE_EFFECTIVE] ) || |
| !$this->canUseCachedValues( $user, self::CACHE_EFFECTIVE, $queryFlags ) |
| ) { |
| $groups = array_unique( array_merge( |
| // explicit groups |
| $this->getUserGroups( $user, $queryFlags ), |
| // implicit groups |
| $this->getUserImplicitGroups( $user, $queryFlags, $recache ) |
| ) ); |
| // TODO: Deprecate passing out user object in the hook by introducing |
| // an alternative hook |
| // We can convert UserIdentity to User only for local users. User class doesn't support interwiki users |
| $isLocal = $user->getWikiId() === UserIdentity::LOCAL || WikiMap::isCurrentWikiId( $user->getWikiId() ); |
| if ( $isLocal && $this->hookContainer->isRegistered( 'UserEffectiveGroups' ) ) { |
| $userObj = User::newFromIdentity( $user ); |
| $userObj->load(); |
| // Hook for additional groups |
| $this->hookRunner->onUserEffectiveGroups( $userObj, $groups ); |
| } |
| $groups = array_diff( $groups, $this->getUserDisabledGroups( $user ) ); |
| // Force re-indexing of groups when a hook has unset one of them |
| $effectiveGroups = array_values( array_unique( $groups ) ); |
| $this->setCache( $userKey, self::CACHE_EFFECTIVE, $effectiveGroups, $queryFlags ); |
| } |
| return $this->userGroupCache[$userKey][self::CACHE_EFFECTIVE]; |
| } |
| |
| /** |
| * Returns a list of groups that the user belongs to but for some reason |
| * are currently disabled. |
| * |
| * This is used to stop people using group they no longer meet conditions for, |
| * as configured by $wgRestrictedGroups. |
| * |
| * @param UserIdentity $user |
| * @return array |
| */ |
| public function getUserDisabledGroups( UserIdentity $user ): array { |
| // Check if the user is system user. Given that such accounts cannot be logged in to and are controlled by |
| // software, we can keep all their user groups enabled. These accounts may also ignore permission checks, |
| // so in some cases the group membership is only declarative. |
| // Always check the local user with the same name for being a system user; it'll usually hold. |
| // Even if we're mistaken, we'll narrow the set of enabled groups, which is safe |
| $userObj = $user instanceof User ? $user : $this->userFactory->newFromName( $user->getName() ); |
| if ( $userObj?->isSystemUser() ) { |
| return []; |
| } |
| |
| $groups = $this->getUserGroups( $user ); |
| |
| $restrictedGroups = $this->restrictedUserGroupConfigReader->getConfig( $this->wikiId ); |
| $disabledGroups = []; |
| foreach ( $groups as $group ) { |
| if ( !array_key_exists( $group, $restrictedGroups ) ) { |
| continue; |
| } |
| $restrictions = $restrictedGroups[$group]; |
| if ( !$restrictions->continuouslyEnforced() ) { |
| continue; |
| } |
| |
| $checker = $this->userRequirementsConditionCheckerFactory->getUserRequirementsConditionChecker( |
| $this, $this->wikiId |
| ); |
| if ( !$checker->recursivelyCheckCondition( $restrictions->getMemberConditions(), $user ) ) { |
| $disabledGroups[] = $group; |
| } |
| } |
| return $disabledGroups; |
| } |
| |
| /** |
| * Returns the groups the user has belonged to. |
| * |
| * The user may still belong to the returned groups. Compare with |
| * getUserGroups(). |
| * |
| * The function will not return groups the user had belonged to before MW 1.17 |
| * |
| * @param UserIdentity $user |
| * @param int $queryFlags |
| * @return string[] Names of the groups the user has belonged to. |
| */ |
| public function getUserFormerGroups( |
| UserIdentity $user, |
| int $queryFlags = IDBAccessObject::READ_NORMAL |
| ): array { |
| $user->assertWiki( $this->wikiId ); |
| $userKey = $this->getCacheKey( $user ); |
| |
| if ( |
| isset( $this->userGroupCache[$userKey][self::CACHE_FORMER] ) && |
| $this->canUseCachedValues( $user, self::CACHE_FORMER, $queryFlags ) |
| ) { |
| return $this->userGroupCache[$userKey][self::CACHE_FORMER]; |
| } |
| |
| if ( !$user->isRegistered() ) { |
| // Anon users don't have groups stored in the database |
| return []; |
| } |
| |
| $formerGroups = $this->getDBConnectionRefForQueryFlags( $queryFlags )->newSelectQueryBuilder() |
| ->select( 'ufg_group' ) |
| ->from( 'user_former_groups' ) |
| ->where( [ 'ufg_user' => $user->getId( $this->wikiId ) ] ) |
| ->caller( __METHOD__ ) |
| ->fetchFieldValues(); |
| $this->setCache( $userKey, self::CACHE_FORMER, $formerGroups, $queryFlags ); |
| |
| return $this->userGroupCache[$userKey][self::CACHE_FORMER]; |
| } |
| |
| /** |
| * Get the groups for the given user based on $wgAutopromote. |
| * |
| * Supports only local-wiki checks. Trying to get autopromote groups for users from other wikis results in |
| * an empty array. |
| * |
| * @param UserIdentity $user The user to get the groups for |
| * @return string[] Array of groups to promote to. |
| * |
| * @see $wgAutopromote |
| */ |
| public function getUserAutopromoteGroups( UserIdentity $user ): array { |
| $user->assertWiki( $this->wikiId ); |
| $isLocal = $user->getWikiId() === UserIdentity::LOCAL || WikiMap::isCurrentWikiId( $user->getWikiId() ); |
| if ( !$isLocal ) { |
| // The code below doesn't support checking for interwiki users, primarily due to use of User class |
| // Config of autopromote groups can also differ from wiki to wiki, which can likely lead to wrong results. |
| return []; |
| } |
| |
| if ( $this->tempUserConfig->isTempName( $user->getName() ) ) { |
| return []; |
| } |
| |
| $promote = []; |
| $checker = $this->userRequirementsConditionCheckerFactory->getUserRequirementsConditionChecker( |
| $this, $this->wikiId |
| ); |
| foreach ( $this->options->get( MainConfigNames::Autopromote ) as $group => $cond ) { |
| if ( $checker->recursivelyCheckCondition( $cond, $user ) ) { |
| $promote[] = $group; |
| } |
| } |
| |
| if ( $this->hookContainer->isRegistered( 'GetAutoPromoteGroups' ) ) { |
| // TODO: Deprecate passing out user object in the hook by introducing |
| // an alternative hook |
| $userObj = User::newFromIdentity( $user ); |
| $this->hookRunner->onGetAutoPromoteGroups( $userObj, $promote ); |
| } |
| return $promote; |
| } |
| |
| /** |
| * Get the groups for the given user based on the given criteria. |
| * |
| * Does not return groups the user already belongs to or has once belonged. |
| * |
| * @param UserIdentity $user The user to get the groups for |
| * @param string $event Key in $wgAutopromoteOnce (each event has groups/criteria) |
| * |
| * @return string[] Groups the user should be promoted to. |
| * |
| * @see $wgAutopromoteOnce |
| */ |
| public function getUserAutopromoteOnceGroups( |
| UserIdentity $user, |
| string $event |
| ): array { |
| $user->assertWiki( $this->wikiId ); |
| $autopromoteOnce = $this->options->get( MainConfigNames::AutopromoteOnce ); |
| $promote = []; |
| |
| if ( isset( $autopromoteOnce[$event] ) && count( $autopromoteOnce[$event] ) ) { |
| if ( $this->tempUserConfig->isTempName( $user->getName() ) ) { |
| return []; |
| } |
| $currentGroups = $this->getUserGroups( $user ); |
| $formerGroups = $this->getUserFormerGroups( $user ); |
| $checker = $this->userRequirementsConditionCheckerFactory->getUserRequirementsConditionChecker( |
| $this, $this->wikiId |
| ); |
| foreach ( $autopromoteOnce[$event] as $group => $cond ) { |
| // Do not check if the user's already a member |
| if ( in_array( $group, $currentGroups ) ) { |
| continue; |
| } |
| // Do not autopromote if the user has belonged to the group |
| if ( in_array( $group, $formerGroups ) ) { |
| continue; |
| } |
| // Finally - check the conditions |
| if ( $checker->recursivelyCheckCondition( $cond, $user ) ) { |
| $promote[] = $group; |
| } |
| } |
| } |
| |
| return $promote; |
| } |
| |
| /** |
| * Returns the list of privileged groups that $user belongs to. |
| * |
| * Privileged groups are ones that can be abused. |
| * |
| * Depending on how extensions extend this method, it might return values |
| * that are not strictly user groups (ACL list names, etc.). |
| * It is meant for logging/auditing, not for passing to methods that expect group names. |
| * |
| * @param UserIdentity $user |
| * @param int $queryFlags |
| * @param bool $recache Whether to avoid the cache |
| * @return string[] |
| * @since 1.41 (also backported to 1.39.5 and 1.40.1) |
| * @see $wgPrivilegedGroups |
| * @see https://www.mediawiki.org/wiki/Manual:Hooks/UserGetPrivilegedGroups |
| */ |
| public function getUserPrivilegedGroups( |
| UserIdentity $user, |
| int $queryFlags = IDBAccessObject::READ_NORMAL, |
| bool $recache = false |
| ): array { |
| $userKey = $this->getCacheKey( $user ); |
| |
| if ( |
| !$recache && |
| isset( $this->userGroupCache[$userKey][self::CACHE_PRIVILEGED] ) && |
| $this->canUseCachedValues( $user, self::CACHE_PRIVILEGED, $queryFlags ) |
| ) { |
| return $this->userGroupCache[$userKey][self::CACHE_PRIVILEGED]; |
| } |
| |
| if ( !$user->isRegistered() ) { |
| return []; |
| } |
| |
| $groups = array_intersect( |
| $this->getUserEffectiveGroups( $user, $queryFlags, $recache ), |
| $this->options->get( MainConfigNames::PrivilegedGroups ) |
| ); |
| |
| $this->hookRunner->onUserPrivilegedGroups( $user, $groups ); |
| |
| $this->setCache( |
| $this->getCacheKey( $user ), |
| self::CACHE_PRIVILEGED, |
| array_values( array_unique( $groups ) ), |
| $queryFlags |
| ); |
| |
| return $this->userGroupCache[$userKey][self::CACHE_PRIVILEGED]; |
| } |
| |
| /** |
| * Add the user to the group if they meet given criteria. |
| * |
| * Contrary to autopromotion by $wgAutopromote, the group will be |
| * possible to remove manually via Special:UserRights. In such cases, it |
| * will not be re-added automatically. The user will also not lose the |
| * group if they no longer meet the criteria. |
| * |
| * @param UserIdentity $user User to add to the groups |
| * @param string $event Key in $wgAutopromoteOnce (each event has groups/criteria) |
| * |
| * @return string[] Array of groups the user has been promoted to. |
| * |
| * @see $wgAutopromoteOnce |
| */ |
| public function addUserToAutopromoteOnceGroups( |
| UserIdentity $user, |
| string $event |
| ): array { |
| $user->assertWiki( $this->wikiId ); |
| Assert::precondition( |
| !$this->wikiId || WikiMap::isCurrentWikiDbDomain( $this->wikiId ), |
| __METHOD__ . " is not supported for foreign wikis: {$this->wikiId} used" |
| ); |
| |
| if ( |
| $this->readOnlyMode->isReadOnly( $this->wikiId ) || |
| !$user->isRegistered() || |
| $this->tempUserConfig->isTempName( $user->getName() ) |
| ) { |
| return []; |
| } |
| |
| $toPromote = $this->getUserAutopromoteOnceGroups( $user, $event ); |
| if ( $toPromote === [] ) { |
| return []; |
| } |
| |
| $userObj = User::newFromIdentity( $user ); |
| if ( !$userObj->checkAndSetTouched() ) { |
| // @codeCoverageIgnoreStart |
| // raced out (bug T48834) |
| return []; |
| // @codeCoverageIgnoreEnd |
| } |
| |
| // previous groups |
| $oldGroups = $this->getUserGroups( $user ); |
| $oldUGMs = $this->getUserGroupMemberships( $user ); |
| $this->addUserToMultipleGroups( $user, $toPromote ); |
| // all groups |
| $newGroups = array_merge( $oldGroups, $toPromote ); |
| $newUGMs = $this->getUserGroupMemberships( $user ); |
| |
| // update groups in external authentication database |
| // TODO: deprecate passing full User object to hook |
| $this->hookRunner->onUserGroupsChanged( |
| $userObj, |
| $toPromote, |
| [], |
| false, |
| false, |
| $oldUGMs, |
| $newUGMs |
| ); |
| |
| $logEntry = new ManualLogEntry( 'rights', 'autopromote' ); |
| $logEntry->setPerformer( $user ); |
| $logEntry->setTarget( $userObj->getUserPage() ); |
| $logEntry->setParameters( [ |
| '4::oldgroups' => $oldGroups, |
| '5::newgroups' => $newGroups, |
| ] ); |
| $logid = $logEntry->insert(); |
| |
| // Allow excluding autopromotions into select groups from RecentChanges (T377829). |
| $groupsToShowInRC = array_diff( |
| $toPromote, |
| $this->options->get( MainConfigNames::AutopromoteOnceRCExcludedGroups ) |
| ); |
| |
| if ( $this->options->get( MainConfigNames::AutopromoteOnceLogInRC ) && count( $groupsToShowInRC ) ) { |
| $logEntry->publish( $logid ); |
| } |
| |
| return $toPromote; |
| } |
| |
| /** |
| * Get the list of explicit group memberships this user has. |
| * The implicit * and user groups are not included. |
| * |
| * @return string[] |
| */ |
| public function getUserGroups( |
| UserIdentity $user, |
| int $queryFlags = IDBAccessObject::READ_NORMAL |
| ): array { |
| return array_keys( $this->getUserGroupMemberships( $user, $queryFlags ) ); |
| } |
| |
| /** |
| * Loads and returns UserGroupMembership objects for all the groups a user currently |
| * belongs to. |
| * |
| * @param UserIdentity $user the user to search for |
| * @param int $queryFlags |
| * @return UserGroupMembership[] Associative array of (group name => UserGroupMembership object) |
| */ |
| public function getUserGroupMemberships( |
| UserIdentity $user, |
| int $queryFlags = IDBAccessObject::READ_NORMAL |
| ): array { |
| $user->assertWiki( $this->wikiId ); |
| $userKey = $this->getCacheKey( $user ); |
| |
| if ( |
| $this->canUseCachedValues( $user, self::CACHE_MEMBERSHIP, $queryFlags ) && |
| isset( $this->userGroupCache[$userKey][self::CACHE_MEMBERSHIP] ) |
| ) { |
| /** @suppress PhanTypeMismatchReturn */ |
| return $this->userGroupCache[$userKey][self::CACHE_MEMBERSHIP]; |
| } |
| |
| if ( !$user->isRegistered() ) { |
| // Anon users don't have groups stored in the database |
| return []; |
| } |
| |
| $queryBuilder = $this->newQueryBuilder( $this->getDBConnectionRefForQueryFlags( $queryFlags ) ); |
| $res = $queryBuilder |
| ->where( [ 'ug_user' => $user->getId( $this->wikiId ) ] ) |
| ->caller( __METHOD__ ) |
| ->fetchResultSet(); |
| |
| $ugms = []; |
| foreach ( $res as $row ) { |
| $ugm = $this->newGroupMembershipFromRow( $row ); |
| if ( $ugm->isExpired() ) { |
| $this->queueUserGroupExpiryJobOnce(); |
| continue; |
| } |
| $ugms[$ugm->getGroup()] = $ugm; |
| } |
| ksort( $ugms ); |
| |
| $this->setCache( $userKey, self::CACHE_MEMBERSHIP, $ugms, $queryFlags ); |
| |
| return $ugms; |
| } |
| |
| /** |
| * Queue a background job to purge expired user group memberships. |
| */ |
| private function queueUserGroupExpiryJobOnce(): void { |
| if ( $this->expiryPurgeJobQueued ) { |
| return; |
| } |
| if ( $this->readOnlyMode->isReadOnly( $this->wikiId ) ) { |
| // purgeExpired() requires DB writes and cannot run while the wiki is readOnly |
| return; |
| } |
| $this->expiryPurgeJobQueued = true; |
| |
| DeferredUpdates::addCallableUpdate( function () { |
| $this->jobQueueGroup->push( new UserGroupExpiryJob( [] ) ); |
| } ); |
| } |
| |
| /** |
| * Add the user to the given group. This takes immediate effect. |
| * If the user is already in the group, the expiry time will be updated to the new |
| * expiry time. (If $expiry is omitted or null, the membership will be altered to |
| * never expire.) |
| * |
| * @param UserIdentity $user |
| * @param string $group Name of the group to add |
| * @param string|null $expiry Optional expiry timestamp in any format acceptable to |
| * wfTimestamp(), or null if the group assignment should not expire |
| * @param bool $allowUpdate Whether to perform "upsert" instead of INSERT |
| * |
| * @throws InvalidArgumentException |
| * @return bool |
| */ |
| public function addUserToGroup( |
| UserIdentity $user, |
| string $group, |
| ?string $expiry = null, |
| bool $allowUpdate = false |
| ): bool { |
| $user->assertWiki( $this->wikiId ); |
| if ( $this->readOnlyMode->isReadOnly( $this->wikiId ) ) { |
| return false; |
| } |
| |
| $isTemp = $this->tempUserConfig->isTempName( $user->getName() ); |
| if ( !$user->isRegistered() ) { |
| throw new InvalidArgumentException( |
| 'UserGroupManager::addUserToGroup() needs a positive user ID. ' . |
| 'Perhaps addUserToGroup() was called before the user was added to the database.' |
| ); |
| } |
| if ( $isTemp ) { |
| throw new InvalidArgumentException( |
| 'UserGroupManager::addUserToGroup() cannot be called on a temporary user.' |
| ); |
| } |
| |
| if ( $expiry ) { |
| $expiry = wfTimestamp( TS::MW, $expiry ); |
| } |
| |
| // TODO: Deprecate passing out user object in the hook by introducing |
| // an alternative hook |
| $isLocal = $user->getWikiId() === UserIdentity::LOCAL || WikiMap::isCurrentWikiId( $user->getWikiId() ); |
| if ( $isLocal && $this->hookContainer->isRegistered( 'UserAddGroup' ) ) { |
| $userObj = User::newFromIdentity( $user ); |
| $userObj->load(); |
| if ( !$this->hookRunner->onUserAddGroup( $userObj, $group, $expiry ) ) { |
| return false; |
| } |
| } |
| |
| $oldUgms = $this->getUserGroupMemberships( $user, IDBAccessObject::READ_LATEST ); |
| $dbw = $this->connectionProvider->getPrimaryDatabase( $this->wikiId ); |
| |
| $dbw->startAtomic( __METHOD__ ); |
| $dbw->newInsertQueryBuilder() |
| ->insertInto( 'user_groups' ) |
| ->ignore() |
| ->row( [ |
| 'ug_user' => $user->getId( $this->wikiId ), |
| 'ug_group' => $group, |
| 'ug_expiry' => $expiry ? $dbw->timestamp( $expiry ) : null, |
| ] ) |
| ->caller( __METHOD__ )->execute(); |
| |
| $affected = $dbw->affectedRows(); |
| if ( !$affected ) { |
| // A conflicting row already exists; it should be overridden if it is either expired |
| // or if $allowUpdate is true and the current row is different than the loaded row. |
| $conds = [ |
| 'ug_user' => $user->getId( $this->wikiId ), |
| 'ug_group' => $group |
| ]; |
| if ( $allowUpdate ) { |
| // Update the current row if its expiry does not match that of the loaded row |
| $conds[] = $expiry |
| ? $dbw->expr( 'ug_expiry', '=', null ) |
| ->or( 'ug_expiry', '!=', $dbw->timestamp( $expiry ) ) |
| : $dbw->expr( 'ug_expiry', '!=', null ); |
| } else { |
| // Update the current row if it is expired |
| $conds[] = $dbw->expr( 'ug_expiry', '<', $dbw->timestamp() ); |
| } |
| $dbw->newUpdateQueryBuilder() |
| ->update( 'user_groups' ) |
| ->set( [ 'ug_expiry' => $expiry ? $dbw->timestamp( $expiry ) : null ] ) |
| ->where( $conds ) |
| ->caller( __METHOD__ )->execute(); |
| $affected = $dbw->affectedRows(); |
| } |
| $dbw->endAtomic( __METHOD__ ); |
| |
| // Purge old, expired memberships from the DB |
| DeferredUpdates::addCallableUpdate( function ( $fname ) { |
| $dbr = $this->connectionProvider->getReplicaDatabase( $this->wikiId ); |
| $hasExpiredRow = (bool)$dbr->newSelectQueryBuilder() |
| ->select( '1' ) |
| ->from( 'user_groups' ) |
| ->where( [ $dbr->expr( 'ug_expiry', '<', $dbr->timestamp() ) ] ) |
| ->caller( $fname ) |
| ->fetchField(); |
| if ( $hasExpiredRow ) { |
| $this->jobQueueGroup->push( new UserGroupExpiryJob( [] ) ); |
| } |
| } ); |
| |
| if ( $affected > 0 ) { |
| $oldUgms[$group] = new UserGroupMembership( $user->getId( $this->wikiId ), $group, $expiry ); |
| if ( !$oldUgms[$group]->isExpired() ) { |
| $this->setCache( |
| $this->getCacheKey( $user ), |
| self::CACHE_MEMBERSHIP, |
| $oldUgms, |
| IDBAccessObject::READ_LATEST |
| ); |
| $this->clearUserCacheForKind( $user, self::CACHE_EFFECTIVE ); |
| } |
| foreach ( $this->clearCacheCallbacks as $callback ) { |
| $callback( $user ); |
| } |
| return true; |
| } |
| return false; |
| } |
| |
| /** |
| * Add the user to the given list of groups. |
| * |
| * @since 1.37 |
| * |
| * @param UserIdentity $user |
| * @param string[] $groups Names of the groups to add |
| * @param string|null $expiry Optional expiry timestamp in any format acceptable to |
| * wfTimestamp(), or null if the group assignment should not expire |
| * @param bool $allowUpdate Whether to perform "upsert" instead of INSERT |
| * |
| * @throws InvalidArgumentException |
| */ |
| public function addUserToMultipleGroups( |
| UserIdentity $user, |
| array $groups, |
| ?string $expiry = null, |
| bool $allowUpdate = false |
| ) { |
| foreach ( $groups as $group ) { |
| $this->addUserToGroup( $user, $group, $expiry, $allowUpdate ); |
| } |
| } |
| |
| /** |
| * Remove the user from the given group. This takes immediate effect. |
| * |
| * @param UserIdentity $user |
| * @param string $group Name of the group to remove |
| * @throws InvalidArgumentException |
| * @return bool |
| */ |
| public function removeUserFromGroup( UserIdentity $user, string $group ): bool { |
| $user->assertWiki( $this->wikiId ); |
| // TODO: Deprecate passing out user object in the hook by introducing |
| // an alternative hook |
| $isLocal = $user->getWikiId() === UserIdentity::LOCAL || WikiMap::isCurrentWikiId( $user->getWikiId() ); |
| if ( $isLocal && $this->hookContainer->isRegistered( 'UserRemoveGroup' ) ) { |
| $userObj = User::newFromIdentity( $user ); |
| $userObj->load(); |
| if ( !$this->hookRunner->onUserRemoveGroup( $userObj, $group ) ) { |
| return false; |
| } |
| } |
| |
| if ( $this->readOnlyMode->isReadOnly( $this->wikiId ) ) { |
| return false; |
| } |
| |
| if ( !$user->isRegistered() ) { |
| throw new InvalidArgumentException( |
| 'UserGroupManager::removeUserFromGroup() needs a positive user ID. ' . |
| 'Perhaps removeUserFromGroup() was called before the user was added to the database.' |
| ); |
| } |
| |
| $oldUgms = $this->getUserGroupMemberships( $user, IDBAccessObject::READ_LATEST ); |
| $oldFormerGroups = $this->getUserFormerGroups( $user, IDBAccessObject::READ_LATEST ); |
| $dbw = $this->connectionProvider->getPrimaryDatabase( $this->wikiId ); |
| $dbw->newDeleteQueryBuilder() |
| ->deleteFrom( 'user_groups' ) |
| ->where( [ 'ug_user' => $user->getId( $this->wikiId ), 'ug_group' => $group ] ) |
| ->caller( __METHOD__ )->execute(); |
| |
| if ( !$dbw->affectedRows() ) { |
| return false; |
| } |
| // Remember that the user was in this group |
| $dbw->newInsertQueryBuilder() |
| ->insertInto( 'user_former_groups' ) |
| ->ignore() |
| ->row( [ 'ufg_user' => $user->getId( $this->wikiId ), 'ufg_group' => $group ] ) |
| ->caller( __METHOD__ )->execute(); |
| |
| unset( $oldUgms[$group] ); |
| $userKey = $this->getCacheKey( $user ); |
| $this->setCache( $userKey, self::CACHE_MEMBERSHIP, $oldUgms, IDBAccessObject::READ_LATEST ); |
| $oldFormerGroups[] = $group; |
| $this->setCache( $userKey, self::CACHE_FORMER, $oldFormerGroups, IDBAccessObject::READ_LATEST ); |
| $this->clearUserCacheForKind( $user, self::CACHE_EFFECTIVE ); |
| foreach ( $this->clearCacheCallbacks as $callback ) { |
| $callback( $user ); |
| } |
| return true; |
| } |
| |
| /** |
| * @internal |
| */ |
| public function newQueryBuilder( IReadableDatabase $db ): SelectQueryBuilder { |
| return $db->newSelectQueryBuilder() |
| ->select( [ |
| 'ug_user', |
| 'ug_group', |
| 'ug_expiry', |
| ] ) |
| ->from( 'user_groups' ); |
| } |
| |
| /** |
| * Purge expired memberships from the user_groups table |
| * @internal |
| * @note this could be slow and is intended for use in a background job |
| * @return int|false false if purging wasn't attempted (e.g. because of |
| * readonly), the number of rows purged (might be 0) otherwise |
| */ |
| public function purgeExpired() { |
| if ( $this->readOnlyMode->isReadOnly( $this->wikiId ) ) { |
| return false; |
| } |
| |
| $ticket = $this->connectionProvider->getEmptyTransactionTicket( __METHOD__ ); |
| $dbw = $this->connectionProvider->getPrimaryDatabase( $this->wikiId ); |
| |
| // per-wiki |
| $lockKey = "{$dbw->getDomainID()}:UserGroupManager:purge"; |
| $scopedLock = $dbw->getScopedLockAndFlush( $lockKey, __METHOD__, 0 ); |
| if ( !$scopedLock ) { |
| // @codeCoverageIgnoreStart |
| // already running |
| return false; |
| // @codeCoverageIgnoreEnd |
| } |
| |
| $now = time(); |
| $purgedRows = 0; |
| do { |
| $expiredMembershipsByUser = []; |
| $oldUGMsByUser = []; |
| $newUGMsByUser = []; |
| |
| $dbw->startAtomic( __METHOD__ ); |
| $res = $this->newQueryBuilder( $dbw ) |
| ->where( [ $dbw->expr( 'ug_expiry', '<', $dbw->timestamp( $now ) ) ] ) |
| ->forUpdate() |
| ->limit( 100 ) |
| ->caller( __METHOD__ ) |
| ->fetchResultSet(); |
| |
| if ( $res->numRows() > 0 ) { |
| // array of users/groups to insert to user_former_groups |
| $insertData = []; |
| // array for deleting the rows that are to be moved around |
| $deleteCond = []; |
| foreach ( $res as $row ) { |
| $insertData[] = [ 'ufg_user' => $row->ug_user, 'ufg_group' => $row->ug_group ]; |
| $deleteCond[] = $dbw |
| ->expr( 'ug_user', '=', $row->ug_user ) |
| ->and( 'ug_group', '=', $row->ug_group ); |
| |
| $expiredMembershipsByUser[(int)$row->ug_user][$row->ug_group] = true; |
| } |
| $affectedUserIds = array_keys( $expiredMembershipsByUser ); |
| |
| $allRows = $this->newQueryBuilder( $dbw ) |
| ->where( [ 'ug_user' => $affectedUserIds ] ) |
| ->caller( __METHOD__ ) |
| ->fetchResultSet(); |
| |
| foreach ( $allRows as $row ) { |
| $userId = (int)$row->ug_user; |
| $ugm = $this->newGroupMembershipFromRow( $row ); |
| $group = $ugm->getGroup(); |
| |
| $oldUGMsByUser[$userId][$group] = $ugm; |
| if ( !isset( $expiredMembershipsByUser[$userId][$group] ) ) { |
| $newUGMsByUser[$userId][$group] = $ugm; |
| } |
| } |
| |
| // Delete the rows we're about to move |
| $dbw->newDeleteQueryBuilder() |
| ->deleteFrom( 'user_groups' ) |
| ->where( $dbw->orExpr( $deleteCond ) ) |
| ->caller( __METHOD__ )->execute(); |
| // Push the groups to user_former_groups |
| $dbw->newInsertQueryBuilder() |
| ->insertInto( 'user_former_groups' ) |
| ->ignore() |
| ->rows( $insertData ) |
| ->caller( __METHOD__ )->execute(); |
| // Count how many rows were purged |
| $purgedRows += $res->numRows(); |
| } |
| |
| $dbw->endAtomic( __METHOD__ ); |
| |
| $this->connectionProvider->commitAndWaitForReplication( __METHOD__, $ticket ); |
| |
| // call hooks after commit |
| foreach ( $expiredMembershipsByUser as $userId => $data ) { |
| $user = $this->userFactory->newFromId( $userId ); |
| |
| $remove = array_keys( $data ); |
| $oldUGMs = $oldUGMsByUser[$userId] ?? []; |
| $newUGMs = $newUGMsByUser[$userId] ?? []; |
| |
| $this->hookRunner->onUserGroupsChanged( |
| $user, |
| [], |
| $remove, |
| false, // performer: automatic change |
| false, // reason: none |
| $oldUGMs, |
| $newUGMs |
| ); |
| } |
| } while ( $res->numRows() > 0 ); |
| return $purgedRows; |
| } |
| |
| /** |
| * @return string[] |
| */ |
| private function expandChangeableGroupConfig( array $config, string $group ): array { |
| if ( empty( $config[$group] ) ) { |
| return []; |
| } |
| |
| if ( $config[$group] === true ) { |
| // You get everything |
| return $this->listAllGroups(); |
| } |
| |
| if ( is_array( $config[$group] ) ) { |
| return $config[$group]; |
| } |
| |
| return []; |
| } |
| |
| /** |
| * Returns an array of the groups that a particular group can add/remove. |
| * |
| * @since 1.37 |
| * @param string $group The group to check for whether it can add/remove |
| * @return array [ |
| * 'add' => [ addablegroups ], |
| * 'remove' => [ removablegroups ], |
| * 'add-self' => [ addablegroups to self ], |
| * 'remove-self' => [ removable groups from self ] ] |
| */ |
| public function getGroupsChangeableByGroup( string $group ): array { |
| return [ |
| 'add' => $this->expandChangeableGroupConfig( |
| $this->options->get( MainConfigNames::AddGroups ), $group |
| ), |
| 'remove' => $this->expandChangeableGroupConfig( |
| $this->options->get( MainConfigNames::RemoveGroups ), $group |
| ), |
| 'add-self' => $this->expandChangeableGroupConfig( |
| $this->options->get( MainConfigNames::GroupsAddToSelf ), $group |
| ), |
| 'remove-self' => $this->expandChangeableGroupConfig( |
| $this->options->get( MainConfigNames::GroupsRemoveFromSelf ), $group |
| ), |
| ]; |
| } |
| |
| /** |
| * Returns an array of groups that this $actor can add and remove. |
| * |
| * @since 1.37 |
| * @param Authority $authority |
| * @return array [ |
| * 'add' => [ addablegroups ], |
| * 'remove' => [ removablegroups ], |
| * 'add-self' => [ addablegroups to self ], |
| * 'remove-self' => [ removable groups from self ] |
| * ] |
| * @phan-return array{add:list<string>,remove:list<string>,add-self:list<string>,remove-self:list<string>} |
| */ |
| public function getGroupsChangeableBy( Authority $authority ): array { |
| if ( $authority->isAllowed( 'userrights' ) ) { |
| // This group gives the right to modify everything (reverse- |
| // compatibility with old "userrights lets you change |
| // everything") |
| $all = array_values( $this->listAllGroups() ); |
| return [ |
| 'add' => $all, |
| 'remove' => $all, |
| 'add-self' => [], |
| 'remove-self' => [] |
| ]; |
| } |
| |
| // Okay, it's not so simple; we will have to go through the arrays |
| $actorGroups = $this->getUserEffectiveGroups( $authority->getUser() ); |
| $changeableGroups = []; |
| foreach ( $actorGroups as $actorGroup ) { |
| $changeableGroups[] = $this->getGroupsChangeableByGroup( $actorGroup ); |
| } |
| $groups = array_merge_recursive( [ |
| 'add' => [], |
| 'remove' => [], |
| 'add-self' => [], |
| 'remove-self' => [] |
| ], ...$changeableGroups ); |
| return array_map( array_values( ... ), array_map( array_unique( ... ), $groups ) ); |
| } |
| |
| /** |
| * Cleans cached group memberships for a given user |
| */ |
| public function clearCache( UserIdentity $user ) { |
| $user->assertWiki( $this->wikiId ); |
| $userKey = $this->getCacheKey( $user ); |
| unset( $this->userGroupCache[$userKey] ); |
| unset( $this->queryFlagsUsedForCaching[$userKey] ); |
| } |
| |
| /** |
| * Sets cached group memberships and query flags for a given user |
| * |
| * @param string $userKey |
| * @param string $cacheKind one of self::CACHE_KIND_* constants |
| * @param array $groupValue |
| * @param int $queryFlags |
| */ |
| private function setCache( |
| string $userKey, |
| string $cacheKind, |
| array $groupValue, |
| int $queryFlags |
| ) { |
| $this->userGroupCache[$userKey][$cacheKind] = $groupValue; |
| $this->queryFlagsUsedForCaching[$userKey][$cacheKind] = $queryFlags; |
| } |
| |
| /** |
| * Clears a cached group membership and query key for a given user |
| * |
| * @param UserIdentity $user |
| * @param string $cacheKind one of self::CACHE_* constants |
| */ |
| private function clearUserCacheForKind( UserIdentity $user, string $cacheKind ) { |
| $userKey = $this->getCacheKey( $user ); |
| unset( $this->userGroupCache[$userKey][$cacheKind] ); |
| unset( $this->queryFlagsUsedForCaching[$userKey][$cacheKind] ); |
| } |
| |
| /** |
| * @param int $recency a bit field composed of IDBAccessObject::READ_XXX flags |
| * @return IReadableDatabase |
| */ |
| private function getDBConnectionRefForQueryFlags( int $recency ): IReadableDatabase { |
| if ( ( IDBAccessObject::READ_LATEST & $recency ) === IDBAccessObject::READ_LATEST ) { |
| return $this->connectionProvider->getPrimaryDatabase( $this->wikiId ); |
| } |
| return $this->connectionProvider->getReplicaDatabase( $this->wikiId ); |
| } |
| |
| private function getCacheKey( UserIdentity $user ): string { |
| return $user->isRegistered() ? "u:{$user->getId( $this->wikiId )}" : "anon:{$user->getName()}"; |
| } |
| |
| /** |
| * Determines if it's ok to use cached options values for a given user and query flags |
| * @param UserIdentity $user |
| * @param string $cacheKind one of self::CACHE_* constants |
| * @param int $queryFlags |
| * @return bool |
| */ |
| private function canUseCachedValues( |
| UserIdentity $user, |
| string $cacheKind, |
| int $queryFlags |
| ): bool { |
| if ( !$user->isRegistered() ) { |
| // Anon users don't have groups stored in the database, |
| // so $queryFlags are ignored. |
| return true; |
| } |
| if ( $queryFlags >= IDBAccessObject::READ_LOCKING ) { |
| return false; |
| } |
| $userKey = $this->getCacheKey( $user ); |
| $queryFlagsUsed = $this->queryFlagsUsedForCaching[$userKey][$cacheKind] ?? IDBAccessObject::READ_NONE; |
| return $queryFlagsUsed >= $queryFlags; |
| } |
| } |