Claudio D'Alicandro

(@ClaudioSThought on twitter)

Backend Dev @Terravision

The Requirements

The Toolbox:

The Symfony security layer

Authentication and authorization

Access COntrol for urls

# app/config/security.yml
security:
    # ...
    access_control:
        - { path: ^/admin, roles: ROLE_USER_IP, ip: 127.0.0.1 }
        - { path: ^/admin, roles: ROLE_USER_HOST, host: symfony\.com$ }
        - { path: ^/admin, roles: ROLE_USER_METHOD, methods: [POST, PUT] }
        - { path: ^/admin, roles: ROLE_USER }

this doesn't help...

access control for Objects

ACL (Access control LIST)

In complex applications, you will often face the problem that access decisions cannot only be based on the person (Token) who is requesting access, but also involve a domain object that access is being requested for. This is where the ACL system comes in.

                                                              -- symfony.com

...Interesting...

Using ACL's isn't trivial, and for simpler use cases, it may be overkill.

                       -- symfony.com

Cheering!

Acl: how to use it?

Must be enabled, and will be persisted outside of our control

# app/config/security.yml
security:
    acl:
        connection: default
$ php app/console init:acl
public function addCommentAction(Post $post)
{
    $comment = new Comment();
    // ... setup $form, and submit data

    if ($form->isValid()) {
        $entityManager = $this->getDoctrine()->getManager();
        $entityManager->persist($comment);
        $entityManager->flush();

        // creating the ACL
        $aclProvider = $this->get('security.acl.provider');
        $objectIdentity = ObjectIdentity::fromDomainObject($comment);
        $acl = $aclProvider->createAcl($objectIdentity);

        // retrieving the security identity of the currently logged-in user
        $securityContext = $this->get('security.context');
        $user = $securityContext->getToken()->getUser();
        $securityIdentity = UserSecurityIdentity::fromAccount($user);

        // grant owner access
        $acl->insertObjectAce($securityIdentity, MaskBuilder::MASK_OWNER);
        $aclProvider->updateAcl($acl);
    }
}

The Controller with ACL

the object

Inside the ACLs we have to define the object identity  and Access Control Entries, eachone join with a Security Identity and a Scope among the following: 

  • Object-scope
  • Class-scope
  • Object-field-scope 
  • Class-field-scope

the user

The owner identity is defined in the User Security Identity 
(there is also a Role Security Identity
and there are three levels of ownership

  1. OPERATOR
  2. MASTER
  3. OWNER

Ok great, but:

  • Is a considerable effort
  • I have to clarify the ownership
  • Access control is a domain logic

There is an easier way?

Wait...

RBAC

class RoleVoter implements VoterInterface
{
// ...
}

ACL

class AclVoter implements VoterInterface
{
// ...
}

Maybe there is a solution!

Voters

The voters are simple services that respond to certain questions and abstain when the question asked is not their responsibility.

interface VoterInterface
{
    const ACCESS_GRANTED = 1;
    const ACCESS_ABSTAIN = 0;
    const ACCESS_DENIED  = -1;

    /**
     * [...]
     */
    public function supportsAttribute($attribute);

    /**
     * [...]
     */
    public function supportsClass($class);

    /**
     * [...]
     */
    public function vote(TokenInterface $token, $object, array $attributes);
}

Attributes

Questions posed to the security system

class RoleVoter implements VoterInterface
{
    private $prefix;

    public function __construct($prefix = 'ROLE_')
    {
        $this->prefix = $prefix;
    }

    public function supportsAttribute($attribute)
    {
        return 0 === strpos($attribute, $this->prefix);
    }

    // ...
}

Decision Strategies

  • affermative (default)
  • unanimous
  • consensus
# app/config/security.yml
security:
    access_decision_manager:
        # strategy can be: affirmative, unanimous or consensus
        strategy: unanimous

Ok, let's use a custom voter to implement this feature!

The controller without any kind of security limitation

class ExampleController extends Controller
{
    public function getOrderAction($orderIdentifier)
    {
        if ($orderView = $this->getOrderView($orderIdentifier)) {

            return new JsonResponse($orderView);
        }

        throw $this->createNotFoundException();
    }
    
    private function getOrderView($orderIdentifier)
    {
        return $this
            ->get('example.order.use_case.fetch_order_view_from_order_identifier')
            ->fetch($orderIdentifier);
    }
}

the ownership check service

<?php

namespace Example\Order\Service;

use Example\Order\DTO\OrderView;
use Example\Order\Rule\OrderViewOwnerCheck;
use Example\User\Entity\User;

class OrderViewOwnerChecker implements OrderViewOwnerCheckerInterface
{
    public function checkUserOwnershipOnOrder(User $user, OrderView $orderView)
    {
        return OrderViewOwnerCheck::isThisUserOwnerOfThisOrderView($user, $orderView);
    }
}

Define attributes

<?php

namespace Example\Order\Security\Attributes;

final class OrderViewAttributes
{
    const CAN_READ_THIS_ORDER_VIEW   = 'can_read_this_order_view';
    const CAN_EDIT_THIS_ORDER_VIEW   = 'can_edit_this_order_view';
    const CAN_INSERT_THIS_ORDER_VIEW = 'can_insert_this_order_view';
    const CAN_DELETE_THIS_ORDER_VIEW = 'can_delete_this_order_view';
}

custom voter

class OrderViewReadVoter implements VoterInterface
{
    const ORDER_VIEW_CLASS = 'Example\Order\DTO\OrderView';

    private $orderViewOwnerChecker;

    public function __construct(OrderViewOwnerCheckerInterface $orderViewOwnerChecker)
    {
        $this->orderViewOwnerChecker = $orderViewOwnerChecker;
    }

    public function supportsAttribute($attribute)
    {
        return in_array($attribute, [
            OrderViewAttributes::CAN_READ_THE_ORDER_VIEW
        ], true);
    }

    public function supportsClass($class)
    {
        $reflectionClass = new \ReflectionClass($class);

        return $class === self::ORDER_VIEW_CLASS
            || $reflectionClass->isSubclassOf(self::ORDER_VIEW_CLASS);
    }

    // [...]
}
class OrderViewReadVoter implements VoterInterface
{
    // [...]

    public function vote(TokenInterface $token, $object, array $attributes)
    {
        $result = VoterInterface::ACCESS_ABSTAIN;

        if (!$object instanceof OrderView) {
            return $result;
        }

        $user = $token->getUser();

        if (!is_object($user)) {
            return VoterInterface::ACCESS_DENIED;
        }

        foreach ($attributes as $attribute) {
            if ($this->supportsAttribute($attribute)) {
                $result = VoterInterface::ACCESS_DENIED;

                if ($this->orderViewOwnerChecker->checkUserOwnershipOnOrderView($user, $object)) {
                    return VoterInterface::ACCESS_GRANTED;
                }
            }
        }

        return $result;
    }
}

Configuring the voter

<!-- [...] -->
<service class="Example\Infrastructure\ExampleBundle\Voter\OrderViewReadVoter" 
         id="example_infrastructure.example_bundle.voter.order_view_read_voter">
    <argument id="example_order.domain_service.order_view_owner_checker" type="service"/>
    <tag name="security.voter" />
</service>
<!-- [...] -->

Ask the question to the security context

class ExampleController extends Controller
{
    public function getOrderAction($orderIdentifier)
    {
        if ($orderView = $this->getOrderView($orderIdentifier)) {
            if (!$this->isAuthorized())
            {
                throw $this->createAccessDeniedException();
            }
            return new JsonResponse($orderView);
        }

        throw $this->createNotFoundException();
    }

    private function isAuthorized()
    {
        return $this->get('security.context')
                    ->isGranted(
                        OrderViewAttributes::CAN_READ_THIS_ORDER_VIEW,
                        $orderView
                    );
    }

    private function getOrderView($orderIdentifier)
    {
        return $this->get('example.order.use_case.fetch_order_view_from_order_identifier')
                    ->fetch($orderIdentifier);
    }
}

Well done !!

The future

// Symfony 2.6
$this->denyAccessUnlessGranted('ROLE_EDIT', $item, 'You cannot edit this item.');

// Previous Symfony versions
if (false === $this->get('security.context')->isGranted('ROLE_EDIT', $item)) {
    throw $this->createAccessDeniedException('You cannot edit this item.');
}
abstract class AbstractVoter implements VoterInterface
{
    public function supportsAttribute($attribute);
    public function supportsClass($class);
    public function vote(TokenInterface $token, $object, array $attributes);
    abstract protected function getSupportedClasses();
    abstract protected function getSupportedAttributes();
    abstract protected function isGranted($attribute, $object, $user = null);
}
class PostVoter extends AbstractVoter
{
    const VIEW = 'view';
    const EDIT = 'edit';

    protected function getSupportedAttributes()
    {
        return array(self::VIEW, self::EDIT);
    }

    protected function getSupportedClasses()
    {
        return array('Acme\DemoBundle\Entity\Post');
    }

    protected function isGranted($attribute, $post, $user = null)
    {
        // make sure there is a user object (i.e. that the user is logged in)
        if (!$user instanceof UserInterface) {
            return false;
        }

        // custom business logic to decide if the given user can view
        // and/or edit the given post
        if ($attribute == self::VIEW && !$post->isPrivate()) {
            return true;
        }

        if ($attribute == self::EDIT && $user->getId() === $post->getOwner()->getId()) {
            return true;
        }

        return false;
    }
}

Questions?

Go

AND

VOTE!

on Joind!
https://joind.in/talk/view/12218

Made with Slides.com