Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.59% covered (success)
98.59%
140 / 142
92.31% covered (success)
92.31%
12 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserGroupAssignmentService
98.59% covered (success)
98.59%
140 / 142
92.31% covered (success)
92.31%
12 / 13
46
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 targetCanHaveUserGroups
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 userCanChangeRights
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 getChangeableGroups
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 computeChangeableGroups
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
7
 saveChangesToUserGroups
92.86% covered (success)
92.86%
26 / 28
0.00% covered (danger)
0.00%
0 / 1
8.02
 getKnownGroups
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getRestrictedGroupChecker
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addRightsLogEntry
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 addRightsLogEntryOnWiki
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
3
 getPageForTargetUser
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getPageTitleForTargetUser
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 serialiseUgmForLog
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\User;
8
9use MediaWiki\Config\ServiceOptions;
10use MediaWiki\HookContainer\HookRunner;
11use MediaWiki\Logging\ManualLogEntry;
12use MediaWiki\MainConfigNames;
13use MediaWiki\Page\PageIdentity;
14use MediaWiki\Page\PageIdentityValue;
15use MediaWiki\Page\PageStoreFactory;
16use MediaWiki\Permissions\Authority;
17use MediaWiki\Title\Title;
18use MediaWiki\User\TempUser\TempUserConfig;
19use MediaWiki\WikiMap\WikiMap;
20use 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 */
33class 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}