Drop ACE, use voters




Marie Minasyan

December 13th, 2013, Warsaw

Who am I?


  Marie Minasyan


    IT Consultant

    Why this topic?




Talk link: https://joind.in/10367

@marie_minasyan

How are we going to do this?


  1. Symfony2 security management
  2. ACE
  3. Role voters
  4. Conclusion


1. Basic security in Symfony2


How does symfony2 manage permissions?

# security.yml    
security: encoders: #... role_hierarchy: ROLE_ADMIN: ROLE_USER ROLE_SUPER_ADMIN: [
ROLE_USER, ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH] providers: #... firewalls: #... access_control: - { path: ^/, roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: ^/profile$, roles: ROLE_USER } - { path: ^/admin$, roles: ROLE_ADMIN }

Security

Authentication + authorization
Security context
    • Token storage
    • Authorization checker


class SecurityContext implements SecurityContextInterface
{
    private $tokenStorage;
    private $authorizationChecker;
    ...
}

$this->get('security.context')->isGranted('SOME_ROLE', $someObject);

Autorization checker


    • Token Storage
    • Access decision manager


class AuthorizationChecker implements 
AuthorizationCheckerInterface
{
    private $tokenStorage;
    private $accessDecisionManager;
    private $authenticationManager;
    private $alwaysAuthenticate;
    ...
}


Access Decision Manager

 
class AccessDecisionManager implements AccessDecisionManagerInterface
{
    private $voters;
    private $strategy;
    private $allowIfAllAbstainDecisions;
    private $allowIfEqualGrantedDeniedDecisions;
...
}

Symfony2 voters

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);
}
AuthenticatedVoter
RoleVoter
RoleHierarchyVoter
ExpressionVoter

Strategies

Affirmative

Consensus

Unanimous







Decide consensus

// vendor/symfony/symfony/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManager
private function decideConsensus(TokenInterface $token, array $attributes, $object = null) {
$grant = 0; $deny = 0;

foreach ($this->voters as $voter) {
$result = $voter->vote($token, $object, $attributes);
switch ($result) {
case VoterInterface::ACCESS_GRANTED: ++$grant; break;
case VoterInterface::ACCESS_DENIED: ++$deny; break;
default: ++$abstain; break;
}
}
if ($grant > $deny) {
return true;
}
if ($deny > $grant) {
return false;
}
if ($grant > 0) {
return $this->allowIfEqualGrantedDeniedDecisions;
}

return $this->allowIfAllAbstainDecisions;
}

Customize your security

# app/config/security.yml
security:
access_decision_manager:
strategy: affirmative
allow_if_all_abstain: false
allow_if_equal_granted_denied: true

But what if I want custom roles?





We will create custom roles for a blog and manage them with ACE and Role voters for comparison


2. ACE


Access Control Engine

ACL !== access control lists in security.yml

Custom roles: CREATE, EDIT, DELETE, ...

How it works?


  1. One ACL = many ACEs
  2. ACL => object identity (entity or class)
  3. ACE => security identity (user or role) + mask  (permission)

          class scope
      class field scope
      object scope
      object field scope

      And in practice?

      # app/config/security.yml
      security:
          acl:
              connection: default
      php app/console init:acl

      Default permissions

      VIEW, EDIT, CREATE, DELETE, UNDELETE, OPERATOR, MASTER, OWNER (powers of 2 from 0 to 7)

          // Symfony/Component/Security/Acl/Permission/BasicPermissionMap.php
      const PERMISSION_VIEW = 'VIEW';
      const PERMISSION_EDIT = 'EDIT'; //...
      const PERMISSION_OWNER = 'OWNER';
      protected $map;
      public function __construct() {
      $this->map = array(
      self::PERMISSION_VIEW => array(
      MaskBuilder::MASK_VIEW,
      MaskBuilder::MASK_EDIT,
      MaskBuilder::MASK_OPERATOR,
      MaskBuilder::MASK_MASTER,
      MaskBuilder::MASK_OWNER,
      ),
      //...

      Blog example

      // create post
      public function createAction(Request $request)
      {
      //...

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

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

      // retrieving the security identity of the currently logged-in user
      $securityIdentity = UserSecurityIdentity::fromAccount(
      $this->getUser());

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

      //...

      Blog example (2)

          // when creating an admin user
      $aclProvider = $this->aclProvider;

      // creating the role identity
      $securityIdentity = UserSecurityIdentity::fromAccount($adminUser);

      // create acl
      $objectIdentity = new ObjectIdentity(
      'Ace\BlogBundle\Entity\User', 'Ace\BlogBundle\Entity\User'
      );

      try {
      $acl = $aclProvider->findAcl($objectIdentity);
      } catch (\Exception $e) {
      $acl = $aclProvider->createAcl($objectIdentity);
      }

      // grant master access
      $acl->insertClassAce($securityIdentity, MaskBuilder::MASK_MASTER);
      $aclProvider->updateAcl($acl);


      Access the roles

      {# show post #}
      {% extends '::base.html.twig' %}
      {% block body -%}
      <h1>{{ entity.title }}</h1>

      <p class="muted">Posted at {{ entity.createdat|date('Y-m-d H:i:s') }} by {{entity.user.username}}</p>

      <p>{{ entity.content|nl2br }}</p>

      {% if is_granted('EDIT', entity) %}
      <a class="btn btn-info" href="{{ path('post_edit', { 'id': entity.id }) }}">
      Edit</a>
      {% endif %}


      {% endblock %}

      public function editAction($id)
      {
      if (false === $this->get('security.context')->isGranted('EDIT', $entity)) {
      throw new AccessDeniedException();
      }
      //...
      }


      Inheritance

      // create post action    
      if ($form->isValid()) {
      $em = $this->getDoctrine()->getManager();
      $em->persist($entity);
      $em->flush();

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

      // retrieving the security identity of the currently logged-in user
      $securityIdentity = UserSecurityIdentity::fromAccount($this->getUser());
      // grant owner access
      $acl->insertObjectAce($securityIdentity, MaskBuilder::MASK_OWNER);

      //parent acl is ROLE_ADMIN that has MASK_MASTER on user class
      $parentACL = $aclProvider->findAcl(
      new ObjectIdentity('user_class', 'Ace\BlogBundle\Entity\User')
      );
      $acl->setParentAcl($parentACL);


      $aclProvider->updateAcl($acl);
      //...

      Custom your permissions

      namespace Ace\BlogBundle\Security\Acl;
      use Symfony\Component\Security\Acl\Permission\BasicPermissionMap;

      class PermissionMap extends BasicPermissionMap
      {
      const PERMISSION_PUBLISH = 'PUBLISH';
      const PERMISSION_UNPUBLISH = 'UNPUBLISH';

      public function __construct()
      {
      parent::__construct();

      $this->map[self::PERMISSION_PUBLISH] = array(
      MaskBuilder::MASK_OPERATOR,
      MaskBuilder::MASK_MASTER,
      MaskBuilder::MASK_OWNER,
      );
      $this->map[self::PERMISSION_UNPUBLISH] = array(
      MaskBuilder::MASK_OPERATOR,
      MaskBuilder::MASK_MASTER,
      MaskBuilder::MASK_OWNER,
      );
      //....


      Custom your permissions (2)

      namespace Ace\BlogBundle\Security\Acl;

      use Symfony\Component\Security\Acl\Permission\MaskBuilder as BaseMaskBuilder;

      class MaskBuilder extends BaseMaskBuilder
      {
      const MASK_DENY = 256; // 1 << 8
      const MASK_PUBLISH = 512; // 1 << 9
      const MASK_UNPUBLISH = 1024; // 1 << 10
      }

      #parameters.yml
      security.acl.permission.map.class: Ace\BlogBundle\Security\Acl\PermissionMap
      $builder = new MaskBuilder();
      $builder
          ->add('VIEW')
          ->add('PUBLISH')
          ->add('UNPIBLISH')
      ;
      $mask = $builder->get(); // int(1+512+1024)

      What happens if I want to modify or delete an ACL?


      // delete post action
      if (false === $this->get('security.context')->isGranted('DELETE', $post)) {
      throw new AccessDeniedException();
      }

      $aclProvider = $this->container->get('security.acl.provider');

      foreach ($post->getComments() as $comment) {
      $aclProvider->deleteAcl(ObjectIdentity::fromDomainObject($comment));
      }

      $aclProvider->deleteAcl(ObjectIdentity::fromDomainObject($entity));

      $em->remove($entity);
      $em->flush();


      ... Unless !

      // persist new comment entity ...

      // creating the ACL

      $aclProvider = $this->get('security.acl.provider');
      $objectIdentity = ObjectIdentity::fromDomainObject($newComment);
      $acl = $aclProvider->createAcl($objectIdentity);

      // retrieving the security identity of the currently logged-in user
      $securityIdentity = UserSecurityIdentity::fromAccount($this->getUser());

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

      // inherit acl from post

      $parentACL = $aclProvider->findAcl($objectIdentity::fromDomainObject($post));
      $acl->setParentAcl($parentACL);

      // deny access to post creator if he isn't the comment's author
      if ($entity->getAuthor()->getId() != $this->getUser()->getId()) {
      $securityIdentity = UserSecurityIdentity::fromAccount($entity->getUser());
      $acl->insertObjectAce($securityIdentity, MaskBuilder::MASK_DENY);
      }
      $aclProvider->updateAcl($acl);


      Unless (2)

      {#post comments#}
      {% for comment in entity.comments %}
      <div class="comment">
      <p class="muted">
      Posted at {{ comment.createdat|date('Y-m-d H:i:s') }}
      by {{ comment.user.username }}
      </p>
      <p>{{ comment.content|nl2br }}</p>

      <p class="pull-right">
      {% if is_granted('EDIT', comment)
      and is_granted('DENY', comment) == false %}

      <a href="{{ path('comment_edit', { 'id': comment.id, 'postId': entity.id }) }}">Edit</a>
      {% endif %}
      {% if is_granted('DELETE', comment)
      and is_granted('DENY', comment) == false %}

      <a href="{{ path('comment_delete', { 'id': comment.id, 'postId': entity.id }) }}">Delete</a>
      {% endif %}
      </p>
      </div>
      {% endfor %}


      Unless (3)



      // delete post action

      if (false === $this->get('security.context')->isGranted('DELETE', $post)) {
      throw new AccessDeniedException();
      }

      $aclProvider = $this->container->get('security.acl.provider');
      $aclProvider->deleteAcl(ObjectIdentity::fromDomainObject($entity));

      $em->remove($entity);
      $em->flush();


      What would you do?

      But who decides?


      ACL Voter

      // vendor/symfony/symfony/src/Symfony/Component/Security/Acl/Voter/AclVoter
      class AclVoter implements VoterInterface
      {
      private $aclProvider;
      private $permissionMap;
      private $objectIdentityRetrievalStrategy;
      private $securityIdentityRetrievalStrategy;
      private $allowIfObjectIdentityUnavailable;
      private $logger;
      //...
      public function supportsAttribute($attribute)
      {
      return $this->permissionMap->contains($attribute);
      }

      public function vote(TokenInterface $token, $object, array $attributes)
      {
      // magic happens here \o/
      }
      }


      3. Role voters

      Blog example with role voters

      class ObjectVoter extends RoleVoter {
      private $supportedRoles;
      public function __construct($supportedRoles) {
      $this->supportedRoles = $supportedRoles;
      }
      public function supportsAttribute($attribute) {
      return in_array($attribute, $this->supportedRoles);
      }
      //...
      }
      # RoleVoters/BlogBundle/Resources/config/services.yml
      services:
      object_voter:
      class: RoleVoters\BlogBundle\Securtiy\ObjectVoter
      public: false
      arguments: ["%customRoles%"]
      tags:
      - { name: security.voter, priority: 100 }

      # parameters.yml
      parameters:
      customRoles: [ EDIT, DELETE, PUBLISH, UNPUBLISH ]

      SecurityBundle => AddSecurityVotersPass (SplPriorityQueue)

      Blog example with voters (2)

      // RoleVoters/BlogBundle/Securtiy/ObjectVoter
      public function vote(TokenInterface $token, $object, array $attributes)
      {
      $result = VoterInterface::ACCESS_ABSTAIN;

      if (!$object implements BlogContentInterface) {
      return $result;
      }

      foreach ($attributes as $attribute) {
      if (!$this->supportsAttribute($attribute)) {
      continue;
      }

      $result = VoterInterface::ACCESS_DENIED;

      if ($object->getAuthor() === $token->getUser()
      || in_array('ROLE_ADMIN', $token->getRoles())) {
      return VoterInterface::ACCESS_GRANTED;
      }
      }
      return $result;
      }


      I'm sorry, what?


      ACE => 16 slides

      Role voters => 2 slides


      4. Conclusion

      Comparison

      A page with an article and 5 comments

      (prod environment, without cache in xhprof results)


      Role voters


      ACE


      What's best?

      Role voters

      Easy
      Fast
      All business logic in the same place
      Easy to maintain
      Easy to test

      ACE

      Hard
      Slower
      Business logic everywhere
      Difficult to maintain
      Hard to test

      One more argument




      Kris can't be wrong!

      So?



      Unless you need to be completely flexible or your business logic doesn't define precise role management,

      Drop ACE, use role voters.

      Thank you for your attention :)


      Marie Minasyan - Drop ACE, use role voters

      Twitter: @marie_minasyan
      Github: @marieminasyan

      Talk link: https://joind.in/10367
      Warsaw, December 13th, 2013

      Drop ACE, use role voters

      By Marie Minasyan

      Drop ACE, use role voters

      • 15,985