Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
98.59% |
140 / 142 |
|
92.31% |
12 / 13 |
CRAP | |
0.00% |
0 / 1 |
| UserGroupAssignmentService | |
98.59% |
140 / 142 |
|
92.31% |
12 / 13 |
46 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| targetCanHaveUserGroups | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
6 | |||
| userCanChangeRights | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
4 | |||
| getChangeableGroups | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| computeChangeableGroups | |
100.00% |
35 / 35 |
|
100.00% |
1 / 1 |
7 | |||
| saveChangesToUserGroups | |
92.86% |
26 / 28 |
|
0.00% |
0 / 1 |
8.02 | |||
| getKnownGroups | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| getRestrictedGroupChecker | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| addRightsLogEntry | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
4 | |||
| addRightsLogEntryOnWiki | |
100.00% |
24 / 24 |
|
100.00% |
1 / 1 |
3 | |||
| getPageForTargetUser | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| getPageTitleForTargetUser | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 | |||
| serialiseUgmForLog | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * @license GPL-2.0-or-later |
| 4 | * @file |
| 5 | */ |
| 6 | |
| 7 | namespace MediaWiki\User; |
| 8 | |
| 9 | use MediaWiki\Config\ServiceOptions; |
| 10 | use MediaWiki\HookContainer\HookRunner; |
| 11 | use MediaWiki\Logging\ManualLogEntry; |
| 12 | use MediaWiki\MainConfigNames; |
| 13 | use MediaWiki\Page\PageIdentity; |
| 14 | use MediaWiki\Page\PageIdentityValue; |
| 15 | use MediaWiki\Page\PageStoreFactory; |
| 16 | use MediaWiki\Permissions\Authority; |
| 17 | use MediaWiki\Title\Title; |
| 18 | use MediaWiki\User\TempUser\TempUserConfig; |
| 19 | use MediaWiki\WikiMap\WikiMap; |
| 20 | use Wikimedia\Rdbms\IConnectionProvider; |
| 21 | |
| 22 | /** |
| 23 | * This class represents a service that provides high-level operations on user groups. |
| 24 | * Contrary to UserGroupManager, this class is not interested in details of how user groups |
| 25 | * are stored or defined, but rather in the business logic of assigning and removing groups. |
| 26 | * |
| 27 | * Therefore, it combines group management with logging and provides permission checks. |
| 28 | * Additionally, the method interfaces are designed to be suitable for calls from user-facing code. |
| 29 | * |
| 30 | * @since 1.45 |
| 31 | * @ingroup User |
| 32 | */ |
| 33 | class UserGroupAssignmentService extends UserGroupAssignmentServiceBase { |
| 34 | |
| 35 | /** @internal */ |
| 36 | public const CONSTRUCTOR_OPTIONS = [ |
| 37 | MainConfigNames::UserrightsInterwikiDelimiter |
| 38 | ]; |
| 39 | |
| 40 | private array $changeableGroupsCache = []; |
| 41 | |
| 42 | public function __construct( |
| 43 | private readonly UserGroupManagerFactory $userGroupManagerFactory, |
| 44 | private readonly UserNameUtils $userNameUtils, |
| 45 | private readonly UserFactory $userFactory, |
| 46 | private readonly RestrictedUserGroupCheckerFactory $restrictedGroupCheckerFactory, |
| 47 | private readonly HookRunner $hookRunner, |
| 48 | private readonly ServiceOptions $options, |
| 49 | private readonly TempUserConfig $tempUserConfig, |
| 50 | private readonly IConnectionProvider $connectionProvider, |
| 51 | private readonly PageStoreFactory $pageStoreFactory, |
| 52 | ) { |
| 53 | parent::__construct( $this->hookRunner ); |
| 54 | $this->options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); |
| 55 | } |
| 56 | |
| 57 | public function targetCanHaveUserGroups( UserIdentity $target ): bool { |
| 58 | // Basic stuff - don't assign groups to anons and temp. accounts |
| 59 | if ( !$target->isRegistered() ) { |
| 60 | return false; |
| 61 | } |
| 62 | if ( $this->userNameUtils->isTemp( $target->getName() ) ) { |
| 63 | return false; |
| 64 | } |
| 65 | |
| 66 | // We also need to make sure that we don't assign groups to remote temp. accounts if they |
| 67 | // are disabled on the current wiki |
| 68 | if ( |
| 69 | $target->getWikiId() !== UserIdentity::LOCAL && |
| 70 | !$this->tempUserConfig->isKnown() && |
| 71 | $this->tempUserConfig->isReservedName( $target->getName() ) |
| 72 | ) { |
| 73 | return false; |
| 74 | } |
| 75 | |
| 76 | return true; |
| 77 | } |
| 78 | |
| 79 | /** |
| 80 | * Check whether the given user can change the target user's rights. |
| 81 | * |
| 82 | * @param Authority $performer User who is attempting to change the target's rights |
| 83 | * @param UserIdentity $target User whose rights are being changed |
| 84 | */ |
| 85 | public function userCanChangeRights( Authority $performer, UserIdentity $target ): bool { |
| 86 | if ( !$this->targetCanHaveUserGroups( $target ) ) { |
| 87 | return false; |
| 88 | } |
| 89 | |
| 90 | // Don't evaluate private conditions for this check, as it could leak the underlying value of these |
| 91 | // conditions through the "View groups" / "Change groups" toolbox links. |
| 92 | $available = $this->getChangeableGroups( $performer, $target, false ); |
| 93 | |
| 94 | // getChangeableGroups already checks for self-assignments, so no need to do that here. |
| 95 | if ( $available['add'] || $available['remove'] ) { |
| 96 | return true; |
| 97 | } |
| 98 | return false; |
| 99 | } |
| 100 | |
| 101 | /** @inheritDoc */ |
| 102 | public function getChangeableGroups( |
| 103 | Authority $performer, |
| 104 | UserIdentity $target, |
| 105 | bool $evaluatePrivateConditionsForRestrictedGroups = true |
| 106 | ): array { |
| 107 | // In order not to run multiple hooks every time this method is called in a request, |
| 108 | // we cache the result based on performer and target. |
| 109 | $cacheKey = $performer->getUser()->getName() . ':' . $target->getName() . ':' . $target->getWikiId() . |
| 110 | ':' . ( $evaluatePrivateConditionsForRestrictedGroups ? 'private' : 'public' ); |
| 111 | |
| 112 | if ( !isset( $this->changeableGroupsCache[$cacheKey] ) ) { |
| 113 | $this->changeableGroupsCache[$cacheKey] = $this->computeChangeableGroups( |
| 114 | $performer, $target, $evaluatePrivateConditionsForRestrictedGroups ); |
| 115 | } |
| 116 | return $this->changeableGroupsCache[$cacheKey]; |
| 117 | } |
| 118 | |
| 119 | /** |
| 120 | * Backend for {@see getChangeableGroups}, does actual computation without caching. |
| 121 | */ |
| 122 | private function computeChangeableGroups( |
| 123 | Authority $performer, |
| 124 | UserIdentity $target, |
| 125 | bool $evaluatePrivateConditionsForRestrictedGroups |
| 126 | ): array { |
| 127 | // If the target is an interwiki user, ensure that the performer is entitled to such changes |
| 128 | // It assumes that the target wiki exists at all |
| 129 | if ( |
| 130 | $target->getWikiId() !== UserIdentity::LOCAL && |
| 131 | !$performer->isAllowed( 'userrights-interwiki' ) |
| 132 | ) { |
| 133 | return [ 'add' => [], 'remove' => [], 'restricted' => [] ]; |
| 134 | } |
| 135 | |
| 136 | $localUserGroupManager = $this->userGroupManagerFactory->getUserGroupManager(); |
| 137 | $groups = $localUserGroupManager->getGroupsChangeableBy( $performer ); |
| 138 | $groups['restricted'] = []; |
| 139 | |
| 140 | $isSelf = $performer->getUser()->equals( $target ); |
| 141 | if ( $isSelf ) { |
| 142 | $groups['add'] = array_unique( array_merge( $groups['add'], $groups['add-self'] ) ); |
| 143 | $groups['remove'] = array_unique( array_merge( $groups['remove'], $groups['remove-self'] ) ); |
| 144 | } |
| 145 | unset( $groups['add-self'], $groups['remove-self'] ); |
| 146 | |
| 147 | $cannotAdd = []; |
| 148 | $restrictedGroupChecker = $this->getRestrictedGroupChecker( $target ); |
| 149 | foreach ( $groups['add'] as $group ) { |
| 150 | if ( $restrictedGroupChecker->isGroupRestricted( $group ) ) { |
| 151 | $groups['restricted'][$group] = [ |
| 152 | 'condition-met' => $restrictedGroupChecker |
| 153 | ->doPerformerAndTargetMeetConditionsForAddingToGroup( |
| 154 | $performer->getUser(), |
| 155 | $target, |
| 156 | $group, |
| 157 | $evaluatePrivateConditionsForRestrictedGroups |
| 158 | ), |
| 159 | 'ignore-condition' => $restrictedGroupChecker |
| 160 | ->canPerformerIgnoreGroupRestrictions( |
| 161 | $performer, |
| 162 | $group |
| 163 | ), |
| 164 | ]; |
| 165 | $canPerformerAdd = $restrictedGroupChecker->canPerformerAddTargetToGroup( |
| 166 | $performer, $target, $group, $evaluatePrivateConditionsForRestrictedGroups ); |
| 167 | // If null was returned, keep the group in addable, as it's potentially addable |
| 168 | // Caller will be able to differentiate between true and null through the 'condition-met' |
| 169 | // value in $groups['restricted'][$group] |
| 170 | if ( $canPerformerAdd === false ) { |
| 171 | $cannotAdd[] = $group; |
| 172 | } |
| 173 | } |
| 174 | } |
| 175 | $groups['add'] = array_diff( $groups['add'], $cannotAdd ); |
| 176 | |
| 177 | return $groups; |
| 178 | } |
| 179 | |
| 180 | /** |
| 181 | * Changes the user groups, ensuring that the performer has the necessary permissions |
| 182 | * and that the changes are logged. |
| 183 | * |
| 184 | * @param Authority $performer |
| 185 | * @param UserIdentity $target |
| 186 | * @param list<string> $addGroups The groups to add (or change expiry of) |
| 187 | * @param list<string> $removeGroups The groups to remove |
| 188 | * @param array<string, ?string> $newExpiries Map of group name to new expiry (string timestamp or null |
| 189 | * for infinite). If a group is in $addGroups but not in this array, it won't expire. |
| 190 | * @param string $reason |
| 191 | * @param array $tags |
| 192 | * @param list<string> $logAtAdditionalWikis List of wiki IDs where the log entry should be added, in addition |
| 193 | * to the current wiki and the target user's wiki. Wikis to which the log entry will be added are deduplicated, |
| 194 | * so no double logging will happen. |
| 195 | * @return array{0:string[],1:string[]} The groups actually added and removed |
| 196 | */ |
| 197 | public function saveChangesToUserGroups( |
| 198 | Authority $performer, |
| 199 | UserIdentity $target, |
| 200 | array $addGroups, |
| 201 | array $removeGroups, |
| 202 | array $newExpiries, |
| 203 | string $reason = '', |
| 204 | array $tags = [], |
| 205 | array $logAtAdditionalWikis = [] |
| 206 | ): array { |
| 207 | $userGroupManager = $this->userGroupManagerFactory->getUserGroupManager( $target->getWikiId() ); |
| 208 | $oldGroupMemberships = $userGroupManager->getUserGroupMemberships( $target ); |
| 209 | |
| 210 | $this->logAccessToPrivateConditions( $performer, $target, $addGroups, $newExpiries, $oldGroupMemberships ); |
| 211 | |
| 212 | $changeable = $this->getChangeableGroups( $performer, $target ); |
| 213 | self::enforceChangeGroupPermissions( $addGroups, $removeGroups, $newExpiries, |
| 214 | $oldGroupMemberships, $changeable ); |
| 215 | |
| 216 | if ( $target->getWikiId() === UserIdentity::LOCAL ) { |
| 217 | // For compatibility local changes are provided as User object to the hook |
| 218 | $hookUser = $this->userFactory->newFromUserIdentity( $target ); |
| 219 | } else { |
| 220 | $hookUser = $target; |
| 221 | } |
| 222 | |
| 223 | // Hooks expect User object as performer; everywhere else use Authority for ease of mocking |
| 224 | if ( $performer instanceof User ) { |
| 225 | $performerUser = $performer; |
| 226 | } else { |
| 227 | $performerUser = $this->userFactory->newFromUserIdentity( $performer->getUser() ); |
| 228 | } |
| 229 | $this->hookRunner->onChangeUserGroups( $performerUser, $hookUser, $addGroups, $removeGroups ); |
| 230 | |
| 231 | // Remove groups, then add new ones/update expiries of existing ones |
| 232 | foreach ( $removeGroups as $index => $group ) { |
| 233 | if ( !$userGroupManager->removeUserFromGroup( $target, $group ) ) { |
| 234 | unset( $removeGroups[$index] ); |
| 235 | } |
| 236 | } |
| 237 | foreach ( $addGroups as $index => $group ) { |
| 238 | $expiry = $newExpiries[$group] ?? null; |
| 239 | if ( !$userGroupManager->addUserToGroup( $target, $group, $expiry, true ) ) { |
| 240 | unset( $addGroups[$index] ); |
| 241 | } |
| 242 | } |
| 243 | $newGroupMemberships = $userGroupManager->getUserGroupMemberships( $target ); |
| 244 | |
| 245 | // Ensure that caches are cleared |
| 246 | $this->userFactory->invalidateCache( $target ); |
| 247 | |
| 248 | // Allow other code to react to the user groups change |
| 249 | $this->hookRunner->onUserGroupsChanged( $hookUser, $addGroups, $removeGroups, |
| 250 | $performerUser, $reason, $oldGroupMemberships, $newGroupMemberships ); |
| 251 | |
| 252 | // Only add a log entry if something actually changed |
| 253 | if ( $newGroupMemberships != $oldGroupMemberships ) { |
| 254 | $this->addRightsLogEntry( $performer->getUser(), $target, $reason, $tags, $oldGroupMemberships, |
| 255 | $newGroupMemberships, $logAtAdditionalWikis ); |
| 256 | } |
| 257 | |
| 258 | return [ $addGroups, $removeGroups ]; |
| 259 | } |
| 260 | |
| 261 | /** @inheritDoc */ |
| 262 | protected function getKnownGroups( UserIdentity $target ): array { |
| 263 | $userGroupManager = $this->userGroupManagerFactory->getUserGroupManager( $target->getWikiId() ); |
| 264 | return $userGroupManager->listAllGroups(); |
| 265 | } |
| 266 | |
| 267 | /** @inheritDoc */ |
| 268 | protected function getRestrictedGroupChecker( UserIdentity $target ): RestrictedUserGroupChecker { |
| 269 | return $this->restrictedGroupCheckerFactory->getRestrictedUserGroupChecker( $target->getWikiId() ); |
| 270 | } |
| 271 | |
| 272 | /** |
| 273 | * Add a rights log entry for rights change on all relevant wikis. |
| 274 | * The relevant wikis are: the current wiki and the wiki where user's rights are changed. |
| 275 | * @param UserIdentity $performer |
| 276 | * @param UserIdentity $target |
| 277 | * @param string $reason |
| 278 | * @param string[] $tags Change tags for the log entry |
| 279 | * @param array<string,UserGroupMembership> $oldUGMs Associative array of (group name => UserGroupMembership) |
| 280 | * @param array<string,UserGroupMembership> $newUGMs Associative array of (group name => UserGroupMembership) |
| 281 | * @param list<string> $additionalWikis List of additional wiki IDs where the log entry should be added |
| 282 | * Values in this array are deduplicated; no double logging will happen |
| 283 | */ |
| 284 | private function addRightsLogEntry( UserIdentity $performer, UserIdentity $target, string $reason, |
| 285 | array $tags, array $oldUGMs, array $newUGMs, array $additionalWikis = [] |
| 286 | ) { |
| 287 | $wikis = array_merge( |
| 288 | [ UserIdentity::LOCAL, $target->getWikiId() ], |
| 289 | $additionalWikis |
| 290 | ); |
| 291 | // Deduplicate wikis, ensure that explicit and implicit references to the current wiki are treated the same |
| 292 | $wikis = array_unique( array_map( |
| 293 | static fn ( $wiki ) => WikiMap::isCurrentWikiId( $wiki ) ? UserIdentity::LOCAL : $wiki, |
| 294 | $wikis |
| 295 | ) ); |
| 296 | |
| 297 | $currentWiki = WikiMap::getCurrentWikiId(); |
| 298 | foreach ( $wikis as $wiki ) { |
| 299 | $logPerformer = $performer; |
| 300 | if ( $wiki !== UserIdentity::LOCAL ) { |
| 301 | $logPerformer = UserIdentityValue::newExternal( $currentWiki, $performer->getName(), $wiki ); |
| 302 | } |
| 303 | $this->addRightsLogEntryOnWiki( $logPerformer, $target, $reason, $tags, $oldUGMs, $newUGMs, $wiki ); |
| 304 | } |
| 305 | } |
| 306 | |
| 307 | /** |
| 308 | * Add a rights log entry for an action. |
| 309 | * @param UserIdentity $performer |
| 310 | * @param UserIdentity $target |
| 311 | * @param string $reason |
| 312 | * @param string[] $tags Change tags for the log entry |
| 313 | * @param array<string,UserGroupMembership> $oldUGMs Associative array of (group name => UserGroupMembership) |
| 314 | * @param array<string,UserGroupMembership> $newUGMs Associative array of (group name => UserGroupMembership) |
| 315 | * @param string|false $wiki The wiki to add the log entry to. |
| 316 | */ |
| 317 | private function addRightsLogEntryOnWiki( UserIdentity $performer, UserIdentity $target, string $reason, |
| 318 | array $tags, array $oldUGMs, array $newUGMs, string|false $wiki = UserIdentity::LOCAL |
| 319 | ) { |
| 320 | ksort( $oldUGMs ); |
| 321 | ksort( $newUGMs ); |
| 322 | $oldUGMs = array_map( self::serialiseUgmForLog( ... ), $oldUGMs ); |
| 323 | $oldGroups = array_keys( $oldUGMs ); |
| 324 | $oldUGMs = array_values( $oldUGMs ); |
| 325 | $newUGMs = array_map( self::serialiseUgmForLog( ... ), $newUGMs ); |
| 326 | $newGroups = array_keys( $newUGMs ); |
| 327 | $newUGMs = array_values( $newUGMs ); |
| 328 | |
| 329 | $logEntry = new ManualLogEntry( 'rights', 'rights' ); |
| 330 | $logEntry->setPerformer( $performer ); |
| 331 | $logEntry->setTarget( $this->getPageForTargetUser( $target, $wiki ) ); |
| 332 | $logEntry->setComment( $reason ); |
| 333 | $logEntry->setParameters( [ |
| 334 | '4::oldgroups' => $oldGroups, |
| 335 | '5::newgroups' => $newGroups, |
| 336 | 'oldmetadata' => $oldUGMs, |
| 337 | 'newmetadata' => $newUGMs, |
| 338 | ] ); |
| 339 | $logId = $logEntry->insert( |
| 340 | $this->connectionProvider->getPrimaryDatabase( $wiki ) |
| 341 | ); |
| 342 | if ( $wiki === UserIdentity::LOCAL || WikiMap::isCurrentWikiId( $wiki ) ) { |
| 343 | // These methods are supported only on the local wiki |
| 344 | $logEntry->addTags( $tags ); |
| 345 | $logEntry->publish( $logId ); |
| 346 | } |
| 347 | } |
| 348 | |
| 349 | /** |
| 350 | * Returns a PageIdentity referring to the target user page. The title is created from the perspective of the |
| 351 | * reference wiki. If target is from reference wiki, this function will try to match an actual page. |
| 352 | */ |
| 353 | private function getPageForTargetUser( UserIdentity $target, string|false $referenceWiki ): PageIdentity { |
| 354 | $pageTitle = $this->getPageTitleForTargetUser( $target, $referenceWiki ); |
| 355 | if ( $referenceWiki === UserIdentity::LOCAL || WikiMap::isCurrentWikiId( $referenceWiki ) ) { |
| 356 | return Title::makeTitle( NS_USER, $pageTitle ); |
| 357 | } |
| 358 | $pageStore = $this->pageStoreFactory->getPageStore( $referenceWiki ); |
| 359 | $pageTitle = strtr( $pageTitle, ' ', '_' ); |
| 360 | $page = $pageStore->getPageByName( NS_USER, $pageTitle ); |
| 361 | return $page ?? new PageIdentityValue( 0, NS_USER, $pageTitle, $referenceWiki ); |
| 362 | } |
| 363 | |
| 364 | /** |
| 365 | * Returns the title of page representing the target user, suitable for use in log entries. |
| 366 | * The returned value doesn't include the namespace. |
| 367 | * |
| 368 | * Interwiki suffix will be added to the title only if the target user's wiki is different from the reference wiki. |
| 369 | * @param UserIdentity $target The target user (only name and wiki ID are relevant for this method) |
| 370 | * @param string|false $referenceWiki The wiki to use as a reference for formatting the title. |
| 371 | * If the wiki is different from the target user's wiki, the returned title will include the interwiki suffix. |
| 372 | */ |
| 373 | public function getPageTitleForTargetUser( |
| 374 | UserIdentity $target, string|false $referenceWiki = UserIdentity::LOCAL |
| 375 | ): string { |
| 376 | $targetName = $target->getName(); |
| 377 | $targetWiki = $target->getWikiId() === UserIdentity::LOCAL ? WikiMap::getCurrentWikiId() : $target->getWikiId(); |
| 378 | $referenceWiki = $referenceWiki === UserIdentity::LOCAL ? WikiMap::getCurrentWikiId() : $referenceWiki; |
| 379 | if ( $referenceWiki === $targetWiki ) { |
| 380 | return $targetName; |
| 381 | } |
| 382 | $delimiter = $this->options->get( MainConfigNames::UserrightsInterwikiDelimiter ); |
| 383 | return $targetName . $delimiter . $targetWiki; |
| 384 | } |
| 385 | |
| 386 | /** |
| 387 | * Serialise a UserGroupMembership object for storage in the log_params section |
| 388 | * of the logging table. Only keeps essential data, removing redundant fields. |
| 389 | */ |
| 390 | private static function serialiseUgmForLog( UserGroupMembership $ugm ): array { |
| 391 | return [ 'expiry' => $ugm->getExpiry() ]; |
| 392 | } |
| 393 | } |