Add UserGroupManager::getUserPrivilegedGroups()

This moves the core part of wfGetPrivilegedGroups() out of Wikimedia
config and makes it possible to move functionality built on it into
core.

Bug: T208477
Change-Id: I6536ef2909caeed047447e8b6a25831d6f00d827
This commit is contained in:
Gergő Tisza 2019-01-07 00:01:00 -08:00 committed by Reedy
parent b71cf7de9e
commit 7a21b9a032
11 changed files with 226 additions and 1 deletions

View file

@ -25,6 +25,8 @@ For notes on 1.40.x and older releases, see HISTORY.
* …
==== New configuration ====
* $wgPrivilegedGroups Users belonging in some of the listed groups will be
audited more aggressively.
* …
==== Changed configuration ====

View file

@ -2115,6 +2115,7 @@ $wgAutoloadLocalClasses = [
'MediaWiki\\User\\Hook\\UserLoadAfterLoadFromSessionHook' => __DIR__ . '/includes/user/Hook/UserLoadAfterLoadFromSessionHook.php',
'MediaWiki\\User\\Hook\\UserLoadDefaultsHook' => __DIR__ . '/includes/user/Hook/UserLoadDefaultsHook.php',
'MediaWiki\\User\\Hook\\UserLogoutHook' => __DIR__ . '/includes/user/Hook/UserLogoutHook.php',
'MediaWiki\\User\\Hook\\UserPrivilegedGroupsHook' => __DIR__ . '/includes/user/Hook/UserPrivilegedGroupsHook.php',
'MediaWiki\\User\\Hook\\UserRemoveGroupHook' => __DIR__ . '/includes/user/Hook/UserRemoveGroupHook.php',
'MediaWiki\\User\\Hook\\UserSaveSettingsHook' => __DIR__ . '/includes/user/Hook/UserSaveSettingsHook.php',
'MediaWiki\\User\\Hook\\UserSendConfirmationMailHook' => __DIR__ . '/includes/user/Hook/UserSendConfirmationMailHook.php',

View file

@ -4960,6 +4960,19 @@ config-schema:
Functionality to make pages inaccessible has not been extensively tested
for security. Use at your own risk!
This replaces $wgWhitelistAccount and $wgWhitelistEdit
PrivilegedGroups:
default:
- bureaucrat
- interface-admin
- suppress
- sysop
type: array
description: |-
List of groups which should be considered privileged (user accounts
belonging in these groups can be abused in dangerous ways).
This is used for some security checks, mainly logging.
@since 1.41
@see UserGroupManager::getUserPrivilegedGroups()
RevokePermissions:
default: { }
type: object

View file

@ -2729,6 +2729,12 @@ $wgHideIdentifiableRedirects = null;
*/
$wgGroupPermissions = null;
/**
* Config variable stub for the PrivilegedGroups setting, for use by phpdoc and IDEs.
* @see MediaWiki\MainConfigSchema::PrivilegedGroups
*/
$wgPrivilegedGroups = null;
/**
* Config variable stub for the RevokePermissions setting, for use by phpdoc and IDEs.
* @see MediaWiki\MainConfigSchema::RevokePermissions

View file

@ -557,6 +557,7 @@ class HookRunner implements
\MediaWiki\User\Hook\UserLoadAfterLoadFromSessionHook,
\MediaWiki\User\Hook\UserLoadDefaultsHook,
\MediaWiki\User\Hook\UserLogoutHook,
\MediaWiki\User\Hook\UserPrivilegedGroupsHook,
\MediaWiki\User\Hook\UserRemoveGroupHook,
\MediaWiki\User\Hook\UserSaveSettingsHook,
\MediaWiki\User\Hook\UserSendConfirmationMailHook,
@ -4234,6 +4235,13 @@ class HookRunner implements
);
}
public function onUserPrivilegedGroups( $userIdentity, &$groups ) {
return $this->container->run(
'UserPrivilegedGroups',
[ $userIdentity, &$groups ]
);
}
public function onUserGetReservedNames( &$reservedUsernames ) {
return $this->container->run(
'UserGetReservedNames',

View file

@ -2744,6 +2744,12 @@ class MainConfigNames {
*/
public const GroupPermissions = 'GroupPermissions';
/**
* Name constant for the PrivilegedGroups setting, for use with Config::get()
* @see MainConfigSchema::PrivilegedGroups
*/
public const PrivilegedGroups = 'PrivilegedGroups';
/**
* Name constant for the RevokePermissions setting, for use with Config::get()
* @see MainConfigSchema::RevokePermissions

View file

@ -7879,6 +7879,23 @@ class MainConfigSchema {
],
];
/**
* List of groups which should be considered privileged (user accounts
* belonging in these groups can be abused in dangerous ways).
* This is used for some security checks, mainly logging.
* @since 1.41
* @see UserGroupManager::getUserPrivilegedGroups()
*/
public const PrivilegedGroups = [
'default' => [
'bureaucrat',
'interface-admin',
'suppress',
'sysop',
],
'type' => 'list',
];
/**
* Permission keys revoked from users in each group.
*

View file

@ -1270,6 +1270,12 @@ return [
'deletelogentry' => true,
],
],
'PrivilegedGroups' => [
0 => 'bureaucrat',
1 => 'interface-admin',
2 => 'suppress',
3 => 'sysop',
],
'RevokePermissions' => [
],
'GroupInheritsPermissions' => [
@ -2742,6 +2748,7 @@ return [
'BlockCIDRLimit' => 'object',
'EnablePartialActionBlocks' => 'boolean',
'GroupPermissions' => 'object',
'PrivilegedGroups' => 'array',
'RevokePermissions' => 'object',
'GroupInheritsPermissions' => 'object',
'ImplicitGroups' => 'array',

View file

@ -0,0 +1,25 @@
<?php
namespace MediaWiki\User\Hook;
use MediaWiki\User\UserIdentity;
/**
* This is a hook handler interface, see docs/Hooks.md.
* Use the hook name "UserPrivilegedGroups" to register handlers implementing this interface.
*
* @stable to implement
* @ingroup Hooks
*/
interface UserPrivilegedGroupsHook {
/**
* This hook is called in UserGroupManager::getUserPrivilegedGroups().
*
* @since 1.41
*
* @param UserIdentity $userIdentity User identity to get groups for
* @param string[] &$groups Current privileged groups
* @return bool|void True or no return value to continue or false to abort
*/
public function onUserPrivilegedGroups( UserIdentity $userIdentity, array &$groups );
}

View file

@ -72,6 +72,7 @@ class UserGroupManager implements IDBAccessObject {
MainConfigNames::GroupsRemoveFromSelf,
MainConfigNames::RevokePermissions,
MainConfigNames::RemoveGroups,
MainConfigNames::PrivilegedGroups,
];
/** @var ServiceOptions */
@ -122,6 +123,9 @@ class UserGroupManager implements IDBAccessObject {
/** string key for former groups cache */
private const CACHE_FORMER = 'former';
/** string key for former groups cache */
private const CACHE_PRIVILEGED = 'privileged';
/**
* @var array Service caches, an assoc. array keyed after the user-keys generated
* by the getCacheKey method and storing values in the following format:
@ -131,6 +135,7 @@ class UserGroupManager implements IDBAccessObject {
* self::CACHE_EFFECTIVE => effective groups cache
* self::CACHE_MEMBERSHIP => [ ] // Array of UserGroupMembership objects
* self::CACHE_FORMER => former groups cache
* self::CACHE_PRIVILEGED => privileged groups cache
* ]
*/
private $userGroupCache = [];
@ -142,8 +147,9 @@ class UserGroupManager implements IDBAccessObject {
* userKey => [
* self::CACHE_IMPLICIT => implicit groups query flag
* self::CACHE_EFFECTIVE => effective groups query flag
* self::CACHE_MEMBERSHIP => membership groups query flag
* self::CACHE_MEMBERSHIP => membership groups query flag
* self::CACHE_FORMER => former groups query flag
* self::CACHE_PRIVILEGED => privileged groups query flag
* ]
*/
private $queryFlagsUsedForCaching = [];
@ -470,6 +476,58 @@ class UserGroupManager implements IDBAccessObject {
return $promote;
}
/**
* Returns the list of privileged groups $user belongs to.
* Privileged groups are ones that can be abused in dangerous way.
*
* Depending on how extensions extend this method, it might return values
* that are not strictly user groups (ACL list names, etc). It is meant
* for logging/auditing, not for passing to methods that expect group names.
*
* @param UserIdentity $user
* @param int $queryFlags
* @param bool $recache Whether to avoid the cache
* @return string[]
* @since 1.36
* @see User::isPrivileged()
* @see $wgPrivilegedGroups
* @see https://www.mediawiki.org/wiki/Manual:Hooks/UserGetPrivilegedGroups
*/
public function getUserPrivilegedGroups(
UserIdentity $user,
int $queryFlags = self::READ_NORMAL,
bool $recache = false
): array {
$userKey = $this->getCacheKey( $user );
if ( !$recache &&
$this->canUseCachedValues( $user, self::CACHE_PRIVILEGED, $queryFlags ) &&
isset( $this->userGroupCache[$userKey][self::CACHE_PRIVILEGED] )
) {
return $this->userGroupCache[$userKey][self::CACHE_PRIVILEGED];
}
if ( !$user->isRegistered() ) {
return [];
}
$groups = array_intersect(
$this->getUserEffectiveGroups( $user, $queryFlags, $recache ),
$this->options->get( 'PrivilegedGroups' )
);
$this->hookRunner->onUserPrivilegedGroups( $user, $groups );
$this->setCache(
$this->getCacheKey( $user ),
self::CACHE_PRIVILEGED,
array_values( array_unique( $groups ) ),
$queryFlags
);
return $this->userGroupCache[$userKey][self::CACHE_PRIVILEGED];
}
/**
* Recursively check a condition. Conditions are in the form
* [ '&' or '|' or '^' or '!', cond1, cond2, ... ]

View file

@ -35,6 +35,8 @@ use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityValue;
use MediaWiki\Utils\MWTimestamp;
use MediaWikiIntegrationTestCase;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\MockObject\Rule\InvokedCount;
use RequestContext;
use SiteConfiguration;
use TestLogger;
@ -119,6 +121,20 @@ class UserGroupManagerTest extends MediaWikiIntegrationTestCase {
$this->clearHooks();
}
/**
* Returns a callable that must be called exactly $invokedCount times.
* @param InvokedCount $invokedCount
* @return callable|MockObject
*/
private function countPromise( $invokedCount ) {
$mockHandler = $this->getMockBuilder( \stdClass::class )
->addMethods( [ '__invoke' ] )
->getMock();
$mockHandler->expects( $invokedCount )
->method( '__invoke' );
return $mockHandler;
}
/**
* @param UserGroupManager $manager
* @param UserIdentity $user
@ -1223,4 +1239,70 @@ class UserGroupManagerTest extends MediaWikiIntegrationTestCase {
$manager = $this->getManager( self::CHANGEABLE_GROUPS_TEST_CONFIG );
$this->assertGroupsEquals( $expected, $manager->getGroupsChangeableByGroup( $group ) );
}
/**
* @covers \MediaWiki\User\UserGroupManager::getUserPrivilegedGroups()
*/
public function testGetUserPrivilegedGroups() {
$this->setMwGlobals( 'wgPrivilegedGroups', [ 'sysop', 'interface-admin', 'bar', 'baz' ] );
$makeHook = function ( $invocationCount, User $userToMatch, array $groupsToAdd ) {
return function ( $u, &$groups ) use ( $userToMatch, $invocationCount, $groupsToAdd ) {
$invocationCount();
$this->assertTrue( $userToMatch->equals( $u ) );
$groups = array_merge( $groups, $groupsToAdd );
};
};
$manager = $this->getManager();
$user = new User;
$user->setName( '*Unregistered 1234' );
$this->assertArrayEquals(
[],
$manager->getUserPrivilegedGroups( $user )
);
$user = $this->getTestUser( [ 'sysop', 'bot', 'interface-admin' ] )->getUser();
$this->setTemporaryHook( 'UserPrivilegedGroups',
$makeHook( $this->countPromise( $this->once() ), $user, [ 'foo' ] ) );
$this->setTemporaryHook( 'UserEffectiveGroups',
$makeHook( $this->countPromise( $this->once() ), $user, [ 'bar', 'boom' ] ) );
$this->assertArrayEquals(
[ 'sysop', 'interface-admin', 'foo', 'bar' ],
$manager->getUserPrivilegedGroups( $user )
);
$this->assertArrayEquals(
[ 'sysop', 'interface-admin', 'foo', 'bar' ],
$manager->getUserPrivilegedGroups( $user )
);
$this->setTemporaryHook( 'UserPrivilegedGroups',
$makeHook( $this->countPromise( $this->once() ), $user, [ 'baz' ] ) );
$this->setTemporaryHook( 'UserEffectiveGroups',
$makeHook( $this->countPromise( $this->once() ), $user, [ 'baz' ] ) );
$this->assertArrayEquals(
[ 'sysop', 'interface-admin', 'foo', 'bar' ],
$manager->getUserPrivilegedGroups( $user )
);
$this->assertArrayEquals(
[ 'sysop', 'interface-admin', 'baz' ],
$manager->getUserPrivilegedGroups( $user, UserGroupManager::READ_NORMAL, true )
);
$this->assertArrayEquals(
[ 'sysop', 'interface-admin', 'baz' ],
$manager->getUserPrivilegedGroups( $user )
);
$this->setTemporaryHook( 'UserPrivilegedGroups', static function () {
} );
$this->setTemporaryHook( 'UserEffectiveGroups', static function () {
} );
$user = $this->getTestUser( [] )->getUser();
$this->assertArrayEquals(
[],
$manager->getUserPrivilegedGroups( $user, UserGroupManager::READ_NORMAL, true )
);
}
}