Skip to content
3 changes: 3 additions & 0 deletions Config/config.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => [
Expand Down
118 changes: 118 additions & 0 deletions EventListener/ApiPlatformPermissionContextSubscriber.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php

declare(strict_types=1);

namespace MauticPlugin\CustomObjectsBundle\EventListener;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;

final class ApiPlatformPermissionContextSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
'mautic.api_platform_permission_context' => ['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;
}
}
11 changes: 9 additions & 2 deletions Tests/Functional/ApiPlatform/CustomFieldOptionFunctionalTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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' => [
Expand Down
113 changes: 111 additions & 2 deletions Tests/Functional/ApiPlatform/CustomItemFunctionalTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, string> $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, array{callable, callable, array<int, string>, 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
*
Expand Down Expand Up @@ -257,22 +359,29 @@ private function createCustomObject(): CustomObject
/**
* @param array<int, string> $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;
Expand Down
Loading