MediaWiki  master
UserGroupManager.php
Go to the documentation of this file.
1 <?php
21 namespace MediaWiki\User;
22 
25 use DeferredUpdates;
26 use IDBAccessObject;
27 use InvalidArgumentException;
28 use JobQueueGroup;
29 use ManualLogEntry;
35 use Psr\Log\LoggerInterface;
36 use ReadOnlyMode;
37 use Sanitizer;
38 use User;
41 use WikiMap;
42 use Wikimedia\Assert\Assert;
43 use Wikimedia\IPUtils;
47 
53 
57  public const CONSTRUCTOR_OPTIONS = [
58  'AddGroups',
59  'Autopromote',
60  'AutopromoteOnce',
61  'AutopromoteOnceLogInRC',
62  'EmailAuthentication',
63  'ImplicitGroups',
64  'GroupPermissions',
65  'GroupsAddToSelf',
66  'GroupsRemoveFromSelf',
67  'RevokePermissions',
68  'RemoveGroups',
69  ];
70 
72  private $options;
73 
76 
78  private $loadBalancer;
79 
81  private $hookContainer;
82 
84  private $hookRunner;
85 
87  private $readOnlyMode;
88 
91 
94 
96  private $jobQueueGroup;
97 
99  private $logger;
100 
103 
105  private $dbDomain;
106 
108  private const CACHE_IMPLICIT = 'implicit';
109 
111  private const CACHE_EFFECTIVE = 'effective';
112 
114  private const CACHE_MEMBERSHIP = 'membership';
115 
117  private const CACHE_FORMER = 'former';
118 
130  private $userGroupCache = [];
131 
144 
157  public function __construct(
159  ConfiguredReadOnlyMode $configuredReadOnlyMode,
165  LoggerInterface $logger,
166  array $clearCacheCallbacks = [],
167  $dbDomain = false
168  ) {
169  $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
170  $this->options = $options;
171  $this->loadBalancerFactory = $loadBalancerFactory;
172  $this->loadBalancer = $loadBalancerFactory->getMainLB( $dbDomain );
173  $this->hookContainer = $hookContainer;
174  $this->hookRunner = new HookRunner( $hookContainer );
175  $this->userEditTracker = $userEditTracker;
176  $this->groupPermissionsLookup = $groupPermissionsLookup;
177  $this->jobQueueGroup = $jobQueueGroup;
178  $this->logger = $logger;
179  // Can't just inject ROM since we LB can be for foreign wiki
180  $this->readOnlyMode = new ReadOnlyMode( $configuredReadOnlyMode, $this->loadBalancer );
181  $this->clearCacheCallbacks = $clearCacheCallbacks;
182  $this->dbDomain = $dbDomain;
183  }
184 
191  public function listAllGroups(): array {
192  return array_values( array_diff(
193  array_merge(
194  array_keys( $this->options->get( 'GroupPermissions' ) ),
195  array_keys( $this->options->get( 'RevokePermissions' ) )
196  ),
197  $this->listAllImplicitGroups()
198  ) );
199  }
200 
205  public function listAllImplicitGroups(): array {
206  return $this->options->get( 'ImplicitGroups' );
207  }
208 
218  public function newGroupMembershipFromRow( \stdClass $row ): UserGroupMembership {
219  return new UserGroupMembership(
220  (int)$row->ug_user,
221  $row->ug_group,
222  $row->ug_expiry === null ? null : wfTimestamp(
223  TS_MW,
224  $row->ug_expiry
225  )
226  );
227  }
228 
237  UserIdentity $user,
238  array $userGroups,
239  int $queryFlags = self::READ_NORMAL
240  ) {
241  $membershipGroups = [];
242  reset( $userGroups );
243  foreach ( $userGroups as $row ) {
244  $ugm = $this->newGroupMembershipFromRow( $row );
245  $membershipGroups[ $ugm->getGroup() ] = $ugm;
246  }
247  $this->setCache(
248  $this->getCacheKey( $user ),
249  self::CACHE_MEMBERSHIP,
250  $membershipGroups,
251  $queryFlags
252  );
253  }
254 
266  public function getUserImplicitGroups(
267  UserIdentity $user,
268  int $queryFlags = self::READ_NORMAL,
269  bool $recache = false
270  ): array {
271  $userKey = $this->getCacheKey( $user );
272  if ( $recache ||
273  !isset( $this->userGroupCache[$userKey][self::CACHE_IMPLICIT] ) ||
274  !$this->canUseCachedValues( $user, self::CACHE_IMPLICIT, $queryFlags )
275  ) {
276  $groups = [ '*' ];
277  if ( $user->isRegistered() ) {
278  $groups[] = 'user';
279 
280  $groups = array_unique( array_merge(
281  $groups,
282  $this->getUserAutopromoteGroups( $user )
283  ) );
284  }
285  $this->setCache( $userKey, self::CACHE_IMPLICIT, $groups, $queryFlags );
286  if ( $recache ) {
287  // Assure data consistency with rights/groups,
288  // as getUserEffectiveGroups() depends on this function
289  $this->clearUserCacheForKind( $user, self::CACHE_EFFECTIVE );
290  }
291  }
292  return $this->userGroupCache[$userKey][self::CACHE_IMPLICIT];
293  }
294 
306  public function getUserEffectiveGroups(
307  UserIdentity $user,
308  int $queryFlags = self::READ_NORMAL,
309  bool $recache = false
310  ): array {
311  $userKey = $this->getCacheKey( $user );
312  // Ignore cache if the $recache flag is set, cached values can not be used
313  // or the cache value is missing
314  if ( $recache ||
315  !$this->canUseCachedValues( $user, self::CACHE_EFFECTIVE, $queryFlags ) ||
316  !isset( $this->userGroupCache[$userKey][self::CACHE_EFFECTIVE] )
317  ) {
318  $groups = array_unique( array_merge(
319  $this->getUserGroups( $user, $queryFlags ), // explicit groups
320  $this->getUserImplicitGroups( $user, $queryFlags, $recache ) // implicit groups
321  ) );
322  // TODO: Deprecate passing out user object in the hook by introducing
323  // an alternative hook
324  if ( $this->hookContainer->isRegistered( 'UserEffectiveGroups' ) ) {
325  $userObj = User::newFromIdentity( $user );
326  $userObj->load();
327  // Hook for additional groups
328  $this->hookRunner->onUserEffectiveGroups( $userObj, $groups );
329  }
330  // Force reindexation of groups when a hook has unset one of them
331  $effectiveGroups = array_values( array_unique( $groups ) );
332  $this->setCache( $userKey, self::CACHE_EFFECTIVE, $effectiveGroups, $queryFlags );
333  }
334  return $this->userGroupCache[$userKey][self::CACHE_EFFECTIVE];
335  }
336 
349  public function getUserFormerGroups(
350  UserIdentity $user,
351  int $queryFlags = self::READ_NORMAL
352  ): array {
353  $userKey = $this->getCacheKey( $user );
354 
355  if ( $this->canUseCachedValues( $user, self::CACHE_FORMER, $queryFlags ) &&
356  isset( $this->userGroupCache[$userKey][self::CACHE_FORMER] )
357  ) {
358  return $this->userGroupCache[$userKey][self::CACHE_FORMER];
359  }
360 
361  if ( !$user->isRegistered() ) {
362  // Anon users don't have groups stored in the database
363  return [];
364  }
365 
366  $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
367  $res = $db->select(
368  'user_former_groups',
369  [ 'ufg_group' ],
370  [ 'ufg_user' => $user->getId() ],
371  __METHOD__
372  );
373  $formerGroups = [];
374  foreach ( $res as $row ) {
375  $formerGroups[] = $row->ufg_group;
376  }
377  $this->setCache( $userKey, self::CACHE_FORMER, $formerGroups, $queryFlags );
378 
379  return $this->userGroupCache[$userKey][self::CACHE_FORMER];
380  }
381 
390  public function getUserAutopromoteGroups( UserIdentity $user ): array {
391  $promote = [];
392  // TODO: remove the need for the full user object
393  $userObj = User::newFromIdentity( $user );
394  foreach ( $this->options->get( 'Autopromote' ) as $group => $cond ) {
395  if ( $this->recCheckCondition( $cond, $userObj ) ) {
396  $promote[] = $group;
397  }
398  }
399 
400  $this->hookRunner->onGetAutoPromoteGroups( $userObj, $promote );
401  return $promote;
402  }
403 
417  UserIdentity $user,
418  string $event
419  ): array {
420  $autopromoteOnce = $this->options->get( 'AutopromoteOnce' );
421  $promote = [];
422 
423  if ( isset( $autopromoteOnce[$event] ) && count( $autopromoteOnce[$event] ) ) {
424  $currentGroups = $this->getUserGroups( $user );
425  $formerGroups = $this->getUserFormerGroups( $user );
426  // TODO: remove the need for the full user object
427  $userObj = User::newFromIdentity( $user );
428  foreach ( $autopromoteOnce[$event] as $group => $cond ) {
429  // Do not check if the user's already a member
430  if ( in_array( $group, $currentGroups ) ) {
431  continue;
432  }
433  // Do not autopromote if the user has belonged to the group
434  if ( in_array( $group, $formerGroups ) ) {
435  continue;
436  }
437  // Finally - check the conditions
438  if ( $this->recCheckCondition( $cond, $userObj ) ) {
439  $promote[] = $group;
440  }
441  }
442  }
443 
444  return $promote;
445  }
446 
463  private function recCheckCondition( $cond, User $user ): bool {
464  $validOps = [ '&', '|', '^', '!' ];
465 
466  if ( is_array( $cond ) && count( $cond ) >= 2 && in_array( $cond[0], $validOps ) ) {
467  // Recursive condition
468  if ( $cond[0] == '&' ) { // AND (all conds pass)
469  foreach ( array_slice( $cond, 1 ) as $subcond ) {
470  if ( !$this->recCheckCondition( $subcond, $user ) ) {
471  return false;
472  }
473  }
474 
475  return true;
476  } elseif ( $cond[0] == '|' ) { // OR (at least one cond passes)
477  foreach ( array_slice( $cond, 1 ) as $subcond ) {
478  if ( $this->recCheckCondition( $subcond, $user ) ) {
479  return true;
480  }
481  }
482 
483  return false;
484  } elseif ( $cond[0] == '^' ) { // XOR (exactly one cond passes)
485  if ( count( $cond ) > 3 ) {
486  $this->logger->warning(
487  'recCheckCondition() given XOR ("^") condition on three or more conditions.' .
488  ' Check your $wgAutopromote and $wgAutopromoteOnce settings.'
489  );
490  }
491  return $this->recCheckCondition( $cond[1], $user )
492  xor $this->recCheckCondition( $cond[2], $user );
493  } elseif ( $cond[0] == '!' ) { // NOT (no conds pass)
494  foreach ( array_slice( $cond, 1 ) as $subcond ) {
495  if ( $this->recCheckCondition( $subcond, $user ) ) {
496  return false;
497  }
498  }
499 
500  return true;
501  }
502  }
503  // If we got here, the array presumably does not contain other conditions;
504  // it's not recursive. Pass it off to checkCondition.
505  if ( !is_array( $cond ) ) {
506  $cond = [ $cond ];
507  }
508 
509  return $this->checkCondition( $cond, $user );
510  }
511 
522  private function checkCondition( array $cond, User $user ): bool {
523  if ( count( $cond ) < 1 ) {
524  return false;
525  }
526 
527  switch ( $cond[0] ) {
529  if ( Sanitizer::validateEmail( $user->getEmail() ) ) {
530  if ( $this->options->get( 'EmailAuthentication' ) ) {
531  return (bool)$user->getEmailAuthenticationTimestamp();
532  } else {
533  return true;
534  }
535  }
536  return false;
537  case APCOND_EDITCOUNT:
538  $reqEditCount = $cond[1];
539 
540  // T157718: Avoid edit count lookup if specified edit count is 0 or invalid
541  if ( $reqEditCount <= 0 ) {
542  return true;
543  }
544  return $user->isRegistered() && $this->userEditTracker->getUserEditCount( $user ) >= $reqEditCount;
545  case APCOND_AGE:
546  $age = time() - (int)wfTimestampOrNull( TS_UNIX, $user->getRegistration() );
547  return $age >= $cond[1];
549  $age = time() - (int)wfTimestampOrNull(
550  TS_UNIX, $this->userEditTracker->getFirstEditTimestamp( $user ) );
551  return $age >= $cond[1];
552  case APCOND_INGROUPS:
553  $groups = array_slice( $cond, 1 );
554  return count( array_intersect( $groups, $this->getUserGroups( $user ) ) ) == count( $groups );
555  case APCOND_ISIP:
556  return $cond[1] == $user->getRequest()->getIP();
557  case APCOND_IPINRANGE:
558  return IPUtils::isInRange( $user->getRequest()->getIP(), $cond[1] );
559  case APCOND_BLOCKED:
560  // Because checking for ipblock-exempt leads back to here (thus infinite recursion),
561  // we stop checking for ipblock-exempt via here. We do this by setting the second
562  // param to true.
563  // See T270145.
564  $block = $user->getBlock( Authority::READ_LATEST, true );
565  return $block && $block->isSitewide();
566  case APCOND_ISBOT:
567  return in_array( 'bot', $this->groupPermissionsLookup
568  ->getGroupPermissions( $this->getUserGroups( $user ) ) );
569  default:
570  $result = null;
571  $this->hookRunner->onAutopromoteCondition( $cond[0],
572  array_slice( $cond, 1 ), $user, $result );
573  if ( $result === null ) {
574  throw new InvalidArgumentException(
575  "Unrecognized condition {$cond[0]} for autopromotion!"
576  );
577  }
578 
579  return (bool)$result;
580  }
581  }
582 
599  UserIdentity $user,
600  string $event
601  ): array {
602  Assert::precondition(
603  !$this->dbDomain || WikiMap::isCurrentWikiDbDomain( $this->dbDomain ),
604  __METHOD__ . " is not supported for foreign domains: {$this->dbDomain} used"
605  );
606 
607  if ( $this->readOnlyMode->isReadOnly() || !$user->getId() ) {
608  return [];
609  }
610 
611  $toPromote = $this->getUserAutopromoteOnceGroups( $user, $event );
612  if ( $toPromote === [] ) {
613  return [];
614  }
615 
616  $userObj = User::newFromIdentity( $user );
617  if ( !$userObj->checkAndSetTouched() ) {
618  return []; // raced out (bug T48834)
619  }
620 
621  $oldGroups = $this->getUserGroups( $user ); // previous groups
622  $oldUGMs = $this->getUserGroupMemberships( $user );
623  foreach ( $toPromote as $group ) {
624  $this->addUserToGroup( $user, $group );
625  }
626  $newGroups = array_merge( $oldGroups, $toPromote ); // all groups
627  $newUGMs = $this->getUserGroupMemberships( $user );
628 
629  // update groups in external authentication database
630  // TODO: deprecate passing full User object to hook
631  $this->hookRunner->onUserGroupsChanged(
632  $userObj,
633  $toPromote, [],
634  false,
635  false,
636  $oldUGMs,
637  $newUGMs
638  );
639 
640  $logEntry = new ManualLogEntry( 'rights', 'autopromote' );
641  $logEntry->setPerformer( $user );
642  $logEntry->setTarget( $userObj->getUserPage() );
643  $logEntry->setParameters( [
644  '4::oldgroups' => $oldGroups,
645  '5::newgroups' => $newGroups,
646  ] );
647  $logid = $logEntry->insert();
648  if ( $this->options->get( 'AutopromoteOnceLogInRC' ) ) {
649  $logEntry->publish( $logid );
650  }
651 
652  return $toPromote;
653  }
654 
663  public function getUserGroups(
664  UserIdentity $user,
665  int $queryFlags = self::READ_NORMAL
666  ): array {
667  return array_keys( $this->getUserGroupMemberships( $user, $queryFlags ) );
668  }
669 
678  public function getUserGroupMemberships(
679  UserIdentity $user,
680  int $queryFlags = self::READ_NORMAL
681  ): array {
682  $userKey = $this->getCacheKey( $user );
683 
684  if ( $this->canUseCachedValues( $user, self::CACHE_MEMBERSHIP, $queryFlags ) &&
685  isset( $this->userGroupCache[$userKey][self::CACHE_MEMBERSHIP] )
686  ) {
688  return $this->userGroupCache[$userKey][self::CACHE_MEMBERSHIP];
689  }
690 
691  if ( !$user->isRegistered() ) {
692  // Anon users don't have groups stored in the database
693  return [];
694  }
695 
696  $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
697  $queryInfo = $this->getQueryInfo();
698  $res = $db->select(
699  $queryInfo['tables'],
700  $queryInfo['fields'],
701  [ 'ug_user' => $user->getId() ],
702  __METHOD__,
703  [],
704  $queryInfo['joins']
705  );
706 
707  $ugms = [];
708  foreach ( $res as $row ) {
709  $ugm = $this->newGroupMembershipFromRow( $row );
710  if ( !$ugm->isExpired() ) {
711  $ugms[$ugm->getGroup()] = $ugm;
712  }
713  }
714  ksort( $ugms );
715 
716  $this->setCache( $userKey, self::CACHE_MEMBERSHIP, $ugms, $queryFlags );
717 
718  return $ugms;
719  }
720 
736  public function addUserToGroup(
737  UserIdentity $user,
738  string $group,
739  string $expiry = null,
740  bool $allowUpdate = false
741  ): bool {
742  if ( $this->readOnlyMode->isReadOnly() ) {
743  return false;
744  }
745 
746  if ( !$user->isRegistered() ) {
747  throw new InvalidArgumentException(
748  'UserGroupManager::addUserToGroup() needs a positive user ID. ' .
749  'Perhaps addUserToGroup() was called before the user was added to the database.'
750  );
751  }
752 
753  if ( $expiry ) {
754  $expiry = wfTimestamp( TS_MW, $expiry );
755  }
756 
757  // TODO: Deprecate passing out user object in the hook by introducing
758  // an alternative hook
759  if ( $this->hookContainer->isRegistered( 'UserAddGroup' ) ) {
760  $userObj = User::newFromIdentity( $user );
761  $userObj->load();
762  if ( !$this->hookRunner->onUserAddGroup( $userObj, $group, $expiry ) ) {
763  return false;
764  }
765  }
766 
767  $oldUgms = $this->getUserGroupMemberships( $user, self::READ_LATEST );
768  $dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY, [], $this->dbDomain );
769 
770  $dbw->startAtomic( __METHOD__ );
771  $dbw->insert(
772  'user_groups',
773  [
774  'ug_user' => $user->getId(),
775  'ug_group' => $group,
776  'ug_expiry' => $expiry ? $dbw->timestamp( $expiry ) : null,
777  ],
778  __METHOD__,
779  [ 'IGNORE' ]
780  );
781 
782  $affected = $dbw->affectedRows();
783  if ( !$affected ) {
784  // Conflicting row already exists; it should be overridden if it is either expired
785  // or if $allowUpdate is true and the current row is different than the loaded row.
786  $conds = [
787  'ug_user' => $user->getId(),
788  'ug_group' => $group
789  ];
790  if ( $allowUpdate ) {
791  // Update the current row if its expiry does not match that of the loaded row
792  $conds[] = $expiry
793  ? "ug_expiry IS NULL OR ug_expiry != {$dbw->addQuotes( $dbw->timestamp( $expiry ) )}"
794  : 'ug_expiry IS NOT NULL';
795  } else {
796  // Update the current row if it is expired
797  $conds[] = "ug_expiry < {$dbw->addQuotes( $dbw->timestamp() )}";
798  }
799  $dbw->update(
800  'user_groups',
801  [ 'ug_expiry' => $expiry ? $dbw->timestamp( $expiry ) : null ],
802  $conds,
803  __METHOD__
804  );
805  $affected = $dbw->affectedRows();
806  }
807  $dbw->endAtomic( __METHOD__ );
808 
809  // Purge old, expired memberships from the DB
810  $fname = __METHOD__;
811  DeferredUpdates::addCallableUpdate( function () use ( $fname ) {
812  $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
813  $hasExpiredRow = $dbr->selectField(
814  'user_groups',
815  '1',
816  [ "ug_expiry < {$dbr->addQuotes( $dbr->timestamp() )}" ],
817  $fname
818  );
819  if ( $hasExpiredRow ) {
820  $this->jobQueueGroup->push( new UserGroupExpiryJob( [] ) );
821  }
822  } );
823 
824  if ( $affected > 0 ) {
825  $oldUgms[$group] = new UserGroupMembership( $user->getId(), $group, $expiry );
826  if ( !$oldUgms[$group]->isExpired() ) {
827  $this->setCache(
828  $this->getCacheKey( $user ),
829  self::CACHE_MEMBERSHIP,
830  $oldUgms,
831  self::READ_LATEST
832  );
833  $this->clearUserCacheForKind( $user, self::CACHE_EFFECTIVE );
834  }
835  foreach ( $this->clearCacheCallbacks as $callback ) {
836  $callback( $user );
837  }
838  return true;
839  }
840  return false;
841  }
842 
851  public function removeUserFromGroup( UserIdentity $user, string $group ): bool {
852  // TODO: Deprecate passing out user object in the hook by introducing
853  // an alternative hook
854  if ( $this->hookContainer->isRegistered( 'UserRemoveGroup' ) ) {
855  $userObj = User::newFromIdentity( $user );
856  $userObj->load();
857  if ( !$this->hookRunner->onUserRemoveGroup( $userObj, $group ) ) {
858  return false;
859  }
860  }
861 
862  if ( $this->readOnlyMode->isReadOnly() ) {
863  return false;
864  }
865 
866  if ( !$user->isRegistered() ) {
867  throw new InvalidArgumentException(
868  'UserGroupManager::removeUserFromGroup() needs a positive user ID. ' .
869  'Perhaps removeUserFromGroup() was called before the user was added to the database.'
870  );
871  }
872 
873  $oldUgms = $this->getUserGroupMemberships( $user, self::READ_LATEST );
874  $oldFormerGroups = $this->getUserFormerGroups( $user, self::READ_LATEST );
875  $dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY, [], $this->dbDomain );
876  $dbw->delete(
877  'user_groups',
878  [ 'ug_user' => $user->getId(), 'ug_group' => $group ],
879  __METHOD__
880  );
881 
882  if ( !$dbw->affectedRows() ) {
883  return false;
884  }
885  // Remember that the user was in this group
886  $dbw->insert(
887  'user_former_groups',
888  [ 'ufg_user' => $user->getId(), 'ufg_group' => $group ],
889  __METHOD__,
890  [ 'IGNORE' ]
891  );
892 
893  unset( $oldUgms[$group] );
894  $userKey = $this->getCacheKey( $user );
895  $this->setCache( $userKey, self::CACHE_MEMBERSHIP, $oldUgms, self::READ_LATEST );
896  $oldFormerGroups[] = $group;
897  $this->setCache( $userKey, self::CACHE_FORMER, $oldFormerGroups, self::READ_LATEST );
898  $this->clearUserCacheForKind( $user, self::CACHE_EFFECTIVE );
899  foreach ( $this->clearCacheCallbacks as $callback ) {
900  $callback( $user );
901  }
902  return true;
903  }
904 
916  public function getQueryInfo(): array {
917  return [
918  'tables' => [ 'user_groups' ],
919  'fields' => [
920  'ug_user',
921  'ug_group',
922  'ug_expiry',
923  ],
924  'joins' => []
925  ];
926  }
927 
935  public function purgeExpired() {
936  if ( $this->readOnlyMode->isReadOnly() ) {
937  return false;
938  }
939 
940  $ticket = $this->loadBalancerFactory->getEmptyTransactionTicket( __METHOD__ );
941  $dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY );
942 
943  $lockKey = "{$dbw->getDomainID()}:UserGroupManager:purge"; // per-wiki
944  $scopedLock = $dbw->getScopedLockAndFlush( $lockKey, __METHOD__, 0 );
945  if ( !$scopedLock ) {
946  return false; // already running
947  }
948 
949  $now = time();
950  $purgedRows = 0;
951  $queryInfo = $this->getQueryInfo();
952  do {
953  $dbw->startAtomic( __METHOD__ );
954 
955  $res = $dbw->select(
956  $queryInfo['tables'],
957  $queryInfo['fields'],
958  [ 'ug_expiry < ' . $dbw->addQuotes( $dbw->timestamp( $now ) ) ],
959  __METHOD__,
960  [ 'FOR UPDATE', 'LIMIT' => 100 ],
961  $queryInfo['joins']
962  );
963 
964  if ( $res->numRows() > 0 ) {
965  $insertData = []; // array of users/groups to insert to user_former_groups
966  $deleteCond = []; // array for deleting the rows that are to be moved around
967  foreach ( $res as $row ) {
968  $insertData[] = [ 'ufg_user' => $row->ug_user, 'ufg_group' => $row->ug_group ];
969  $deleteCond[] = $dbw->makeList(
970  [ 'ug_user' => $row->ug_user, 'ug_group' => $row->ug_group ],
972  );
973  }
974  // Delete the rows we're about to move
975  $dbw->delete(
976  'user_groups',
977  $dbw->makeList( $deleteCond, $dbw::LIST_OR ),
978  __METHOD__
979  );
980  // Push the groups to user_former_groups
981  $dbw->insert(
982  'user_former_groups',
983  $insertData,
984  __METHOD__,
985  [ 'IGNORE' ]
986  );
987  // Count how many rows were purged
988  $purgedRows += $res->numRows();
989  }
990 
991  $dbw->endAtomic( __METHOD__ );
992 
993  $this->loadBalancerFactory->commitAndWaitForReplication( __METHOD__, $ticket );
994  } while ( $res->numRows() > 0 );
995  return $purgedRows;
996  }
997 
1003  private function expandChangeableGroupConfig( array $config, string $group ): array {
1004  if ( empty( $config[$group] ) ) {
1005  return [];
1006  } elseif ( $config[$group] === true ) {
1007  // You get everything
1008  return $this->listAllGroups();
1009  } elseif ( is_array( $config[$group] ) ) {
1010  return $config[$group];
1011  }
1012  return [];
1013  }
1014 
1026  public function getGroupsChangeableByGroup( string $group ): array {
1027  return [
1028  'add' => $this->expandChangeableGroupConfig(
1029  $this->options->get( 'AddGroups' ), $group
1030  ),
1031  'remove' => $this->expandChangeableGroupConfig(
1032  $this->options->get( 'RemoveGroups' ), $group
1033  ),
1034  'add-self' => $this->expandChangeableGroupConfig(
1035  $this->options->get( 'GroupsAddToSelf' ), $group
1036  ),
1037  'remove-self' => $this->expandChangeableGroupConfig(
1038  $this->options->get( 'GroupsRemoveFromSelf' ), $group
1039  ),
1040  ];
1041  }
1042 
1055  public function getGroupsChangeableBy( Authority $authority ): array {
1056  if ( $authority->isAllowed( 'userrights' ) ) {
1057  // This group gives the right to modify everything (reverse-
1058  // compatibility with old "userrights lets you change
1059  // everything")
1060  // Using array_merge to make the groups reindexed
1061  $all = array_merge( $this->listAllGroups() );
1062  return [
1063  'add' => $all,
1064  'remove' => $all,
1065  'add-self' => [],
1066  'remove-self' => []
1067  ];
1068  }
1069 
1070  // Okay, it's not so simple, we will have to go through the arrays
1071  $groups = [
1072  'add' => [],
1073  'remove' => [],
1074  'add-self' => [],
1075  'remove-self' => []
1076  ];
1077  $actorGroups = $this->getUserEffectiveGroups( $authority->getUser() );
1078 
1079  foreach ( $actorGroups as $actorGroup ) {
1080  $groups = array_merge_recursive(
1081  $groups, $this->getGroupsChangeableByGroup( $actorGroup )
1082  );
1083  $groups['add'] = array_unique( $groups['add'] );
1084  $groups['remove'] = array_unique( $groups['remove'] );
1085  $groups['add-self'] = array_unique( $groups['add-self'] );
1086  $groups['remove-self'] = array_unique( $groups['remove-self'] );
1087  }
1088  return $groups;
1089  }
1090 
1096  public function clearCache( UserIdentity $user ) {
1097  $userKey = $this->getCacheKey( $user );
1098  unset( $this->userGroupCache[$userKey] );
1099  unset( $this->queryFlagsUsedForCaching[$userKey] );
1100  }
1101 
1110  private function setCache(
1111  string $userKey,
1112  string $cacheKind,
1113  array $groupValue,
1114  int $queryFlags
1115  ) {
1116  $this->userGroupCache[$userKey][$cacheKind] = $groupValue;
1117  $this->queryFlagsUsedForCaching[$userKey][$cacheKind] = $queryFlags;
1118  }
1119 
1126  private function clearUserCacheForKind( UserIdentity $user, string $cacheKind ) {
1127  $userKey = $this->getCacheKey( $user );
1128  unset( $this->userGroupCache[$userKey][$cacheKind] );
1129  unset( $this->queryFlagsUsedForCaching[$userKey][$cacheKind] );
1130  }
1131 
1136  private function getDBConnectionRefForQueryFlags( int $queryFlags ): DBConnRef {
1137  list( $mode, ) = DBAccessObjectUtils::getDBOptions( $queryFlags );
1138  return $this->loadBalancer->getConnectionRef( $mode, [], $this->dbDomain );
1139  }
1140 
1146  private function getCacheKey( UserIdentity $user ): string {
1147  return $user->isRegistered() ? "u:{$user->getId()}" : "anon:{$user->getName()}";
1148  }
1149 
1157  private function canUseCachedValues(
1158  UserIdentity $user,
1159  string $cacheKind,
1160  int $queryFlags
1161  ): bool {
1162  if ( !$user->isRegistered() ) {
1163  // Anon users don't have groups stored in the database,
1164  // so $queryFlags are ignored.
1165  return true;
1166  }
1167  if ( $queryFlags >= self::READ_LOCKING ) {
1168  return false;
1169  }
1170  $userKey = $this->getCacheKey( $user );
1171  $queryFlagsUsed = $this->queryFlagsUsedForCaching[$userKey][$cacheKind] ?? self::READ_NONE;
1172  return $queryFlagsUsed >= $queryFlags;
1173  }
1174 }
WikiMap\isCurrentWikiDbDomain
static isCurrentWikiDbDomain( $domain)
Definition: WikiMap.php:312
LIST_OR
const LIST_OR
Definition: Defines.php:46
APCOND_AGE
const APCOND_AGE
Definition: Defines.php:179
APCOND_ISBOT
const APCOND_ISBOT
Definition: Defines.php:186
MediaWiki\User\UserGroupManager\checkCondition
checkCondition(array $cond, User $user)
As recCheckCondition, but not recursive.
Definition: UserGroupManager.php:522
MediaWiki\User\UserGroupManager\clearCache
clearCache(UserIdentity $user)
Cleans cached group memberships for a given user.
Definition: UserGroupManager.php:1096
MediaWiki\User\UserGroupManager\removeUserFromGroup
removeUserFromGroup(UserIdentity $user, string $group)
Remove the user from the given group.
Definition: UserGroupManager.php:851
MediaWiki\User\UserGroupManager\CACHE_FORMER
const CACHE_FORMER
string key for former groups cache
Definition: UserGroupManager.php:117
MediaWiki\User\UserGroupManager\$clearCacheCallbacks
callable[] $clearCacheCallbacks
Definition: UserGroupManager.php:102
MediaWiki\User\UserGroupManager\getCacheKey
getCacheKey(UserIdentity $user)
Gets a unique key for various caches.
Definition: UserGroupManager.php:1146
User\isRegistered
isRegistered()
Get whether the user is registered.
Definition: User.php:2958
MediaWiki\User\UserGroupManager\$userGroupCache
array $userGroupCache
Service caches, an assoc.
Definition: UserGroupManager.php:130
APCOND_INGROUPS
const APCOND_INGROUPS
Definition: Defines.php:181
MediaWiki\Permissions\GroupPermissionsLookup
Definition: GroupPermissionsLookup.php:30
MediaWiki\User\UserGroupManager\$options
ServiceOptions $options
Definition: UserGroupManager.php:72
MediaWiki\User\UserGroupManager\getUserEffectiveGroups
getUserEffectiveGroups(UserIdentity $user, int $queryFlags=self::READ_NORMAL, bool $recache=false)
Get the list of implicit group memberships the user has.
Definition: UserGroupManager.php:306
MediaWiki\User\UserGroupManager\$jobQueueGroup
JobQueueGroup $jobQueueGroup
for this $dbDomain
Definition: UserGroupManager.php:96
wfTimestamp
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
Definition: GlobalFunctions.php:1692
MediaWiki\User\UserGroupManager\__construct
__construct(ServiceOptions $options, ConfiguredReadOnlyMode $configuredReadOnlyMode, ILBFactory $loadBalancerFactory, HookContainer $hookContainer, UserEditTracker $userEditTracker, GroupPermissionsLookup $groupPermissionsLookup, JobQueueGroup $jobQueueGroup, LoggerInterface $logger, array $clearCacheCallbacks=[], $dbDomain=false)
Definition: UserGroupManager.php:157
LIST_AND
const LIST_AND
Definition: Defines.php:43
ReadOnlyMode
A service class for fetching the wiki's current read-only mode.
Definition: ReadOnlyMode.php:11
MediaWiki\User\UserGroupManager\getUserFormerGroups
getUserFormerGroups(UserIdentity $user, int $queryFlags=self::READ_NORMAL)
Returns the groups the user has belonged to.
Definition: UserGroupManager.php:349
ConfiguredReadOnlyMode
A read-only mode service which does not depend on LoadBalancer.
Definition: ConfiguredReadOnlyMode.php:9
Sanitizer\validateEmail
static validateEmail( $addr)
Does a string look like an e-mail address?
Definition: Sanitizer.php:1713
MediaWiki\User\UserGroupManager\$logger
LoggerInterface $logger
Definition: UserGroupManager.php:99
MediaWiki\User\UserGroupManager\purgeExpired
purgeExpired()
Purge expired memberships from the user_groups table.
Definition: UserGroupManager.php:935
User\newFromIdentity
static newFromIdentity(UserIdentity $identity)
Returns a User object corresponding to the given UserIdentity.
Definition: User.php:679
MediaWiki\User\UserIdentity\getId
getId( $wikiId=self::LOCAL)
DBAccessObjectUtils\getDBOptions
static getDBOptions( $bitfield)
Get an appropriate DB index, options, and fallback DB index for a query.
Definition: DBAccessObjectUtils.php:52
Wikimedia\Rdbms\ILBFactory\getMainLB
getMainLB( $domain=false)
Get the tracked load balancer instance for a main cluster.
User\getEmailAuthenticationTimestamp
getEmailAuthenticationTimestamp()
Get the timestamp of the user's e-mail authentication.
Definition: User.php:2441
$res
$res
Definition: testCompression.php:57
IDBAccessObject
Interface for database access objects.
Definition: IDBAccessObject.php:57
MediaWiki\User\UserGroupManager\CONSTRUCTOR_OPTIONS
const CONSTRUCTOR_OPTIONS
Definition: UserGroupManager.php:57
User\getBlock
getBlock( $freshness=self::READ_NORMAL, $disableIpBlockExemptChecking=false)
Get the block affecting the user, or null if the user is not blocked.
Definition: User.php:1936
MediaWiki\Permissions\Authority\getUser
getUser()
Returns the performer of the actions associated with this authority.
MediaWiki\User\UserIdentity
Interface for objects representing user identity.
Definition: UserIdentity.php:39
User\getRequest
getRequest()
Get the WebRequest object to use with this object.
Definition: User.php:3067
MediaWiki\User\UserGroupManager\CACHE_IMPLICIT
const CACHE_IMPLICIT
string key for implicit groups cache
Definition: UserGroupManager.php:108
$dbr
$dbr
Definition: testCompression.php:54
MediaWiki\User\UserGroupManager\getUserAutopromoteOnceGroups
getUserAutopromoteOnceGroups(UserIdentity $user, string $event)
Get the groups for the given user based on the given criteria.
Definition: UserGroupManager.php:416
MediaWiki\User\UserGroupManager\$dbDomain
string false $dbDomain
Definition: UserGroupManager.php:105
User\getEmail
getEmail()
Get the user's e-mail address.
Definition: User.php:2428
MediaWiki\User\UserGroupManager
Managers user groups.
Definition: UserGroupManager.php:52
MediaWiki\User\UserGroupManager\$userEditTracker
UserEditTracker $userEditTracker
Definition: UserGroupManager.php:90
MediaWiki\User\UserGroupManager\$hookRunner
HookRunner $hookRunner
Definition: UserGroupManager.php:84
MediaWiki\User\UserGroupManager\addUserToAutopromoteOnceGroups
addUserToAutopromoteOnceGroups(UserIdentity $user, string $event)
Add the user to the group if he/she meets given criteria.
Definition: UserGroupManager.php:598
MediaWiki\Config\ServiceOptions
A class for passing options to services.
Definition: ServiceOptions.php:27
MediaWiki\User\UserGroupManager\addUserToGroup
addUserToGroup(UserIdentity $user, string $group, string $expiry=null, bool $allowUpdate=false)
Add the user to the given group.
Definition: UserGroupManager.php:736
MediaWiki\User\UserIdentity\isRegistered
isRegistered()
MediaWiki\User\UserGroupManager\$hookContainer
HookContainer $hookContainer
Definition: UserGroupManager.php:81
MediaWiki\User\UserGroupManager\listAllImplicitGroups
listAllImplicitGroups()
Get a list of all configured implicit groups.
Definition: UserGroupManager.php:205
MediaWiki\User\UserGroupManager\getUserGroupMemberships
getUserGroupMemberships(UserIdentity $user, int $queryFlags=self::READ_NORMAL)
Loads and returns UserGroupMembership objects for all the groups a user currently belongs to.
Definition: UserGroupManager.php:678
DeferredUpdates
Class for managing the deferral of updates within the scope of a PHP script invocation.
Definition: DeferredUpdates.php:82
wfTimestampOrNull
wfTimestampOrNull( $outputtype=TS_UNIX, $ts=null)
Return a formatted timestamp, or null if input is null.
Definition: GlobalFunctions.php:1708
MediaWiki\User\UserGroupManager\setCache
setCache(string $userKey, string $cacheKind, array $groupValue, int $queryFlags)
Sets cached group memberships and query flags for a given user.
Definition: UserGroupManager.php:1110
MediaWiki\User\UserGroupManager\canUseCachedValues
canUseCachedValues(UserIdentity $user, string $cacheKind, int $queryFlags)
Determines if it's ok to use cached options values for a given user and query flags.
Definition: UserGroupManager.php:1157
APCOND_EDITCOUNT
const APCOND_EDITCOUNT
Definition: Defines.php:178
MediaWiki\User\UserGroupManager\getGroupsChangeableByGroup
getGroupsChangeableByGroup(string $group)
Returns an array of the groups that a particular group can add/remove.
Definition: UserGroupManager.php:1026
DB_REPLICA
const DB_REPLICA
Definition: defines.php:25
UserGroupExpiryJob
Definition: UserGroupExpiryJob.php:27
APCOND_EMAILCONFIRMED
const APCOND_EMAILCONFIRMED
Definition: Defines.php:180
IDBAccessObject\READ_NONE
const READ_NONE
Constants for object loading bitfield flags (higher => higher QoS)
Definition: IDBAccessObject.php:75
DBAccessObjectUtils
Helper class for DAO classes.
Definition: DBAccessObjectUtils.php:29
APCOND_ISIP
const APCOND_ISIP
Definition: Defines.php:182
MediaWiki\Permissions\Authority
This interface represents the authority associated the current execution context, such as a web reque...
Definition: Authority.php:37
MediaWiki\User\UserGroupManager\$readOnlyMode
ReadOnlyMode $readOnlyMode
Definition: UserGroupManager.php:87
MediaWiki\User\UserGroupManager\getUserImplicitGroups
getUserImplicitGroups(UserIdentity $user, int $queryFlags=self::READ_NORMAL, bool $recache=false)
Get the list of implicit group memberships this user has.
Definition: UserGroupManager.php:266
MediaWiki\User\UserGroupManager\newGroupMembershipFromRow
newGroupMembershipFromRow(\stdClass $row)
Creates a new UserGroupMembership instance from $row.
Definition: UserGroupManager.php:218
DB_PRIMARY
const DB_PRIMARY
Definition: defines.php:27
MediaWiki\User\UserGroupManager\expandChangeableGroupConfig
expandChangeableGroupConfig(array $config, string $group)
Definition: UserGroupManager.php:1003
MediaWiki\User
Definition: ActorCache.php:21
MediaWiki\User\UserGroupManager\getGroupsChangeableBy
getGroupsChangeableBy(Authority $authority)
Returns an array of groups that this $actor can add and remove.
Definition: UserGroupManager.php:1055
MediaWiki\User\UserGroupManager\$groupPermissionsLookup
GroupPermissionsLookup $groupPermissionsLookup
Definition: UserGroupManager.php:93
MediaWiki\User\UserGroupManager\getUserAutopromoteGroups
getUserAutopromoteGroups(UserIdentity $user)
Get the groups for the given user based on $wgAutopromote.
Definition: UserGroupManager.php:390
APCOND_IPINRANGE
const APCOND_IPINRANGE
Definition: Defines.php:183
APCOND_BLOCKED
const APCOND_BLOCKED
Definition: Defines.php:185
WikiMap
Helper tools for dealing with other locally-hosted wikis.
Definition: WikiMap.php:29
MediaWiki\User\UserGroupManager\getUserGroups
getUserGroups(UserIdentity $user, int $queryFlags=self::READ_NORMAL)
Get the list of explicit group memberships this user has.
Definition: UserGroupManager.php:663
Wikimedia\Rdbms\DBConnRef
Helper class used for automatically marking an IDatabase connection as reusable (once it no longer ma...
Definition: DBConnRef.php:29
User\getRegistration
getRegistration()
Get the timestamp of account creation.
Definition: User.php:4004
MediaWiki\User\UserGroupManager\getDBConnectionRefForQueryFlags
getDBConnectionRefForQueryFlags(int $queryFlags)
Definition: UserGroupManager.php:1136
MediaWiki\Permissions\Authority\isAllowed
isAllowed(string $permission)
Checks whether this authority has the given permission in general.
MediaWiki\User\UserEditTracker
Track info about user edit counts and timings.
Definition: UserEditTracker.php:21
MediaWiki\User\UserGroupManager\recCheckCondition
recCheckCondition( $cond, User $user)
Recursively check a condition.
Definition: UserGroupManager.php:463
MediaWiki\User\UserGroupManager\$queryFlagsUsedForCaching
array $queryFlagsUsedForCaching
An assoc.
Definition: UserGroupManager.php:143
MediaWiki\$config
Config $config
Definition: MediaWiki.php:42
APCOND_AGE_FROM_EDIT
const APCOND_AGE_FROM_EDIT
Definition: Defines.php:184
MediaWiki\User\UserGroupManager\CACHE_EFFECTIVE
const CACHE_EFFECTIVE
string key for effective groups cache
Definition: UserGroupManager.php:111
ManualLogEntry
Class for creating new log entries and inserting them into the database.
Definition: ManualLogEntry.php:44
MediaWiki\User\UserGroupManager\clearUserCacheForKind
clearUserCacheForKind(UserIdentity $user, string $cacheKind)
Clears a cached group membership and query key for a given user.
Definition: UserGroupManager.php:1126
MediaWiki\User\UserGroupManager\CACHE_MEMBERSHIP
const CACHE_MEMBERSHIP
string key for group memberships cache
Definition: UserGroupManager.php:114
MediaWiki\User\UserGroupManager\$loadBalancerFactory
ILBFactory $loadBalancerFactory
Definition: UserGroupManager.php:75
MediaWiki\HookContainer\HookContainer
HookContainer class.
Definition: HookContainer.php:45
MediaWiki\HookContainer\HookRunner
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Definition: HookRunner.php:554
MediaWiki\User\UserGroupManager\listAllGroups
listAllGroups()
Return the set of defined explicit groups.
Definition: UserGroupManager.php:191
MediaWiki\User\UserGroupManager\loadGroupMembershipsFromArray
loadGroupMembershipsFromArray(UserIdentity $user, array $userGroups, int $queryFlags=self::READ_NORMAL)
Load the user groups cache from the provided user groups data.
Definition: UserGroupManager.php:236
User
The User object encapsulates all of the user-specific settings (user_id, name, rights,...
Definition: User.php:68
DeferredUpdates\addCallableUpdate
static addCallableUpdate( $callable, $stage=self::POSTSEND, $dbw=null)
Add an update to the pending update queue that invokes the specified callback when run.
Definition: DeferredUpdates.php:145
MediaWiki\User\UserGroupManager\getQueryInfo
getQueryInfo()
Return the tables and fields to be selected to construct new UserGroupMembership object using newGrou...
Definition: UserGroupManager.php:916
Sanitizer
HTML sanitizer for MediaWiki.
Definition: Sanitizer.php:34
Wikimedia\Rdbms\ILoadBalancer
Database cluster connection, tracking, load balancing, and transaction manager interface.
Definition: ILoadBalancer.php:81
UserGroupMembership
Represents a "user group membership" – a specific instance of a user belonging to a group.
Definition: UserGroupMembership.php:36
MediaWiki\Config\ServiceOptions\assertRequiredOptions
assertRequiredOptions(array $expectedKeys)
Assert that the list of options provided in this instance exactly match $expectedKeys,...
Definition: ServiceOptions.php:71
Wikimedia\Rdbms\ILBFactory
An interface for generating database load balancers.
Definition: ILBFactory.php:33
JobQueueGroup
Class to handle enqueueing of background jobs.
Definition: JobQueueGroup.php:32
MediaWiki\User\UserGroupManager\$loadBalancer
ILoadBalancer $loadBalancer
Definition: UserGroupManager.php:78