Compare commits

...

2 Commits

Author SHA1 Message Date
v-dumas
0e104f6833 N°1871 - Config param to keep legacy behavior 2026-11-13 18:48:18 +01:00
v-dumas
4f44a64fb1 N°1871 - Security and class hierarchy: allowing only a sub-tree 2026-11-13 18:46:21 +01:00
3 changed files with 546 additions and 1 deletions

View File

@@ -591,10 +591,18 @@ class UserRightsProfile extends UserRightsAddOnAPI
return $aRet;
}
/**
* Restrict results by the read grants of both the queried class and each concrete class.
* The concrete class restriction can be disabled with the `security.disable_final_classes_filter` configuration parameter.
*
* @since 3.2.3
*/
public function GetSelectFilter($oUser, $sClass, $aSettings = [])
{
$this->LoadCache();
$aConditions = [];
// Let us pass an administrator for bypassing the grant matrix check in order to test this method without the need to set up a complex profile
// In the nominal case Administrators never end up here (since they completely bypass GetSelectFilter)
if (!static::IsAdministrator($oUser) && (MetaModel::HasCategory($sClass, 'silo') || MetaModel::HasCategory($sClass, 'bizmodel'))) {
@@ -603,10 +611,34 @@ class UserRightsProfile extends UserRightsAddOnAPI
if ($aObjectPermissions['permission'] == UR_ALLOWED_NO) {
return false;
}
$aAllowedClasses = [];
$bHasDeniedClass = false;
$aConcreteClasses = MetaModel::GetConfig()->Get('security.disable_final_classes_filter') === true ? [] : MetaModel::EnumChildClasses($sClass, ENUM_CHILD_CLASSES_ALL);
foreach ($aConcreteClasses as $sConcreteClass) {
if (MetaModel::IsAbstract($sConcreteClass)) {
continue;
}
$aPermissions = $this->GetUserActionGrant($oUser, $sConcreteClass, UR_ACTION_READ);
if ($aPermissions['permission'] === UR_ALLOWED_YES) {
$aAllowedClasses[] = $sConcreteClass;
} else {
$bHasDeniedClass = true;
}
}
if ($bHasDeniedClass) {
if (count($aAllowedClasses) === 0) {
return false;
}
$aConditions[] = new BinaryExpression(
new FieldExpression('finalclass', $sClass),
'IN',
ListExpression::FromScalars($aAllowedClasses)
);
}
}
$oFilter = true;
$aConditions = [];
// Determine if this class is part of a silo and build the filter for it
$sAttCode = self::GetOwnerOrganizationAttCode($sClass);

View File

@@ -1763,6 +1763,14 @@ class Config
'source_of_value' => '',
'show_in_conf_sample' => false,
],
'security.disable_final_classes_filter' => [
'type' => 'bool',
'description' => 'If true, read rights on the final classes are not checked when querying a parent class: users allowed to read a parent class can see objects of child classes they are not allowed to read.',
'default' => false,
'value' => false,
'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.',

View File

@@ -0,0 +1,505 @@
<?php
/*
* @copyright Copyright (C) 2010-2026 Combodo SAS
* @license http://opensource.org/licenses/AGPL-3.0
*/
namespace Combodo\iTop\Test\UnitTest\Core;
use Combodo\iTop\Test\UnitTest\ItopDataTestCase;
use CMDBSource;
use DBObjectSearch;
use DBObjectSet;
use DBUnionSearch;
use MetaModel;
use FieldExpression;
use ProfilesConfig;
use UserRights;
use UserRightsProfile;
/**
* @since 3.2.3
*/
class UserRightsFinalClassFilterTest extends ItopDataTestCase
{
public const CREATE_TEST_ORG = true;
/** @since 3.2.3 */
private array $aOriginalGrants;
/** @since 3.2.3 */
private array $aOriginalSettings = [];
/** @since 3.2.3 */
private int $iProfileId;
/** @since 3.2.3 */
private array $aCIIds;
/** @since 3.2.3 */
private string $sLogin;
/** @since 3.2.3 */
protected function setUp(): void
{
parent::setUp();
$this->aOriginalGrants = $this->GetNonPublicStaticProperty(ProfilesConfig::class, 'aGRANTS');
foreach (['user_rights_legacy', 'security.disable_joined_classes_filter', 'security.disable_final_classes_filter'] as $sSetting) {
$this->aOriginalSettings[$sSetting] = MetaModel::GetConfig()->Get($sSetting);
MetaModel::GetConfig()->Set($sSetting, false);
}
$this->iProfileId = (int) $this->GivenObjectInDB('URP_Profiles', [
'name' => 'Final class filter test',
'description' => 'Read grants for final class filtering',
]);
$this->sLogin = 'final-class-filter-'.uniqid();
$this->CreateContactlessUser($this->sLogin, $this->iProfileId, uniqid().'Aa1!');
$iNetworkDeviceType = $this->GivenObjectInDB('NetworkDeviceType', ['name' => 'Final class test device type']);
$this->aCIIds = [
'Server' => $this->GivenObjectInDB('Server', ['name' => 'Allowed server']),
'NetworkDevice' => $this->GivenObjectInDB('NetworkDevice', [
'name' => 'Denied network device',
'networkdevicetype_id' => $iNetworkDeviceType,
]),
];
}
/** @since 3.2.3 */
protected function tearDown(): void
{
$this->SetNonPublicStaticProperty(ProfilesConfig::class, 'aGRANTS', $this->aOriginalGrants);
foreach ($this->aOriginalSettings as $sSetting => $value) {
MetaModel::GetConfig()->Set($sSetting, $value);
}
UserRights::FlushPrivileges();
parent::tearDown();
}
/** @since 3.2.3 */
private function LoginWithGrants(array $aGrants, array $aAdditionalProfileGrants = []): void
{
$aConfiguredGrants = array_replace($this->aOriginalGrants, $aAdditionalProfileGrants);
foreach ($aGrants as $sClass => $bAllowed) {
$aConfiguredGrants[$this->iProfileId.'_'.$sClass.'_r'] = $bAllowed;
}
$this->SetNonPublicStaticProperty(ProfilesConfig::class, 'aGRANTS', $aConfiguredGrants);
UserRights::FlushPrivileges();
$this->assertTrue(UserRights::Login($this->sLogin));
}
/**
* @dataProvider ReadGrantsProvider
* @since 3.2.3
*/
public function testReadGrants(array $aGrants, string $sClass, array $aExpectedClasses): void
{
$this->LoginWithGrants($aGrants);
$oSearch = new DBObjectSearch($sClass);
$oSearch->AddCondition('id', array_values($this->aCIIds), 'IN');
$this->AssertVisibleClasses($oSearch, $aExpectedClasses);
}
/** @since 3.2.3 */
public function ReadGrantsProvider(): array
{
return [
'deny descendant through parent' => [
['*' => true, 'NetworkDevice' => false], 'FunctionalCI', ['Server'],
],
'deny parent despite allowed descendant' => [
['*' => true, 'FunctionalCI' => false, 'Server' => true], 'FunctionalCI', [],
],
'deny intermediate class and its descendants' => [
['*' => true, 'DatacenterDevice+' => false], 'FunctionalCI', [],
],
'all concrete descendants denied' => [
['FunctionalCI' => true], 'FunctionalCI', [],
],
'all descendants allowed through inheritance' => [
['FunctionalCI+' => true, 'FunctionalCI' => true], 'FunctionalCI', ['Server', 'NetworkDevice'],
],
'allowed leaf unchanged' => [
['*' => true], 'Server', ['Server'],
],
'denied leaf unchanged' => [
['*' => true, 'Server' => false], 'Server', [],
],
];
}
/** @since 3.2.3 */
public function testAllAllowedDoesNotAddAFilter(): void
{
$this->LoginWithGrants(['*' => true]);
$this->assertTrue(UserRights::GetSelectFilter('FunctionalCI'));
}
/** @since 3.2.3 */
public function testGroupedCountsExcludeDeniedClasses(): void
{
$this->LoginWithGrants(['*' => true, 'NetworkDevice' => false]);
$oSearch = new DBObjectSearch('FunctionalCI');
$oSearch->AddCondition('id', array_values($this->aCIIds), 'IN');
$aRows = CMDBSource::QueryToArray($oSearch->MakeGroupByQuery([], [
'concrete_class' => new FieldExpression('finalclass', 'FunctionalCI'),
]));
$this->assertCount(1, $aRows);
$this->assertSame('Server', $aRows[0]['concrete_class']);
$this->assertSame(1, (int) $aRows[0]['_itop_count_']);
}
/** @since 3.2.3 */
public function testAnotherHierarchyIsFiltered(): void
{
$iPerson = $this->GivenObjectInDB('Person', ['name' => 'Allowed contact', 'first_name' => 'Test']);
$iTeam = $this->GivenObjectInDB('Team', ['name' => 'Denied contact']);
$this->LoginWithGrants(['*' => true, 'Team' => false]);
$oSearch = new DBObjectSearch('Contact');
$oSearch->AddCondition('id', [$iPerson, $iTeam], 'IN');
$oSet = new DBObjectSet($oSearch);
$this->assertSame(1, $oSet->Count());
$this->assertSame([(int) $iPerson => 'Person'], $oSet->GetColumnAsArray('finalclass'));
}
/** @since 3.2.3 */
public function testConcreteParentRetainsItsOwnObjects(): void
{
$aEventValues = ['message' => 'Final class filter test event', 'date' => '2026-01-01 12:00:00'];
$iEvent = $this->GivenObjectInDB('Event', $aEventValues);
$iIssue = $this->GivenObjectInDB('EventIssue', array_merge($aEventValues, [
'issue' => 'Final class filter test issue',
'page' => 'Final class filter test',
]));
$this->LoginWithGrants(['Event' => true, 'Event+' => true, 'EventIssue' => false]);
$oSearch = new DBObjectSearch('Event');
$oSearch->AddCondition('id', [$iEvent, $iIssue], 'IN');
$oSet = new DBObjectSet($oSearch);
$this->assertSame(1, $oSet->Count());
$this->assertSame([(int) $iEvent => 'Event'], $oSet->GetColumnAsArray('finalclass'));
}
/** @since 3.2.3 */
public function testFilterCategoryKeepsItsReadException(): void
{
$this->LoginWithGrants([]);
$this->assertNotFalse(UserRights::GetSelectFilter('URP_Profiles'));
}
/** @since 3.2.3 */
public function testUnionSelectFilterStillAppliesClassRestrictions(): void
{
$this->LoginWithGrants(['*' => true, 'NetworkDevice' => false]);
$oOriginalModule = UserRights::GetModuleInstance();
$oModule = $this->createPartialMock(UserRightsProfile::class, ['GetSelectFilter']);
$oModule->method('GetSelectFilter')->willReturnCallback(
static function ($oUser, $sClass, $aSettings = []) use ($oOriginalModule) {
$oFilter = $oOriginalModule->GetSelectFilter($oUser, $sClass, $aSettings);
if ($oFilter instanceof DBObjectSearch) {
return new DBUnionSearch([$oFilter, DBObjectSearch::FromEmptySet($sClass)]);
}
return $oFilter;
}
);
$this->SetNonPublicStaticProperty(UserRights::class, 'm_oAddOn', $oModule);
try {
$this->assertInstanceOf(DBUnionSearch::class, UserRights::GetSelectFilter('FunctionalCI'));
$oSearch = new DBObjectSearch('FunctionalCI');
$oSearch->AddCondition('id', array_values($this->aCIIds), 'IN');
$this->AssertVisibleClasses($oSearch, ['Server']);
} finally {
$this->SetNonPublicStaticProperty(UserRights::class, 'm_oAddOn', $oOriginalModule);
}
}
/** @since 3.2.3 */
public function testExplicitDenyFromAnotherProfileWins(): void
{
$iDenyProfile = $this->GivenObjectInDB('URP_Profiles', [
'name' => 'Final class deny profile',
'description' => 'Explicit deny overrides another profile',
]);
$this->GivenObjectInDB('URP_UserProfile', [
'userid' => UserRights::GetUserId($this->sLogin),
'profileid' => $iDenyProfile,
]);
$this->LoginWithGrants(['*' => true], [$iDenyProfile.'_NetworkDevice_r' => false]);
$oSearch = new DBObjectSearch('FunctionalCI');
$oSearch->AddCondition('id', array_values($this->aCIIds), 'IN');
$this->AssertVisibleClasses($oSearch, ['Server']);
}
/**
* @dataProvider JoinedAliasesProvider
* @since 3.2.3
*/
public function testJoinedAliases(bool $bDisableJoinFilter, bool $bSelectBoth, string $sLeftClass, string $sRightClass, bool $bVisible): void
{
MetaModel::GetConfig()->Set('security.disable_joined_classes_filter', $bDisableJoinFilter);
$this->LoginWithGrants(['*' => true, 'NetworkDevice' => false]);
$sSelectedAliases = $bSelectBoth ? 'A, B' : 'A';
$oSearch = DBObjectSearch::FromOQL(
"SELECT $sSelectedAliases FROM FunctionalCI AS A JOIN Organization AS O ON A.org_id = O.id
JOIN FunctionalCI AS B ON B.org_id = O.id WHERE A.id = :left_id AND B.id = :right_id"
);
$oSet = new DBObjectSet($oSearch, [], [
'left_id' => $this->aCIIds[$sLeftClass],
'right_id' => $this->aCIIds[$sRightClass],
]);
$this->assertSame($bVisible ? 1 : 0, $oSet->Count());
$aRow = $oSet->FetchAssoc();
if ($bVisible) {
$this->assertSame((int) $this->aCIIds[$sLeftClass], (int) $aRow['A']->GetKey());
if ($bSelectBoth) {
$this->assertSame((int) $this->aCIIds[$sRightClass], (int) $aRow['B']->GetKey());
}
} else {
$this->assertNull($aRow);
}
}
/** @since 3.2.3 */
public function JoinedAliasesProvider(): array
{
return [
'allowed aliases' => [false, true, 'Server', 'Server', true],
'denied selected left alias' => [false, true, 'NetworkDevice', 'Server', false],
'denied selected right alias' => [false, true, 'Server', 'NetworkDevice', false],
'denied non-selected alias' => [false, false, 'Server', 'NetworkDevice', false],
'non-selected alias with join protection disabled' => [true, false, 'Server', 'NetworkDevice', true],
'selected right alias with join protection disabled' => [true, true, 'Server', 'NetworkDevice', false],
'selected left alias with join protection disabled' => [true, false, 'NetworkDevice', 'Server', false],
];
}
/**
* @dataProvider CompoundQueriesProvider
* @since 3.2.3
*/
public function testCompoundQueries(string $sOQL): void
{
$this->LoginWithGrants(['*' => true, 'NetworkDevice' => false]);
$oSearch = DBObjectSearch::FromOQL($sOQL, [
'org_id' => $this->getTestOrgId(),
'server_id' => $this->aCIIds['Server'],
'network_id' => $this->aCIIds['NetworkDevice'],
]);
$this->AssertVisibleClasses($oSearch, ['Server']);
}
/** @since 3.2.3 */
public function CompoundQueriesProvider(): array
{
return [
'union' => [
'SELECT FunctionalCI WHERE id = :server_id UNION SELECT FunctionalCI WHERE id = :network_id',
],
'nested search' => [
'SELECT FunctionalCI WHERE id IN (SELECT FunctionalCI WHERE org_id = :org_id)',
],
];
}
/** @since 3.2.3 */
public function testOrganizationScopeStillApplies(): void
{
$iOtherOrg = $this->GivenObjectInDB('Organization', ['name' => 'Other final class test organization']);
$iOtherServer = $this->GivenObjectInDB('Server', ['name' => 'Server outside scope', 'org_id' => $iOtherOrg]);
$this->GivenObjectInDB('URP_UserOrg', [
'userid' => UserRights::GetUserId($this->sLogin),
'allowed_org_id' => $this->getTestOrgId(),
]);
$this->bIsUsingSilo = true;
$this->LoginWithGrants(['*' => true, 'NetworkDevice' => false]);
$oSearch = new DBObjectSearch('FunctionalCI');
$oSearch->AddCondition('id', array_merge(array_values($this->aCIIds), [$iOtherServer]), 'IN');
$this->AssertVisibleClasses($oSearch, ['Server']);
}
/**
* Given a user with read grants and allowed organizations, check the objects returned by a source OQL.
* Placeholders {allowed_org_1}, {allowed_org_2} and {denied_org} are replaced by test organization ids.
*
* @dataProvider SourceOQLProvider
* @since 3.2.3
*/
public function testSourceOQLReturnsOnlyAllowedClassesAndOrganizations(array $aGrants, string $sSourceOQL, array $aExpectedVisibleObjects, array $aSettings = []): void
{
$aOrgIds = [
'{allowed_org_1}' => (int) $this->GivenObjectInDB('Organization', ['name' => 'Final class allowed org 1']),
'{allowed_org_2}' => (int) $this->GivenObjectInDB('Organization', ['name' => 'Final class allowed org 2']),
'{denied_org}' => (int) $this->GivenObjectInDB('Organization', ['name' => 'Final class denied org']),
];
$aObjectIds = [];
foreach ($aOrgIds as $sOrg => $iOrgId) {
$aObjectIds['Person'.$sOrg] = (int) $this->GivenObjectInDB('Person', ['name' => 'Person '.$sOrg, 'first_name' => 'Test', 'org_id' => $iOrgId]);
$aObjectIds['Team'.$sOrg] = (int) $this->GivenObjectInDB('Team', ['name' => 'Team '.$sOrg, 'org_id' => $iOrgId]);
}
// Memberships, including memberships crossing the allowed organizations scope
foreach ([
'Person{allowed_org_1}' => 'Team{allowed_org_1}',
'Person{allowed_org_2}' => 'Team{denied_org}',
'Person{denied_org}' => 'Team{allowed_org_1}',
] as $sPerson => $sTeam) {
$this->GivenObjectInDB('lnkPersonToTeam', ['person_id' => $aObjectIds[$sPerson], 'team_id' => $aObjectIds[$sTeam]]);
}
foreach (['{allowed_org_1}', '{allowed_org_2}'] as $sOrg) {
$this->GivenObjectInDB('URP_UserOrg', [
'userid' => UserRights::GetUserId($this->sLogin),
'allowed_org_id' => $aOrgIds[$sOrg],
]);
}
foreach ($aSettings as $sSetting => $value) {
MetaModel::GetConfig()->Set($sSetting, $value);
}
$this->bIsUsingSilo = true;
$this->LoginWithGrants($aGrants);
$oSet = new DBObjectSet(DBObjectSearch::FromOQL(strtr($sSourceOQL, $aOrgIds)));
$aVisibleObjects = array_keys(array_intersect($aObjectIds, array_map('intval', array_keys($oSet->GetColumnAsArray('id')))));
sort($aVisibleObjects);
sort($aExpectedVisibleObjects);
$this->assertSame($aExpectedVisibleObjects, $aVisibleObjects, 'Objects visible for the user');
$this->assertSame(count($aExpectedVisibleObjects), $oSet->Count(), 'No other object visible for the user');
}
/** @since 3.2.3 */
public function SourceOQLProvider(): array
{
$aContactWithoutTeam = ['*' => true, 'Team' => false];
$aDisableJoinedClassesFilter = ['security.disable_joined_classes_filter' => true];
$aDisableFinalClassesFilter = ['security.disable_final_classes_filter' => true];
$sPersonJoinLink = 'SELECT Person AS P JOIN lnkPersonToTeam AS L ON L.person_id = P.id WHERE L.id > 0';
$sPersonJoinTeam = 'SELECT Person AS P JOIN lnkPersonToTeam AS L ON L.person_id = P.id JOIN Team AS T ON L.team_id = T.id WHERE T.id > 0';
$sTeamJoinPerson = 'SELECT Team AS T JOIN lnkPersonToTeam AS L ON L.team_id = T.id JOIN Person AS P ON L.person_id = P.id';
$sContactJoinLink = 'SELECT Contact AS C JOIN lnkPersonToTeam AS L ON L.person_id = C.id';
// The joined Contact alias can only match a Team, so it must be restricted to its allowed final classes
$sPersonJoinContact = 'SELECT Person AS P JOIN lnkPersonToTeam AS L ON L.person_id = P.id JOIN Contact AS C ON L.team_id = C.id';
$sSelectJoinedContact = 'SELECT C FROM Person AS P JOIN lnkPersonToTeam AS L ON L.person_id = P.id JOIN Contact AS C ON L.team_id = C.id';
return [
'Contact without condition, Team denied, final classes filter disabled' => [
$aContactWithoutTeam, 'SELECT Contact AS C', ['Person{allowed_org_1}', 'Person{allowed_org_2}', 'Team{allowed_org_1}', 'Team{allowed_org_2}'], $aDisableFinalClassesFilter,
],
'Team, Team denied, final classes filter disabled' => [
$aContactWithoutTeam, 'SELECT Team AS T', [], $aDisableFinalClassesFilter,
],
'Selected joined Contact, Team denied, final classes filter disabled' => [
$aContactWithoutTeam, $sSelectJoinedContact, ['Team{allowed_org_1}'], $aDisableFinalClassesFilter,
],
'Person joined to Team as Contact, Team denied' => [
$aContactWithoutTeam, $sPersonJoinContact, [],
],
'Person joined to Team as Contact, Team denied, joined classes filter disabled' => [
$aContactWithoutTeam, $sPersonJoinContact, ['Person{allowed_org_1}', 'Person{allowed_org_2}'], $aDisableJoinedClassesFilter,
],
'Person joined to Team as Contact, all contacts allowed' => [
['*' => true], $sPersonJoinContact, ['Person{allowed_org_1}'],
],
'Selected joined Contact, Team denied' => [
$aContactWithoutTeam, $sSelectJoinedContact, [],
],
'Selected joined Contact, Team denied, joined classes filter disabled' => [
$aContactWithoutTeam, $sSelectJoinedContact, [], $aDisableJoinedClassesFilter,
],
'Selected joined Contact, all contacts allowed' => [
['*' => true], $sSelectJoinedContact, ['Team{allowed_org_1}'],
],
'Person joined to link, Team denied' => [
$aContactWithoutTeam, $sPersonJoinLink, ['Person{allowed_org_1}', 'Person{allowed_org_2}'],
],
'Person joined to Team, Team denied' => [
$aContactWithoutTeam, $sPersonJoinTeam, [],
],
'Person joined to Team, Team denied, joined classes filter disabled' => [
$aContactWithoutTeam, $sPersonJoinTeam, ['Person{allowed_org_1}', 'Person{allowed_org_2}'], $aDisableJoinedClassesFilter,
],
'Person joined to Team, all contacts allowed' => [
['*' => true], $sPersonJoinTeam, ['Person{allowed_org_1}'],
],
'Person joined to Team, all contacts allowed, joined classes filter disabled' => [
['*' => true], $sPersonJoinTeam, ['Person{allowed_org_1}', 'Person{allowed_org_2}'], $aDisableJoinedClassesFilter,
],
'Team joined to Person, Team denied' => [
$aContactWithoutTeam, $sTeamJoinPerson, [],
],
'Team joined to Person, all contacts allowed' => [
['*' => true], $sTeamJoinPerson, ['Team{allowed_org_1}'],
],
'Contact joined to link, Team denied' => [
$aContactWithoutTeam, $sContactJoinLink, ['Person{allowed_org_1}', 'Person{allowed_org_2}'],
],
'Contact in an allowed org, Team denied' => [
$aContactWithoutTeam,
'SELECT Contact AS C WHERE C.org_id = {allowed_org_2}',
['Person{allowed_org_2}'],
],
'Contact in a denied org, Team denied' => [
$aContactWithoutTeam,
'SELECT Contact AS C WHERE C.org_id = {denied_org}',
[],
],
'Contact without condition, Team denied' => [
$aContactWithoutTeam,
'SELECT Contact AS C',
['Person{allowed_org_1}', 'Person{allowed_org_2}'],
],
'Person in an allowed org, Team denied' => [
$aContactWithoutTeam,
'SELECT Person AS P WHERE P.org_id = {allowed_org_1}',
['Person{allowed_org_1}'],
],
'Team, Team denied' => [
$aContactWithoutTeam,
'SELECT Team AS T',
[],
],
'Contact with all contacts allowed' => [
['*' => true],
'SELECT Contact AS C',
['Person{allowed_org_1}', 'Person{allowed_org_2}', 'Team{allowed_org_1}', 'Team{allowed_org_2}'],
],
];
}
/** @since 3.2.3 */
public function testAllowAllDataStillBypassesRestrictions(): void
{
$this->LoginWithGrants(['*' => true, 'NetworkDevice' => false]);
$oSearch = new DBObjectSearch('FunctionalCI');
$oSearch->AddCondition('id', array_values($this->aCIIds), 'IN');
// Explicitly test the privileged bypass, not a normal user search.
$oSearch->AllowAllData();
$this->AssertVisibleClasses($oSearch, ['Server', 'NetworkDevice']);
}
/** @since 3.2.3 */
public function testAdministratorStillBypassesRestrictions(): void
{
$sAdminLogin = 'final-class-admin-'.uniqid();
$this->CreateContactlessUser($sAdminLogin, self::$aURP_Profiles['Administrator'], uniqid().'Aa1!');
$this->LoginWithGrants(['*' => true, 'NetworkDevice' => false]);
UserRights::Logoff();
$this->assertTrue(UserRights::Login($sAdminLogin));
$this->assertTrue(UserRights::GetSelectFilter('FunctionalCI'));
$oSearch = new DBObjectSearch('FunctionalCI');
$oSearch->AddCondition('id', array_values($this->aCIIds), 'IN');
$this->AssertVisibleClasses($oSearch, ['Server', 'NetworkDevice']);
}
/** @since 3.2.3 */
private function AssertVisibleClasses(\DBSearch $oSearch, array $aExpectedClasses): void
{
$oSet = new DBObjectSet($oSearch);
$this->assertSame(count($aExpectedClasses), $oSet->Count());
$aExpectedIds = [];
foreach ($aExpectedClasses as $sClass) {
$aExpectedIds[$this->aCIIds[$sClass]] = $sClass;
}
$aActualIds = $oSet->GetColumnAsArray('finalclass');
ksort($aExpectedIds);
ksort($aActualIds);
$this->assertSame($aExpectedIds, $aActualIds);
}
}