Page MenuHomePhabricator

Create a MediaWiki maintenance script to mass remove users from a given user group
Closed, ResolvedPublic

Description

It is not that infrequent (cf. T184981 as the last example) that when doing permissions changes we have to remove all users from a given group. Migrating them is already covered by the migrateUserGroups.php maintenance script, but batch removing them there's nothing to do that but (a) do so manually [and if they're a lot you can really burn out doing so] or (b) after the group has dissapeared, run an SQL query directly on the database.

A maintenance script to do this will be more elegant, provided that, without flooding the recent changes, leaves traces in the Special:Log/rights. It should support doing so in batches (for large operations), and a --summary option so the sysadmin can explain why this is being done.

Update: another case: T188780.

Event Timeline

I'm drafting the script, but it can take some time until I get it complete...

So the idea is to get the script first query select * from user_groups where ug_group = '$group'; ($group = the group we'd like to remove) and then, batch do the following: DELETE FROM user_groups WHERE ug_group = '$group';, logging the result on the Special:Log/rights.

@Mainframe98 Can you take a look at my shy attempt on creating one at P6645? Thanks!

Apparently https://www.mediawiki.org/wiki/Manual:MigrateUserGroup.php can be used for this (Another usecase is when you want to remove a group from all members.), but it is not documented how. Anyway, if at all possible we should make the removals logged in the rights log, and add probably the users removed to the user former rights table as well, so we need to create that script. Sigh, what a work...

Change 448998 had a related patch set uploaded (by Reedy; owner: Reedy):
[mediawiki/core@master] Add a maintenance script to remove all users from a User Group

https://gerrit.wikimedia.org/r/448998

Change 448998 had a related patch set uploaded (by Reedy; owner: Reedy):
[mediawiki/core@master] Add a maintenance script to remove all users from a User Group

https://gerrit.wikimedia.org/r/448998

Soooo... This is enough to remove the user from the group, and add it to user_former_groups...

Question is whether we want to be running hooks (migrateUserGroup doesn't) for the changes in groups and whether we want log entries (again, migrateUserGroup doesn't)...

If we do, we need to steal a load of code from below, or refactor it to make it more widely useable...

	/**
	 * Save user groups changes in the database. This function does not throw errors;
	 * instead, it ignores groups that the performer does not have permission to set.
	 *
	 * @param User|UserRightsProxy $user
	 * @param array $add Array of groups to add
	 * @param array $remove Array of groups to remove
	 * @param string $reason Reason for group change
	 * @param array $tags Array of change tags to add to the log entry
	 * @param array $groupExpiries Associative array of (group name => expiry),
	 *   containing only those groups that are to have new expiry values set
	 * @return array Tuple of added, then removed groups
	 */
	function doSaveUserGroups( $user, array $add, array $remove, $reason = '',
		array $tags = [], array $groupExpiries = []
	) {
		// Validate input set...
		$isself = $user->getName() == $this->getUser()->getName();
		$groups = $user->getGroups();
		$ugms = $user->getGroupMemberships();
		$changeable = $this->changeableGroups();
		$addable = array_merge( $changeable['add'], $isself ? $changeable['add-self'] : [] );
		$removable = array_merge( $changeable['remove'], $isself ? $changeable['remove-self'] : [] );

		$remove = array_unique(
			array_intersect( (array)$remove, $removable, $groups ) );
		$add = array_intersect( (array)$add, $addable );

		// add only groups that are not already present or that need their expiry updated,
		// UNLESS the user can only add this group (not remove it) and the expiry time
		// is being brought forward (T156784)
		$add = array_filter( $add,
			function ( $group ) use ( $groups, $groupExpiries, $removable, $ugms ) {
				if ( isset( $groupExpiries[$group] ) &&
					!in_array( $group, $removable ) &&
					isset( $ugms[$group] ) &&
					( $ugms[$group]->getExpiry() ?: 'infinity' ) >
						( $groupExpiries[$group] ?: 'infinity' )
				) {
					return false;
				}
				return !in_array( $group, $groups ) || array_key_exists( $group, $groupExpiries );
			} );

		Hooks::run( 'ChangeUserGroups', [ $this->getUser(), $user, &$add, &$remove ] );

		$oldGroups = $groups;
		$oldUGMs = $user->getGroupMemberships();
		$newGroups = $oldGroups;

		// Remove groups, then add new ones/update expiries of existing ones
		if ( $remove ) {
			foreach ( $remove as $index => $group ) {
				if ( !$user->removeGroup( $group ) ) {
					unset( $remove[$index] );
				}
			}
			$newGroups = array_diff( $newGroups, $remove );
		}
		if ( $add ) {
			foreach ( $add as $index => $group ) {
				$expiry = $groupExpiries[$group] ?? null;
				if ( !$user->addGroup( $group, $expiry ) ) {
					unset( $add[$index] );
				}
			}
			$newGroups = array_merge( $newGroups, $add );
		}
		$newGroups = array_unique( $newGroups );
		$newUGMs = $user->getGroupMemberships();

		// Ensure that caches are cleared
		$user->invalidateCache();

		// update groups in external authentication database
		Hooks::run( 'UserGroupsChanged', [ $user, $add, $remove, $this->getUser(),
			$reason, $oldUGMs, $newUGMs ] );
		MediaWiki\Auth\AuthManager::callLegacyAuthPlugin(
			'updateExternalDBGroups', [ $user, $add, $remove ]
		);

		wfDebug( 'oldGroups: ' . print_r( $oldGroups, true ) . "\n" );
		wfDebug( 'newGroups: ' . print_r( $newGroups, true ) . "\n" );
		wfDebug( 'oldUGMs: ' . print_r( $oldUGMs, true ) . "\n" );
		wfDebug( 'newUGMs: ' . print_r( $newUGMs, true ) . "\n" );
		// Deprecated in favor of UserGroupsChanged hook
		Hooks::run( 'UserRights', [ &$user, $add, $remove ], '1.26' );

		// Only add a log entry if something actually changed
		if ( $newGroups != $oldGroups || $newUGMs != $oldUGMs ) {
			$this->addLogEntry( $user, $oldGroups, $newGroups, $reason, $tags, $oldUGMs, $newUGMs );
		}

		return [ $add, $remove ];
	}

	/**
	 * Serialise a UserGroupMembership object for storage in the log_params section
	 * of the logging table. Only keeps essential data, removing redundant fields.
	 *
	 * @param UserGroupMembership|null $ugm May be null if things get borked
	 * @return array
	 */
	protected static function serialiseUgmForLog( $ugm ) {
		if ( !$ugm instanceof UserGroupMembership ) {
			return null;
		}
		return [ 'expiry' => $ugm->getExpiry() ];
	}

	/**
	 * Add a rights log entry for an action.
	 * @param User|UserRightsProxy $user
	 * @param array $oldGroups
	 * @param array $newGroups
	 * @param string $reason
	 * @param array $tags Change tags for the log entry
	 * @param array $oldUGMs Associative array of (group name => UserGroupMembership)
	 * @param array $newUGMs Associative array of (group name => UserGroupMembership)
	 */
	protected function addLogEntry( $user, array $oldGroups, array $newGroups, $reason,
		array $tags, array $oldUGMs, array $newUGMs
	) {
		// make sure $oldUGMs and $newUGMs are in the same order, and serialise
		// each UGM object to a simplified array
		$oldUGMs = array_map( function ( $group ) use ( $oldUGMs ) {
			return isset( $oldUGMs[$group] ) ?
				self::serialiseUgmForLog( $oldUGMs[$group] ) :
				null;
		}, $oldGroups );
		$newUGMs = array_map( function ( $group ) use ( $newUGMs ) {
			return isset( $newUGMs[$group] ) ?
				self::serialiseUgmForLog( $newUGMs[$group] ) :
				null;
		}, $newGroups );

		$logEntry = new ManualLogEntry( 'rights', 'rights' );
		$logEntry->setPerformer( $this->getUser() );
		$logEntry->setTarget( $user->getUserPage() );
		$logEntry->setComment( $reason );
		$logEntry->setParameters( [
			'4::oldgroups' => $oldGroups,
			'5::newgroups' => $newGroups,
			'oldmetadata' => $oldUGMs,
			'newmetadata' => $newUGMs,
		] );
		$logid = $logEntry->insert();
		if ( count( $tags ) ) {
			$logEntry->setTags( $tags );
		}
		$logEntry->publish( $logid );
	}

For now I'd say there's no need to log these, the same as migrateUserGroups.php does. Maybe in the future if it's not going to cost undue work.

Reedy claimed this task.
Reedy removed a project: Patch-For-Review.

Waiting on jerkins to finish doing his thing

Change 448998 merged by jenkins-bot:
[mediawiki/core@master] Add a maintenance script to remove all users from a User Group

https://gerrit.wikimedia.org/r/448998

Change 463280 had a related patch set uploaded (by Reedy; owner: Reedy):
[mediawiki/core@wmf/1.32.0-wmf.23] Add a maintenance script to remove all users from a User Group

https://gerrit.wikimedia.org/r/463280

Change 463280 merged by jenkins-bot:
[mediawiki/core@wmf/1.32.0-wmf.23] Add a maintenance script to remove all users from a User Group

https://gerrit.wikimedia.org/r/463280