diff --git a/Config/config.php b/Config/config.php index fea80efa2..add337b95 100644 --- a/Config/config.php +++ b/Config/config.php @@ -637,6 +637,9 @@ ], ], 'events' => [ + 'custom_objects.api_platform.permission_context.subscriber' => [ + 'class' => \MauticPlugin\CustomObjectsBundle\EventListener\ApiPlatformPermissionContextSubscriber::class, + ], 'custom_object.api.subscriber' => [ 'class' => \MauticPlugin\CustomObjectsBundle\EventListener\ApiSubscriber::class, 'arguments' => [ diff --git a/EventListener/ApiPlatformPermissionContextSubscriber.php b/EventListener/ApiPlatformPermissionContextSubscriber.php new file mode 100644 index 000000000..1c5d073d0 --- /dev/null +++ b/EventListener/ApiPlatformPermissionContextSubscriber.php @@ -0,0 +1,118 @@ + ['onApiPlatformPermissionContext', 0], + ]; + } + + /* + * Note, the new ApiPlatformPermissionContextEvent will be available in Mautic 7. + * Till then we have to go with a plain object type. + */ + public function onApiPlatformPermissionContext(object $event): void + { + if (!method_exists($event, 'getPermission') + || !method_exists($event, 'setPermission') + || !method_exists($event, 'getRequestObject') + || !method_exists($event, 'setRequestObject') + ) { + return; + } + + $permission = $event->getPermission(); + if (!is_string($permission) || 0 !== strpos($permission, 'custom_objects:')) { + return; + } + + if (false === strpos($permission, '[') && false === strpos($permission, '(')) { + return; + } + + $requestObject = $event->getRequestObject(); + $objectPath = $this->extractObjectPath($permission); + + if (null !== $objectPath) { + $requestObject = $this->resolveRequestObject($requestObject, $objectPath); + $permission = substr($permission, 0, strpos($permission, '(')); + } + + $event->setRequestObject($requestObject); + $event->setPermission($this->resolvePermissionPlaceholder($event, $requestObject, $permission)); + } + + private function extractObjectPath(string $permission): ?string + { + if (1 !== preg_match('#\((.*?)\)#', $permission, $match)) { + return null; + } + + if (!isset($match[1]) || '' === $match[1]) { + return null; + } + + return $match[1]; + } + + private function resolveRequestObject(mixed $requestObject, string $objectPath): mixed + { + foreach (explode('.', $objectPath) as $property) { + if (!is_object($requestObject) || !method_exists($requestObject, $property)) { + return $requestObject; + } + + $requestObject = $requestObject->$property(); + } + + return $requestObject; + } + + private function resolvePermissionPlaceholder(object $event, mixed $requestObject, string $permission): string + { + if (1 !== preg_match('#\[(.*?)\]#', $permission, $match)) { + return $permission; + } + + if (!isset($match[1]) || '' === $match[1]) { + return $permission; + } + + $property = $match[1]; + $objectId = null; + $request = method_exists($event, 'getRequest') ? $event->getRequest() : null; + $content = $request instanceof Request ? json_decode($request->getContent(), true) : null; + + if (is_array($content) && array_key_exists($property, $content)) { + $objectId = $content[$property]; + } elseif (is_object($requestObject)) { + $getter = 'get'.ucfirst($property); + if (method_exists($requestObject, $getter)) { + $objectId = $requestObject->$getter(); + } + } + + if (is_object($objectId) && method_exists($objectId, 'getId')) { + $objectId = $objectId->getId(); + } + + if (is_string($objectId) && false !== strrpos($objectId, '/')) { + $objectId = substr($objectId, strrpos($objectId, '/') + 1); + } + + if (null === $objectId || '' === $objectId) { + return $permission; + } + + return preg_replace('#\[(.*?)\]#', (string) $objectId, $permission, 1) ?? $permission; + } +} diff --git a/Tests/Functional/ApiPlatform/CustomFieldOptionFunctionalTest.php b/Tests/Functional/ApiPlatform/CustomFieldOptionFunctionalTest.php index 81d331717..e182f70ba 100644 --- a/Tests/Functional/ApiPlatform/CustomFieldOptionFunctionalTest.php +++ b/Tests/Functional/ApiPlatform/CustomFieldOptionFunctionalTest.php @@ -13,6 +13,13 @@ final class CustomFieldOptionFunctionalTest extends AbstractApiPlatformFunctionalTest { + public function setUp(): void + { + $this->configParams['custom_objects_enabled'] = true; + + parent::setUp(); + } + public function testCustomFieldOptionCRUD(): void { foreach ($this->getCRUDProvider() as $parameters) { @@ -148,8 +155,8 @@ private function getCRUDProvider(): array Response::HTTP_CREATED, Response::HTTP_OK, 'New Custom Field Option', - Response::HTTP_FORBIDDEN, - null, + Response::HTTP_OK, + 'Edited Custom Field Option', Response::HTTP_NO_CONTENT, ], 'no_create' => [ diff --git a/Tests/Functional/ApiPlatform/CustomItemFunctionalTest.php b/Tests/Functional/ApiPlatform/CustomItemFunctionalTest.php index 0a7594a93..fc7a1f7b9 100644 --- a/Tests/Functional/ApiPlatform/CustomItemFunctionalTest.php +++ b/Tests/Functional/ApiPlatform/CustomItemFunctionalTest.php @@ -70,6 +70,108 @@ public function getCustomItemsDataProvider(): iterable yield [['viewown', 'editown', 'create', 'deleteown', 'publishown'], Response::HTTP_FORBIDDEN]; } + public function testGetCustomItemDeniesOwnPermissionForOtherUsersItem(): void + { + $user = $this->getUser(); + + self::assertNotNull($user); + + $customItem = $this->createCustomItem(['viewown'], false, $user->getId() + 9999); + $response = $this->retrieveEntity('/api/v2/custom_items/'.$customItem->getId()); + $json = json_decode($response->getContent(), true); + + self::assertSame(Response::HTTP_FORBIDDEN, $response->getStatusCode()); + $this->assertAccessForbiddenContent($json); + } + + /** + * @dataProvider ownedCustomItemOperationsDataProvider + * + * @param array $permissions + */ + public function testOwnedCustomItemAllowsOwnScopedOperations(callable $operation, callable $assertion, array $permissions, int $expectedResponse): void + { + $customItem = $this->createCustomItem($permissions, true); + + $response = $operation($this, $customItem); + + self::assertSame($expectedResponse, $response->getStatusCode()); + + $assertion($this, $response, $customItem); + } + + /** + * @return iterable, int}> + */ + public function ownedCustomItemOperationsDataProvider(): iterable + { + $getOperation = static fn (self $test, CustomItem $customItem): Response => $test->retrieveEntity('/api/v2/custom_items/'.$customItem->getId()); + $putOperation = static fn (self $test, CustomItem $customItem): Response => $test->updateEntity('/api/v2/custom_items/'.$customItem->getId(), [ + 'name' => 'Custom Item Edited', + 'fieldValues' => [ + [ + 'id' => '/api/v2/custom_fields/'.$customItem->getCustomObject()->getCustomFields()->first()->getId(), + 'value' => 'test3', + ], + ], + ]); + $patchOperation = static fn (self $test, CustomItem $customItem): Response => $test->patchEntity('/api/v2/custom_items/'.$customItem->getId(), [ + 'fieldValues' => [ + [ + 'id' => '/api/v2/custom_fields/'.$customItem->getCustomObject()->getCustomFields()->first()->getId(), + 'value' => 'test2', + ], + ], + ]); + $deleteOperation = static fn (self $test, CustomItem $customItem): Response => $test->deleteEntity('/api/v2/custom_items/'.$customItem->getId()); + + $forbiddenAssertion = static function (self $test, Response $response): void { + $json = json_decode($response->getContent(), true); + $test->assertAccessForbiddenContent($json); + }; + $getSuccessAssertion = static function (self $test, Response $response, CustomItem $customItem): void { + $json = json_decode($response->getContent(), true); + $test->assertSuccessContent($json, $customItem); + }; + $writeSuccessAssertion = static function (self $test, Response $response, CustomItem $customItem): void { + $json = json_decode($response->getContent(), true); + $test->em->clear(); + $customItem = $test->em->getRepository(CustomItem::class)->find($customItem->getId()); + $test->customItemModel->populateCustomFields($customItem); + $test->assertSuccessContent($json, $customItem); + }; + $deleteSuccessAssertion = static function (self $test, Response $response, CustomItem $customItem): void { + $json = json_decode($response->getContent(), true); + $test->em->clear(); + self::assertNull($json); + self::assertNull($test->em->getRepository(CustomItem::class)->find($customItem->getId())); + }; + + yield [$getOperation, $getSuccessAssertion, ['viewown'], Response::HTTP_OK]; + + yield [$getOperation, $forbiddenAssertion, [], Response::HTTP_FORBIDDEN]; + + yield [$getOperation, $getSuccessAssertion, ['viewown', 'viewother'], Response::HTTP_OK]; + + yield [$putOperation, $writeSuccessAssertion, ['editown'], Response::HTTP_OK]; + + yield [$putOperation, $forbiddenAssertion, ['editother'], Response::HTTP_FORBIDDEN]; + + yield [$putOperation, $writeSuccessAssertion, ['editown', 'editother'], Response::HTTP_OK]; + + yield [$patchOperation, $writeSuccessAssertion, ['editown'], Response::HTTP_OK]; + + yield [$patchOperation, $forbiddenAssertion, ['editother'], Response::HTTP_FORBIDDEN]; + + yield [$patchOperation, $writeSuccessAssertion, ['editown', 'editother'], Response::HTTP_OK]; + + yield [$deleteOperation, $deleteSuccessAssertion, ['deleteown'], Response::HTTP_NO_CONTENT]; + + yield [$deleteOperation, $forbiddenAssertion, ['deleteother'], Response::HTTP_FORBIDDEN]; + + yield [$deleteOperation, $deleteSuccessAssertion, ['deleteown', 'deleteother'], Response::HTTP_NO_CONTENT]; + } + /** * @dataProvider postCustomItemsDataProvider * @@ -257,22 +359,29 @@ private function createCustomObject(): CustomObject /** * @param array $permissions */ - private function createCustomItem(array $permissions): CustomItem + private function createCustomItem(array $permissions, bool $ownedByCurrentUser = false, ?int $ownerId = null): CustomItem { $customObject = $this->createCustomObject(); $category = $this->createCategory(); $customField = $this->createCustomField($customObject); + $user = $this->getUser(); $customItem = new CustomItem($customObject); $customItem->setName('Custom Item'); $customItem->setLanguage('en'); $customItem->setCategory($category); + + if ($ownedByCurrentUser && null !== $user) { + $customItem->setCreatedBy($user); + } elseif (null !== $ownerId) { + $customItem->setCreatedBy($ownerId); + } + $customFieldValue = new CustomFieldValueText($customField, $customItem, 'value'); $customItem->addCustomFieldValue($customFieldValue); $this->em->persist($customItem); $this->em->flush(); - $user = $this->getUser(); $this->setPermission($user, 'custom_objects:'.$customObject->getId(), $permissions); return $customItem;