Fine-tuning permissions with eZ Platform policy limitations

By Mario Blažek -

eZ Platform policy Limitations are part of the permissions system. They provide more advanced access control to policies. In this blog post, we are going to deep dive into Limitations.

In my previous blog post about eZ Platform policies, we showed how easy it is to create custom policies and how to consume them. This post continues where the previous left off. The eZ Platform limitations are the next step into having fine-grained permissions. Having a combination of both policies and limitations is something that the best CMS needs to have in its permission system. While policies grant access to a certain function, limitations narrow it down to specified criteria. Let’s take a look into the limitations, how to implement a custom one and finally how to consume it.

Creating a simple limitation

While eZ Platform comes with some built-in limitations, like ContentTypeLimitation or StateLimitation, to name a few, it is very valuable to know how to implement own limitations. In this example, we want to fine-tune access to anonymize feature for infocollector module. We want to implement permission to check if an editor can anonymize collected information of a specified content type. Implementing custom limitation requires two custom classes: the first one that holds limitation identifier and limitation values named Limitation, and the second one that holds an access check logic, named LimitationType. Let’s define the first one and name it AnonymizeCollectionLimitation:

<?php

declare(strict_types=1);

namespace Netgen\InformationCollection\Limitation;

use eZ\Publish\API\Repository\Values\User\Limitation;

class AnonymizeCollectionLimitation extends Limitation
{
    /**
    * {@inheritDoc}
    */
    public function getIdentifier(): string
    {
        return 'AnonymizeCollection';
    }
}

To identify a limitation value, a custom limitation class must extend from eZ\Publish\API\Repository\Values\User\Limitation. LimitationType concrete implementation looks is a bit more complex, it needs to implement eZ\Publish\SPI\Limitation\Type interface and provide the implementation for six methods:

  • acceptValue() - checks if the given Limitation value can be applied
  • validate() - makes sure that the Limitation value is valid
  • buildValue() - acts as a factory to create the Limitation value object
  • evaluate() - holds an access check logic
  • getCriterion() - builds a Criterion for usage with a search-engine
  • valueSchema() - it was probably meant to be used in pre-Symfony context, looks outdated, none of the existing limitations uses it

So our implementation of AnonymizeCollectionLimitationType is:

<?php

declare(strict_types=1);

namespace Netgen\InformationCollection\Limitation;

use eZ\Publish\API\Repository\Exceptions\NotFoundException as APINotFoundException;
use eZ\Publish\API\Repository\Exceptions\NotImplementedException;
use eZ\Publish\API\Repository\Values\Content\Query\CriterionInterface;
use eZ\Publish\API\Repository\Values\User\Limitation;
use eZ\Publish\API\Repository\Values\ValueObject;
use eZ\Publish\API\Repository\Values\User\UserReference as APIUserReference;
use eZ\Publish\API\Repository\Values\Content\Content;
use eZ\Publish\Core\Base\Exceptions\InvalidArgumentException;
use eZ\Publish\Core\Base\Exceptions\InvalidArgumentType;
use eZ\Publish\API\Repository\Values\User\Limitation as APILimitationValue;
use eZ\Publish\Core\Limitation\AbstractPersistenceLimitationType;
use eZ\Publish\SPI\Limitation\Type;
use eZ\Publish\Core\FieldType\ValidationError;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion;

class AnonymizeCollectionLimitationType extends AbstractPersistenceLimitationType implements Type
{
    /**
     * {@inheritDoc}
     */
    public function acceptValue(APILimitationValue $limitationValue): void
    {
        if (!$limitationValue instanceof AnonymizeCollectionLimitation) {
            throw new InvalidArgumentType('$limitationValue', 'AnonymizeCollectionLimitation', $limitationValue);
        } elseif (!is_array($limitationValue->limitationValues)) {
            throw new InvalidArgumentType('$limitationValue->limitationValues', 'array', $limitationValue->limitationValues);
        }

        foreach ($limitationValue->limitationValues as $key => $identifier) {
            if (!is_string($identifier)) {
                throw new InvalidArgumentType("\$limitationValue->limitationValues[{$key}]", 'string', $identifier);
            }
        }
    }

    /**
     * {@inheritDoc}
     */
    public function validate(APILimitationValue $limitationValue): array
    {
        $validationErrors = [];
        foreach ($limitationValue->limitationValues as $key => $identifier) {
            try {
                $this->persistence->contentTypeHandler()->loadByIdentifier($identifier);
            } catch (APINotFoundException $e) {
                $validationErrors[] = new ValidationError(
                    "limitationValues[%key%] => '%value%' does not exist in the backend",
                    null,
                    [
                        'value' => $identifier,
                        'key' => $key,
                    ]
                );
            }
        }

        return $validationErrors;
    }

    /**
     * {@inheritDoc}
     */
    public function buildValue(array $limitationValues): Limitation
    {
        return new AnonymizeCollectionLimitation(['limitationValues' => $limitationValues]);
    }

    /**
     * {@inheritDoc}
     */
    public function evaluate(APILimitationValue $value, APIUserReference $currentUser, ValueObject $object, array $targets = null): bool
    {
        if (!$value instanceof AnonymizeCollectionLimitation) {
            throw new InvalidArgumentException('$value', 'Must be of type: AnonymizeCollectionLimitation');
        }

        if (!$object instanceof Content) {
            throw new InvalidArgumentException(
                '$object',
                'Must be of type Content'
            );
        }

        if (empty($value->limitationValues)) {
            return false;
        }
        
        $contentType = $this->persistence
            ->contentTypeHandler()
            ->load(
                $object->contentInfo->contentTypeId
            );

        return in_array($contentType->identifier, $value->limitationValues);
    }

    /**
     * {@inheritDoc}
     */
    public function getCriterion(APILimitationValue $value, APIUserReference $currentUser): CriterionInterface
    {
        if (empty($value->limitationValues)) {
            throw new \RuntimeException('$value->limitationValues is empty, it should not have been stored in the first place');
        }

        if (!isset($value->limitationValues[1])) {
            // 1 limitation value: EQ operation
            return new Criterion\ContentTypeIdentifier($value->limitationValues[0]);
        }

        // several limitation values: IN operation
        return new Criterion\ContentTypeIdentifier($value->limitationValues);
    }

    /**
     * {@inheritDoc}
     */
    public function valueSchema()
    {
        throw new NotImplementedException(__METHOD__);
    }
}

Notice that we are extending eZ\Publish\Core\Limitation\AbstractPersistenceLimitationType which is not mandatory, but provides PersistenceHandler that can save us some time. 

And finally, register it as a service inside the Symfony's dependency injection container, set required service tag for limitation types, the ezpublish.limitationType and alias to be the same identifier as it’s already defined in our custom Limitation value object by getIdentifier() method:

services:
    netgen_information_collection.limitation_type.anonymize:
        class: Netgen\InformationCollection\Limitation\AnonymizeCollectionLimitationType
        arguments:
            - "@ezpublish.api.persistence_handler"
        tags:
            - { name: ezpublish.limitationType, alias: AnonymizeCollection }

Next, tell the InformationCollectionPolicyProvider, which we have implemented in the last blog post, to use this limitation to anonymize function:

<?php

declare(strict_types=1);

namespace Netgen\InformationCollection\PolicyProvider;

use eZ\Bundle\EzPublishCoreBundle\DependencyInjection\Configuration\ConfigBuilderInterface;
use eZ\Bundle\EzPublishCoreBundle\DependencyInjection\Security\PolicyProvider\PolicyProviderInterface;

class InformationCollectionPolicyProvider implements PolicyProviderInterface
{
    /**
     * {@inheritDoc}
     */
    public function addPolicies(ConfigBuilderInterface $configBuilder): void
    {
        $configBuilder->addConfig([
            'infocollector' => [
                'read' => [],
                'delete' => [],
                'anonymize' => [
                    'AnonymizeCollection',
                ],
            ],
        ]);
    }
}

Integration inside the admin interface

Having both Limitation and LimitationType implemented is fine, but we still don’t have a way to assign our custom AnonymizeCollectionLimitation to the editor in a proper manner. To be able to assign a limitation in the Roles section of admin interface, we need to implement limitation mapper that knows how to translate our custom limitation to eZ Platform repository forms. We are going to implement both LimitationFormMapper and LimitationValueMapper into the same class as for both functionalities we need ContentTypeService.

LimitationFormMapper

LimitationFormMapper implements EzSystems\RepositoryForms\Limitation\LimitationFormMapperInterface interface and its responsibility is to fill up values in the policy edit interface. In our mapper, we want to iterate through all the available content types and check if any of them has fields marked as info collector. If they do, we need to have them added to the list of available content types to our edit interface. 

For convenience purposes, eZ developers have already prepared the EzSystems\RepositoryForms\Limitation\Mapper\MultipleSelectionBasedMapper which handles most of the stuff required for multi-selection form fields. Now the only thing left for us is to implement the getSelectionChoices() method.  

To make our system aware of this mapper, of course, we need to add to dependency injection container, tag service definition with ez.limitation.formMapper, and set limitationType: AnonymizeCollection

And to make everything look nice, create an ezrepoforms_policies.en.yml file inside your bundle translations directory. Usually, it is Resource\translations directory:


policy.limitation.identifier.anonymizecollection: 'Content types with info collectors'

edit_limitations

LimitationValueMapper

LimitationValueMapper implements the EzSystems\RepositoryForms\Limitation\LimitationValueMapperInterface interface and it is responsible for displaying selected limitation values in role view interface. Our responsibility is to implement the mapLimitationValue() method from the interface. So our AnonymizeCollectionLimitation will hold selected content type identifiers inside the $limitationValues property. We need to iterate through them and load the corresponding ContentType value object for every identifier. Those values will be passed to the special template that will try to find the ez_limitation_anonymizecollection_value Twig block. This will fail if we don’t provide a template with this block inside it.

Let’s create a template, with our limitation Twig block inside of it. You can check how other blocks are defined in the main template EzSystemsRepositoryFormsBundle::limitation_values.html.twig. Our limitation block is very simple, it just iterates over ContentType value objects and outputs names:

{% block ez_limitation_anonymizecollection_value %}
{% spaceless %}
    {% for value in values %}
        {{ value.name }}{% if not loop.last %},{% endif %}
    {% endfor %}
{% endspaceless %}
{% endblock %}

And add the template configuration to your ezplatform.yml configuration file: 

ezplatform:
    system:
        default:
            limitation_value_templates:
                - { template: 'NetgenInformationCollectionBundle::limitation_value.html.twig', priority: 0 }

The same thing as it was in the previous example applies here, except for the tag name which is a bit different. ez.limitation.valueMapper in this case, while limitationType: AnonymizeCollection must be the same.

view_roles

 

So the final implementation of our form and value mapper is the following: 

<?php

declare(strict_types=1);

namespace Netgen\InformationCollection\Limitation;

use eZ\Publish\API\Repository\ContentTypeService;
use eZ\Publish\API\Repository\Exceptions\NotFoundException;
use eZ\Publish\API\Repository\Values\User\Limitation;
use EzSystems\RepositoryForms\Limitation\LimitationValueMapperInterface;
use EzSystems\RepositoryForms\Limitation\Mapper\MultipleSelectionBasedMapper;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;

class AnonymizeCollectionLimitationMapper extends MultipleSelectionBasedMapper implements LimitationValueMapperInterface
{
    /**
     * @var \eZ\Publish\API\Repository\ContentTypeService
     */
    protected $contentTypeService;
    
    /**
     * @var \Psr\Log\LoggerInterface
     */
    protected $logger;

    public function __construct(ContentTypeService $contentTypeService, ?LoggerInterface $logger = null)
    {
        $this->contentTypeService = $contentTypeService;
        $this->logger = $logger ?? new NullLogger();
    }

    public function mapLimitationValue(Limitation $limitation)
    {
        $values = [];
        foreach ($limitation->limitationValues as $identifier) {
            try {
                $values[] = $this->contentTypeService->loadContentTypeByIdentifier($identifier);
            } catch (NotFoundException $e) {
                $this->logger->error(sprintf('Could not map limitation value: Content Type with identifier = %s not found', $identifier));
            }
        }

        return $values;
    }

    protected function getSelectionChoices()
    {
        $contentTypeChoices = [];
        foreach ($this->contentTypeService->loadContentTypeGroups() as $group) {
            foreach ($this->contentTypeService->loadContentTypes($group) as $contentType) {
                foreach ($contentType->getFieldDefinitions() as $fieldDefinition) {
                    if ($fieldDefinition->isInfoCollector) {
                        $contentTypeChoices[$contentType->identifier] = $contentType->getName($contentType->mainLanguageCode);

                        break;
                    }
                }
            }
        }

        return $contentTypeChoices;
    }
}

And this is how it needs to be plugged inside Symfony’s dependency injection container:

netgen_information_collection.limitation.form_mapper.anonymize:
    parent: ezrepoforms.limitation.form_mapper.multiple_selection
    class: Netgen\InformationCollection\Limitation\AnonymizeCollectionLimitationMapper
    arguments:
        - "@ezpublish.api.service.content_type"
        - "@?logger"
    tags:
        - { name: ez.limitation.formMapper, limitationType: AnonymizeCollection }
        - { name: ez.limitation.valueMapper, limitationType: AnonymizeCollection }

Now checking our brand new limitation in any controller that extends Symfony’s base controller can be done like this: 

<?php

declare(strict_types=1);

use eZ\Publish\Core\MVC\Symfony\Security\Authorization\Attribute;

$attribute = new Attribute(
    'infocollector',
    'anonymize',
    [
        'valueObject' => $content,
    ]
);

$this->denyAccessUnlessGranted($attribute);

Using Target for even more granular permissions checks

Not so long ago, support for the Target object has been implemented. You can find more information on implementation details in this pull request. To continue with our example use case, let’s extend it with the ability to not only check if an editor can anonymize collections of a certain content type, but also if an editor can anonymize certain fields in those collections.

To implement this functionality, we need two things. First, a value object that will implement simple marker interface eZ\Publish\SPI\Limitation\Target. This object will have a field definition identifier. Let’s add it: 

<?php

declare(strict_types=1);

namespace Netgen\InformationCollection\Limitation;

use eZ\Publish\SPI\Limitation\Target;

final class FieldDefinitionIdentifier implements Target
{
    /**
     * @var string
     */
    private $fieldDefIdentifier;

    public function __construct(string $fieldDefIdentifier)
    {
        $this->fieldDefIdentifier = $fieldDefIdentifier;
    }

    /**
     * @return string
     */
    public function getIdentifier(): string
    {
        return $this->fieldDefIdentifier;
    }
}

And the second, our AnonymizeCollectionLimitationType must implement the eZ\Publish\SPI\Limitation\TargetAwareType interface instead of the eZ\Publish\SPI\Limitation\Type. The changes we need are minimal as TargetAwareType already extends Type interface. Even if method signatures look almost the same at first, most notable changes are inside the method doc block. It clearly states that as $targets argument, it expects an array of Target objects and to ValueObject descendants.  

So in our hypothetical scenario, the implementation looks like this:

<?php

declare(strict_types=1);

namespace Netgen\InformationCollection\Limitation;

use eZ\Publish\API\Repository\Values\ValueObject;
use eZ\Publish\API\Repository\Values\User\UserReference as APIUserReference;
use eZ\Publish\API\Repository\Values\Content\Content;
use eZ\Publish\Core\Base\Exceptions\InvalidArgumentException;
use eZ\Publish\API\Repository\Values\User\Limitation as APILimitationValue;
use eZ\Publish\Core\Limitation\AbstractPersistenceLimitationType;
use eZ\Publish\SPI\Limitation\TargetAwareType;

class AnonymizeCollectionLimitationType extends AbstractPersistenceLimitationType implements TargetAwareType
{
    /**
     * {@inheritDoc}
     */
    public function evaluate(APILimitationValue $value, APIUserReference $currentUser, ValueObject $object, array $targets = null): ?bool
    {
        $targets = $targets ?? [];
        foreach ($targets as $target) {
            if (!$target instanceof FieldDefinitionIdentifier) {
                continue;
            }

            $accessVote = $this->myService->canUserAnonymizeField($target, $value, $currentUser);

            if ($accessVote === self::ACCESS_ABSTAIN) {
                continue;
            }

            return $accessVote;
        }

        if (!$value instanceof AnonymizeCollectionLimitation) {
            throw new InvalidArgumentException('$value', 'Must be of type: AnonymizeCollectionLimitation');
        }

        if (!$object instanceof Content) {
            throw new InvalidArgumentException(
                '$object',
                'Must be of type Content'
            );
        }

        if (empty($value->limitationValues)) {
            return false;
        }
        
        $contentType = $this->persistence
            ->contentTypeHandler()
            ->load(
                $object->contentInfo->contentTypeId
            );

        return in_array($contentType->identifier, $value->limitationValues);
    }
}

All the methods, except the evaluate() method, are exactly the same. The only difference to the previous implementation is that we need to take care of our Target objects. So, in this case, to check for permission just pass an array of our custom Target objects by targets key in an array as the third argument of Attribute class constructor.

<?php

declare(strict_types=1);

use eZ\Publish\Core\MVC\Symfony\Security\Authorization\Attribute;
use Netgen\InformationCollection\Limitation\FieldDefinitionIdentifier;

$attribute = new Attribute(
    'infocollector',
    'anonymize',
    [
        'valueObject' => $content,
        'targets' => [
            new FieldDefinitionIdentifier('name'),
            new FieldDefinitionIdentifier('lastname'),
        ],
    ]
);

$this->denyAccessUnlessGranted($attribute);

The whole implementation with Target objects can be avoided as the original evaluate() method in the first example allows any descendant of eZ’s base ValueObject class. In my opinion, going with Target objects is a more elaborate, strict and reasonable way.

Conclusion

With this second blog post about the eZ Platform’s permissions system, we went from setting up very basic policies to the more strict solution with limitations. The examples demonstrated how easy it is to set up custom policies, limitations and even fine-grained limitations with targets. As a developer who works with eZ Platform on a daily basis, I can only admire and respect the whole permissions system in eZ Platform. The good job from the legacy model has been continued with an added level of Symfony’s extensibility.

Comments

This site uses cookies. Some of these cookies are essential, while others help us improve your experience by providing insights into how the site is being used. For more detailed information on the cookies we use, please check our Privacy Policy.