diff --git a/core/userrights.class.inc.php b/core/userrights.class.inc.php
index ff88fbf638..054abb361c 100644
--- a/core/userrights.class.inc.php
+++ b/core/userrights.class.inc.php
@@ -1541,8 +1541,8 @@ class UserRights
*/
public static function IsActionAllowed($sClass, $iActionCode, $oInstanceSet = null, $oUser = null)
{
- // When initializing, we need to let everything pass trough
- if (!self::CheckLogin()) {
+ // When initializing, we need to let everything pass through
+ if (is_null($oUser) && !self::CheckLogin()) {
return UR_ALLOWED_YES;
}
diff --git a/dictionaries/en.dictionary.itop.core.php b/dictionaries/en.dictionary.itop.core.php
index 2ab22beee6..802247a632 100644
--- a/dictionaries/en.dictionary.itop.core.php
+++ b/dictionaries/en.dictionary.itop.core.php
@@ -216,6 +216,14 @@ Operators:
'Core:Context=GUI:Console' => 'Console',
'Core:Context=CRON' => 'cron',
'Core:Context=GUI:Portal' => 'Portal',
+
+ 'Core:GetQuota:Error' => 'Error while getting %1$s quota',
+ 'Core:ConsoleUsers' => 'console users',
+ 'Core:DisabledUsers' => 'disabled users',
+ 'Core:PortalUsers' => 'portal users',
+ 'Core:BusinessPartnerUser' => 'business partner users',
+ 'Core:ReadOnlyUsers' => 'read-only users',
+ 'Core:ApplicationUsers' => 'application users',
]);
//////////////////////////////////////////////////////////////////////
diff --git a/dictionaries/fr.dictionary.itop.core.php b/dictionaries/fr.dictionary.itop.core.php
index f4803cedfb..e7349302f3 100644
--- a/dictionaries/fr.dictionary.itop.core.php
+++ b/dictionaries/fr.dictionary.itop.core.php
@@ -161,6 +161,14 @@ Opérateurs :
'Core:Context=CRON+' => 'cron',
'Core:Context=GUI:Portal' => 'Portal',
'Core:Context=GUI:Portal+' => 'GUI:Portal',
+
+ 'Core:GetQuota:Error' => 'Erreur lors de la récupération du quota des %1$s',
+ 'Core:ConsoleUsers' => 'utilisateurs console',
+ 'Core:DisabledUsers' => 'utilisateurs désactivés',
+ 'Core:PortalUsers' => 'utilisateurs du portail',
+ 'Core:BusinessPartnerUser' => 'utilisateurs partenaires business',
+ 'Core:ReadOnlyUsers' => 'utilisateurs en lecture seule',
+ 'Core:ApplicationUsers' => 'utilisateurs applicatifs',
]);
//////////////////////////////////////////////////////////////////////
diff --git a/lib/composer/autoload_classmap.php b/lib/composer/autoload_classmap.php
index a51c3ba362..323cd9b46d 100644
--- a/lib/composer/autoload_classmap.php
+++ b/lib/composer/autoload_classmap.php
@@ -651,6 +651,7 @@ return array(
'Combodo\\iTop\\SessionTracker\\SessionGC' => $baseDir . '/sources/SessionTracker/SessionGC.php',
'Combodo\\iTop\\SessionTracker\\SessionHandler' => $baseDir . '/sources/SessionTracker/SessionHandler.php',
'Combodo\\iTop\\SessionTracker\\iSessionHandlerExtension' => $baseDir . '/sources/SessionTracker/iSessionHandlerExtension.php',
+ 'Combodo\\iTop\\Users\\ITopUserQuotaRepository' => $baseDir . '/sources/Users/ITopUserQuotaRepository.php',
'CompileCSSService' => $baseDir . '/application/compilecssservice.class.inc.php',
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
'Config' => $baseDir . '/core/config.class.inc.php',
diff --git a/lib/composer/autoload_static.php b/lib/composer/autoload_static.php
index db32c807e5..b1b5060675 100644
--- a/lib/composer/autoload_static.php
+++ b/lib/composer/autoload_static.php
@@ -1052,6 +1052,7 @@ class ComposerStaticInitfc0e9e9dea11dcbb6272414776c30685
'Combodo\\iTop\\SessionTracker\\SessionGC' => __DIR__ . '/../..' . '/sources/SessionTracker/SessionGC.php',
'Combodo\\iTop\\SessionTracker\\SessionHandler' => __DIR__ . '/../..' . '/sources/SessionTracker/SessionHandler.php',
'Combodo\\iTop\\SessionTracker\\iSessionHandlerExtension' => __DIR__ . '/../..' . '/sources/SessionTracker/iSessionHandlerExtension.php',
+ 'Combodo\\iTop\\Users\\ITopUserQuotaRepository' => __DIR__ . '/../..' . '/sources/Users/ITopUserQuotaRepository.php',
'CompileCSSService' => __DIR__ . '/../..' . '/application/compilecssservice.class.inc.php',
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
'Config' => __DIR__ . '/../..' . '/core/config.class.inc.php',
diff --git a/sources/Users/ITopUserQuotaRepository.php b/sources/Users/ITopUserQuotaRepository.php
new file mode 100644
index 0000000000..3736f48bf6
--- /dev/null
+++ b/sources/Users/ITopUserQuotaRepository.php
@@ -0,0 +1,221 @@
+getMessage());
+ throw new Exception(Dict::Format('Core:GetQuota:Error', Dict::S('Core:ConsoleUsers')));
+ }
+
+ // TODO remove read only users
+ return $oFilter;
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function GetApplicationUsers(bool $bAllData = true): null|DBObjectSearch|DBUnionSearch
+ {
+ $sOQLApplicationUser = 'SELECT UserToken';
+ try {
+ $oFilter = $bAllData ? DBObjectSearch::FromOQL_AllData($sOQLApplicationUser) : DBObjectSearch::FromOQL($sOQLApplicationUser);
+ }
+ catch (Exception $e) {
+ IssueLog::Error('Core:GetConsoleUsersQuota:Error : '.$e->getMessage());
+ throw new Exception(Dict::Format('Core:GetQuota:Error', Dict::S('Core:ApplicationUsers')));
+ }
+
+ return $oFilter;
+
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function GetDisabledUsers(bool $bAllData = true): null|DBObjectSearch|DBUnionSearch
+ {
+ $sOQLDisabledUser = "
+ SELECT User AS u
+ WHERE u.status = 'disabled'
+ ";
+ try {
+ $oFilter = $bAllData ? DBObjectSearch::FromOQL_AllData($sOQLDisabledUser) : DBObjectSearch::FromOQL($sOQLDisabledUser);
+ }
+ catch (Exception $e) {
+ IssueLog::Error('Core:GetDisabledUsersQuota:Error : '.$e->getMessage());
+ throw new Exception(Dict::Format('Core:GetQuota:Error', Dict::S('Core:DisabledUsers')));
+ }
+
+ return $oFilter;
+ }
+
+private function IsUserReadOnly(User $oUser, string $sClassCategory)
+{
+ UserRights::Login($oUser->GetName());
+
+ foreach (MetaModel::GetClasses($sClassCategory) as $sClass) {
+ $aClassStimuli = MetaModel::EnumStimuli($sClass);
+ if (count($aClassStimuli) > 0) {
+ $aStimuli = [];
+ foreach ($aClassStimuli as $sStimulusCode => $oStimulus) {
+ if (UserRights::IsStimulusAllowed($sClass, $sStimulusCode, null, $oUser)) {
+ $aStimuli[] =
+ $oStimulus->GetLabel();
+ }
+ }
+ $sStimuli = implode(', ', $aStimuli);
+ } else {
+ $sStimuli = '';
+ }
+
+ if (
+ UserRights::IsActionAllowed($sClass, UR_ACTION_MODIFY, null, $oUser) ||
+ UserRights::IsActionAllowed($sClass, UR_ACTION_BULK_MODIFY, null, $oUser) ||
+ UserRights::IsActionAllowed($sClass, UR_ACTION_DELETE, null, $oUser) ||
+ UserRights::IsActionAllowed($sClass, UR_ACTION_BULK_DELETE, null, $oUser) ||
+ $sStimuli != ''
+ ) {
+ UserRights::Logoff();
+ return false;
+ }
+ }
+ UserRights::Logoff();
+ return true;
+}
+
+ /**
+ * @throws DictExceptionMissingString
+ * @throws CoreException
+ * @throws Exception
+ */
+ public function GetReadOnlyUsers(): array
+ {
+ $aReadOnlyUsers = [];
+ $oAllUsersFilter = $this->GetAllUsers();
+ $aAllUsers = $this->GetUsersFromFilter($oAllUsersFilter);
+ /** @var User $oUser */
+ foreach ($aAllUsers as $oUser) {
+ $bIsReadOnlyUser = true;
+
+ if (!$this->IsUserReadOnly($oUser, 'bizmodel') ||
+ !$this->IsUserReadOnly($oUser, 'grant_by_profile')) {
+ $bIsReadOnlyUser = false;
+ }
+
+ if ($bIsReadOnlyUser) {
+ $aReadOnlyUsers[] = $oUser;
+ }
+
+ }
+
+ // TODO remove disabled users
+ return $aReadOnlyUsers;
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function getPortalUsers(bool $bAllData = true): null|DBObjectSearch|DBUnionSearch
+ {
+ $sOQLPortalUser = '
+ SELECT User AS u
+ JOIN URP_UserProfile AS uup ON uup.userid = u.id
+ JOIN URP_Profiles AS up ON uup.profileid = up.id
+ WHERE up.name = \' '.PORTAL_PROFILE_NAME.'\'';
+
+ try {
+ $oFilter = $bAllData ? DBObjectSearch::FromOQL_AllData($sOQLPortalUser) : DBObjectSearch::FromOQL($sOQLPortalUser);
+ }
+ catch (Exception $e) {
+ IssueLog::Error('combodo-users-quota-slave/GetUsersInQuota : '.$e->getMessage(), 'combodo-users-quota');
+ throw new Exception(Dict::Format('Core:GetQuota:Error', Dict::S('Core:PortalUsers')));
+ }
+
+ // TODO remove read only users
+ return $oFilter;
+ }
+
+ /**
+ * @throws CoreUnexpectedValue
+ * @throws CoreException
+ * @throws MySQLException
+ */
+ public function GetUsersFromFilter(DBObjectSearch|DBUnionSearch|null $oFilter, array $aOrderBy = [], array $aArgs = []): array
+ {
+ $aUsers = [];
+ if (is_null($oFilter)) {
+ return $aUsers;
+ }
+ $oSet = new DBObjectSet($oFilter, $aOrderBy, $aArgs);
+ while ($oUser = $oSet->fetch()) {
+ $aUsers[] = $oUser;
+ }
+
+ return $aUsers;
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function GetAllUsers(bool $bAllData = true): DBUnionSearch|DBObjectSearch|DBSearch|null
+ {
+ $sOqlUser = 'SELECT User';
+
+ try {
+ $oFilter = $bAllData ? DBObjectSearch::FromOQL_AllData($sOqlUser) : DBObjectSearch::FromOQL($sOqlUser);
+ }
+ catch (Exception $e) {
+ IssueLog::Error('combodo-users-quota-slave/GetUsersNotInQuota : '.$e->getMessage(), 'combodo-users-quota');
+ throw new Exception(Dict::S('CombodoUserQuota:Error'));
+ }
+
+ return $oFilter;
+
+ }
+
+}
\ No newline at end of file
diff --git a/tests/php-unit-tests/unitary-tests/sources/Users/ITopUserQuotaRepositoryTest.php b/tests/php-unit-tests/unitary-tests/sources/Users/ITopUserQuotaRepositoryTest.php
new file mode 100644
index 0000000000..e9108abfa1
--- /dev/null
+++ b/tests/php-unit-tests/unitary-tests/sources/Users/ITopUserQuotaRepositoryTest.php
@@ -0,0 +1,187 @@
+createUsersQuotaDataset();
+ self::$bDatasetInitialized = true;
+ }
+
+
+ /** * Creates a deterministic dataset for quota tests. * Users are created only once (idempotent on login). */
+ private function createUsersQuotaDataset(): void
+ {
+ // Keep names unique and easy to clean up later if needed.
+ $sPrefix = 'quota_test_';
+
+ // Create one user per quota "kind".
+ // NOTE: profile names can vary by iTop distribution; we try common ones.
+ $this->createUserIfMissing($sPrefix.'console', true, ['Administrator', 'Configuration Administrator']);
+ $this->createUserIfMissing($sPrefix.'portal', true, ['Portal user', 'Portal User']);
+ $this->createUserIfMissing($sPrefix.'readonly', true, ['ReadOnlyCI']);
+ $this->createUserIfMissing($sPrefix.'application', true, ['Service Desk Agent', 'Change Manager', 'Administrator']);
+ $this->createUserIfMissing($sPrefix.'disabled', false, ['Service Desk Agent', 'Administrator']);
+ $this->createUserIfMissing($sPrefix.'disabled', false, ['Service Desk Agent', 'Administrator']);
+
+
+
+ }
+
+ private function createUserIfMissing(string $sLogin, bool $bEnabled, array $aCandidateProfileNames): void
+ {
+ if ($this->findUserByLogin($sLogin) !== null) {
+ return;
+ }
+
+ $iProfileId = $this->findFirstProfileIdByNames($aCandidateProfileNames);
+ $this->assertNotNull(
+ $iProfileId,
+ sprintf('Could not find any profile among: %s', implode(', ', $aCandidateProfileNames))
+ );
+
+ $oOrg = MetaModel::NewObject('Organization');
+ $oOrg->Set('name', 'Quota Test Org');
+ $oOrg->DBInsert();
+
+ $oPerson = MetaModel::NewObject('Person');
+ $oPerson->Set('name', strtoupper($sLogin));
+ $oPerson->Set('first_name', 'Quota');
+ $oPerson->Set('org_id', $oOrg->GetKey());
+ $oPerson->Set('email', $sLogin.'@example.invalid');
+ $oPerson->DBInsert();
+
+ $oUser = MetaModel::NewObject('UserLocal');
+ $oUser->Set('login', $sLogin);
+ $oUser->Set('password', 'QuotaTest#123');
+ $oUser->Set('contactid', $oPerson->GetKey());
+ $oUser->Set('status', $bEnabled ? 'enabled' : 'disabled');
+
+ $oProfileList = $oUser->Get('profile_list');
+ $oLink = MetaModel::NewObject('URP_UserProfile');
+ $oLink->Set('profileid', $iProfileId);
+ $oProfileList->AddItem($oLink);
+ $oUser->Set('profile_list', $oProfileList);
+
+ $oUser->DBInsert();
+ }
+
+ private function findFirstProfileIdByNames(array $aProfileNames): ?int
+ {
+ foreach ($aProfileNames as $sProfileName) {
+ $oSearch = DBObjectSearch::FromOQL('SELECT URP_Profiles WHERE name = :name');
+ $oSet = new CMDBObjectSet($oSearch, [], ['name' => $sProfileName]);
+ $oProfile = $oSet->Fetch();
+ if ($oProfile !== false && $oProfile !== null) {
+ return (int) $oProfile->GetKey();
+ }
+ }
+ return null;
+ }
+
+ private function findUserByLogin(string $sLogin): ?User
+ {
+ $oSearch = DBObjectSearch::FromOQL('SELECT User WHERE login = :login');
+ $oSet = new CMDBObjectSet($oSearch, [], ['login' => $sLogin]);
+ $oUser = $oSet->Fetch();
+ return ($oUser instanceof User) ? $oUser : null;
+ }
+
+
+ public function testNotDuplicateInDifferentQuotas(): void
+ {
+ $oITopUserRepository = new ITopUserQuotaRepository();
+
+ $aQuotaUsers = [
+ 'console' => $oITopUserRepository->GetUsersFromFilter($oITopUserRepository->GetConsoleUsers()),
+ 'portal' => $oITopUserRepository->GetUsersFromFilter($oITopUserRepository->GetPortalUsers()),
+ 'disabled' => $oITopUserRepository->GetUsersFromFilter($oITopUserRepository->GetDisabledUsers()),
+ 'readonly' => $oITopUserRepository->GetReadOnlyUsers(),
+ 'application' => $oITopUserRepository->GetUsersFromFilter($oITopUserRepository->GetApplicationUsers()),
+ ];
+
+ $aUserToQuotas = [];
+ foreach ($aQuotaUsers as $sQuota => $aUsers) {
+ foreach ($aUsers as $oUser) {
+ $sUserId = (string) $oUser->GetKey();
+ $aUserToQuotas[$sUserId][$sQuota] = true;
+ }
+ }
+
+ $aDuplicates = [];
+ foreach ($aUserToQuotas as $sUserId => $aQuotas) {
+ $aQuotaNames = array_keys($aQuotas);
+ if (count($aQuotaNames) > 1) {
+ sort($aQuotaNames);
+ $aDuplicates[] = sprintf('User #%s appears in: %s', $sUserId, implode(', ', $aQuotaNames));
+ }
+ }
+
+ $this->assertEmpty(
+ $aDuplicates,
+ "Some users are counted in multiple quotas:\n- ".implode("\n- ", $aDuplicates)
+ );
+ }
+
+ public function testAllUsersAreInQuota () {
+ $oITopUserRepository = new ITopUserQuotaRepository();
+
+ $oConsoleUsersFilter = $oITopUserRepository->GetConsoleUsers();
+ $aConsoleUsers = $oITopUserRepository->GetUsersFromFilter($oConsoleUsersFilter);
+ $oPortalUsersFilter = $oITopUserRepository->GetPortalUsers();
+ $aPortalUsers = $oITopUserRepository->GetUsersFromFilter($oPortalUsersFilter);
+ $oDisabledUsersFilter = $oITopUserRepository->GetDisabledUsers();
+ $aDisabledUsers = $oITopUserRepository->GetUsersFromFilter($oDisabledUsersFilter);
+ $aReadOnlyUsers = $oITopUserRepository->GetReadOnlyUsers();
+ $oApplicationUsersFilter = $oITopUserRepository->GetApplicationUsers();
+ $aApplicationUsers = $oITopUserRepository->GetUsersFromFilter($oApplicationUsersFilter);
+
+ $aAllUsersFromQuota = array_merge($aConsoleUsers, $aPortalUsers, $aDisabledUsers, $aReadOnlyUsers, $aApplicationUsers);
+
+ $oAllUsersFilter = $oITopUserRepository->GetAllUsers();
+ $aAllUsersFromOQL = $oITopUserRepository->GetUsersFromFilter($oAllUsersFilter);
+
+ $this->assertEmpty(array_merge(array_diff($aAllUsersFromQuota, $aAllUsersFromOQL), array_diff($aAllUsersFromOQL, $aAllUsersFromQuota)));
+ }
+
+ public function testAllUsersInQuotaAreUsersObjects ()
+ {
+ $oITopUserRepository = new ITopUserQuotaRepository();
+
+ $oConsoleUsersFilter = $oITopUserRepository->GetConsoleUsers();
+ $aConsoleUsers = $oITopUserRepository->GetUsersFromFilter($oConsoleUsersFilter);
+ $oPortalUsersFilter = $oITopUserRepository->GetPortalUsers();
+ $aPortalUsers = $oITopUserRepository->GetUsersFromFilter($oPortalUsersFilter);
+ $oDisabledUsersFilter = $oITopUserRepository->GetDisabledUsers();
+ $aDisabledUsers = $oITopUserRepository->GetUsersFromFilter($oDisabledUsersFilter);
+ $aReadOnlyUsers = $oITopUserRepository->GetReadOnlyUsers();
+ $oApplicationUsersFilter = $oITopUserRepository->GetApplicationUsers();
+ $aApplicationUsers = $oITopUserRepository->GetUsersFromFilter($oApplicationUsersFilter);
+
+ $aAllQuotaUsers = array_merge($aConsoleUsers, $aPortalUsers, $aDisabledUsers, $aReadOnlyUsers, $aApplicationUsers);
+
+ foreach ($aAllQuotaUsers as $oUser) {
+ $this->assertInstanceOf(User::class, $oUser);
+ }
+
+
+ }
+
+}
\ No newline at end of file