Compare commits

...

5 Commits

Author SHA1 Message Date
Stephen Abello
75b413e866 Update core/config.class.inc.php
Co-authored-by: Molkobain <lajarige.guillaume@free.fr>
2026-04-08 09:30:01 +02:00
Stephen Abello
94a8ef53dd Apply suggestion from code review 2026-04-08 09:28:53 +02:00
Stephen Abello
050e269921 N°9448 - Fix external auth variable value 2026-04-07 11:53:43 +02:00
Stephen Abello
e467ca83cf N°8532 - Apply filters on all DBSearch classes (#848)
Co-authored-by: Molkobain <lajarige.guillaume@free.fr>
Co-authored-by: Thomas Casteleyn <thomas.casteleyn@super-visions.com>
2026-03-31 15:41:28 +02:00
Stephen Abello
7791585387 N°9231 - Make OrmDocument apply same safety to attachments and regular documents (#860) 2026-03-30 15:25:52 +02:00
9 changed files with 593 additions and 34 deletions

View File

@@ -75,13 +75,10 @@ class LoginExternal extends AbstractLoginFSMExtension
}
/**
* @return bool
* @return bool|mixed
*/
private function GetAuthUser()
{
$sExtAuthVar = MetaModel::GetConfig()->GetExternalAuthenticationVariable(); // In which variable is the info passed ?
eval('$sAuthUser = isset('.$sExtAuthVar.') ? '.$sExtAuthVar.' : false;'); // Retrieve the value
/** @var string $sAuthUser */
return $sAuthUser; // Retrieve the value
return MetaModel::GetConfig()->GetExternalAuthenticationVariable();
}
}

View File

@@ -75,6 +75,7 @@ define('DEFAULT_EXT_AUTH_VARIABLE', '$_SERVER[\'REMOTE_USER\']');
define('DEFAULT_ENCRYPTION_KEY', '@iT0pEncr1pti0n!'); // We'll use a random generated key later (if possible)
define('DEFAULT_ENCRYPTION_LIB', 'Mcrypt'); // We'll define the best encryption available later
define('DEFAULT_HASH_ALGO', PASSWORD_DEFAULT);
/**
* Config
* configuration data (this class cannot not be localized, because it is responsible for loading the dictionaries)
@@ -869,6 +870,14 @@ class Config
'source_of_value' => '',
'show_in_conf_sample' => false,
],
'ext_auth_variable' => [
'type' => 'string',
'description' => 'External authentication expression (allowed: $_SERVER[\'key\'], $_COOKIE[\'key\'], $_REQUEST[\'key\'], getallheaders()[\'Header-Name\'])',
'default' => '$_SERVER[\'REMOTE_USER\']',
'value' => '$_SERVER[\'REMOTE_USER\']',
'source_of_value' => '',
'show_in_conf_sample' => false,
],
'login_debug' => [
'type' => 'bool',
'description' => 'Activate the login FSM debug',
@@ -1730,6 +1739,14 @@ class Config
'source_of_value' => '',
'show_in_conf_sample' => false,
],
'security.disable_joined_classes_filter' => [
'type' => 'bool',
'description' => 'If true, scope filters aren\'t applied to joined classes or union classes not directly listed in the SELECT clause.',
'default' => true,
'value' => true,
'source_of_value' => '',
'show_in_conf_sample' => false,
],
'security.hide_administrators' => [
'type' => 'bool',
'description' => 'If true, non-administrator users will not be able to see the administrator accounts, the Administrator profile and the links between the administrator accounts and their profiles.',
@@ -1958,11 +1975,6 @@ class Config
*/
protected $m_sAllowedLoginTypes;
/**
* @var string Name of the PHP variable in which external authentication information is passed by the web server
*/
protected $m_sExtAuthVariable;
/**
* @var string Encryption key used for all attributes of type "encrypted string". Can be set to a random value
* unless you want to import a database from another iTop instance, in which case you must use
@@ -2035,7 +2047,6 @@ class Config
$this->m_bSecureConnectionRequired = DEFAULT_SECURE_CONNECTION_REQUIRED;
$this->m_sDefaultLanguage = 'EN US';
$this->m_sAllowedLoginTypes = DEFAULT_ALLOWED_LOGIN_TYPES;
$this->m_sExtAuthVariable = DEFAULT_EXT_AUTH_VARIABLE;
$this->m_aCharsets = [];
$this->m_bQueryCacheEnabled = DEFAULT_QUERY_CACHE_ENABLED;
$this->m_iPasswordHashAlgo = DEFAULT_HASH_ALGO;
@@ -2189,7 +2200,6 @@ class Config
$this->m_sDefaultLanguage = isset($MySettings['default_language']) ? trim($MySettings['default_language']) : 'EN US';
$this->m_sAllowedLoginTypes = isset($MySettings['allowed_login_types']) ? trim($MySettings['allowed_login_types']) : DEFAULT_ALLOWED_LOGIN_TYPES;
$this->m_sExtAuthVariable = isset($MySettings['ext_auth_variable']) ? trim($MySettings['ext_auth_variable']) : DEFAULT_EXT_AUTH_VARIABLE;
$this->m_sEncryptionKey = isset($MySettings['encryption_key']) ? trim($MySettings['encryption_key']) : $this->m_sEncryptionKey;
$this->m_sEncryptionLibrary = isset($MySettings['encryption_library']) ? trim($MySettings['encryption_library']) : $this->m_sEncryptionLibrary;
$this->m_aCharsets = isset($MySettings['csv_import_charsets']) ? $MySettings['csv_import_charsets'] : [];
@@ -2342,9 +2352,73 @@ class Config
return explode('|', $this->m_sAllowedLoginTypes);
}
/**
* @return bool|mixed
* @since 3.2.3 return the parsed value instead of an unsecured variable name
*/
public function GetExternalAuthenticationVariable()
{
return $this->m_sExtAuthVariable;
$sExpression = $this->Get('ext_auth_variable');
$aParsed = $this->ParseExternalAuthVariableExpression($sExpression);
if ($aParsed === null) {
return false;
}
$sKey = $aParsed['key'];
switch ($aParsed['type']) {
case 'server':
return $_SERVER[$sKey] ?? false;
case 'cookie':
return $_COOKIE[$sKey] ?? false;
case 'request':
return $_REQUEST[$sKey] ?? false;
case 'header':
if (!function_exists('getallheaders')) {
return false;
}
$aHeaders = getallheaders();
if (!is_array($aHeaders)) {
return false;
}
return $aHeaders[$sKey] ?? false;
}
return false;
}
/**
* @param $sExpression
* @return array|null
*/
private function ParseExternalAuthVariableExpression($sExpression)
{
// If it's a configuration parameter it's probably already trimmed, but just in case
$sExpression = trim((string) $sExpression);
if ($sExpression === '') {
return null;
}
// Match $_SERVER/$_COOKIE/$_REQUEST['key'] with optional whitespace and single/double quotes.
if (preg_match('/^\$_(SERVER|COOKIE|REQUEST)\s*\[\s*(["\'])\s*([^"\']+)\2\s*\]\s*$/', $sExpression, $aMatches) === 1) {
$sContext = strtoupper($aMatches[1]);
$sKey = $aMatches[3];
return [
'type' => strtolower($sContext),
'key' => $sKey,
'normalized' => '$_'.$sContext.'[\''.$sKey.'\']',
];
}
// Match getallheaders()['Header-Name'] in a case-insensitive way.
if (preg_match('/^getallheaders\(\)\s*\[\s*(["\'])\s*([^"\']+)\1\s*\]\s*$/i', $sExpression, $aMatches) === 1) {
$sKey = $aMatches[2];
return [
'type' => 'header',
'key' => $sKey,
'normalized' => 'getallheaders()[\''.$sKey.'\']',
];
}
return null;
}
public function GetCSVImportCharsets()
@@ -2440,7 +2514,7 @@ class Config
public function SetExternalAuthenticationVariable($sExtAuthVariable)
{
$this->m_sExtAuthVariable = $sExtAuthVariable;
$this->Set('ext_auth_variable', $sExtAuthVariable);
}
public function SetEncryptionKey($sKey)
@@ -2495,7 +2569,6 @@ class Config
$aSettings['secure_connection_required'] = $this->m_bSecureConnectionRequired;
$aSettings['default_language'] = $this->m_sDefaultLanguage;
$aSettings['allowed_login_types'] = $this->m_sAllowedLoginTypes;
$aSettings['ext_auth_variable'] = $this->m_sExtAuthVariable;
$aSettings['encryption_key'] = $this->m_sEncryptionKey;
$aSettings['encryption_library'] = $this->m_sEncryptionLibrary;
$aSettings['csv_import_charsets'] = $this->m_aCharsets;
@@ -2600,7 +2673,6 @@ class Config
$aOtherValues = [
'default_language' => $this->m_sDefaultLanguage,
'allowed_login_types' => $this->m_sAllowedLoginTypes,
'ext_auth_variable' => $this->m_sExtAuthVariable,
'encryption_key' => $this->m_sEncryptionKey,
'encryption_library' => $this->m_sEncryptionLibrary,
'csv_import_charsets' => $this->m_aCharsets,

View File

@@ -1925,4 +1925,37 @@ class DBObjectSearch extends DBSearch
{
return $this->GetCriteria()->ListParameters();
}
/**
* @inheritDoc
* @return DBObjectSearch
*/
protected function ApplyDataFilters(): DBObjectSearch
{
if ($this->IsAllDataAllowed() || $this->IsDataFiltered()) {
return $this;
}
$oSearch = $this;
$aClassesToFilter = $this->GetSelectedClasses();
// Opt-in for joined classes filtering, otherwise only filter the selected class(es)
if (MetaModel::GetConfig()->Get('security.disable_joined_classes_filter') === false) {
$aClassesToFilter = $this->GetJoinedClasses();
}
// Apply filter (this is similar to the one in DBSearch but the factorization could make it less readable)
foreach ($aClassesToFilter as $sClassAlias => $sClass) {
$oVisibleObjects = UserRights::GetSelectFilter($sClass, $this->GetModifierProperties('UserRightsGetSelectFilter'));
if ($oVisibleObjects === false) {
$oVisibleObjects = DBObjectSearch::FromEmptySet($sClass);
}
if (is_object($oVisibleObjects)) {
$oVisibleObjects->AllowAllData();
$oSearch = $oSearch->Filter($sClassAlias, $oVisibleObjects);
$oSearch->SetDataFiltered();
}
}
return $oSearch;
}
}

View File

@@ -1122,21 +1122,7 @@ abstract class DBSearch
*/
protected function GetSQLQuery($aOrderBy, $aArgs, $aAttToLoad, $aExtendedDataSpec, $iLimitCount, $iLimitStart, $bGetCount, $aGroupByExpr = null, $aSelectExpr = null)
{
$oSearch = $this;
if (!$this->IsAllDataAllowed() && !$this->IsDataFiltered()) {
foreach ($this->GetSelectedClasses() as $sClassAlias => $sClass) {
$oVisibleObjects = UserRights::GetSelectFilter($sClass, $this->GetModifierProperties('UserRightsGetSelectFilter'));
if ($oVisibleObjects === false) {
// Make sure this is a valid search object, saying NO for all
$oVisibleObjects = DBObjectSearch::FromEmptySet($sClass);
}
if (is_object($oVisibleObjects)) {
$oVisibleObjects->AllowAllData();
$oSearch = $oSearch->Filter($sClassAlias, $oVisibleObjects);
$oSearch->SetDataFiltered();
}
}
}
$oSearch = $this->ApplyDataFilters();
if (is_array($aGroupByExpr)) {
foreach ($aGroupByExpr as $sAlias => $oGroupByExp) {
@@ -1608,4 +1594,33 @@ abstract class DBSearch
* @return array{\VariableExpression}
*/
abstract public function GetExpectedArguments(): array;
/**
* Apply data filters to the search, if needed
*
* @return DBSearch
* @throws CoreException
*/
protected function ApplyDataFilters(): DBSearch
{
if ($this->IsAllDataAllowed() || $this->IsDataFiltered()) {
return $this;
}
$oSearch = $this;
$aClassesToFilter = $this->GetSelectedClasses();
foreach ($aClassesToFilter as $sClassAlias => $sClass) {
$oVisibleObjects = UserRights::GetSelectFilter($sClass, $this->GetModifierProperties('UserRightsGetSelectFilter'));
if ($oVisibleObjects === false) {
$oVisibleObjects = DBObjectSearch::FromEmptySet($sClass);
}
if (is_object($oVisibleObjects)) {
$oVisibleObjects->AllowAllData();
$oSearch = $oSearch->Filter($sClassAlias, $oVisibleObjects);
$oSearch->SetDataFiltered();
}
}
return $oSearch;
}
}

View File

@@ -673,4 +673,30 @@ class DBUnionSearch extends DBSearch
return $aVariableCriteria;
}
/**
* @inheritDoc
* @return DBUnionSearch
*/
protected function ApplyDataFilters(): DBUnionSearch
{
if ($this->IsAllDataAllowed() || $this->IsDataFiltered()) {
return $this;
}
// Opt-in for joined classes filtering, otherwise fallback on DBSearch filtering
if (MetaModel::GetConfig()->Get('security.disable_joined_classes_filter') === true) {
return parent::ApplyDataFilters();
}
// Apply filters per sub-search
$aFilteredSearches = [];
foreach ($this->GetSearches() as $oSubSearch) {
// Recursively call ApplyDataFilters on sub-searches
$aFilteredSearches[] = $oSubSearch->ApplyDataFilters();
}
$oSearch = new DBUnionSearch($aFilteredSearches);
return $oSearch;
}
}

View File

@@ -350,15 +350,18 @@ class ormDocument
if (!is_object($oObj)) {
// If access to the document is not granted, check if the access to the host object is allowed
$oObj = MetaModel::GetObject($sClass, $id, false, true);
$bHasHostRights = false;
if ($oObj instanceof Attachment) {
$sItemClass = $oObj->Get('item_class');
$sItemId = $oObj->Get('item_id');
$oHost = MetaModel::GetObject($sItemClass, $sItemId, false, false);
if (!is_object($oHost)) {
$oObj = null;
if (is_object($oHost)) {
$bHasHostRights = true;
}
}
if (!is_object($oObj)) {
// We could neither read the object nor get a host object matching our rights
if ($bHasHostRights !== true) {
throw new Exception("Invalid id ($id) for class '$sClass' - the object does not exist or you are not allowed to view it");
}
}

View File

@@ -0,0 +1,95 @@
<?php
/**
* Copyright (C) 2010-2024 Combodo SAS
*
* This file is part of iTop.
*
* iTop is free software; you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* iTop is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with iTop. If not, see <http://www.gnu.org/licenses/>
*/
namespace Combodo\iTop\Test\UnitTest\Application;
use Combodo\iTop\Test\UnitTest\ItopDataTestCase;
use utils;
class LoginExternalTest extends ItopDataTestCase
{
private $oConfig;
private $sOriginalExtAuthVariable;
protected function setUp(): void
{
parent::setUp();
require_once APPROOT.'application/loginexternal.class.inc.php';
$this->oConfig = utils::GetConfig();
$this->sOriginalExtAuthVariable = $this->oConfig->Get('ext_auth_variable');
}
protected function tearDown(): void
{
$this->oConfig->SetExternalAuthenticationVariable($this->sOriginalExtAuthVariable);
parent::tearDown();
}
private function CallGetAuthUser()
{
$oLoginExternal = new \LoginExternal();
$oMethod = new \ReflectionMethod(\LoginExternal::class, 'GetAuthUser');
$oMethod->setAccessible(true);
return $oMethod->invoke($oLoginExternal);
}
public function testGetAuthUserFromServerVariable()
{
$_SERVER['REMOTE_USER'] = 'alice';
$this->oConfig->SetExternalAuthenticationVariable('$_SERVER[\'REMOTE_USER\']');
$this->assertSame('alice', $this->CallGetAuthUser());
}
public function testGetAuthUserFromCookie()
{
$_COOKIE['auth_user'] = 'bob';
$this->oConfig->SetExternalAuthenticationVariable('$_COOKIE[\'auth_user\']');
$this->assertSame('bob', $this->CallGetAuthUser());
}
public function testGetAuthUserFromRequest()
{
$_REQUEST['auth_user'] = 'carol';
$this->oConfig->SetExternalAuthenticationVariable('$_REQUEST[\'auth_user\']');
$this->assertSame('carol', $this->CallGetAuthUser());
}
public function testInvalidExpressionReturnsFalse()
{
$this->oConfig->SetExternalAuthenticationVariable('$_SERVER[\'HTTP_X_CMD\']) ? print(\'x\') : false; //');
$this->assertFalse($this->CallGetAuthUser());
}
public function testGetAuthUserFromHeaderWithoutAllowlist()
{
if (!function_exists('getallheaders')) {
$this->markTestSkipped('getallheaders() not available');
}
$_SERVER['HTTP_X_REMOTE_USER'] = 'CN=header-test';
$this->oConfig->SetExternalAuthenticationVariable('getallheaders()[\'X-Remote-User\']');
$this->assertSame('CN=header-test', $this->CallGetAuthUser());
}
}

View File

@@ -0,0 +1,193 @@
<?php
namespace Combodo\iTop\Test\UnitTest\Core;
use CMDBSource;
use Combodo\iTop\Test\UnitTest\ItopDataTestCase;
use DBObjectSearch;
use DBObjectSet;
use DBSearch;
use lnkFunctionalCIToTicket;
use MetaModel;
use ormLinkSet;
use UserRequest;
use UserRights;
class DBSearchFilterJoinTest extends ItopDataTestCase
{
private const RESTRICTED_PROFILE = 'Configuration Manager';
private $aData = [];
protected function setUp(): void
{
parent::setUp();
$this->RequireOnceItopFile('application/startup.inc.php');
$this->aData = $this->CreateDBSearchFilterTestData();
DBSearch::EnableQueryCache(false, false);
$this->LoginRestrictedUser($this->aData['allowed_org_id'], self::RESTRICTED_PROFILE);
}
protected function tearDown(): void
{
parent::tearDown();
}
/**
* @dataProvider JoinedAndNestedOqlProvider
*/
public function testDBSearchFilterAppliedToJoinsWhenEnabled(string $sOql, int $iExpectedCount): void
{
$this->EnableJoinFilterConfig(true);
$oSearch = DBObjectSearch::FromOQL($sOql, ['denied_org' => $this->aData['denied_org_name'], 'allowed_org' => $this->aData['allowed_org_name']]);
$oSet = new \DBObjectSet($oSearch);
CMDBSource::TestQuery($oSearch->MakeSelectQuery());
$this->assertEquals($iExpectedCount, $oSet->Count());
}
/**
* @dataProvider JoinedAndNestedOqlProvider
*/
public function testDBSearchFilterAppliedToJoinsWhenDisabled(string $sOql, int $iExpectedCount, int $iExpectedDisabledCount): void
{
$this->EnableJoinFilterConfig(false);
$oSearch = DBObjectSearch::FromOQL($sOql, ['denied_org' => $this->aData['denied_org_name'], 'allowed_org' => $this->aData['allowed_org_name']]);
$oSet = new \DBObjectSet($oSearch);
CMDBSource::TestQuery($oSearch->MakeSelectQuery());
$this->assertEquals($iExpectedDisabledCount, $oSet->Count());
}
/**
* @dataProvider JoinedAndNestedOqlProvider
*/
public function testAllowAllDataBypassesDBSearchFilterWhenEnabled(string $sOql, int $iExpectedCount, int $iExpectedDisabledCount): void
{
$this->EnableJoinFilterConfig(true);
$oSearch = DBObjectSearch::FromOQL($sOql, ['denied_org' => $this->aData['denied_org_name'], 'allowed_org' => $this->aData['allowed_org_name']]);
$oSearch->AllowAllData();
$oSet = new \DBObjectSet($oSearch);
CMDBSource::TestQuery($oSearch->MakeSelectQuery());
$this->assertEquals($iExpectedDisabledCount, $oSet->Count());
}
/**
* @dataProvider JoinedAndNestedOqlProvider
*/
public function testAllowAllDataBypassesDBSearchFilterWhenDisabled(string $sOql, int $iExpectedCount, int $iExpectedDisabledCount): void
{
$this->EnableJoinFilterConfig(false);
$oSearch = DBObjectSearch::FromOQL($sOql, ['denied_org' => $this->aData['denied_org_name'], 'allowed_org' => $this->aData['allowed_org_name']]);
$oSearch->AllowAllData();
$oSet = new \DBObjectSet($oSearch);
CMDBSource::TestQuery($oSearch->MakeSelectQuery());
$this->assertEquals($iExpectedDisabledCount, $oSet->Count());
}
public function JoinedAndNestedOqlProvider(): array
{
return [
'join-filter-on-org' => [
'oql' => "SELECT OSF FROM OSFamily AS OSF JOIN VirtualMachine AS VM ON VM.osfamily_id = OSF.id JOIN Organization AS O ON VM.org_id = O.id WHERE O.name = :denied_org",
'expected_filtered_count' => 0,
'expected_unfiltered_count' => 1,
],
'nested-in-select' => [
'oql' => "SELECT OSF FROM OSFamily AS OSF WHERE OSF.id IN (SELECT OSF1 FROM OSFamily AS OSF1 JOIN VirtualMachine AS VM ON VM.osfamily_id = OSF1.id JOIN Organization AS O ON VM.org_id = O.id WHERE O.name = :denied_org)",
'expected_filtered_count' => 0,
'expected_unfiltered_count' => 1,
],
'userrequest-join-person-org' => [
'oql' => "SELECT OSF FROM OSFamily AS OSF JOIN VirtualMachine AS VM ON VM.osfamily_id = OSF.id JOIN lnkFunctionalCIToTicket AS L ON L.functionalci_id = VM.id JOIN UserRequest AS UR ON L.ticket_id = UR.id JOIN Person AS P ON UR.caller_id = P.id JOIN Organization AS O ON P.org_id = O.id WHERE O.name = :denied_org",
'expected_filtered_count' => 0,
'expected_unfiltered_count' => 1,
],
'union-join-filter-on-org' => [
'oql' => "SELECT OSF FROM OSFamily AS OSF JOIN VirtualMachine AS VM ON VM.osfamily_id = OSF.id JOIN Organization AS O ON VM.org_id = O.id WHERE O.name = :denied_org UNION SELECT OSF2 FROM OSFamily AS OSF2 JOIN VirtualMachine AS VM2 ON VM2.osfamily_id = OSF2.id JOIN Organization AS O2 ON VM2.org_id = O2.id WHERE O2.name = :allowed_org",
'expected_filtered_count' => 1,
'expected_unfiltered_count' => 2,
],
];
}
private function EnableJoinFilterConfig(bool $bEnabled): void
{
$oConfig = MetaModel::GetConfig();
$oConfig->Set('security.disable_joined_classes_filter', !$bEnabled);
}
private function CreateDBSearchFilterTestData(): array
{
$sSuffix = 'DBSearchFilterJoinTest';
$sAllowedOrgName = 'DBSearchFilterAllowedOrg-'.$sSuffix;
$iAllowedOrgId = $this->GivenObjectInDB('Organization', [
'name' => $sAllowedOrgName,
]);
$this->debug("Org allowed id: $iAllowedOrgId");
$sDeniedOrgName = 'DBSearchFilterDeniedOrg-'.$sSuffix;
$iDeniedOrgId = $this->GivenObjectInDB('Organization', [
'name' => $sDeniedOrgName,
]);
$this->debug("Org denied id: $iDeniedOrgId");
$iDeniedOsFamilyId = $this->GivenObjectInDB('OSFamily', [
'name' => 'DBSearchFilterOsFamilyDenied-'.$sSuffix,
]);
$iAllowedOsFamilyId = $this->GivenObjectInDB('OSFamily', [
'name' => 'DBSearchFilterOsFamilyAllowed-'.$sSuffix,
]);
$iDeniedVMId = $this->GivenObjectInDB('VirtualMachine', [
'name' => 'DBSearchFilterVmDenied-'.$sSuffix,
'org_id' => $iDeniedOrgId,
'osfamily_id' => $iDeniedOsFamilyId,
'virtualhost_id' => 1,
]);
$iVirtualHostId = $this->GivenObjectInDB('Hypervisor', [
'name' => 'DBSearchFilterVHost-'.$sSuffix,
'org_id' => $iAllowedOrgId,
]);
$this->GivenObjectInDB('VirtualMachine', [
'name' => 'DBSearchFilterVmAllowed-'.$sSuffix,
'org_id' => $iAllowedOrgId,
'osfamily_id' => $iAllowedOsFamilyId,
'virtualhost_id' => $iVirtualHostId,
]);
$oDeniedPerson = $this->CreatePerson('Denied-'.$sSuffix, $iDeniedOrgId);
$oUserRequest = $this->CreateUserRequest('Denied'.$sSuffix, [
'caller_id' => $oDeniedPerson->GetKey(),
'org_id' => $iDeniedOrgId,
]);
// Add Virtual Machine to UserRequest lnk
$oLinkSet = new ormLinkSet(UserRequest::class, 'functionalcis_list', DBObjectSet::FromScratch(lnkFunctionalCIToTicket::class));
$oLink = MetaModel::NewObject(lnkFunctionalCIToTicket::class, ['functionalci_id' => $iDeniedVMId]);
$oLinkSet->AddItem($oLink);
$oUserRequest->Set('functionalcis_list', $oLinkSet);
$oUserRequest->DBUpdate();
return [
'allowed_org_id' => $iAllowedOrgId,
'allowed_org_name' => $sAllowedOrgName,
'denied_org_name' => $sDeniedOrgName,
];
}
private function LoginRestrictedUser(int $iAllowedOrgId, string $sProfileName): void
{
$sLogin = $this->GivenUserRestrictedToAnOrganizationInDB($iAllowedOrgId, self::$aURP_Profiles[$sProfileName]);
UserRights::Login($sLogin);
}
}

View File

@@ -7,14 +7,36 @@
namespace Combodo\iTop\Test\UnitTest\Core;
use Combodo\iTop\Application\WebPage\CaptureWebPage;
use Combodo\iTop\Test\UnitTest\ItopDataTestCase;
use ormDocument;
use UserRights;
/**
* Tests of the ormDocument class
*/
class ormDocumentTest extends ItopDataTestCase
{
private const RESTRICTED_PROFILE = 'Configuration Manager';
private int $iUserOrg;
private int $iOrgDifferentFromUser;
protected function setUp(): void
{
parent::setUp();
$this->iUserOrg = $this->GivenObjectInDB('Organization', [
'name' => 'UserOrg',
]);
$this->iOrgDifferentFromUser = $this->GivenObjectInDB('Organization', [
'name' => 'OrgDifferentFromUser',
]);
$this->LoginRestrictedUser($this->iUserOrg, self::RESTRICTED_PROFILE);
$this->ResetMetaModelQueyCacheGetObject();
}
/**
* @inheritDoc
*/
@@ -139,4 +161,107 @@ class ormDocumentTest extends ItopDataTestCase
],
];
}
/**
* Test that DownloadDocument enforces rights for documents
*
* @dataProvider DownloadDocumentRightsProvider
*/
public function testDownloadDocumentDifferentOrg(string $sTargetClass, string $sAttCode, string $sData, string $sFileName, ?string $sHostClass)
{
$iDeniedDocumentId = $this->CreateDownloadTargetInOrg($sTargetClass, $sAttCode, $this->iOrgDifferentFromUser, $sData, $sFileName, $sHostClass);
$oPageDenied = new CaptureWebPage();
ormDocument::DownloadDocument($oPageDenied, $sTargetClass, $iDeniedDocumentId, $sAttCode);
$sDeniedHtml = (string) $oPageDenied->GetHtml();
$this->assertStringContainsString(
'the object does not exist or you are not allowed to view it',
$sDeniedHtml,
'Expected error message when rights are missing.'
);
$this->assertStringNotContainsString($sData, $sDeniedHtml, 'Unexpected file data present when rights are missing.');
}
/**
* Test that DownloadDocument allows to retrieve document with the same org (or host object org)
*
* @dataProvider DownloadDocumentRightsProvider
*/
public function testDownloadDocumentSameOrg(string $sTargetClass, string $sAttCode, string $sData, string $sFileName, ?string $sHostClass)
{
$iAllowedDocumentId = $this->CreateDownloadTargetInOrg($sTargetClass, $sAttCode, $this->iUserOrg, $sData, $sFileName, $sHostClass);
$oPageAllowed = new CaptureWebPage();
ormDocument::DownloadDocument($oPageAllowed, $sTargetClass, $iAllowedDocumentId, $sAttCode);
$sAllowedHtml = (string) $oPageAllowed->GetHtml();
$this->assertStringContainsString($sData, $sAllowedHtml, 'Expected file data present when rights are sufficient.');
$this->assertStringNotContainsString('the object does not exist or you are not allowed to view it', $sAllowedHtml, 'Unexpected error message when rights are sufficient.');
}
public function DownloadDocumentRightsProvider(): array
{
return [
'DocumentFile' => [
'class' => 'DocumentFile',
'data_attribute_id' => 'file',
'data' => 'document_data',
'file_name' => 'document.txt',
'host_class' => null],
'Attachment' => [
'class' => 'Attachment',
'data_attribute_id' => 'contents',
'data' => 'attachment_data',
'file_name' => 'attachment.txt',
'host_class' => 'UserRequest'],
];
}
/**
* Helper to avoid duplicating object creation in tests
* Created objects and host objects depending on the Document class
* @param string $sTargetClass
* @param string $sAttCode
* @param int $iOrgId
* @param string $sData
* @param string $sFileName
* @param string|null $sHostClass
* @return int
* @throws \Exception
*/
private function CreateDownloadTargetInOrg(string $sTargetClass, string $sAttCode, int $iOrgId, string $sData, string $sFileName, ?string $sHostClass): int
{
if ($sTargetClass === 'DocumentFile') {
return $this->GivenObjectInDB($sTargetClass, [
'name' => 'UnitTestDocFile_'.uniqid(),
'org_id' => $iOrgId,
$sAttCode => new ormDocument($sData, 'text/plain', $sFileName),
]);
}
if ($sTargetClass === 'Attachment') {
$iHostId = $this->GivenObjectInDB($sHostClass, [
'title' => 'UnitTestUserRequest_'.uniqid(),
'org_id' => $iOrgId,
'description' => 'A user request for testing attachment download rights',
]);
return $this->GivenObjectInDB('Attachment', [
'item_class' => $sHostClass,
'item_id' => $iHostId,
$sAttCode => new ormDocument($sData, 'text/plain', $sFileName),
]);
}
throw new \Exception("Unsupported target class: $sTargetClass");
}
private function LoginRestrictedUser(int $iAllowedOrgId, string $sProfileName): void
{
if (UserRights::IsLoggedIn()) {
UserRights::Logoff();
}
$sLogin = $this->GivenUserRestrictedToAnOrganizationInDB($iAllowedOrgId, self::$aURP_Profiles[$sProfileName]);
UserRights::Login($sLogin);
}
}