Private: Field-Based Permissions with JMS Serializer Bundle

  • Colin Frei

In one of our customer projects based on Symfony we provide an internal API to other applications, with which they can fetch information about users. As more applications start to use this API, we realized that some of the data about users provided by the API is too sensitive for all the applications. So we decided to adjust the API to allow the customer to configure in the config file what API user should be allowed to see what fields. We achieved this fairly easily with the JMS Serializer Bundle using a custom Exclusion Strategy.

 The JMS Serializer Bundle allows you to set exclusion strategies which are used to decide for each field and class if it should be displayed. Our first step was to write an exclusion strategy class implementing ExclusionStrategyInterace, that checked for each field, if the current API user is allowed to see it. That looks a bit like this:

use JMSSerializerExclusionExclusionStrategyInterface;
use JMSSerializerMetadataClassMetadata;
use JMSSerializerMetadataPropertyMetadata;
use JMSSerializerContext;

class FieldPermissionExclusionStrategy implements ExclusionStrategyInterface
{
    private $fields;

    public function __construct(array $fields)
    {
        $this->fields = $fields;
    }

    public function shouldSkipClass(ClassMetadata $metadata, Context $context)
    {
        // Not skipping classes yet
        return false;
    }

    public function shouldSkipProperty(PropertyMetadata $metadata, Context $context)
    {
        return !in_array($metadata->name, $this->fields);
    }
}

We pass the fields the current user is allowed to see into the class as an array in the constructor. Then, in the shouldSkipProperty method, we check in our list of allowed fields if the current property is allowed or not and return true or false accordingly.

The metadata and context passed in to the method would allow setting much more complicated conditions, but in our case a simple in_array is enough. We also didn't implement the shouldSkipClass method, since in our case we only care about properties, but implementing it could be similiar to the shouldSkipProperty method.

This small class already does the bulk of the work. In my container I can now pass it to a context, which I pass to the serializer and it'll apply the permissions i set, like this:

$context = new JMSSerializerSerializationContext();
$context->addExclusionStrategy(new FieldPermissionExclusionStrategy(array('firstName', 'lastName', 'email'));
$serializer->serialize($object, 'xml', $context);

We can clean that up a bit though – on one hand I need to always pass in the current users allowed fields (which I cheated on above), and I always need to build the context for every call to serialize(). To prevent that, we decided to overwrite the Serializer service, and set the context and set the permissions there, so it's in one place. So we added a LiipSerializer class, extending the default serializer:

use JMSSerializerSerializer;
use JMSSerializerSerializerContext;
use SymfonyComponentSecurityCoreSecurityContextInterface;

class LiipSerializer extends Serializer
{
    private $securityContext;
    private $apiUsers;

    public function initLiipSerializer(array $apiUsers, SecurityContextInterace $securityContext)
    {
        $this->apiUsers = $apiUsers;
        $this->securityContext = $securityContext;
    }

    public function serialize($data, $format, SerializationContext $context = null)
    {
        if (null === $context) {
            $context = new SerializationContext();
        }

        $context->addExclusionStrategy(new IarPermissionExclusionStrategy($this->getCurrentUsersFields()));

        return parent::serialize($data, $format, $context);
    }

    private function getCurrentUsersFields()
    {
        $username = $this->securityContext->getToken()->getUsername();
        if (!isset($this->apiUsers[$username])) {
            return array();
        }

        return $this->apiUsers[$username];
    }
}

What we do here is override the serialize() method, and set up our context before calling the parent method with our modified context, meaning we don't need to care about that when calling serialize().

Since we're extending the Serializer class, we don't want to mess with the __construct() method, and instead we have a new method called initLiipSerializer(), with which we pass the securityContext and the array of users in.

We call that initLiipSerializer() method using a call element in the service definition:

<service id="liip.liip_serializer" class="LiipAcmeBundleServiceLiipSerializer">
    <call method="initLiipSerializer">
        <argument>%api_user_fields%</argument>
        <argument type="service" id="security.context" />
    </call>
</service>

In our parameters.yml we also have a section with the field definitions:

api_user_fields:
    user1: [firstName, lastName, email]

And that's all! Now all I need to do is call

$serializer->serialize($object, 'xml');

, like i did before, except that $serializer is now my new serializer service, and it'll take my field permissions into account!

The same approach can also be used to set the version (with $context->setVersion();), or the format, based on whatever criterias you like.


Tell us what you think