
Drop ACE, use voters
Marie Minasyan
December 13th, 2013, Warsaw
Who am I?

@marie_minasyan
How are we going to do this?
- Symfony2 security management
- ACE
- Role voters
- 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
- 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?
-
One ACL = many ACEs
-
ACL => object identity (entity or class)
- 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.ymlSecurityBundle => AddSecurityVotersPass (SplPriorityQueue)
parameters:
customRoles: [ EDIT, DELETE, PUBLISH, UNPUBLISH ]
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)




What's best?
Role voters
Fast
All business logic in the same place
Easy to maintain
Easy to test

ACE
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
- 16,541