Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.34% covered (success)
94.34%
200 / 212
73.33% covered (warning)
73.33%
11 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserGroupAssignmentService
94.34% covered (success)
94.34%
200 / 212
73.33% covered (warning)
73.33%
11 / 15
77.05
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
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%
34 / 34
100.00% covered (success)
100.00%
1 / 1
7
 saveChangesToUserGroups
85.71% covered (warning)
85.71%
24 / 28
0.00% covered (danger)
0.00%
0 / 1
8.19
 validateUserGroups
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
5
 logAccessToPrivateConditions
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 getPrivateConditionsInvolvedInChange
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
9
 addLogEntry
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
1
 getPageTitleForTargetUser
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 serialiseUgmForLog
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 enforceChangeGroupPermissions
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getDisallowedGroupChanges
96.97% covered (success)
96.97%
32 / 33
0.00% covered (danger)
0.00%
0 / 1
21
 expiryToTimestamp
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
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\Permissions\Authority;
14use MediaWiki\Title\Title;
15use MediaWiki\User\TempUser\TempUserConfig;
16use Wikimedia\Timestamp\TimestampFormat as TS;
17
18/**
19 * This class represents a service that provides high-level operations on user groups.
20 * Contrary to UserGroupManager, this class is not interested in details of how user groups
21 * are stored or defined, but rather in the business logic of assigning and removing groups.
22 *
23 * Therefore, it combines group management with logging and provides permission checks.
24 * Additionally, the method interfaces are designed to be suitable for calls from user-facing code.
25 *
26 * @since 1.45
27 * @ingroup User
28 */
29class UserGroupAssignmentService {
30
31    /** @internal */
32    public const CONSTRUCTOR_OPTIONS = [
33        MainConfigNames::UserrightsInterwikiDelimiter
34    ];
35
36    private array $changeableGroupsCache = [];
37
38    public function __construct(
39        private readonly UserGroupManagerFactory $userGroupManagerFactory,
40        private readonly UserNameUtils $userNameUtils,
41        private readonly UserFactory $userFactory,
42        private readonly RestrictedUserGroupChecker $restrictedGroupChecker,
43        private readonly HookRunner $hookRunner,
44        private readonly ServiceOptions $options,
45        private readonly TempUserConfig $tempUserConfig,
46    ) {
47        $this->options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
48    }
49
50    /**
51     * Checks whether the target user can have groups assigned at all.
52     */
53    public function targetCanHaveUserGroups( UserIdentity $target ): bool {
54        // Basic stuff - don't assign groups to anons and temp. accounts
55        if ( !$target->isRegistered() ) {
56            return false;
57        }
58        if ( $this->userNameUtils->isTemp( $target->getName() ) ) {
59            return false;
60        }
61
62        // We also need to make sure that we don't assign groups to remote temp. accounts if they
63        // are disabled on the current wiki
64        if (
65            $target->getWikiId() !== UserIdentity::LOCAL &&
66            !$this->tempUserConfig->isKnown() &&
67            $this->tempUserConfig->isReservedName( $target->getName() )
68        ) {
69            return false;
70        }
71
72        return true;
73    }
74
75    /**
76     * Check whether the given user can change the target user's rights.
77     *
78     * @param Authority $performer User who is attempting to change the target's rights
79     * @param UserIdentity $target User whose rights are being changed
80     */
81    public function userCanChangeRights( Authority $performer, UserIdentity $target ): bool {
82        if ( !$this->targetCanHaveUserGroups( $target ) ) {
83            return false;
84        }
85
86        // Don't evaluate private conditions for this check, as it could leak the underlying value of these
87        // conditions through the "View groups" / "Change groups" toolbox links.
88        $available = $this->getChangeableGroups( $performer, $target, false );
89
90        // getChangeableGroups already checks for self-assignments, so no need to do that here.
91        if ( $available['add'] || $available['remove'] ) {
92            return true;
93        }
94        return false;
95    }
96
97    /**
98     * Returns the groups that the performer can add or remove from the target user.
99     * The result of this function is cached for the duration of the request.
100     * @return array [
101     *   'add' => [ addablegroups ],
102     *   'remove' => [ removablegroups ],
103     *   'restricted' => [ groupname => [
104     *     'condition-met' => bool,
105     *     'ignore-condition' => bool,
106     *     'message' => string
107     *   ] ]
108     *  ]
109     * @phan-return array{add:list<string>,remove:list<string>,restricted:array<string,array>}
110     */
111    public function getChangeableGroups(
112        Authority $performer,
113        UserIdentity $target,
114        bool $evaluatePrivateConditionsForRestrictedGroups = true
115    ): array {
116        // In order not to run multiple hooks every time this method is called in a request,
117        // we cache the result based on performer and target.
118        $cacheKey = $performer->getUser()->getName() . ':' . $target->getName() . ':' . $target->getWikiId() .
119            ':' . ( $evaluatePrivateConditionsForRestrictedGroups ? 'private' : 'public' );
120
121        if ( !isset( $this->changeableGroupsCache[$cacheKey] ) ) {
122            $this->changeableGroupsCache[$cacheKey] = $this->computeChangeableGroups(
123                $performer, $target, $evaluatePrivateConditionsForRestrictedGroups );
124        }
125        return $this->changeableGroupsCache[$cacheKey];
126    }
127
128    /**
129     * Backend for {@see getChangeableGroups}, does actual computation without caching.
130     */
131    private function computeChangeableGroups(
132        Authority $performer,
133        UserIdentity $target,
134        bool $evaluatePrivateConditionsForRestrictedGroups
135    ): array {
136        // If the target is an interwiki user, ensure that the performer is entitled to such changes
137        // It assumes that the target wiki exists at all
138        if (
139            $target->getWikiId() !== UserIdentity::LOCAL &&
140            !$performer->isAllowed( 'userrights-interwiki' )
141        ) {
142            return [ 'add' => [], 'remove' => [], 'restricted' => [] ];
143        }
144
145        $localUserGroupManager = $this->userGroupManagerFactory->getUserGroupManager();
146        $groups = $localUserGroupManager->getGroupsChangeableBy( $performer );
147        $groups['restricted'] = [];
148
149        $isSelf = $performer->getUser()->equals( $target );
150        if ( $isSelf ) {
151            $groups['add'] = array_unique( array_merge( $groups['add'], $groups['add-self'] ) );
152            $groups['remove'] = array_unique( array_merge( $groups['remove'], $groups['remove-self'] ) );
153        }
154        unset( $groups['add-self'], $groups['remove-self'] );
155
156        $cannotAdd = [];
157        foreach ( $groups['add'] as $group ) {
158            if ( $this->restrictedGroupChecker->isGroupRestricted( $group ) ) {
159                $groups['restricted'][$group] = [
160                    'condition-met' => $this->restrictedGroupChecker
161                        ->doPerformerAndTargetMeetConditionsForAddingToGroup(
162                            $performer->getUser(),
163                            $target,
164                            $group,
165                            $evaluatePrivateConditionsForRestrictedGroups
166                        ),
167                    'ignore-condition' => $this->restrictedGroupChecker
168                        ->canPerformerIgnoreGroupRestrictions(
169                            $performer,
170                            $group
171                        ),
172                ];
173                $canPerformerAdd = $this->restrictedGroupChecker->canPerformerAddTargetToGroup(
174                    $performer, $target, $group, $evaluatePrivateConditionsForRestrictedGroups );
175                // If null was returned, keep the group in addable, as it's potentially addable
176                // Caller will be able to differentiate between true and null through the 'condition-met'
177                // value in $groups['restricted'][$group]
178                if ( $canPerformerAdd === false ) {
179                    $cannotAdd[] = $group;
180                }
181            }
182        }
183        $groups['add'] = array_diff( $groups['add'], $cannotAdd );
184
185        return $groups;
186    }
187
188    /**
189     * Changes the user groups, ensuring that the performer has the necessary permissions
190     * and that the changes are logged.
191     *
192     * @param Authority $performer
193     * @param UserIdentity $target
194     * @param list<string> $addGroups The groups to add (or change expiry of)
195     * @param list<string> $removeGroups The groups to remove
196     * @param array<string, ?string> $newExpiries Map of group name to new expiry (string timestamp or null
197     *   for infinite). If a group is in $addGroups but not in this array, it won't expire.
198     * @param string $reason
199     * @param array $tags
200     * @return array{0:string[],1:string[]} The groups actually added and removed
201     */
202    public function saveChangesToUserGroups(
203        Authority $performer,
204        UserIdentity $target,
205        array $addGroups,
206        array $removeGroups,
207        array $newExpiries,
208        string $reason = '',
209        array $tags = []
210    ): array {
211        $userGroupManager = $this->userGroupManagerFactory->getUserGroupManager( $target->getWikiId() );
212        $oldGroupMemberships = $userGroupManager->getUserGroupMemberships( $target );
213
214        $this->logAccessToPrivateConditions( $performer, $target, $addGroups, $newExpiries, $oldGroupMemberships );
215
216        $changeable = $this->getChangeableGroups( $performer, $target );
217        self::enforceChangeGroupPermissions( $addGroups, $removeGroups, $newExpiries,
218            $oldGroupMemberships, $changeable );
219
220        if ( $target->getWikiId() === UserIdentity::LOCAL ) {
221            // For compatibility local changes are provided as User object to the hook
222            $hookUser = $this->userFactory->newFromUserIdentity( $target );
223        } else {
224            $hookUser = $target;
225        }
226
227        // Hooks expect User object as performer; everywhere else use Authority for ease of mocking
228        if ( $performer instanceof User ) {
229            $performerUser = $performer;
230        } else {
231            $performerUser = $this->userFactory->newFromUserIdentity( $performer->getUser() );
232        }
233        $this->hookRunner->onChangeUserGroups( $performerUser, $hookUser, $addGroups, $removeGroups );
234
235        // Remove groups, then add new ones/update expiries of existing ones
236        foreach ( $removeGroups as $index => $group ) {
237            if ( !$userGroupManager->removeUserFromGroup( $target, $group ) ) {
238                unset( $removeGroups[$index] );
239            }
240        }
241        foreach ( $addGroups as $index => $group ) {
242            $expiry = $newExpiries[$group] ?? null;
243            if ( !$userGroupManager->addUserToGroup( $target, $group, $expiry, true ) ) {
244                unset( $addGroups[$index] );
245            }
246        }
247        $newGroupMemberships = $userGroupManager->getUserGroupMemberships( $target );
248
249        // Ensure that caches are cleared
250        $this->userFactory->invalidateCache( $target );
251
252        // Allow other code to react to the user groups change
253        $this->hookRunner->onUserGroupsChanged( $hookUser, $addGroups, $removeGroups,
254            $performerUser, $reason, $oldGroupMemberships, $newGroupMemberships );
255
256        // Only add a log entry if something actually changed
257        if ( $newGroupMemberships != $oldGroupMemberships ) {
258            $this->addLogEntry( $performer->getUser(), $target, $reason, $tags, $oldGroupMemberships,
259                $newGroupMemberships );
260        }
261
262        return [ $addGroups, $removeGroups ];
263    }
264
265    /**
266     * Validates the requested changes to user groups and returns an array, specifying if some groups are unchangeable
267     * and for what reasons.
268     * @param Authority $performer
269     * @param UserIdentity $target
270     * @param list<string> $addGroups
271     * @param list<string> $removeGroups
272     * @param array<string, ?string> $newExpiries
273     * @param array<string, UserGroupMembership> $groupMemberships
274     * @return array<string, string> Map of user groups to the reason why they cannot be given, removed or updated,
275     *     keyed by the group names. The supported reasons are: 'rights', 'restricted', 'private-condition'.
276     */
277    public function validateUserGroups(
278        Authority $performer,
279        UserIdentity $target,
280        array $addGroups,
281        array $removeGroups,
282        array $newExpiries,
283        array $groupMemberships,
284    ) {
285        // We have to find out which groups the user is unable to change and also whether it's due to
286        // private conditions or not. In the former case, we need to be able to log access to the conditions.
287        $permittedChangesNoPrivate = $this->getChangeableGroups( $performer, $target, false );
288        $permittedChangesWithPrivate = $this->getChangeableGroups( $performer, $target );
289
290        [ $unaddableNoPrivate, $irremovableNoPrivate ] = self::getDisallowedGroupChanges(
291            $addGroups, $removeGroups, $newExpiries, $groupMemberships, $permittedChangesNoPrivate );
292        [ $unaddableWithPrivate, $irremovableWithPrivate ] = self::getDisallowedGroupChanges(
293            $addGroups, $removeGroups, $newExpiries, $groupMemberships, $permittedChangesWithPrivate );
294
295        $unchangeableGroupsNoPrivate = array_merge( $unaddableNoPrivate, $irremovableNoPrivate );
296        $unchangeableGroupsWithPrivate = array_merge( $unaddableWithPrivate, $irremovableWithPrivate );
297
298        $unchangeableGroupsDueToPrivate = array_diff( $unchangeableGroupsWithPrivate, $unchangeableGroupsNoPrivate );
299
300        $restrictedGroups = $permittedChangesWithPrivate['restricted'];
301
302        $userGroupManager = $this->userGroupManagerFactory->getUserGroupManager( $target->getWikiId() );
303        $knownGroups = $userGroupManager->listAllGroups();
304
305        $result = [];
306        foreach ( $unchangeableGroupsWithPrivate as $group ) {
307            // Sometimes people are assigned to groups that no longer are defined. Let's ignore them for validation
308            if ( !in_array( $group, $knownGroups ) ) {
309                continue;
310            }
311
312            if ( in_array( $group, $unchangeableGroupsDueToPrivate ) ) {
313                $result[$group] = 'private-condition';
314            } elseif ( isset( $restrictedGroups[$group] ) ) {
315                $result[$group] = 'restricted';
316            } else {
317                $result[$group] = 'rights';
318            }
319        }
320        return $result;
321    }
322
323    /**
324     * Triggers a hook that allows extensions to log when user read some private conditions.
325     * @param Authority $performer The user who submitted request to change the user groups
326     * @param UserIdentity $target The user whose groups are changed
327     * @param list<string> $addGroups A list of groups that were attempted to be added (or have expiry changed)
328     * @param array<string, ?string> $newExpiries New expiration times for the groups (null or missing means infinity)
329     * @param array<string, UserGroupMembership> $existingUGMs Existing group memberships for the target user
330     * @return void
331     */
332    public function logAccessToPrivateConditions(
333        Authority $performer,
334        UserIdentity $target,
335        array $addGroups,
336        array $newExpiries,
337        array $existingUGMs,
338    ): void {
339        // Potentially changeable - groups that might be changed if it weren't for private conditions
340        $potentiallyChangeable = $this->getChangeableGroups( $performer, $target, false );
341        $conditions = $this->getPrivateConditionsInvolvedInChange(
342            $addGroups,
343            $newExpiries,
344            $existingUGMs,
345            $potentiallyChangeable
346        );
347
348        if ( !$conditions ) {
349            return;
350        }
351
352        $this->hookRunner->onReadPrivateUserRequirementsCondition( $performer->getUser(), $target, $conditions );
353    }
354
355    /**
356     * For added or prolonged groups, returns a list of private conditions that the groups depends on.
357     * @param list<string> $addGroups
358     * @param array<string, ?string> $newExpiries
359     * @param array<string, UserGroupMembership> $existingUGMs
360     * @param array $potentiallyChangeableGroups Information about changeable groups without evaluating private
361     *     conditions.
362     * @return list<mixed>
363     */
364    private function getPrivateConditionsInvolvedInChange(
365        array $addGroups,
366        array $newExpiries,
367        array $existingUGMs,
368        array $potentiallyChangeableGroups,
369    ): array {
370        $restrictedGroups = $potentiallyChangeableGroups['restricted'];
371        $groupsWithPrivateConditionsInvolved = [];
372        foreach ( $restrictedGroups as $group => $groupData ) {
373            if ( $groupData['condition-met'] === null && !$groupData['ignore-condition'] ) {
374                $groupsWithPrivateConditionsInvolved[] = $group;
375            }
376        }
377
378        $groupsToCheck = [];
379        foreach ( $addGroups as $group ) {
380            // If a group is for sure unaddable or addable, private conditions don't matter, so we don't
381            // consider them as involved into the change
382            if ( !in_array( $group, $groupsWithPrivateConditionsInvolved ) ) {
383                continue;
384            }
385
386            // Ensure that we only test groups that are added or prolonged (conditions don't apply for
387            // removals from groups)
388            if ( !isset( $existingUGMs[$group] ) ) {
389                $groupsToCheck[] = $group;
390                continue;
391            }
392            $currentExpiry = $existingUGMs[$group]->getExpiry() ?? 'infinity';
393            $newExpiry = $newExpiries[$group] ?? 'infinity';
394
395            if ( $newExpiry > $currentExpiry ) {
396                $groupsToCheck[] = $group;
397            }
398        }
399
400        $privateConditions = [];
401        foreach ( $groupsToCheck as $group ) {
402            $privateConditions = array_merge(
403                $privateConditions,
404                $this->restrictedGroupChecker->getPrivateConditionsForGroup( $group )
405            );
406        }
407        return array_values( array_unique( $privateConditions ) );
408    }
409
410    /**
411     * Add a rights log entry for an action.
412     * @param UserIdentity $performer
413     * @param UserIdentity $target
414     * @param string $reason
415     * @param string[] $tags Change tags for the log entry
416     * @param array<string,UserGroupMembership> $oldUGMs Associative array of (group name => UserGroupMembership)
417     * @param array<string,UserGroupMembership> $newUGMs Associative array of (group name => UserGroupMembership)
418     */
419    private function addLogEntry( UserIdentity $performer, UserIdentity $target, string $reason,
420        array $tags, array $oldUGMs, array $newUGMs
421    ) {
422        ksort( $oldUGMs );
423        ksort( $newUGMs );
424        $oldUGMs = array_map( self::serialiseUgmForLog( ... ), $oldUGMs );
425        $oldGroups = array_keys( $oldUGMs );
426        $oldUGMs = array_values( $oldUGMs );
427        $newUGMs = array_map( self::serialiseUgmForLog( ... ), $newUGMs );
428        $newGroups = array_keys( $newUGMs );
429        $newUGMs = array_values( $newUGMs );
430
431        $logEntry = new ManualLogEntry( 'rights', 'rights' );
432        $logEntry->setPerformer( $performer );
433        $logEntry->setTarget( Title::makeTitle( NS_USER, $this->getPageTitleForTargetUser( $target ) ) );
434        $logEntry->setComment( $reason );
435        $logEntry->setParameters( [
436            '4::oldgroups' => $oldGroups,
437            '5::newgroups' => $newGroups,
438            'oldmetadata' => $oldUGMs,
439            'newmetadata' => $newUGMs,
440        ] );
441        $logId = $logEntry->insert();
442        $logEntry->addTags( $tags );
443        $logEntry->publish( $logId );
444    }
445
446    /**
447     * Returns the title of page representing the target user, suitable for use in log entries.
448     * The returned value doesn't include the namespace.
449     */
450    public function getPageTitleForTargetUser( UserIdentity $target ): string {
451        $targetName = $target->getName();
452        if ( $target->getWikiId() !== UserIdentity::LOCAL ) {
453            $targetName .= $this->options->get( MainConfigNames::UserrightsInterwikiDelimiter )
454                . $target->getWikiId();
455        }
456        return $targetName;
457    }
458
459    /**
460     * Serialise a UserGroupMembership object for storage in the log_params section
461     * of the logging table. Only keeps essential data, removing redundant fields.
462     */
463    private static function serialiseUgmForLog( UserGroupMembership $ugm ): array {
464        return [ 'expiry' => $ugm->getExpiry() ];
465    }
466
467    /**
468     * Ensures that the content of $addGroups, $removeGroups and $newExpiries is compliant
469     * with the possible changes defined in $permittedChanges. If there's a change that
470     * is not permitted, it is removed from the respective array.
471     * @param list<string> &$addGroups
472     * @param list<string> &$removeGroups
473     * @param array<string, ?string> &$newExpiries
474     * @param array<string, UserGroupMembership> $existingUGMs
475     * @param array{add:list<string>,remove:list<string>} $permittedChanges
476     * @return void
477     */
478    public static function enforceChangeGroupPermissions(
479        array &$addGroups,
480        array &$removeGroups,
481        array &$newExpiries,
482        array $existingUGMs,
483        array $permittedChanges
484    ): void {
485        [ $unaddableGroups, $irremovableGroups ] = self::getDisallowedGroupChanges(
486            $addGroups, $removeGroups, $newExpiries, $existingUGMs, $permittedChanges
487        );
488
489        $addGroups = array_diff( $addGroups, $unaddableGroups );
490        $removeGroups = array_diff( $removeGroups, $irremovableGroups );
491        foreach ( $unaddableGroups as $group ) {
492            unset( $newExpiries[$group] );
493        }
494    }
495
496    /**
497     * Returns which of the attempted group changed are not allowed, given $permittedChanges.
498     * @param list<string> $addGroups
499     * @param list<string> $removeGroups
500     * @param array<string, ?string> $newExpiries
501     * @param array<string, UserGroupMembership> $existingUGMs
502     * @param array{add:list<string>,remove:list<string>} $permittedChanges
503     * @return array{0:list<string>,1:list<string>} List of unaddable groups and list of irremovable groups
504     */
505    private static function getDisallowedGroupChanges(
506        array $addGroups,
507        array $removeGroups,
508        array $newExpiries,
509        array $existingUGMs,
510        array $permittedChanges
511    ): array {
512        $canAdd = $permittedChanges['add'];
513        $canRemove = $permittedChanges['remove'];
514        $involvedGroups = array_unique( array_merge( array_keys( $existingUGMs ), $addGroups, $removeGroups ) );
515
516        // These do not reflect actual permissions, but rather the groups to remove from $addGroups and $removeGroups
517        $unaddableGroups = [];
518        $irremovableGroups = [];
519
520        foreach ( $involvedGroups as $group ) {
521            $hasGroup = isset( $existingUGMs[$group] );
522            $wantsAddGroup = in_array( $group, $addGroups );
523            $wantsRemoveGroup = in_array( $group, $removeGroups );
524
525            // Better safe than sorry - catch it if the input is contradictory
526            if (
527                ( !$hasGroup && $wantsRemoveGroup ) ||
528                ( $wantsAddGroup && $wantsRemoveGroup )
529            ) {
530                $unaddableGroups[] = $group;
531                $irremovableGroups[] = $group;
532                continue;
533            }
534            // If there's no change, we don't have to change anything
535            if ( !$hasGroup && !$wantsAddGroup ) {
536                continue;
537            }
538            if ( $hasGroup && !$wantsRemoveGroup && !$wantsAddGroup ) {
539                // We have to check for adding group, because it's set when changing expiry
540                continue;
541            }
542
543            if ( $hasGroup && $wantsRemoveGroup ) {
544                if ( !in_array( $group, $canRemove ) ) {
545                    $irremovableGroups[] = $group;
546                }
547            } elseif ( !$hasGroup && $wantsAddGroup ) {
548                if ( !in_array( $group, $canAdd ) ) {
549                    $unaddableGroups[] = $group;
550                }
551            } elseif ( $hasGroup && $wantsAddGroup ) {
552                $currentExpiry = $existingUGMs[$group]->getExpiry() ?? 'infinity';
553                $wantedExpiry = $newExpiries[$group] ?? 'infinity';
554
555                if ( $wantedExpiry > $currentExpiry ) {
556                    // Prolongation requires 'add' permission
557                    $canChange = in_array( $group, $canAdd );
558                } else {
559                    // Shortening requires 'remove' permission
560                    $canChange = in_array( $group, $canRemove );
561                }
562
563                if ( !$canChange ) {
564                    // Restore the original group expiry if user can't change it
565                    $unaddableGroups[] = $group;
566                }
567            }
568        }
569
570        return [ $unaddableGroups, $irremovableGroups ];
571    }
572
573    /**
574     * Converts a user group membership expiry string into a timestamp. Words like
575     * 'existing' or 'other' should have been filtered out before calling this
576     * function.
577     *
578     * @param string $expiry
579     * @return string|null|false A string containing a valid timestamp, or null
580     *   if the expiry is infinite, or false if the timestamp is not valid
581     */
582    public static function expiryToTimestamp( $expiry ) {
583        if ( wfIsInfinity( $expiry ) ) {
584            return null;
585        }
586
587        $unix = strtotime( $expiry );
588
589        if ( !$unix || $unix === -1 ) {
590            return false;
591        }
592
593        // @todo FIXME: Non-qualified absolute times are not in users specified timezone
594        // and there isn't notice about it in the ui (see ProtectionForm::getExpiry)
595        return wfTimestamp( TS::MW, $unix );
596    }
597}