Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
82 / 82
100.00% covered (success)
100.00%
3 / 3
CRAP
100.00% covered (success)
100.00%
1 / 1
DemoteIneligibleUsers
100.00% covered (success)
100.00%
82 / 82
100.00% covered (success)
100.00%
3 / 3
17
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
59 / 59
100.00% covered (success)
100.00%
1 / 1
12
 readRelayLog
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2/**
3 * Checks if members of restricted groups are still eligible for their group memberships.
4 * If not, and the group is configured with automatic demotion, the user is removed from the group.
5 * Usage: php demoteIneligibleUsers.php
6 * where <user> can be either the username or user ID
7 *
8 * @license GPL-2.0-or-later
9 * @file
10 * @ingroup Maintenance
11 */
12
13use MediaWiki\Maintenance\Maintenance;
14use MediaWiki\Permissions\UltimateAuthority;
15use MediaWiki\User\User;
16use MediaWiki\User\UserIdentityValue;
17
18// @codeCoverageIgnoreStart
19require_once __DIR__ . '/Maintenance.php';
20// @codeCoverageIgnoreEnd
21
22/**
23 * @since 1.46
24 */
25class DemoteIneligibleUsers extends Maintenance {
26    public function __construct() {
27        parent::__construct();
28        $this->addDescription( 'Demote users who no longer meet conditions for group membership' );
29        $this->addOption( 'dry-run', 'Perform a dry run' );
30        $this->addOption(
31            'relay-log',
32            'Groups for which the rights changes should be logged on designated remote wikis. ' .
33            'Use syntax group=wiki. This setting can be specified many times.',
34            false,
35            true,
36            false,
37            true
38        );
39    }
40
41    public function execute() {
42        $dryRun = $this->hasOption( 'dry-run' );
43
44        // Keyed by group name, value is array of wikis to relay the logs to
45        $relayLog = $this->readRelayLog();
46
47        $services = $this->getServiceContainer();
48        $restrictedGroups = $services->getRestrictedUserGroupConfigReader()->getConfig();
49
50        $demotableGroups = array_filter(
51            $restrictedGroups,
52            static fn ( $restriction ) => $restriction->allowsAutomaticDemotion()
53        );
54
55        if ( !$demotableGroups ) {
56            $this->output( "No groups are configured for automatic demotion, exiting.\n" );
57            return;
58        }
59
60        $dbr = $this->getReplicaDB();
61        $groupMembers = $dbr->newSelectQueryBuilder()
62            ->select( [ 'user_id', 'user_name', 'ug_group' ] )
63            ->from( 'user' )
64            ->join( 'user_groups', null, 'ug_user = user_id' )
65            ->where( [ 'ug_group' => array_keys( $demotableGroups ) ] )
66            ->caller( __METHOD__ )
67            ->fetchResultSet();
68
69        // Remove all groups later at once, not to produce multiple logs for a single user; both arrays keyed by user ID
70        $userIdentities = [];
71        $groupsToRemove = [];
72
73        $userRequirementsChecker = $services->getUserRequirementsConditionChecker();
74        $userFactory = $services->getUserFactory();
75        foreach ( $groupMembers as $member ) {
76            $userIdentity = UserIdentityValue::newRegistered( $member->user_id, $member->user_name );
77            $groupConditions = $demotableGroups[$member->ug_group]->getMemberConditions();
78
79            if (
80                $userRequirementsChecker->recursivelyCheckCondition( $groupConditions, $userIdentity )
81                || $userFactory->newFromUserIdentity( $userIdentity )->isSystemUser()
82            ) {
83                continue;
84            }
85
86            $userIdentities[$userIdentity->getId()] = $userIdentity;
87            $groupsToRemove[$userIdentity->getId()][] = $member->ug_group;
88        }
89
90        if ( !$userIdentities ) {
91            $this->output( "No ineligible users found, exiting.\n" );
92            return;
93        }
94
95        $numUsers = count( $userIdentities );
96        if ( $dryRun ) {
97            $this->output( "DRY RUN: $numUsers users would be affected normally\n" );
98        } else {
99            $this->output( "Removing groups from $numUsers users...\n" );
100        }
101
102        $performingUser = User::newSystemUser( User::MAINTENANCE_SCRIPT_USER, [ 'steal' => true ] );
103        $performingAuthority = new UltimateAuthority( $performingUser );
104
105        $groupAssignmentService = $services->getUserGroupAssignmentService();
106        $removedText = $dryRun ? 'Would remove' : 'Removed';
107        foreach ( $userIdentities as $userIdentity ) {
108            $userName = $userIdentity->getName();
109            $removeUserGroups = $groupsToRemove[$userIdentity->getId()];
110            if ( !$dryRun ) {
111                $logReason = wfMessage( 'restrictedgroups-autodemotion-log-reason' )
112                    ->params( $userName )
113                    ->numParams( count( $removeUserGroups ) )
114                    ->text();
115                $additionalWikis = [];
116                foreach ( $removeUserGroups as $group ) {
117                    if ( isset( $relayLog[$group] ) ) {
118                        $additionalWikis = array_merge( $additionalWikis, $relayLog[$group] );
119                    }
120                }
121                $groupAssignmentService->saveChangesToUserGroups(
122                    $performingAuthority, $userIdentity, [], $removeUserGroups, [], $logReason, [], $additionalWikis );
123            }
124            $groupsList = implode( ', ', $removeUserGroups );
125            $this->output( "$removedText groups from $userName$groupsList\n" );
126        }
127        $this->output( "Finished processing. $removedText groups from $numUsers users\n" );
128    }
129
130    /**
131     * Reads the relay-log options and returns an array keyed by group name, with values being arrays of
132     * wikis to relay the logs to.
133     * @return array<string, list<string>>
134     */
135    private function readRelayLog(): array {
136        if ( !$this->hasOption( 'relay-log' ) ) {
137            return [];
138        }
139        $additionalWikis = [];
140        foreach ( $this->getOption( 'relay-log' ) as $relayOption ) {
141            $parts = explode( '=', $relayOption );
142            if ( count( $parts ) !== 2 ) {
143                $this->output( "Invalid relay-log option: $relayOption, skipping.\n" );
144                continue;
145            }
146            [ $group, $wiki ] = $parts;
147            $additionalWikis[$group][] = $wiki;
148        }
149        return $additionalWikis;
150    }
151}
152
153// @codeCoverageIgnoreStart
154$maintClass = DemoteIneligibleUsers::class;
155require_once RUN_MAINTENANCE_IF_MAIN;
156// @codeCoverageIgnoreEnd