Compare commits

...

5 Commits

Author SHA1 Message Date
jf-cbd
7947754054 WIP improving quota computing 2026-06-03 17:57:32 +02:00
jf-cbd
f9afebf7c6 Rollback 2026-06-03 16:04:05 +02:00
jf-cbd
f3e2c30380 WIP 2026-06-03 15:27:06 +02:00
jf-cbd
ee5c7b46f6 Update profiles definition 2026-06-03 15:26:40 +02:00
jf-cbd
41726b0cc9 WIP on new user read-only profiles 2026-05-29 17:11:12 +02:00
9 changed files with 560 additions and 3 deletions

View File

@@ -85,6 +85,13 @@
<class id="Attachment"/>
</classes>
</group>
<group id="Ticket" _delta="define">
<classes>
<class id="Ticket"/>
<class id="WorkOrder"/>
<class id="Attachment"/>
</classes>
</group>
<group id="Portal" _delta="define">
<classes>
<class id="lnkFunctionalCIToTicket"/>
@@ -205,6 +212,60 @@
</group>
</groups>
<profiles>
<profile id="5500" _delta="define">
<name>Configuration ReadOnly</name>
<description>This read-only profile allows to see CIs objects.</description>
<groups>
<group id="Configuration">
<actions>
<action id="action:read">allow</action>
<action id="action:bulk read">allow</action>
</actions>
</group>
<group id="General">
<actions>
<action id="action:read">allow</action>
<action id="action:bulk read">allow</action>
</actions>
</group>
</groups>
</profile>
<profile id="5501" _delta="define">
<name>Ticket ReadOnly</name>
<description>This read-only profile allows to see Ticket objects.</description>
<groups>
<group id="Ticket">
<actions>
<action id="action:read">allow</action>
<action id="action:bulk read">allow</action>
</actions>
</group>
<group id="General">
<actions>
<action id="action:read">allow</action>
<action id="action:bulk read">allow</action>
</actions>
</group>
</groups>
</profile>
<profile id="5502" _delta="define">
<name>Service Catalog ReadOnly</name>
<description>This read-only profile allows to see Service Catalog objects.</description>
<groups>
<group id="Service">
<actions>
<action id="action:read">allow</action>
<action id="action:bulk read">allow</action>
</actions>
</group>
<group id="General">
<actions>
<action id="action:read">allow</action>
<action id="action:bulk read">allow</action>
</actions>
</group>
</groups>
</profile>
<profile id="117" _delta="define">
<name>SuperUser</name>
<description>This profile allows all actions which are not Administrator restricted.</description>

View File

@@ -216,6 +216,14 @@ Operators:<br/>
'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',
]);
//////////////////////////////////////////////////////////////////////

View File

@@ -161,6 +161,14 @@ Opérateurs :<br/>
'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',
]);
//////////////////////////////////////////////////////////////////////

View File

@@ -643,6 +643,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',

View File

@@ -1044,6 +1044,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',

View File

@@ -0,0 +1,241 @@
<?php
namespace Combodo\iTop\Users;
use ArchivedObjectException;
use CoreException;
use CoreUnexpectedValue;
use DBObjectSearch;
use DBObjectSet;
use DBUnionSearch;
use Dict;
use DictExceptionMissingString;
use DictExceptionUnknownLanguage;
use Exception;
use IssueLog;
use MetaModel;
use MySQLException;
use OQLException;
use User;
use UserRights;
/**
*
*/
class ITopUserQuotaRepository
{
/**
* @param string $sExcludedUsers
* @param string $sExcludedProfiles
* @param bool $bAllData
* @param string $sExcludedFinalClasses
*
* @return array
* @throws CoreException
* @throws CoreUnexpectedValue
* @throws DictExceptionMissingString
* @throws MySQLException
* @throws Exception
*/
public function GetConsoleUsers(string $sExcludedUsers = '', string $sExcludedProfiles = '', bool $bAllData = true, string $sExcludedFinalClasses = 'UserToken, UserRemoteSaaS'): array
{
$sOQLInQuotaUser = "
SELECT User AS u
WHERE u.status != 'disabled'
AND u.login NOT IN ('$sExcludedUsers')
AND u.finalclass != ' $sExcludedFinalClasses '
AND id NOT IN (
SELECT User AS uex
JOIN URP_UserProfile AS uup ON uup.userid = uex.id
JOIN URP_Profiles AS up ON uup.profileid = up.id
WHERE up.name IN ('$sExcludedProfiles'))
";
try {
$oFilter = $bAllData ? DBObjectSearch::FromOQL_AllData($sOQLInQuotaUser) : DBObjectSearch::FromOQL($sOQLInQuotaUser);
}
catch (Exception $e) {
IssueLog::Error('Core:GetConsoleUsersQuota:Error : '.$e->getMessage());
throw new Exception(Dict::Format('Core:GetQuota:Error', Dict::S('Core:ConsoleUsers')));
}
$aConsoleUsers = $this->GetUsersFromFilter($oFilter);
$aPortalUsers = $this->GetPortalUsers();
$aReadOnlyUsers = $this->GetReadOnlyUsers();
return array_diff($aConsoleUsers, $aPortalUsers, $aReadOnlyUsers);
}
/**
* @throws Exception
*/
public function GetApplicationUsers(bool $bAllData = true): array
{
$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 $this->GetUsersFromFilter($oFilter);
}
/**
* @throws Exception
*/
public function GetDisabledUsers(bool $bAllData = true): array
{
$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 $this->GetUsersFromFilter($oFilter);
}
/**
* @throws CoreException
* @throws MySQLException
* @throws CoreUnexpectedValue
* @throws OQLException
* @throws ArchivedObjectException
* @throws DictExceptionUnknownLanguage
*/
private function IsUserReadOnly(User $oUser, string $sClassCategory): bool
{
if ($oUser->Get('status') == 'disabled') {
return false;
}
// check if user is a portal user
$oProfileLinks = $oUser->Get('profile_list');
while ($oLink = $oProfileLinks->Fetch()) {
$iProfileId = $oLink->Get('profileid');
if (!$iProfileId) {
continue;
}
$oProfile = MetaModel::GetObject('URP_Profiles', $iProfileId, false);
if ($oProfile && $oProfile->Get('name') === PORTAL_PROFILE_NAME) {
return false;
}
}
// login (mandatory to compute rights)
UserRights::Login($oUser->GetName());
foreach (MetaModel::GetClasses($sClassCategory) as $sClass) {
// no need to check stimulis for now since users can't execute stimulus without UR_ACTION_MODIFY
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)
) {
UserRights::Logoff();
return false;
}
}
UserRights::Logoff();
return true;
}
/**
* @throws DictExceptionMissingString
* @throws CoreException
* @throws Exception
*/
public function GetReadOnlyUsers(): array
{
$aReadOnlyUsers = [];
$aAllUsers = $this->GetAllUsers();
/** @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;
}
}
// remove portal users
$aPortalUsers = $this->GetPortalUsers();
$aReadOnlyUsers = array_diff($aReadOnlyUsers, $aPortalUsers);
// remove disabled users
$aDisabledUsers = $this->GetDisabledUsers();
return array_diff($aReadOnlyUsers, $aDisabledUsers);
}
/**
* @throws Exception
*/
public function GetPortalUsers(bool $bAllData = true): array
{
$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 $this->GetUsersFromFilter($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): array
{
$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 $this->GetUsersFromFilter($oFilter);
}
}

View File

@@ -86,8 +86,6 @@ abstract class ItopDataTestCase extends ItopTestCase
*/
public const DEFAULT_TEST_ENVIRONMENT = 'production';
public const USE_TRANSACTION = true;
public const CREATE_TEST_ORG = false;
protected static $aURP_Profiles = [
'Administrator' => 1,
'Portal user' => 2,
@@ -102,8 +100,13 @@ abstract class ItopDataTestCase extends ItopTestCase
'Document author' => 11,
'Portal power user' => 12,
'REST Services User' => 1024,
'Configuration ReadOnly' => 5500,
'Ticket ReadOnly' => 5501,
'Service Catalog ReadOnly' => 5502,
];
public const CREATE_TEST_ORG = false;
/**
* This method is called before the first test of this test class is run (in the current process).
*/

View File

@@ -34,6 +34,7 @@ use DBObject;
use DBObjectSearch;
use DBObjectSet;
use DeleteException;
use Dict;
use MetaModel;
use UserLocal;
use UserRights;
@@ -96,6 +97,127 @@ class UserRightsTest extends ItopDataTestCase
return $oUser;
}
/**
* @param array $aProfileIds
* @param array $aShouldBeAllowedToSeeClass
* @param array $aShouldBeAllowedToEditClass
*
* @return void
* @throws \ArchivedObjectException
* @throws \CoreCannotSaveObjectException
* @throws \CoreException
* @throws \CoreUnexpectedValue
* @throws \CoreWarning
* @throws \DictExceptionUnknownLanguage
* @throws \MySQLException
* @throws \OQLException
* @dataProvider ReadOnlyProvider
*/
public function testReadOnlyUser(array $aProfileIds, array $aShouldBeAllowedToSeeClass, array $aShouldBeAllowedToEditClass): void
{
$oUser = $this->GivenUserWithProfiles('test1', $aProfileIds);
$oUser->DBInsert();
$_SESSION = [];
UserRights::Login($oUser->Get('login'));
$aClassesToTest = ['FunctionalCI', 'Ticket', 'ServiceFamily'];
foreach ($aClassesToTest as $sClass) {
$bShouldBeAllowedToSee = in_array($sClass, $aShouldBeAllowedToSeeClass);
$bIsAllowedReading = (bool)UserRights::IsActionAllowed($sClass, UR_ACTION_READ);
$this->assertSame(
$bShouldBeAllowedToSee,
$bIsAllowedReading,
"User with profiles ".implode(',', $aProfileIds)." should ".($bShouldBeAllowedToSee ? "" : "NOT ")."be allowed to see class $sClass"
);
$bShouldBeAllowedToEdit = in_array($sClass, $aShouldBeAllowedToEditClass);
$bIsAllowedEditing = (bool)UserRights::IsActionAllowed($sClass, UR_ACTION_MODIFY);
$this->assertSame($bIsAllowedEditing, $bShouldBeAllowedToEdit,
"User with profiles ".implode(',', $aProfileIds)." should ".($bShouldBeAllowedToEdit ? "" : "NOT ")."be allowed to edit class $sClass"
);
}
}
protected function ReadOnlyProvider() : array {
return [
'CI' => [
'ProfilesId' => [
5500,
],
'ShouldBeAllowedToSeeClasses' => [
'FunctionalCI',
],
'ShouldBeAllowedToEditClasses' => []
],
'Tickets' => [
'ProfilesId' => [
5501,
],
'ShouldBeAllowedToSeeClasses' => [
'Ticket',
],
'ShouldBeAllowedToEditClasses' => []
],
'Catalog' => [
'ProfilesId' => [
5502,
],
'ShouldBeAllowedToSeeClasses' => [
'ServiceFamily',
],
'ShouldBeAllowedToEditClasses' => []
],
'CI and Tickets' => [
'ProfilesId' => [
5500, 5501,
],
'ShouldBeAllowedToSeeClasses' => [
'FunctionalCI', 'Ticket',
],
'ShouldBeAllowedToEditClasses' => []
],
'CI and Catalog' => [
'ProfilesId' => [
5500, 5502,
],
'ShouldBeAllowedToSeeClasses' => [
'FunctionalCI', 'ServiceFamily',
],
'ShouldBeAllowedToEditClasses' => []
],
'Tickets and Catalog' => [
'ProfilesId' => [
5501, 5502,
],
'ShouldBeAllowedToSeeClasses' => [
'Ticket', 'ServiceFamily',
],
'ShouldBeAllowedToEditClasses' => []
],
'Tickets and Catalog + profile Ccnfiguration Manager' => [
'ProfilesId' => [
5501, 5502, 3
],
'ShouldBeAllowedToSeeClasses' => [
'FunctionalCI', 'Ticket', 'ServiceFamily',
],
'ShouldBeAllowedToEditClasses' => ['FunctionalCI']
],
'CI, Tickets and Catalog' => [
'ProfilesId' => [
5500, 5501, 5502,
],
'ShouldBeAllowedToSeeClasses' => [
'FunctionalCI', 'Ticket', 'ServiceFamily',
],
'ShouldBeAllowedToEditClasses' => []
],
];
}
public function testIsLoggedIn()
{
$this->assertFalse(UserRights::IsLoggedIn());
@@ -433,7 +555,7 @@ class UserRightsTest extends ItopDataTestCase
$oUser = $this->GivenUserWithProfiles('test1', [$iProfileId, 2]);
$this->expectException(CoreCannotSaveObjectException::class);
$this->expectExceptionMessage('Profile "Portal user" cannot be given to privileged Users (Administrators, SuperUsers and REST Services Users)');
$this->expectExceptionMessage(Dict::Format('Class:User/Error:PrivilegedUserMustHaveAccessToBackOffice', PORTAL_PROFILE_NAME));
$oUser->DBInsert();
}

View File

@@ -0,0 +1,112 @@
<?php
namespace Users;
use Combodo\iTop\Test\UnitTest\ItopDataTestCase;
use Combodo\iTop\Users\ITopUserQuotaRepository;
use User;
class ITopUserQuotaRepositoryTest extends ItopDataTestCase{
protected function setUp(): void
{
parent::setUp();
$this->CreateReadOnlyUsers();
}
private function CreateReadOnlyUsers()
{
$this->GivenUserInDB('qpf_z17H3232*"ré$"é', ['Configuration ReadOnly']);
$this->GivenUserInDB('qpf_z17H3232*"ré$"é', ['Ticket ReadOnly']);
$this->GivenUserInDB('qpf_z17H3232*"ré$"é', ['Service Catalog ReadOnly']);
$this->GivenUserInDB('qpf_z17H3232*"ré$"é', ['Configuration ReadOnly', 'Ticket ReadOnly']);
$this->GivenUserInDB('qpf_z17H3232*"ré$"é', ['Ticket ReadOnly', 'Service Catalog ReadOnly']);
$this->GivenUserInDB('qpf_z17H3232*"ré$"é', ['Configuration ReadOnly', 'Ticket ReadOnly', 'Service Catalog ReadOnly']);
}
private function CreateDisabledUser() {
$sUser = $this->GivenUserInDB('qpf_z17H3232*"ré$"é', ['Configuration Manager']);
// get user by login
$oUser = \MetaModel::GetObjectByName('User', $sUser);
$oUser->Set('status', 'disabled');
$oUser->DBUpdate();
}
/**
* @throws \CoreUnexpectedValue
* @throws \DictExceptionMissingString
* @throws \CoreException
* @throws \MySQLException
* @throws \Exception
*/
public function testNotDuplicateInDifferentQuotas(): void
{
$oITopUserRepository = new ITopUserQuotaRepository();
$aQuotaUsers = [
'console' => $oITopUserRepository->GetConsoleUsers(),
'portal' => $oITopUserRepository->GetPortalUsers(),
'disabled' => $oITopUserRepository->GetDisabledUsers(),
'readonly' => $oITopUserRepository->GetReadOnlyUsers(),
'application' => $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();
$aConsoleUsers = $oITopUserRepository->GetConsoleUsers();
$aPortalUsers = $oITopUserRepository->GetPortalUsers();
$aDisabledUsers = $oITopUserRepository->GetDisabledUsers();
$aReadOnlyUsers = $oITopUserRepository->GetReadOnlyUsers();
$aApplicationUsers = $oITopUserRepository->GetApplicationUsers();
$aAllUsersFromQuota = array_merge($aConsoleUsers, $aPortalUsers, $aDisabledUsers, $aReadOnlyUsers, $aApplicationUsers);
$aAllUsersFromOQL = $oITopUserRepository->GetAllUsers();
$this->assertEmpty(array_merge(array_diff($aAllUsersFromQuota, $aAllUsersFromOQL), array_diff($aAllUsersFromOQL, $aAllUsersFromQuota)));
}
public function testAllUsersInQuotaAreUsersObjects ()
{
$oITopUserRepository = new ITopUserQuotaRepository();
$aConsoleUsers = $oITopUserRepository->GetConsoleUsers();
$aPortalUsers = $oITopUserRepository->GetPortalUsers();
$aDisabledUsers = $oITopUserRepository->GetDisabledUsers();
$aReadOnlyUsers = $oITopUserRepository->GetReadOnlyUsers();
$aApplicationUsers = $oITopUserRepository->GetApplicationUsers();
$aAllQuotaUsers = array_merge($aConsoleUsers, $aPortalUsers, $aDisabledUsers, $aReadOnlyUsers, $aApplicationUsers);
foreach ($aAllQuotaUsers as $oUser) {
$this->assertInstanceOf(User::class, $oUser);
}
}
}