==== Removed and replaced external libraries ====
=== Bug fixes in 1.28 ===
+* (T137264) SECURITY: XSS in unclosed internal links
+* (T133147) SECURITY: Escape '<' and ']]>' in inline <style> blocks
+* (T133147) SECURITY: Require login to preview user CSS pages
+* (T132926) SECURITY: Do not allow undeleting a revision deleted file if it is
+ the top file
+* (T129738) SECURITY: Make $wgBlockDisablesLogin also restrict logged in
+ permissions
+* (T129738) SECURITY: Make blocks log users out if $wgBlockDisablesLogin is true
+* (T139670) Move 'UserGetRights' call before application of
+ Session::getAllowedUserRights()
=== Action API changes in 1.28 ===
* Added 'maxarticlesize' property to action=query&meta=siteinfo which contains
=== Action API internal changes in 1.28 ===
* Added a new hook, 'ApiMakeParserOptions', to allow extensions to better
interact with ApiParse and ApiExpandTemplates.
+* (T139565) SECURITY: API: Generate head items in the context of the given title
+* (T115333) SECURITY: Check read permission when loading page content in ApiParse
=== Languages updated in 1.28 ===
'EnhancedChangesList' => __DIR__ . '/includes/changes/EnhancedChangesList.php',
'EnotifNotifyJob' => __DIR__ . '/includes/jobqueue/jobs/EnotifNotifyJob.php',
'EnqueueJob' => __DIR__ . '/includes/jobqueue/jobs/EnqueueJob.php',
- 'EnqueueableDataUpdate' => __DIR__ . '/includes/deferred/DataUpdate.php',
+ 'EnqueueableDataUpdate' => __DIR__ . '/includes/deferred/EnqueueableDataUpdate.php',
'EraseArchivedFile' => __DIR__ . '/maintenance/eraseArchivedFile.php',
'ErrorPageError' => __DIR__ . '/includes/exception/ErrorPageError.php',
'EventRelayer' => __DIR__ . '/includes/libs/eventrelayer/EventRelayer.php',
* PHP version, and chosen database backend. The Wikimedia Foundation shares this data with
* MediaWiki developers to help guide future development efforts.
*
- * For details about what data is sent, see: https://www.mediawiki.org/wiki/Pingback
+ * For details about what data is sent, see: https://www.mediawiki.org/wiki/Manual:$wgPingback
*
* @var bool
* @since 1.28
private function isUserCssPreview() {
return $this->getConfig()->get( 'AllowUserCss' )
- && $this->getUser()->isLoggedIn()
&& $this->getTitle()
&& $this->getTitle()->isCssSubpage()
&& $this->userCanPreview();
*
* This is public so we can display it in the installer
*
+ * Developers: If you're adding a new piece of data to this, please ensure
+ * that you update https://www.mediawiki.org/wiki/Manual:$wgPingback
+ *
* @return array
*/
public function getSystemInfo() {
abstract class DatabaseBase implements IDatabase {
/** Number of times to re-try an operation in case of deadlock */
const DEADLOCK_TRIES = 4;
-
/** Minimum time to wait before retry, in microseconds */
const DEADLOCK_DELAY_MIN = 500000;
-
/** Maximum time to wait before retry */
const DEADLOCK_DELAY_MAX = 1500000;
+ /** How long before it is worth doing a dummy query to test the connection */
+ const PING_TTL = 1.0;
+
+ /** @var string SQL query */
protected $mLastQuery = '';
+ /** @var bool */
protected $mDoneWrites = false;
+ /** @var string|bool */
protected $mPHPError = false;
-
- protected $mServer, $mUser, $mPassword, $mDBname;
+ /** @var string */
+ protected $mServer;
+ /** @var string */
+ protected $mUser;
+ /** @var string */
+ protected $mPassword;
+ /** @var string */
+ protected $mDBname;
/** @var BagOStuff APC cache */
protected $srvCache;
/** @var resource Database connection */
protected $mConn = null;
+ /** @var bool */
protected $mOpened = false;
/** @var array[] List of (callable, method name) */
/** @var bool Whether to suppress triggering of post-commit callbacks */
protected $suppressPostCommitCallbacks = false;
+ /** @var string */
protected $mTablePrefix;
+ /** @var string */
protected $mSchema;
+ /** @var integer */
protected $mFlags;
+ /** @var bool */
protected $mForeign;
+ /** @var array */
protected $mLBInfo = [];
+ /** @var bool|null */
protected $mDefaultBigSelects = null;
+ /** @var array|bool */
protected $mSchemaVars = false;
/** @var array */
protected $mSessionVars = [];
-
+ /** @var array|null */
protected $preparedArgs;
-
+ /** @var string|bool|null Stashed value of html_errors INI setting */
protected $htmlErrors;
-
+ /** @var string */
protected $delimiter = ';';
/**
*/
protected $allViews = null;
+ /** @var float UNIX timestamp */
+ protected $lastPing = 0.0;
+
/** @var TransactionProfiler */
protected $trxProfiler;
$priorWritesPending = $this->writesOrCallbacksPending();
$this->mLastQuery = $sql;
- $isWriteQuery = $this->isWriteQuery( $sql );
- if ( $isWriteQuery ) {
+ $isWrite = $this->isWriteQuery( $sql );
+ if ( $isWrite ) {
$reason = $this->getReadOnlyReason();
if ( $reason !== false ) {
throw new DBReadOnlyError( $this, "Database is read-only: $reason" );
}
# Keep track of whether the transaction has write queries pending
- if ( $this->mTrxLevel && !$this->mTrxDoneWrites && $isWriteQuery ) {
+ if ( $this->mTrxLevel && !$this->mTrxDoneWrites && $isWrite ) {
$this->mTrxDoneWrites = true;
$this->getTransactionProfiler()->transactionWritingIn(
$this->mServer, $this->mDBname, $this->mTrxShortId );
}
- $isMaster = !is_null( $this->getLBInfo( 'master' ) );
- # generalizeSQL will probably cut down the query to reasonable
- # logging size most of the time. The substr is really just a sanity check.
- if ( $isMaster ) {
- $queryProf = 'query-m: ' . substr( DatabaseBase::generalizeSQL( $sql ), 0, 255 );
- $totalProf = 'DatabaseBase::query-master';
- } else {
- $queryProf = 'query: ' . substr( DatabaseBase::generalizeSQL( $sql ), 0, 255 );
- $totalProf = 'DatabaseBase::query';
- }
- # Include query transaction state
- $queryProf .= $this->mTrxShortId ? " [TRX#{$this->mTrxShortId}]" : "";
-
- $profiler = Profiler::instance();
- if ( !$profiler instanceof ProfilerStub ) {
- $totalProfSection = $profiler->scopedProfileIn( $totalProf );
- $queryProfSection = $profiler->scopedProfileIn( $queryProf );
- }
-
if ( $this->debug() ) {
wfDebugLog( 'queries', sprintf( "%s: %s", $this->mDBname, $commentedSql ) );
}
# Avoid fatals if close() was called
$this->assertOpen();
- # Do the query and handle errors
- $startTime = microtime( true );
- $ret = $this->doQuery( $commentedSql );
- $queryRuntime = microtime( true ) - $startTime;
- # Log the query time and feed it into the DB trx profiler
- $this->getTransactionProfiler()->recordQueryCompletion(
- $queryProf, $startTime, $isWriteQuery, $this->affectedRows() );
-
- MWDebug::query( $sql, $fname, $isMaster, $queryRuntime );
+ # Send the query to the server
+ $ret = $this->doProfiledQuery( $sql, $commentedSql, $isWrite, $fname );
# Try reconnecting if the connection was lost
if ( false === $ret && $this->wasErrorReissuable() ) {
$this->reportQueryError( $lastError, $lastErrno, $sql, $fname );
} else {
# Should be safe to silently retry the query
- $startTime = microtime( true );
- $ret = $this->doQuery( $commentedSql );
- $queryRuntime = microtime( true ) - $startTime;
- # Log the query time and feed it into the DB trx profiler
- $this->getTransactionProfiler()->recordQueryCompletion(
- $queryProf, $startTime, $isWriteQuery, $this->affectedRows() );
+ $ret = $this->doProfiledQuery( $sql, $commentedSql, $isWrite, $fname );
}
} else {
wfDebug( "Failed\n" );
$res = $this->resultObject( $ret );
- // Destroy profile sections in the opposite order to their creation
- ScopedCallback::consume( $queryProfSection );
- ScopedCallback::consume( $totalProfSection );
+ return $res;
+ }
- if ( $isWriteQuery && $this->mTrxLevel ) {
- $this->mTrxWriteDuration += $queryRuntime;
- $this->mTrxWriteCallers[] = $fname;
+ private function doProfiledQuery( $sql, $commentedSql, $isWrite, $fname ) {
+ $isMaster = !is_null( $this->getLBInfo( 'master' ) );
+ # generalizeSQL() will probably cut down the query to reasonable
+ # logging size most of the time. The substr is really just a sanity check.
+ if ( $isMaster ) {
+ $queryProf = 'query-m: ' . substr( DatabaseBase::generalizeSQL( $sql ), 0, 255 );
+ } else {
+ $queryProf = 'query: ' . substr( DatabaseBase::generalizeSQL( $sql ), 0, 255 );
}
- return $res;
+ # Include query transaction state
+ $queryProf .= $this->mTrxShortId ? " [TRX#{$this->mTrxShortId}]" : "";
+
+ $profiler = Profiler::instance();
+ if ( !( $profiler instanceof ProfilerStub ) ) {
+ $queryProfSection = $profiler->scopedProfileIn( $queryProf );
+ }
+
+ $startTime = microtime( true );
+ $ret = $this->doQuery( $commentedSql );
+ $queryRuntime = microtime( true ) - $startTime;
+
+ unset( $queryProfSection ); // profile out (if set)
+
+ if ( $ret !== false ) {
+ $this->lastPing = $startTime;
+ if ( $isWrite && $this->mTrxLevel ) {
+ $this->mTrxWriteDuration += $queryRuntime;
+ $this->mTrxWriteCallers[] = $fname;
+ }
+ }
+
+ $this->getTransactionProfiler()->recordQueryCompletion(
+ $queryProf, $startTime, $isWrite, $this->affectedRows()
+ );
+ MWDebug::query( $sql, $fname, $isMaster, $queryRuntime );
+
+ return $ret;
}
private function canRecoverFromDisconnect( $sql, $priorWritesPending ) {
}
public function ping() {
+ if ( $this->isOpen() && ( microtime( true ) - $this->lastPing ) < self::PING_TTL ) {
+ return true;
+ }
try {
// This will reconnect if possible, or error out if not
$this->query( "SELECT 1 AS ping", __METHOD__ );
* @return bool
*/
protected function reconnect() {
- # Stub. Not essential to override.
- return true;
+ $this->closeConnection();
+ $this->mOpened = false;
+ $this->mConn = false;
+ try {
+ $this->open( $this->mServer, $this->mUser, $this->mPassword, $this->mDBname );
+ $this->lastPing = microtime( true );
+ $ok = true;
+ } catch ( DBConnectionError $e ) {
+ $ok = false;
+ }
+
+ return $ok;
}
public function getSessionLagStatus() {
return strlen( $name ) && $name[0] == '`' && substr( $name, -1, 1 ) == '`';
}
- function reconnect() {
- $this->closeConnection();
- $this->mOpened = false;
- $this->mConn = false;
- $this->open( $this->mServer, $this->mUser, $this->mPassword, $this->mDBname );
-
- return true;
- }
-
function getLag() {
if ( $this->getLagDetectionMethod() === 'pt-heartbeat' ) {
return $this->getLagFromPtHeartbeat();
public function approveMasterChanges( array $options ) {
$limit = isset( $options['maxWriteDuration'] ) ? $options['maxWriteDuration'] : 0;
$this->forEachOpenMasterConnection( function ( DatabaseBase $conn ) use ( $limit ) {
- // If atomic section or explicit transactions are still open, some caller must have
+ // If atomic sections or explicit transactions are still open, some caller must have
// caught an exception but failed to properly rollback any changes. Detect that and
// throw and error (causing rollback).
if ( $conn->explicitTrxActive() ) {
wfMessage( 'transaction-duration-limit-exceeded', $time, $limit )->text()
);
}
+ // If a connection sits idle while slow queries execute on another, that connection
+ // may end up dropped before the commit round is reached. Ping servers to detect this.
+ if ( $conn->writesOrCallbacksPending() && !$conn->ping() ) {
+ throw new DBTransactionError(
+ $conn,
+ "A connection to the {$conn->getDBname()} database was lost before commit."
+ );
+ }
} );
}
return $remaining;
}
}
-
-/**
- * Interface that marks a DataUpdate as enqueuable via the JobQueue
- *
- * Such updates must be representable using IJobSpecification, so that
- * they can be serialized into jobs and enqueued for later execution
- *
- * @since 1.27
- */
-interface EnqueueableDataUpdate {
- /**
- * @return array (wiki => wiki ID, job => IJobSpecification)
- */
- public function getAsJobSpecification();
-}
--- /dev/null
+<?php
+/**
+ * Interface that marks a DataUpdate as enqueuable via the JobQueue
+ *
+ * Such updates must be representable using IJobSpecification, so that
+ * they can be serialized into jobs and enqueued for later execution
+ *
+ * @since 1.27
+ */
+interface EnqueueableDataUpdate {
+ /**
+ * @return array (wiki => wiki ID, job => IJobSpecification)
+ */
+ public function getAsJobSpecification();
+}
$lb->waitForAll( $pos );
}
- $fname = __METHOD__;
- // Re-ping all masters with transactions. This throws DBError if some
- // connection died while waiting on locks/slaves, triggering a rollback.
- wfGetLBFactory()->forEachLB( function( LoadBalancer $lb ) use ( $fname ) {
- $lb->forEachOpenConnection( function( IDatabase $conn ) use ( $fname ) {
- if ( $conn->writesOrCallbacksPending() ) {
- $conn->ping();
- }
- } );
- } );
-
// Actually commit the DB master changes
wfGetLBFactory()->commitMasterChanges( __METHOD__ );
* @ingroup Cache
*/
+use \MediaWiki\MediaWikiServices;
+
/**
* Class to store objects in the database
*
if ( isset( $dataRows[$key] ) ) { // HIT?
$row = $dataRows[$key];
$this->debug( "get: retrieved data; expiry time is " . $row->exptime );
+ $db = null;
try {
$db = $this->getDB( $row->serverIndex );
if ( $this->isExpired( $db, $row->exptime ) ) { // MISS
$values[$key] = $this->unserialize( $db->decodeBlob( $row->value ) );
}
} catch ( DBQueryError $e ) {
- $this->handleWriteError( $e, $row->serverIndex );
+ $this->handleWriteError( $e, $db, $row->serverIndex );
}
} else { // MISS
$this->debug( 'get: no matching rows' );
$result = true;
$exptime = (int)$expiry;
foreach ( $keysByTable as $serverIndex => $serverKeys ) {
+ $db = null;
try {
$db = $this->getDB( $serverIndex );
} catch ( DBError $e ) {
- $this->handleWriteError( $e, $serverIndex );
+ $this->handleWriteError( $e, $db, $serverIndex );
$result = false;
continue;
}
__METHOD__
);
} catch ( DBError $e ) {
- $this->handleWriteError( $e, $serverIndex );
+ $this->handleWriteError( $e, $db, $serverIndex );
$result = false;
}
protected function cas( $casToken, $key, $value, $exptime = 0 ) {
list( $serverIndex, $tableName ) = $this->getTableByKey( $key );
+ $db = null;
try {
$db = $this->getDB( $serverIndex );
$exptime = intval( $exptime );
__METHOD__
);
} catch ( DBQueryError $e ) {
- $this->handleWriteError( $e, $serverIndex );
+ $this->handleWriteError( $e, $db, $serverIndex );
return false;
}
public function delete( $key ) {
list( $serverIndex, $tableName ) = $this->getTableByKey( $key );
+ $db = null;
try {
$db = $this->getDB( $serverIndex );
$db->delete(
[ 'keyname' => $key ],
__METHOD__ );
} catch ( DBError $e ) {
- $this->handleWriteError( $e, $serverIndex );
+ $this->handleWriteError( $e, $db, $serverIndex );
return false;
}
public function incr( $key, $step = 1 ) {
list( $serverIndex, $tableName ) = $this->getTableByKey( $key );
+ $db = null;
try {
$db = $this->getDB( $serverIndex );
$step = intval( $step );
$newValue = null;
}
} catch ( DBError $e ) {
- $this->handleWriteError( $e, $serverIndex );
+ $this->handleWriteError( $e, $db, $serverIndex );
return null;
}
public function changeTTL( $key, $expiry = 0 ) {
list( $serverIndex, $tableName ) = $this->getTableByKey( $key );
+ $db = null;
try {
$db = $this->getDB( $serverIndex );
$db->update(
return false;
}
} catch ( DBError $e ) {
- $this->handleWriteError( $e, $serverIndex );
+ $this->handleWriteError( $e, $db, $serverIndex );
return false;
}
*/
public function deleteObjectsExpiringBefore( $timestamp, $progressCallback = false ) {
for ( $serverIndex = 0; $serverIndex < $this->numServers; $serverIndex++ ) {
+ $db = null;
try {
$db = $this->getDB( $serverIndex );
$dbTimestamp = $db->timestamp( $timestamp );
}
}
} catch ( DBError $e ) {
- $this->handleWriteError( $e, $serverIndex );
+ $this->handleWriteError( $e, $db, $serverIndex );
return false;
}
}
*/
public function deleteAll() {
for ( $serverIndex = 0; $serverIndex < $this->numServers; $serverIndex++ ) {
+ $db = null;
try {
$db = $this->getDB( $serverIndex );
for ( $i = 0; $i < $this->shards; $i++ ) {
$db->delete( $this->getTableNameByShard( $i ), '*', __METHOD__ );
}
} catch ( DBError $e ) {
- $this->handleWriteError( $e, $serverIndex );
+ $this->handleWriteError( $e, $db, $serverIndex );
return false;
}
}
* Handle a DBQueryError which occurred during a write operation.
*
* @param DBError $exception
+ * @param IDatabase|null $db DB handle or null if connection failed
* @param int $serverIndex
* @throws Exception
*/
- protected function handleWriteError( DBError $exception, $serverIndex ) {
- if ( $exception instanceof DBConnectionError ) {
+ protected function handleWriteError( DBError $exception, IDatabase $db = null, $serverIndex ) {
+ if ( !$db ) {
$this->markServerDown( $exception, $serverIndex );
- }
- if ( $exception->db && $exception->db->wasReadOnlyError() ) {
- if ( $exception->db->trxLevel() ) {
- if ( !$this->serverInfos ) {
- // Errors like deadlocks and connection drops already cause rollback.
- // For consistency, we have no choice but to throw an error and trigger
- // complete rollback if the main DB is also being used as the cache DB.
- throw $exception;
- }
-
- try {
- $exception->db->rollback( __METHOD__ );
- } catch ( DBError $e ) {
- }
+ } elseif ( $db->wasReadOnlyError() ) {
+ if ( $db->trxLevel() && $this->usesMainDB() ) {
+ // Errors like deadlocks and connection drops already cause rollback.
+ // For consistency, we have no choice but to throw an error and trigger
+ // complete rollback if the main DB is also being used as the cache DB.
+ throw $exception;
}
}
* @param DBError $exception
* @param int $serverIndex
*/
- protected function markServerDown( $exception, $serverIndex ) {
+ protected function markServerDown( DBError $exception, $serverIndex ) {
unset( $this->conns[$serverIndex] ); // bug T103435
if ( isset( $this->connFailureTimes[$serverIndex] ) ) {
}
}
+ /**
+ * @return bool Whether the main DB is used, e.g. wfGetDB( DB_MASTER )
+ */
+ protected function usesMainDB() {
+ return !$this->serverInfos;
+ }
+
protected function waitForSlaves() {
- if ( !$this->serverInfos ) {
+ if ( $this->usesMainDB() ) {
// Main LB is used; wait for any slaves to catch up
try {
- wfGetLBFactory()->waitForReplication( [ 'wiki' => wfWikiID() ] );
+ $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ $lbFactory->waitForReplication( [ 'wiki' => wfWikiID() ] );
return true;
} catch ( DBReplicationWaitError $e ) {
return false;
public function getRights() {
if ( is_null( $this->mRights ) ) {
$this->mRights = self::getGroupPermissions( $this->getEffectiveGroups() );
+ Hooks::run( 'UserGetRights', [ $this, &$this->mRights ] );
// Deny any rights denied by the user's session, unless this
// endpoint has no sessions.
}
}
- Hooks::run( 'UserGetRights', [ $this, &$this->mRights ] );
// Force reindexation of rights when a hook has unset one of them
$this->mRights = array_values( array_unique( $this->mRights ) );
$noPass = PasswordFactory::newInvalidPassword()->toString();
$dbw = wfGetDB( DB_MASTER );
- $inWrite = $dbw->writesOrCallbacksPending();
$seqVal = $dbw->nextSequenceValue( 'user_user_id_seq' );
$dbw->insert( 'user',
[
[ 'IGNORE' ]
);
if ( !$dbw->affectedRows() ) {
- // The queries below cannot happen in the same REPEATABLE-READ snapshot.
- // Handle this by COMMIT, if possible, or by LOCK IN SHARE MODE otherwise.
- if ( $inWrite ) {
- // Can't commit due to pending writes that may need atomicity.
- // This may cause some lock contention unlike the case below.
- $options = [ 'LOCK IN SHARE MODE' ];
- $flags = self::READ_LOCKING;
- } else {
- // Often, this case happens early in views before any writes when
- // using CentralAuth. It's should be OK to commit and break the snapshot.
- $dbw->commit( __METHOD__, 'flush' );
- $options = [];
- $flags = self::READ_LATEST;
- }
- $this->mId = $dbw->selectField( 'user', 'user_id',
- [ 'user_name' => $this->mName ], __METHOD__, $options );
+ // Use locking reads to bypass any REPEATABLE-READ snapshot.
+ $this->mId = $dbw->selectField(
+ 'user',
+ 'user_id',
+ [ 'user_name' => $this->mName ],
+ __METHOD__,
+ [ 'LOCK IN SHARE MODE' ]
+ );
$loaded = false;
if ( $this->mId ) {
- if ( $this->loadFromDatabase( $flags ) ) {
+ if ( $this->loadFromDatabase( self::READ_LOCKING ) ) {
$loaded = true;
}
}
$this->assertNotContains( 'nukeworld', $rights );
}
+ /**
+ * @covers User::getRights
+ */
+ public function testUserGetRightsHooks() {
+ $user = new User;
+ $user->addGroup( 'unittesters' );
+ $user->addGroup( 'testwriters' );
+ $userWrapper = TestingAccessWrapper::newFromObject( $user );
+
+ $rights = $user->getRights();
+ $this->assertContains( 'test', $rights, 'sanity check' );
+ $this->assertContains( 'runtest', $rights, 'sanity check' );
+ $this->assertContains( 'writetest', $rights, 'sanity check' );
+ $this->assertNotContains( 'nukeworld', $rights, 'sanity check' );
+
+ // Add a hook manipluating the rights
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'UserGetRights' => [ function ( $user, &$rights ) {
+ $rights[] = 'nukeworld';
+ $rights = array_diff( $rights, [ 'writetest' ] );
+ } ] ] );
+
+ $userWrapper->mRights = null;
+ $rights = $user->getRights();
+ $this->assertContains( 'test', $rights );
+ $this->assertContains( 'runtest', $rights );
+ $this->assertNotContains( 'writetest', $rights );
+ $this->assertContains( 'nukeworld', $rights );
+
+ // Add a Session that limits rights
+ $mock = $this->getMockBuilder( stdclass::class )
+ ->setMethods( [ 'getAllowedUserRights', 'deregisterSession', 'getSessionId' ] )
+ ->getMock();
+ $mock->method( 'getAllowedUserRights' )->willReturn( [ 'test', 'writetest' ] );
+ $mock->method( 'getSessionId' )->willReturn(
+ new MediaWiki\Session\SessionId( str_repeat( 'X', 32 ) )
+ );
+ $session = MediaWiki\Session\TestUtils::getDummySession( $mock );
+ $mockRequest = $this->getMockBuilder( FauxRequest::class )
+ ->setMethods( [ 'getSession' ] )
+ ->getMock();
+ $mockRequest->method( 'getSession' )->willReturn( $session );
+ $userWrapper->mRequest = $mockRequest;
+
+ $userWrapper->mRights = null;
+ $rights = $user->getRights();
+ $this->assertContains( 'test', $rights );
+ $this->assertNotContains( 'runtest', $rights );
+ $this->assertNotContains( 'writetest', $rights );
+ $this->assertNotContains( 'nukeworld', $rights );
+ }
+
/**
* @dataProvider provideGetGroupsWithPermission
* @covers User::getGroupsWithPermission