From d60ed6ebbe3cb5a20855beaf11a4d889505a960f Mon Sep 17 00:00:00 2001 From: jf-cbd Date: Tue, 2 Jun 2026 18:18:27 +0200 Subject: [PATCH] WIP --- core/userrights.class.inc.php | 4 +- dictionaries/en.dictionary.itop.core.php | 8 + dictionaries/fr.dictionary.itop.core.php | 8 + lib/composer/autoload_classmap.php | 1 + lib/composer/autoload_static.php | 1 + sources/Users/ITopUserQuotaRepository.php | 221 ++++++++++++++++++ .../Users/ITopUserQuotaRepositoryTest.php | 187 +++++++++++++++ 7 files changed, 428 insertions(+), 2 deletions(-) create mode 100644 sources/Users/ITopUserQuotaRepository.php create mode 100644 tests/php-unit-tests/unitary-tests/sources/Users/ITopUserQuotaRepositoryTest.php 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