Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.06% covered (success)
98.06%
101 / 103
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserGroupAssignmentServiceBase
98.06% covered (success)
98.06%
101 / 103
71.43% covered (warning)
71.43%
5 / 7
44
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
 getChangeableGroups
n/a
0 / 0
n/a
0 / 0
0
 validateUserGroups
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
5
 getKnownGroups
n/a
0 / 0
n/a
0 / 0
0
 logAccessToPrivateConditions
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 getPrivateConditionsInvolvedInChange
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
9
 getRestrictedGroupChecker
n/a
0 / 0
n/a
0 / 0
0
 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
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\User;
8
9use MediaWiki\HookContainer\HookRunner;
10use MediaWiki\Permissions\Authority;
11use Wikimedia\Timestamp\ConvertibleTimestamp;
12use Wikimedia\Timestamp\TimestampFormat as TS;
13
14/**
15 * This class represents a base for service that provides high-level operations on user groups.
16 * It is responsible for common operations, such as validation and logging primitives.
17 *
18 * @since 1.46
19 * @ingroup User
20 */
21abstract class UserGroupAssignmentServiceBase {
22
23    public function __construct(
24        private readonly HookRunner $hookRunner,
25    ) {
26    }
27
28    /**
29     * Returns the groups that the performer can add or remove from the target user.
30     * The result of this function is cached for the duration of the request.
31     * @param Authority $performer
32     * @param UserIdentity $target
33     * @param bool $evaluatePrivateConditionsForRestrictedGroups If true, all conditions for restricted groups will
34     *     be evaluated (including private). If false, private conditions will be left unevaluated and 'condition-met'
35     *     can be potentially null, if the result depends on private conditions.
36     * @return array [
37     *   'add' => [ addablegroups ],
38     *   'remove' => [ removablegroups ],
39     *   'restricted' => [ groupname => [
40     *     'condition-met' => ?bool,
41     *     'ignore-condition' => bool,
42     *     'message' => string
43     *   ] ]
44     *  ]
45     * @phan-return array{add:list<string>,remove:list<string>,restricted:array<string,array>}
46     */
47    abstract public function getChangeableGroups(
48        Authority $performer,
49        UserIdentity $target,
50        bool $evaluatePrivateConditionsForRestrictedGroups = true
51    ): array;
52
53    /**
54     * Validates the requested changes to user groups and returns an array, specifying if some groups are unchangeable
55     * and for what reasons.
56     * @param Authority $performer
57     * @param UserIdentity $target
58     * @param list<string> $addGroups
59     * @param list<string> $removeGroups
60     * @param array<string, ?string> $newExpiries
61     * @param array<string, UserGroupMembership> $groupMemberships
62     * @return array<string, string> Map of user groups to the reason why they cannot be given, removed or updated,
63     *     keyed by the group names. The supported reasons are: 'rights', 'restricted', 'private-condition'.
64     */
65    public function validateUserGroups(
66        Authority $performer,
67        UserIdentity $target,
68        array $addGroups,
69        array $removeGroups,
70        array $newExpiries,
71        array $groupMemberships,
72    ): array {
73        // We have to find out which groups the user is unable to change and also whether it's due to
74        // private conditions or not. In the former case, we need to be able to log access to the conditions.
75        $permittedChangesNoPrivate = $this->getChangeableGroups( $performer, $target, false );
76        $permittedChangesWithPrivate = $this->getChangeableGroups( $performer, $target );
77
78        [ $unaddableNoPrivate, $irremovableNoPrivate ] = $this->getDisallowedGroupChanges(
79            $addGroups, $removeGroups, $newExpiries, $groupMemberships, $permittedChangesNoPrivate );
80        [ $unaddableWithPrivate, $irremovableWithPrivate ] = $this->getDisallowedGroupChanges(
81            $addGroups, $removeGroups, $newExpiries, $groupMemberships, $permittedChangesWithPrivate );
82
83        $unchangeableGroupsNoPrivate = array_merge( $unaddableNoPrivate, $irremovableNoPrivate );
84        $unchangeableGroupsWithPrivate = array_merge( $unaddableWithPrivate, $irremovableWithPrivate );
85
86        $unchangeableGroupsDueToPrivate = array_diff( $unchangeableGroupsWithPrivate, $unchangeableGroupsNoPrivate );
87
88        $restrictedGroups = $permittedChangesWithPrivate['restricted'];
89        $knownGroups = $this->getKnownGroups( $target );
90
91        $result = [];
92        foreach ( $unchangeableGroupsWithPrivate as $group ) {
93            // Sometimes people are assigned to groups that no longer are defined. Let's ignore them for validation
94            if ( !in_array( $group, $knownGroups ) ) {
95                continue;
96            }
97
98            if ( in_array( $group, $unchangeableGroupsDueToPrivate ) ) {
99                $result[$group] = 'private-condition';
100            } elseif ( isset( $restrictedGroups[$group] ) ) {
101                $result[$group] = 'restricted';
102            } else {
103                $result[$group] = 'rights';
104            }
105        }
106        return $result;
107    }
108
109    /**
110     * Returns a list of possible known groups for a given identity. The result of this function is used to
111     * skip undefined pre-existing groups when validating group changes.
112     * @param UserIdentity $target The target user
113     * @return string[]
114     */
115    abstract protected function getKnownGroups( UserIdentity $target ): array;
116
117    /**
118     * Triggers a hook that allows extensions to log when user read some private conditions.
119     * @param Authority $performer The user who submitted request to change the user groups
120     * @param UserIdentity $target The user whose groups are changed
121     * @param list<string> $addGroups A list of groups that were attempted to be added (or have expiry changed)
122     * @param array<string, ?string> $newExpiries New expiration times for the groups (null or missing means infinity)
123     * @param array<string, UserGroupMembership> $existingUGMs Existing group memberships for the target user
124     * @return void
125     */
126    public function logAccessToPrivateConditions(
127        Authority $performer,
128        UserIdentity $target,
129        array $addGroups,
130        array $newExpiries,
131        array $existingUGMs,
132    ): void {
133        // Potentially changeable - groups that might be changed if it weren't for private conditions
134        $potentiallyChangeable = $this->getChangeableGroups( $performer, $target, false );
135        $conditions = $this->getPrivateConditionsInvolvedInChange(
136            $target,
137            $addGroups,
138            $newExpiries,
139            $existingUGMs,
140            $potentiallyChangeable
141        );
142
143        if ( !$conditions ) {
144            return;
145        }
146
147        $this->hookRunner->onReadPrivateUserRequirementsCondition( $performer->getUser(), $target, $conditions );
148    }
149
150    /**
151     * For added or prolonged groups, returns a list of private conditions that the groups depends on.
152     * @param UserIdentity $target The user whose rights were changed (used to load the restriction
153     *     configuration from a proper wiki)
154     * @param list<string> $addGroups
155     * @param array<string, ?string> $newExpiries
156     * @param array<string, UserGroupMembership> $existingUGMs
157     * @param array $potentiallyChangeableGroups Information about changeable groups without evaluating private
158     *     conditions.
159     * @return list<mixed>
160     */
161    private function getPrivateConditionsInvolvedInChange(
162        UserIdentity $target,
163        array $addGroups,
164        array $newExpiries,
165        array $existingUGMs,
166        array $potentiallyChangeableGroups,
167    ): array {
168        $restrictedGroups = $potentiallyChangeableGroups['restricted'];
169        $groupsWithPrivateConditionsInvolved = [];
170        foreach ( $restrictedGroups as $group => $groupData ) {
171            if ( $groupData['condition-met'] === null && !$groupData['ignore-condition'] ) {
172                $groupsWithPrivateConditionsInvolved[] = $group;
173            }
174        }
175
176        $groupsToCheck = [];
177        foreach ( $addGroups as $group ) {
178            // If a group is for sure unaddable or addable, private conditions don't matter, so we don't
179            // consider them as involved into the change
180            if ( !in_array( $group, $groupsWithPrivateConditionsInvolved ) ) {
181                continue;
182            }
183
184            // Ensure that we only test groups that are added or prolonged (conditions don't apply for
185            // removals from groups)
186            if ( !isset( $existingUGMs[$group] ) ) {
187                $groupsToCheck[] = $group;
188                continue;
189            }
190            $currentExpiry = $existingUGMs[$group]->getExpiry() ?? 'infinity';
191            $newExpiry = $newExpiries[$group] ?? 'infinity';
192
193            if ( $newExpiry > $currentExpiry ) {
194                $groupsToCheck[] = $group;
195            }
196        }
197
198        $privateConditions = [];
199        $restrictedGroupChecker = $this->getRestrictedGroupChecker( $target );
200        foreach ( $groupsToCheck as $group ) {
201            $privateConditions = array_merge(
202                $privateConditions,
203                $restrictedGroupChecker->getPrivateConditionsForGroup( $group )
204            );
205        }
206        return array_values( array_unique( $privateConditions ) );
207    }
208
209    /**
210     * Returns an instance of {@see RestrictedUserGroupChecker} suitable for use with the target user.
211     */
212    abstract protected function getRestrictedGroupChecker( UserIdentity $target ): RestrictedUserGroupChecker;
213
214    /**
215     * Ensures that the content of $addGroups, $removeGroups and $newExpiries is compliant
216     * with the possible changes defined in $permittedChanges. If there's a change that
217     * is not permitted, it is removed from the respective array.
218     * @param list<string> &$addGroups
219     * @param list<string> &$removeGroups
220     * @param array<string, ?string> &$newExpiries
221     * @param array<string, UserGroupMembership> $existingUGMs
222     * @param array{add:list<string>,remove:list<string>} $permittedChanges
223     * @return void
224     */
225    public static function enforceChangeGroupPermissions(
226        array &$addGroups,
227        array &$removeGroups,
228        array &$newExpiries,
229        array $existingUGMs,
230        array $permittedChanges
231    ): void {
232        [ $unaddableGroups, $irremovableGroups ] = self::getDisallowedGroupChanges(
233            $addGroups, $removeGroups, $newExpiries, $existingUGMs, $permittedChanges
234        );
235
236        $addGroups = array_diff( $addGroups, $unaddableGroups );
237        $removeGroups = array_diff( $removeGroups, $irremovableGroups );
238        foreach ( $unaddableGroups as $group ) {
239            unset( $newExpiries[$group] );
240        }
241    }
242
243    /**
244     * Returns which of the attempted group changed are not allowed, given $permittedChanges.
245     * @param list<string> $addGroups
246     * @param list<string> $removeGroups
247     * @param array<string, ?string> $newExpiries
248     * @param array<string, UserGroupMembership> $existingUGMs
249     * @param array{add:list<string>,remove:list<string>} $permittedChanges
250     * @return array{0:list<string>,1:list<string>} List of unaddable groups and list of irremovable groups
251     */
252    private static function getDisallowedGroupChanges(
253        array $addGroups,
254        array $removeGroups,
255        array $newExpiries,
256        array $existingUGMs,
257        array $permittedChanges
258    ): array {
259        $canAdd = $permittedChanges['add'];
260        $canRemove = $permittedChanges['remove'];
261        $involvedGroups = array_unique( array_merge( array_keys( $existingUGMs ), $addGroups, $removeGroups ) );
262
263        // These do not reflect actual permissions, but rather the groups to remove from $addGroups and $removeGroups
264        $unaddableGroups = [];
265        $irremovableGroups = [];
266
267        foreach ( $involvedGroups as $group ) {
268            $hasGroup = isset( $existingUGMs[$group] );
269            $wantsAddGroup = in_array( $group, $addGroups );
270            $wantsRemoveGroup = in_array( $group, $removeGroups );
271
272            // Better safe than sorry - catch it if the input is contradictory
273            if (
274                ( !$hasGroup && $wantsRemoveGroup ) ||
275                ( $wantsAddGroup && $wantsRemoveGroup )
276            ) {
277                $unaddableGroups[] = $group;
278                $irremovableGroups[] = $group;
279                continue;
280            }
281            // If there's no change, we don't have to change anything
282            if ( !$hasGroup && !$wantsAddGroup ) {
283                continue;
284            }
285            if ( $hasGroup && !$wantsRemoveGroup && !$wantsAddGroup ) {
286                // We have to check for adding group, because it's set when changing expiry
287                continue;
288            }
289
290            if ( $hasGroup && $wantsRemoveGroup ) {
291                if ( !in_array( $group, $canRemove ) ) {
292                    $irremovableGroups[] = $group;
293                }
294            } elseif ( !$hasGroup && $wantsAddGroup ) {
295                if ( !in_array( $group, $canAdd ) ) {
296                    $unaddableGroups[] = $group;
297                }
298            } elseif ( $hasGroup && $wantsAddGroup ) {
299                $currentExpiry = $existingUGMs[$group]->getExpiry() ?? 'infinity';
300                $wantedExpiry = $newExpiries[$group] ?? 'infinity';
301
302                if ( $wantedExpiry > $currentExpiry ) {
303                    // Prolongation requires 'add' permission
304                    $canChange = in_array( $group, $canAdd );
305                } else {
306                    // Shortening requires 'remove' permission
307                    $canChange = in_array( $group, $canRemove );
308                }
309
310                if ( !$canChange ) {
311                    // Restore the original group expiry if user can't change it
312                    $unaddableGroups[] = $group;
313                }
314            }
315        }
316
317        return [ $unaddableGroups, $irremovableGroups ];
318    }
319
320    /**
321     * Converts a user group membership expiry string into a timestamp. Words like
322     * 'existing' or 'other' should have been filtered out before calling this
323     * function.
324     *
325     * @param string $expiry
326     * @return string|null|false A string containing a valid timestamp, or null
327     *   if the expiry is infinite, or false if the timestamp is not valid
328     */
329    public static function expiryToTimestamp( $expiry ) {
330        if ( wfIsInfinity( $expiry ) ) {
331            return null;
332        }
333
334        $unix = strtotime( $expiry, ConvertibleTimestamp::time() );
335
336        if ( !$unix || $unix === -1 ) {
337            return false;
338        }
339
340        // @todo FIXME: Non-qualified absolute times are not in users specified timezone
341        // and there isn't notice about it in the ui (see ProtectionForm::getExpiry)
342        return wfTimestamp( TS::MW, $unix );
343    }
344}