CVE-2022-48365 (published 2023-03-12T05:15Z)

An issue was discovered in eZ Platform Ibexa Kernel before 1.3.26. The Company admin role gives excessive privileges.

CVSS v3 base score: 7.2, HIGH (CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H)

Weakness information:
cwe_id: CWE-269
cwe_name: Improper Privilege Management
description: The product does not properly assign, modify, track, or check privileges for an actor, creating an unintended sphere of control for that actor.
extended_description: 
            
         
         

Fix information:
repo_url: https://github.com/ezsystems/ezpublish-kernel
commit_details: repo_url: https://github.com/ezsystems/ezpublish-kernel
author: Dawid Parafiński
author_date: 2022-11-10 12:22:29+01:00
msg: Merge pull request from GHSA-99r3-xmmq-7q7g
repository: repo_url: https://github.com/ezsystems/ezpublish-kernel
repo_name: ezsystems/ezpublish-kernel
description: Kernel (Repository, MVC layer, REST) for eZ Platform
repo_language: PHP
owner: ezsystems
file_changes: filename: policies.yml
change_type: ModificationType.MODIFY
diff: @@ -30,7 +30,7 @@ parameters:
             administrate: ~
 
         role:
-            assign: ~
+            assign: { MemberOf: true, Role: true }
             update: ~
             create: ~
             delete: ~

programming_language: TypeScript
filename: roles.yml
change_type: ModificationType.MODIFY
diff: @@ -146,3 +146,15 @@ services:
         class: "%ezpublish.api.role.limitation_type.blocking.class%"
         arguments: ['AntiSpam']
         tags: [{name: ezpublish.limitationType, alias: AntiSpam}]
+
+    Ibexa\Core\Limitation\MemberOfLimitationType:
+        arguments:
+            $persistence: '@ezpublish.api.persistence_handler'
+        tags:
+            - { name: ezpublish.limitationType, alias: MemberOf }
+
+    Ibexa\Core\Limitation\RoleLimitationType:
+        arguments:
+            $persistence: '@ezpublish.api.persistence_handler'
+        tags:
+            - { name: ezpublish.limitationType, alias: Role }

programming_language: CoffeeScript
filename: phpunit-integration-legacy.xml
change_type: ModificationType.MODIFY
diff: @@ -73,6 +73,9 @@
         <testsuite name="gateway-integration">
             <file>eZ/Publish/SPI/Tests/Search/Content/IndexerGatewayTest.php</file>
         </testsuite>
+        <testsuite name="Ibexa\Tests\Integration\">
+            <directory>tests/integration</directory>
+        </testsuite>
     </testsuites>
     <filter>
         <whitelist>

programming_language: PHP
filename: phpunit.xml
change_type: ModificationType.MODIFY
diff: @@ -73,6 +73,9 @@
     <testsuite name="eZ\Publish\API\Repository\Values\Content">
       <directory>eZ/Publish/API/Repository/Tests/Values/Content</directory>
     </testsuite>
+    <testsuite name="Ibexa\Tests\Core\">
+        <directory>tests/lib</directory>
+    </testsuite>
   </testsuites>
   <filter>
     <whitelist>

programming_language: C#
filename: MemberOfLimitation.php
change_type: ModificationType.ADD
diff: @@ -0,0 +1,21 @@
+<?php
+
+/**
+ * @copyright Copyright (C) eZ Systems AS. All rights reserved.
+ * @license For full copyright and license information view LICENSE file distributed with this source code.
+ */
+declare(strict_types=1);
+
+namespace Ibexa\Contracts\Core\Repository\Values\User\Limitation;
+
+use eZ\Publish\API\Repository\Values\User\Limitation;
+
+final class MemberOfLimitation extends Limitation
+{
+    public const IDENTIFIER = 'MemberOf';
+
+    public function getIdentifier(): string
+    {
+        return self::IDENTIFIER;
+    }
+}

programming_language: PHP
filename: RoleLimitation.php
change_type: ModificationType.ADD
diff: @@ -0,0 +1,21 @@
+<?php
+
+/**
+ * @copyright Copyright (C) eZ Systems AS. All rights reserved.
+ * @license For full copyright and license information view LICENSE file distributed with this source code.
+ */
+declare(strict_types=1);
+
+namespace Ibexa\Contracts\Core\Repository\Values\User\Limitation;
+
+use eZ\Publish\API\Repository\Values\User\Limitation;
+
+final class RoleLimitation extends Limitation
+{
+    public const IDENTIFIER = 'Role';
+
+    public function getIdentifier(): string
+    {
+        return self::IDENTIFIER;
+    }
+}

programming_language: PHP
filename: MemberOfLimitationType.php
change_type: ModificationType.ADD
diff: @@ -0,0 +1,201 @@
+<?php
+
+/**
+ * @copyright Copyright (C) eZ Systems AS. All rights reserved.
+ * @license For full copyright and license information view LICENSE file distributed with this source code.
+ */
+declare(strict_types=1);
+
+namespace Ibexa\Core\Limitation;
+
+use eZ\Publish\API\Repository\Exceptions\NotImplementedException;
+use eZ\Publish\API\Repository\Values\User\User;
+use eZ\Publish\API\Repository\Values\User\UserGroup;
+use eZ\Publish\API\Repository\Values\User\UserGroupRoleAssignment;
+use eZ\Publish\API\Repository\Values\User\UserReference as APIUserReference;
+use eZ\Publish\API\Repository\Exceptions\NotFoundException;
+use eZ\Publish\API\Repository\Values\User\UserRoleAssignment;
+use eZ\Publish\API\Repository\Values\ValueObject;
+use eZ\Publish\Core\Base\Exceptions\InvalidArgumentException;
+use eZ\Publish\Core\Base\Exceptions\InvalidArgumentType;
+use eZ\Publish\Core\FieldType\ValidationError;
+use eZ\Publish\Core\Limitation\AbstractPersistenceLimitationType;
+use eZ\Publish\SPI\Limitation\Type as SPILimitationTypeInterface;
+use eZ\Publish\API\Repository\Values\User\Limitation as APILimitationValue;
+use Ibexa\Contracts\Core\Repository\Values\User\Limitation\MemberOfLimitation;
+
+final class MemberOfLimitationType extends AbstractPersistenceLimitationType implements SPILimitationTypeInterface
+{
+    public const SELF_USER_GROUP = -1;
+
+    /**
+     * @throws \eZ\Publish\Core\Base\Exceptions\InvalidArgumentType
+     */
+    public function acceptValue(APILimitationValue $limitationValue): void
+    {
+        if (!$limitationValue instanceof MemberOfLimitation) {
+            throw new InvalidArgumentType(
+                '$limitationValue',
+                MemberOfLimitation::class,
+                $limitationValue
+            );
+        }
+
+        if (!is_array($limitationValue->limitationValues)) {
+            throw new InvalidArgumentType(
+                '$limitationValue->limitationValues',
+                'array',
+                $limitationValue->limitationValues
+            );
+        }
+
+        foreach ($limitationValue->limitationValues as $key => $id) {
+            if (!is_int($id)) {
+                throw new InvalidArgumentType("\$limitationValue->limitationValues[{$key}]", 'int|string', $id);
+            }
+        }
+    }
+
+    public function validate(APILimitationValue $limitationValue)
+    {
+        $validationErrors = [];
+
+        foreach ($limitationValue->limitationValues as $key => $id) {
+            if ($id === self::SELF_USER_GROUP) {
+                continue;
+            }
+            try {
+                $this->persistence->contentHandler()->loadContentInfo($id);
+            } catch (NotFoundException $e) {
+                $validationErrors[] = new ValidationError(
+                    "limitationValues[%key%] => '%value%' does not exist in the backend",
+                    null,
+                    [
+                        'value' => $id,
+                        'key' => $key,
+                    ]
+                );
+            }
+        }
+
+        return $validationErrors;
+    }
+
+    /**
+     * @param mixed[] $limitationValues
+     *
+     * @return \eZ\Publish\API\Repository\Values\User\Limitation
+     */
+    public function buildValue(array $limitationValues): APILimitationValue
+    {
+        return new MemberOfLimitation(['limitationValues' => $limitationValues]);
+    }
+
+    public function evaluate(APILimitationValue $value, APIUserReference $currentUser, ValueObject $object, array $targets = null)
+    {
+        if (!$value instanceof MemberOfLimitation) {
+            throw new InvalidArgumentException(
+                '$value',
+                sprintf('Must be of type: %s', MemberOfLimitation::class)
+            );
+        }
+
+        if (!$object instanceof User
+            && !$object instanceof UserGroup
+            && !$object instanceof UserRoleAssignment
+            && !$object instanceof UserGroupRoleAssignment
+        ) {
+            return self::ACCESS_ABSTAIN;
+        }
+
+        if ($object instanceof User) {
+            return $this->evaluateUser($value, $object, $currentUser);
+        }
+
+        if ($object instanceof UserGroup) {
+            return $this->evaluateUserGroup($value, $object, $currentUser);
+        }
+
+        if ($object instanceof UserRoleAssignment) {
+            return $this->evaluateUser($value, $object->getUser(), $currentUser);
+        }
+
+        if ($object instanceof UserGroupRoleAssignment) {
+            return $this->evaluateUserGroup($value, $object->getUserGroup(), $currentUser);
+        }
+
+        return self::ACCESS_DENIED;
+    }
+
+    public function getCriterion(APILimitationValue $value, APIUserReference $currentUser)
+    {
+        throw new NotImplementedException('Member of Limitation Criterion');
+    }
+
+    public function valueSchema()
+    {
+        throw new NotImplementedException(__METHOD__);
+    }
+
+    private function evaluateUser(MemberOfLimitation $value, User $object, APIUserReference $currentUser): bool
+    {
+        if (empty($value->limitationValues)) {
+            return self::ACCESS_DENIED;
+        }
+
+        $userLocations = $this->persistence->locationHandler()->loadLocationsByContent($object->getUserId());
+
+        $userGroups = [];
+        foreach ($userLocations as $userLocation) {
+            $userGroups[] = $this->persistence->locationHandler()->load($userLocation->parentId);
+        }
+        $userGroupsIdList = array_column($userGroups, 'contentId');
+        $limitationValuesUserGroupsIdList = $value->limitationValues;
+
+        if (in_array(self::SELF_USER_GROUP, $limitationValuesUserGroupsIdList)) {
+            $currentUserGroupsIdList = $this->getCurrentUserGroupsIdList($currentUser);
+
+            // Granted, if current user is in exactly those same groups
+            if (count(array_intersect($userGroupsIdList, $currentUserGroupsIdList)) === count($userGroupsIdList)) {
+                return self::ACCESS_GRANTED;
+            }
+
+            // Unset SELF value, for next check
+            $key = array_search(self::SELF_USER_GROUP, $limitationValuesUserGroupsIdList);
+            unset($limitationValuesUserGroupsIdList[$key]);
+        }
+
+        // Granted, if limitationValues matched user groups 1:1
+        if (!empty($limitationValuesUserGroupsIdList)
+            && empty(array_diff($userGroupsIdList, $limitationValuesUserGroupsIdList))
+        ) {
+            return self::ACCESS_GRANTED;
+        }
+
+        return self::ACCESS_DENIED;
+    }
+
+    private function evaluateUserGroup(MemberOfLimitation $value, UserGroup $userGroup, APIUserReference $currentUser): bool
+    {
+        $limitationValuesUserGroupsIdList = $value->limitationValues;
+        if (in_array(self::SELF_USER_GROUP, $limitationValuesUserGroupsIdList)) {
+            $limitationValuesUserGroupsIdList = $this->getCurrentUserGroupsIdList($currentUser);
+        }
+
+        return in_array($userGroup->id, $limitationValuesUserGroupsIdList);
+    }
+
+    private function getCurrentUserGroupsIdList(APIUserReference $currentUser): array
+    {
+        $currentUserLocations = $this->persistence->locationHandler()->loadLocationsByContent($currentUser->getUserId());
+        $currentUserGroups = [];
+        foreach ($currentUserLocations as $currentUserLocation) {
+            $currentUserGroups[] = $this->persistence->locationHandler()->load($currentUserLocation->parentId);
+        }
+
+        return array_column(
+            $currentUserGroups,
+            'contentId'
+        );
+    }
+}

programming_language: PHP
filename: RoleLimitationType.php
change_type: ModificationType.ADD
diff: @@ -0,0 +1,143 @@
+<?php
+
+/**
+ * @copyright Copyright (C) eZ Systems AS. All rights reserved.
+ * @license For full copyright and license information view LICENSE file distributed with this source code.
+ */
+declare(strict_types=1);
+
+namespace Ibexa\Core\Limitation;
+
+use eZ\Publish\API\Repository\Exceptions\NotImplementedException;
+use eZ\Publish\API\Repository\Values\User\Role;
+use eZ\Publish\API\Repository\Values\User\User;
+use eZ\Publish\API\Repository\Values\User\UserGroup;
+use eZ\Publish\API\Repository\Values\User\UserGroupRoleAssignment;
+use eZ\Publish\API\Repository\Values\User\UserReference as APIUserReference;
+use eZ\Publish\API\Repository\Exceptions\NotFoundException;
+use eZ\Publish\API\Repository\Values\User\UserRoleAssignment;
+use eZ\Publish\API\Repository\Values\ValueObject;
+use eZ\Publish\Core\Base\Exceptions\InvalidArgumentException;
+use eZ\Publish\Core\Base\Exceptions\InvalidArgumentType;
+use eZ\Publish\Core\FieldType\ValidationError;
+use eZ\Publish\Core\Limitation\AbstractPersistenceLimitationType;
+use eZ\Publish\SPI\Limitation\Type as SPILimitationTypeInterface;
+use eZ\Publish\API\Repository\Values\User\Limitation as APILimitationValue;
+use Ibexa\Contracts\Core\Repository\Values\User\Limitation\RoleLimitation;
+
+final class RoleLimitationType extends AbstractPersistenceLimitationType implements SPILimitationTypeInterface
+{
+    /**
+     * @throws \eZ\Publish\Core\Base\Exceptions\InvalidArgumentType
+     */
+    public function acceptValue(APILimitationValue $limitationValue): void
+    {
+        if (!$limitationValue instanceof RoleLimitation) {
+            throw new InvalidArgumentType(
+                '$limitationValue',
+                RoleLimitation::class,
+                $limitationValue
+            );
+        }
+
+        if (!is_array($limitationValue->limitationValues)) {
+            throw new InvalidArgumentType(
+                '$limitationValue->limitationValues',
+                'array',
+                $limitationValue->limitationValues
+            );
+        }
+
+        foreach ($limitationValue->limitationValues as $key => $id) {
+            if (!is_int($id)) {
+                throw new InvalidArgumentType("\$limitationValue->limitationValues[{$key}]", 'int|string', $id);
+            }
+        }
+    }
+
+    public function validate(APILimitationValue $limitationValue)
+    {
+        $validationErrors = [];
+
+        foreach ($limitationValue->limitationValues as $key => $id) {
+            try {
+                $this->persistence->userHandler()->loadRole($id);
+            } catch (NotFoundException $e) {
+                $validationErrors[] = new ValidationError(
+                    "limitationValues[%key%] => '%value%' does not exist in the backend",
+                    null,
+                    [
+                        'value' => $id,
+                        'key' => $key,
+                    ]
+                );
+            }
+        }
+
+        return $validationErrors;
+    }
+
+    /**
+     * @param mixed[] $limitationValues
+     *
+     * @return \eZ\Publish\API\Repository\Values\User\Limitation
+     */
+    public function buildValue(array $limitationValues): APILimitationValue
+    {
+        return new RoleLimitation(['limitationValues' => $limitationValues]);
+    }
+
+    public function evaluate(APILimitationValue $value, APIUserReference $currentUser, ValueObject $object, array $targets = null)
+    {
+        if (!$value instanceof RoleLimitation) {
+            throw new InvalidArgumentException(
+                '$value',
+                sprintf('Must be of type: %s', RoleLimitation::class)
+            );
+        }
+
+        if (
+            !$object instanceof Role
+            && !$object instanceof UserRoleAssignment
+            && !$object instanceof UserGroupRoleAssignment
+            && ($targets === null && ($object instanceof User || $object instanceof UserGroup))
+        ) {
+            return self::ACCESS_ABSTAIN;
+        }
+
+        if ($targets !== null) {
+            foreach ($targets as $target) {
+                if ($target instanceof Role && !$this->evaluateRole($value, $target)) {
+                    return self::ACCESS_DENIED;
+                }
+
+                return self::ACCESS_GRANTED;
+            }
+        }
+
+        if ($object instanceof Role) {
+            return $this->evaluateRole($value, $object);
+        }
+
+        if ($object instanceof UserRoleAssignment || $object instanceof UserGroupRoleAssignment) {
+            return $this->evaluateRole($value, $object->getRole());
+        }
+
+        return self::ACCESS_DENIED;
+    }
+
+    public function getCriterion(APILimitationValue $value, APIUserReference $currentUser)
+    {
+        throw new NotImplementedException('Role Limitation Criterion');
+    }
+
+    public function valueSchema()
+    {
+        throw new NotImplementedException(__METHOD__);
+    }
+
+    private function evaluateRole(RoleLimitation $value, Role $role): bool
+    {
+        return in_array($role->id, $value->limitationValues);
+    }
+}

programming_language: PHP
filename: MemberOfLimitationTest.php
change_type: ModificationType.ADD
diff: @@ -0,0 +1,74 @@
+<?php
+
+/**
+ * @copyright Copyright (C) eZ Systems AS. All rights reserved.
+ * @license For full copyright and license information view LICENSE file distributed with this source code.
+ */
+declare(strict_types=1);
+
+namespace Ibexa\Tests\Integration\Core\Limitation;
+
+use eZ\Publish\API\Repository\Repository;
+use eZ\Publish\API\Repository\Tests\Limitation\PermissionResolver\BaseLimitationIntegrationTest;
+use Ibexa\Contracts\Core\Repository\Values\User\Limitation\MemberOfLimitation;
+use Ibexa\Core\Limitation\MemberOfLimitationType;
+
+class MemberOfLimitationTest extends BaseLimitationIntegrationTest
+{
+    private const ADMIN_GROUP_ID = 14;
+    private const USERS_GROUP_ID = 4;
+
+    public function userPermissionLimitationProvider(): array
+    {
+        $allowInAdministratorsLimitation = new MemberOfLimitation();
+        $allowInAdministratorsLimitation->limitationValues[] = self::ADMIN_GROUP_ID;
+
+        $allowInUsersLimitation = new MemberOfLimitation();
+        $allowInUsersLimitation->limitationValues[] = self::USERS_GROUP_ID;
+
+        $allowInSelfGroupLimitation = new MemberOfLimitation();
+        $allowInSelfGroupLimitation->limitationValues[] = MemberOfLimitationType::SELF_USER_GROUP;
+
+        return [
+            [[$allowInAdministratorsLimitation], false],
+            [[$allowInUsersLimitation], true],
+            [[$allowInSelfGroupLimitation], true],
+        ];
+    }
+
+    /**
+     * @dataProvider userPermissionLimitationProvider
+     */
+    public function testCanUserAssignRoleToUser(array $limitations, bool $expectedResult): void
+    {
+        $repository = $this->getRepository();
+        $roleService = $repository->getRoleService();
+        $userService = $repository->getUserService();
+
+        $adminRoleThatWillBeSet = $roleService->loadRoleByIdentifier('Administrator');
+        $this->loginAsEditorUserWithLimitations('role', 'assign', $limitations);
+
+        $this->assertCanUser(
+            $expectedResult,
+            'role',
+            'assign',
+            $limitations,
+            $userService->loadUser($this->permissionResolver->getCurrentUserReference()->getUserId()),
+            [$adminRoleThatWillBeSet]
+        );
+
+        $this->assertCanUser(
+            $expectedResult,
+            'role',
+            'assign',
+            $limitations,
+            $repository->sudo(
+                static function (Repository $repository) {
+                    return $repository->getUserService()->loadUserGroup(self::USERS_GROUP_ID);
+                },
+                $repository
+            ),
+            [$adminRoleThatWillBeSet]
+        );
+    }
+}

programming_language: PHP
filename: RoleLimitationTest.php
change_type: ModificationType.ADD
diff: @@ -0,0 +1,69 @@
+<?php
+
+/**
+ * @copyright Copyright (C) eZ Systems AS. All rights reserved.
+ * @license For full copyright and license information view LICENSE file distributed with this source code.
+ */
+declare(strict_types=1);
+
+namespace Ibexa\Tests\Integration\Core\Limitation;
+
+use eZ\Publish\API\Repository\Repository;
+use eZ\Publish\API\Repository\Tests\Limitation\PermissionResolver\BaseLimitationIntegrationTest;
+use Ibexa\Contracts\Core\Repository\Values\User\Limitation\RoleLimitation;
+
+class RoleLimitationTest extends BaseLimitationIntegrationTest
+{
+    private const USERS_GROUP_ID = 4;
+
+    public function userPermissionLimitationProvider(): array
+    {
+        $allowEditorLimitation = new RoleLimitation();
+        $roleService = $this->getRepository()->getRoleService();
+        $allowEditorLimitation->limitationValues[] = $roleService->loadRoleByIdentifier('Editor')->id;
+
+        $allowAdministratorLimitation = new RoleLimitation();
+        $allowAdministratorLimitation->limitationValues[] = $roleService->loadRoleByIdentifier('Administrator')->id;
+
+        return [
+            [[$allowEditorLimitation], false],
+            [[$allowAdministratorLimitation], true],
+        ];
+    }
+
+    /**
+     * @dataProvider userPermissionLimitationProvider
+     */
+    public function testCanUserAssignRole(array $limitations, bool $expectedResult): void
+    {
+        $repository = $this->getRepository();
+        $roleService = $repository->getRoleService();
+        $userService = $repository->getUserService();
+
+        $adminRoleThatWillBeSet = $roleService->loadRoleByIdentifier('Administrator');
+        $this->loginAsEditorUserWithLimitations('role', 'assign', $limitations);
+
+        $this->assertCanUser(
+            $expectedResult,
+            'role',
+            'assign',
+            $limitations,
+            $userService->loadUser($this->permissionResolver->getCurrentUserReference()->getUserId()),
+            [$adminRoleThatWillBeSet]
+        );
+
+        $this->assertCanUser(
+            $expectedResult,
+            'role',
+            'assign',
+            $limitations,
+            $repository->sudo(
+                static function (Repository $repository) {
+                    return $repository->getUserService()->loadUserGroup(self::USERS_GROUP_ID);
+                },
+                $repository
+            ),
+            [$adminRoleThatWillBeSet]
+        );
+    }
+}

programming_language: PHP
filename: MemberOfLimitationTypeTest.php
change_type: ModificationType.ADD
diff: @@ -0,0 +1,517 @@
+<?php
+
+/**
+ * @copyright Copyright (C) eZ Systems AS. All rights reserved.
+ * @license For full copyright and license information view LICENSE file distributed with this source code.
+ */
+declare(strict_types=1);
+
+namespace Ibexa\Tests\Core\Limitation;
+
+use eZ\Publish\API\Repository\Values\Content\ContentInfo;
+use eZ\Publish\API\Repository\Values\ValueObject;
+use eZ\Publish\Core\Base\Exceptions\InvalidArgumentType;
+use eZ\Publish\Core\Base\Exceptions\NotFoundException;
+use eZ\Publish\Core\Limitation\Tests\Base;
+use eZ\Publish\Core\Persistence\Legacy\Content\Handler as ContentHandlerInterface;
+use eZ\Publish\Core\Repository\Values\User\UserGroupRoleAssignment;
+use eZ\Publish\SPI\Persistence\Content\Location;
+use eZ\Publish\SPI\Persistence\Content\Location\Handler as LocationHandlerInterface;
+use eZ\Publish\Core\Repository\Values\Content\Content;
+use eZ\Publish\Core\Repository\Values\Content\VersionInfo;
+use eZ\Publish\Core\Repository\Values\User\Role;
+use eZ\Publish\Core\Repository\Values\User\User;
+use eZ\Publish\Core\Repository\Values\User\UserGroup;
+use eZ\Publish\Core\Repository\Values\User\UserRoleAssignment;
+use Ibexa\Contracts\Core\Repository\Values\User\Limitation\MemberOfLimitation;
+use Ibexa\Core\Limitation\MemberOfLimitationType;
+
+class MemberOfLimitationTypeTest extends Base
+{
+    /** @var \Ibexa\Core\Limitation\MemberOfLimitationType */
+    private $limitationType;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->limitationType = new MemberOfLimitationType(
+            $this->getPersistenceMock()
+        );
+    }
+
+    /**
+     * @dataProvider providerForTestAcceptValue
+     */
+    public function testAcceptValue(MemberOfLimitation $limitation): void
+    {
+        $this->expectNotToPerformAssertions();
+        $this->limitationType->acceptValue($limitation);
+    }
+
+    public function providerForTestAcceptValue(): array
+    {
+        return [
+            [
+                new MemberOfLimitation([
+                    'limitationValues' => [],
+                ]),
+            ],
+            [
+                new MemberOfLimitation([
+                    'limitationValues' => [MemberOfLimitationType::SELF_USER_GROUP, 8],
+                ]),
+            ],
+        ];
+    }
+
+    /**
+     * @dataProvider providerForTestAcceptValueException
+     */
+    public function testAcceptValueException(MemberOfLimitation $limitation): void
+    {
+        $this->expectException(InvalidArgumentType::class);
+        $this->limitationType->acceptValue($limitation);
+    }
+
+    public function providerForTestAcceptValueException(): array
+    {
+        return [
+            [
+                new MemberOfLimitation([
+                    'limitationValues' => 1,
+                ]),
+            ],
+            [
+                new MemberOfLimitation([
+                    'limitationValues' => null,
+                ]),
+            ],
+            [
+                new MemberOfLimitation([
+                    'limitationValues' => 'string',
+                ]),
+            ],
+            [
+                new MemberOfLimitation([
+                    'limitationValues' => ['string'],
+                ]),
+            ],
+        ];
+    }
+
+    /**
+     * @dataProvider providerForTestAcceptValue
+     */
+    public function testValidatePass(MemberOfLimitation $limitation): void
+    {
+        $contentHandlerMock = $this->createMock(ContentHandlerInterface::class);
+
+        $contentHandlerMock
+            ->method('loadContentInfo')
+            ->with(8);
+
+        $this->getPersistenceMock()
+            ->method('contentHandler')
+            ->willReturn($contentHandlerMock);
+
+        $validationErrors = $this->limitationType->validate($limitation);
+
+        self::assertEmpty($validationErrors);
+    }
+
+    /**
+     * @dataProvider providerForTestValidateError
+     */
+    public function testValidateError(MemberOfLimitation $limitation, int $errorCount): void
+    {
+        $contentHandlerMock = $this->createMock(ContentHandlerInterface::class);
+
+        if ($limitation->limitationValues !== null) {
+            $contentHandlerMock
+                ->method('loadContentInfo')
+                ->withConsecutive([14], [18])
+                ->willReturnOnConsecutiveCalls(
+                    $this->throwException(new NotFoundException('UserGroup', 18)),
+                    new ContentInfo()
+                );
+
+            $this->getPersistenceMock()
+                ->method('contentHandler')
+                ->willReturn($contentHandlerMock);
+        }
+
+        $validationErrors = $this->limitationType->validate($limitation);
+        self::assertCount($errorCount, $validationErrors);
+    }
+
+    public function providerForTestValidateError()
+    {
+        return [
+            [
+                new MemberOfLimitation([
+                    'limitationValues' => [14, 18],
+                ]),
+                1,
+            ],
+        ];
+    }
+
+    /**
+     * @dataProvider providerForTestEvaluate
+     */
+    public function testEvaluate(
+        MemberOfLimitation $limitation,
+        ValueObject $object,
+        ?bool $expected
+    ): void {
+        $locationHandlerMock = $this->createMock(LocationHandlerInterface::class);
+
+        if ($object instanceof User || $object instanceof UserRoleAssignment) {
+            $locationHandlerMock
+                ->method('loadLocationsByContent')
+                ->with($object instanceof User ? $object->getUserId() : $object->getUser()->getUserId())
+                ->willReturn([
+                    new Location(['parentId' => 13]),
+                    new Location(['parentId' => 14]),
+                ]);
+
+            $locationHandlerMock
+                ->method('load')
+                ->withConsecutive(
+                    [13], [14]
+                )
+                ->willReturnOnConsecutiveCalls(
+                    new Location(['contentId' => 14]),
+                    new Location(['contentId' => 25])
+                );
+
+            $this->getPersistenceMock()
+                ->method('locationHandler')
+                ->willReturn($locationHandlerMock);
+        }
+
+        $value = (new MemberOfLimitationType($this->getPersistenceMock()))->evaluate(
+            $limitation,
+            $this->getUserMock(),
+            $object
+        );
+
+        self::assertEquals($expected, $value);
+    }
+
+    public function providerForTestEvaluate()
+    {
+        return [
+            'valid_group_limitation' => [
+                'limitation' => new MemberOfLimitation([
+                    'limitationValues' => [14, 18],
+                ]),
+                'object' => new UserGroup([
+                    'content' => new Content([
+                        'versionInfo' => new VersionInfo([
+                            'contentInfo' => new ContentInfo([
+                                'id' => 14,
+                            ]),
+                        ]),
+                    ]),
+                ]),
+                'expected' => true,
+            ],
+            'allow_non_user_groups_limitation' => [
+                'limitation' => new MemberOfLimitation([
+                    'limitationValues' => [],
+                ]),
+                'object' => new UserGroup([
+                    'content' => new Content([
+                        'versionInfo' => new VersionInfo([
+                            'contentInfo' => new ContentInfo([
+                                'id' => 14,
+                            ]),
+                        ]),
+                    ]),
+                ]),
+                'expected' => false,
+            ],
+            'pass_to_next_limitation' => [
+                'limitation' => new MemberOfLimitation([
+                    'limitationValues' => [14, 18],
+                ]),
+                'object' => new VersionInfo([
+                    'contentInfo' => new ContentInfo([
+                        'id' => 14,
+                    ]),
+                ]),
+                'expected' => null,
+            ],
+            'invalid_user_must_have_permission_to_every_group_user_is_in' => [
+                'limitation' => new MemberOfLimitation([
+                    'limitationValues' => [25, 10],
+                ]),
+                'object' => new User([
+                    'content' => new Content([
+                        'versionInfo' => new VersionInfo([
+                            'contentInfo' => new ContentInfo([
+                                'id' => 66,
+                            ]),
+                        ]),
+                    ]),
+                ]),
+                'expected' => false,
+            ],
+            'user_must_have_permission_to_every_group_user_is_in' => [
+                'limitation' => new MemberOfLimitation([
+                    'limitationValues' => [14, 25],
+                ]),
+                'object' => new User([
+                    'content' => new Content([
+                        'versionInfo' => new VersionInfo([
+                            'contentInfo' => new ContentInfo([
+                                'id' => 66,
+                            ]),
+                        ]),
+                    ]),
+                ]),
+                'expected' => true,
+            ],
+            'user_role_assigment_valid' => [
+                'limitation' => new MemberOfLimitation([
+                    'limitationValues' => [14, 25],
+                ]),
+                'object' => new UserRoleAssignment([
+                    'user' => new User([
+                        'content' => new Content([
+                            'versionInfo' => new VersionInfo([
+                                'contentInfo' => new ContentInfo([
+                                    'id' => 66,
+                                ]),
+                            ]),
+                        ]),
+                    ]),
+                ]),
+                'expected' => true,
+            ],
+            'user_role_assigment_invalid_user' => [
+                'limitation' => new MemberOfLimitation([
+                    'limitationValues' => [25, 10],
+                ]),
+                'object' => new UserRoleAssignment([
+                    'user' => new User([
+                        'content' => new Content([
+                            'versionInfo' => new VersionInfo([
+                                'contentInfo' => new ContentInfo([
+                                    'id' => 66,
+                                ]),
+                            ]),
+                        ]),
+                    ]),
+                    'role' => new Role(['id' => 7]),
+                ]),
+                'expected' => false,
+            ],
+            'user_group_role_assigment_valid' => [
+                'limitation' => new MemberOfLimitation([
+                    'limitationValues' => [14, 18],
+                ]),
+                'object' => new UserGroupRoleAssignment([
+                    'userGroup' => new UserGroup([
+                        'content' => new Content([
+                            'versionInfo' => new VersionInfo([
+                                'contentInfo' => new ContentInfo([
+                                    'id' => 14,
+                                ]),
+                            ]),
+                        ]),
+                    ]),
+                    'role' => new Role(['id' => 7]),
+                ]),
+                'expected' => true,
+            ],
+            'user_group_role_assigment_invalid_user_group' => [
+                'limitation' => new MemberOfLimitation([
+                    'limitationValues' => [18],
+                ]),
+                'object' => new UserGroupRoleAssignment([
+                    'userGroup' => new UserGroup([
+                        'content' => new Content([
+                            'versionInfo' => new VersionInfo([
+                                'contentInfo' => new ContentInfo([
+                                    'id' => 14,
+                                ]),
+                            ]),
+                        ]),
+                    ]),
+                    'role' => new Role(['id' => 7]),
+                ]),
+                'expected' => false,
+            ],
+        ];
+    }
+
+    /**
+     * @param \eZ\Publish\API\Repository\Values\User\User|\eZ\Publish\API\Repository\Values\User\UserRoleAssignment $object
+     * @dataProvider providerForTestEvaluateSelfGroup
+     */
+    public function testEvaluateSelfGroup(
+        MemberOfLimitation $limitation,
+        ValueObject $object,
+        array $currentUserGroupLocations,
+        ?bool $expected
+    ): void {
+        $locationHandlerMock = $this->createMock(LocationHandlerInterface::class);
+
+        $currentUserLocation = [];
+
+        foreach ($currentUserGroupLocations as $groupLocation) {
+            $currentUserLocation[] = new Location(['parentId' => $groupLocation->contentId - 1]);
+        }
+        $locationHandlerMock
+            ->method('loadLocationsByContent')
+            ->withConsecutive(
+                [$object instanceof User ? $object->getUserId() : $object->getUser()->getUserId()],
+                [$this->getUserMock()->getUserId()]
+            )
+            ->willReturnOnConsecutiveCalls(
+                [
+                    new Location(['parentId' => 13]),
+                    new Location(['parentId' => 43]),
+                ],
+                $currentUserLocation
+            );
+
+        $locationHandlerMock
+            ->method('load')
+            ->withConsecutive(
+                [13], [43]
+            )
+            ->willReturnOnConsecutiveCalls(
+                new Location(['contentId' => 14]),
+                new Location(['contentId' => 44]),
+                ...$currentUserGroupLocations
+            );
+
+        $this->getPersistenceMock()
+            ->method('locationHandler')
+            ->willReturn($locationHandlerMock);
+
+        $this->getPersistenceMock()
+            ->method('locationHandler')
+            ->willReturn($locationHandlerMock);
+
+        $value = (new MemberOfLimitationType($this->getPersistenceMock()))->evaluate(
+            $limitation,
+            $this->getUserMock(),
+            $object
+        );
+
+        self::assertEquals($expected, $value);
+    }
+
+    public function providerForTestEvaluateSelfGroup(): array
+    {
+        return [
+            'role_assign_to_user_in_same_group' => [
+                'limitation' => new MemberOfLimitation([
+                    'limitationValues' => [MemberOfLimitationType::SELF_USER_GROUP],
+                ]),
+                'object' => new User([
+                    'content' => new Content([
+                        'versionInfo' => new VersionInfo([
+                            'contentInfo' => new ContentInfo([
+                                'id' => 66,
+                            ]),
+                        ]),
+                    ]),
+                ]),
+                'currentUserGroupLocations' => [
+                    new Location(['contentId' => 14]),
+                    new Location(['contentId' => 44]),
+                    new Location(['contentId' => 55]),
+                ],
+                'expected' => true,
+            ],
+            'role_assign_to_user_in_other_group' => [
+                'limitation' => new MemberOfLimitation([
+                    'limitationValues' => [MemberOfLimitationType::SELF_USER_GROUP],
+                ]),
+                'object' => new User([
+                    'content' => new Content([
+                        'versionInfo' => new VersionInfo([
+                            'contentInfo' => new ContentInfo([
+                                'id' => 66,
+                            ]),
+                        ]),
+                    ]),
+                ]),
+                'currentUserGroupLocations' => [
+                    new Location(['contentId' => 11]),
+                    new Location(['contentId' => 14]),
+                ],
+                'expected' => false,
+            ],
+            'role_assign_to_user_in_overlapped_groups' => [
+                'limitation' => new MemberOfLimitation([
+                    'limitationValues' => [MemberOfLimitationType::SELF_USER_GROUP, 14, 44],
+                ]),
+                'object' => new User([
+                    'content' => new Content([
+                        'versionInfo' => new VersionInfo([
+                            'contentInfo' => new ContentInfo([
+                                'id' => 66,
+                            ]),
+                        ]),
+                    ]),
+                ]),
+                'currentUserGroupLocations' => [
+                    new Location(['contentId' => 1]),
+                ],
+                'expected' => true,
+            ],
+            'user_role_assigment_to_user_in_same_group' => [
+                'limitation' => new MemberOfLimitation([
+                    'limitationValues' => [MemberOfLimitationType::SELF_USER_GROUP],
+                ]),
+                'object' => new UserRoleAssignment([
+                    'user' => new User([
+                        'content' => new Content([
+                            'versionInfo' => new VersionInfo([
+                                'contentInfo' => new ContentInfo([
+                                    'id' => 66,
+                                ]),
+                            ]),
+                        ]),
+                    ]),
+                    'role' => new Role(['id' => 4]),
+                ]),
+                'currentUserGroupLocations' => [
+                    new Location(['contentId' => 14]),
+                    new Location(['contentId' => 44]),
+                    new Location(['contentId' => 55]),
+                ],
+                'expected' => true,
+            ],
+            'user_role_assigment_to_user_in_other_group' => [
+                'limitation' => new MemberOfLimitation([
+                    'limitationValues' => [MemberOfLimitationType::SELF_USER_GROUP],
+                ]),
+                'object' => new UserRoleAssignment([
+                    'user' => new User([
+                        'content' => new Content([
+                            'versionInfo' => new VersionInfo([
+                                'contentInfo' => new ContentInfo([
+                                    'id' => 66,
+                                ]),
+                            ]),
+                        ]),
+                    ]),
+                    'role' => new Role(['id' => 4]),
+                ]),
+                'currentUserGroupLocations' => [
+                    new Location(['contentId' => 11]),
+                    new Location(['contentId' => 14]),
+                ],
+                'expected' => false,
+            ],
+        ];
+    }
+}

programming_language: PHP
filename: RoleLimitationTypeTest.php
change_type: ModificationType.ADD
diff: @@ -0,0 +1,318 @@
+<?php
+
+/**
+ * @copyright Copyright (C) eZ Systems AS. All rights reserved.
+ * @license For full copyright and license information view LICENSE file distributed with this source code.
+ */
+declare(strict_types=1);
+
+namespace Ibexa\Tests\Core\Limitation;
+
+use eZ\Publish\API\Repository\Values\Content\ContentInfo;
+use eZ\Publish\API\Repository\Values\ValueObject;
+use eZ\Publish\Core\Base\Exceptions\InvalidArgumentType;
+use eZ\Publish\Core\Base\Exceptions\NotFoundException;
+use eZ\Publish\Core\Limitation\Tests\Base;
+use eZ\Publish\Core\Persistence\Legacy\User\Handler as UserHandlerInterface;
+use eZ\Publish\Core\Persistence\Legacy\Content\Handler as ContentHandlerInterface;
+use eZ\Publish\Core\Repository\Values\User\UserGroup;
+use eZ\Publish\Core\Repository\Values\User\UserGroupRoleAssignment;
+use eZ\Publish\SPI\Persistence\Content\Location;
+use eZ\Publish\SPI\Persistence\Content\Location\Handler as LocationHandlerInterface;
+use eZ\Publish\Core\Repository\Values\Content\Content;
+use eZ\Publish\Core\Repository\Values\Content\VersionInfo;
+use eZ\Publish\Core\Repository\Values\User\Role;
+use eZ\Publish\Core\Repository\Values\User\User;
+use eZ\Publish\Core\Repository\Values\User\UserRoleAssignment;
+use Ibexa\Contracts\Core\Repository\Values\User\Limitation\RoleLimitation;
+use Ibexa\Core\Limitation\RoleLimitationType;
+
+class RoleLimitationTypeTest extends Base
+{
+    /** @var \Ibexa\Core\Limitation\RoleLimitationType */
+    private $limitationType;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->limitationType = new RoleLimitationType(
+            $this->getPersistenceMock()
+        );
+    }
+
+    /**
+     * @dataProvider providerForTestAcceptValue
+     */
+    public function testAcceptValue(RoleLimitation $limitation): void
+    {
+        $this->expectNotToPerformAssertions();
+        $this->limitationType->acceptValue($limitation);
+    }
+
+    public function providerForTestAcceptValue(): array
+    {
+        return [
+            [
+                new RoleLimitation([
+                    'limitationValues' => [],
+                ]),
+            ],
+            [
+                new RoleLimitation([
+                    'limitationValues' => [4, 8],
+                ]),
+            ],
+        ];
+    }
+
+    /**
+     * @dataProvider providerForTestAcceptValueException
+     */
+    public function testAcceptValueException(RoleLimitation $limitation): void
+    {
+        $this->expectException(InvalidArgumentType::class);
+        $this->limitationType->acceptValue($limitation);
+    }
+
+    public function providerForTestAcceptValueException(): array
+    {
+        return [
+            [
+                new RoleLimitation([
+                    'limitationValues' => 1,
+                ]),
+            ],
+            [
+                new RoleLimitation([
+                    'limitationValues' => null,
+                ]),
+            ],
+            [
+                new RoleLimitation([
+                    'limitationValues' => 'string',
+                ]),
+            ],
+            [
+                new RoleLimitation([
+                    'limitationValues' => ['string'],
+                ]),
+            ],
+        ];
+    }
+
+    /**
+     * @dataProvider providerForTestAcceptValue
+     */
+    public function testValidatePass(RoleLimitation $limitation): void
+    {
+        $userHandlerMock = $this->createMock(UserHandlerInterface::class);
+        $contentHandlerMock = $this->createMock(ContentHandlerInterface::class);
+
+        if ($limitation->limitationValues !== null) {
+            $userHandlerMock
+                ->method('loadRole')
+                ->withConsecutive([4, Role::STATUS_DEFINED], [8, Role::STATUS_DEFINED]);
+
+            $this->getPersistenceMock()
+                ->method('userHandler')
+                ->willReturn($userHandlerMock);
+        }
+
+        if ($limitation->limitationValues !== null) {
+            $contentHandlerMock
+                ->method('loadContentInfo')
+                ->withConsecutive([14], [21]);
+
+            $this->getPersistenceMock()
+                ->method('contentHandler')
+                ->willReturn($contentHandlerMock);
+        }
+
+        $validationErrors = $this->limitationType->validate($limitation);
+
+        self::assertEmpty($validationErrors);
+    }
+
+    /**
+     * @dataProvider providerForTestValidateError
+     */
+    public function testValidateError(RoleLimitation $limitation, int $errorCount): void
+    {
+        $userHandlerMock = $this->createMock(UserHandlerInterface::class);
+
+        if ($limitation->limitationValues !== null) {
+            $userHandlerMock
+                ->method('loadRole')
+                ->withConsecutive([4, Role::STATUS_DEFINED], [8, Role::STATUS_DEFINED])
+                ->willReturnOnConsecutiveCalls(
+                    $this->throwException(new NotFoundException('Role', 4)),
+                    new Role()
+                );
+
+            $this->getPersistenceMock()
+                ->method('userHandler')
+                ->willReturn($userHandlerMock);
+        }
+
+        $validationErrors = $this->limitationType->validate($limitation);
+        self::assertCount($errorCount, $validationErrors);
+    }
+
+    public function providerForTestValidateError()
+    {
+        return [
+            [
+                new RoleLimitation([
+                    'limitationValues' => [4, 8],
+                ]),
+                1,
+            ],
+        ];
+    }
+
+    /**
+     * @dataProvider providerForTestEvaluate
+     */
+    public function testEvaluate(
+        RoleLimitation $limitation,
+        ValueObject $object,
+        ?array $targets,
+        ?bool $expected
+    ): void {
+        $locationHandlerMock = $this->createMock(LocationHandlerInterface::class);
+
+        if ($object instanceof UserRoleAssignment) {
+            $locationHandlerMock
+                ->method('loadLocationsByContent')
+                ->with($object instanceof User ? $object->getUserId() : $object->getUser()->getUserId())
+                ->willReturn([
+                    new Location(['contentId' => 14]),
+                    new Location(['contentId' => 25]),
+                ]);
+
+            $this->getPersistenceMock()
+                ->method('locationHandler')
+                ->willReturn($locationHandlerMock);
+        }
+
+        $value = (new RoleLimitationType($this->getPersistenceMock()))->evaluate(
+            $limitation,
+            $this->getUserMock(),
+            $object,
+            $targets
+        );
+
+        self::assertEquals($expected, $value);
+    }
+
+    public function providerForTestEvaluate()
+    {
+        return [
+            'valid_role_limitation' => [
+                'limitation' => new RoleLimitation([
+                    'limitationValues' => [4, 8],
+                ]),
+                'object' => new Role(['id' => 4]),
+                'targets' => null,
+                'expected' => true,
+            ],
+            'allow_non_role_limitation' => [
+                'limitation' => new RoleLimitation([
+                    'limitationValues' => [],
+                ]),
+                'object' => new Role(['id' => 4]),
+                'targets' => null,
+                'expected' => false,
+            ],
+            'pass_to_next_limitation' => [
+                'limitation' => new RoleLimitation([
+                    'limitationValues' => [4, 8],
+                ]),
+                'object' => new VersionInfo([
+                    'contentInfo' => new ContentInfo([
+                        'id' => 14,
+                    ]),
+                ]),
+                'targets' => null,
+                'expected' => null,
+            ],
+            'user_role_assigment_valid' => [
+                'limitation' => new RoleLimitation([
+                    'limitationValues' => [4, 8],
+                ]),
+                'object' => new UserRoleAssignment([
+                    'user' => new User([
+                        'content' => new Content([
+                            'versionInfo' => new VersionInfo([
+                                'contentInfo' => new ContentInfo([
+                                    'id' => 66,
+                                ]),
+                            ]),
+                        ]),
+                    ]),
+                    'role' => new Role(['id' => 4]),
+                ]),
+                'targets' => null,
+                'expected' => true,
+            ],
+            'user_role_assigment_invalid_role' => [
+                'limitation' => new RoleLimitation([
+                    'limitationValues' => [4, 8],
+                ]),
+                'object' => new UserRoleAssignment([
+                    'user' => new User([
+                        'content' => new Content([
+                            'versionInfo' => new VersionInfo([
+                                'contentInfo' => new ContentInfo([
+                                    'id' => 66,
+                                ]),
+                            ]),
+                        ]),
+                    ]),
+                    'role' => new Role(['id' => 7]),
+                ]),
+                'targets' => null,
+                'expected' => false,
+            ],
+            'user_group_role_assigment_valid' => [
+                'limitation' => new RoleLimitation([
+                    'limitationValues' => [4, 8],
+                ]),
+                'object' => new UserGroupRoleAssignment([
+                    'userGroup' => new UserGroup([
+                        'content' => new Content([
+                            'versionInfo' => new VersionInfo([
+                                'contentInfo' => new ContentInfo([
+                                    'id' => 66,
+                                ]),
+                            ]),
+                        ]),
+                    ]),
+                    'role' => new Role(['id' => 4]),
+                ]),
+                'targets' => null,
+                'expected' => true,
+            ],
+            'user_group_role_assigment_invalid_role' => [
+                'limitation' => new RoleLimitation([
+                    'limitationValues' => [4, 8],
+                ]),
+                'object' => new UserGroupRoleAssignment([
+                    'userGroup' => new UserGroup([
+                        'content' => new Content([
+                            'versionInfo' => new VersionInfo([
+                                'contentInfo' => new ContentInfo([
+                                    'id' => 66,
+                                ]),
+                            ]),
+                        ]),
+                    ]),
+                    'role' => new Role(['id' => 7]),
+                ]),
+                'targets' => null,
+                'expected' => false,
+            ],
+        ];
+    }
+}

programming_language: PHP