Marie Minasyan
December 13th, 2013, Warsaw
@marie_minasyan
# 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 }
class SecurityContext implements SecurityContextInterface
{
private $tokenStorage;
private $authorizationChecker;
...
}
$this->get('security.context')->isGranted('SOME_ROLE', $someObject);
class AuthorizationChecker implements
AuthorizationCheckerInterface
{
private $tokenStorage;
private $accessDecisionManager;
private $authenticationManager;
private $alwaysAuthenticate;
...
}
class AccessDecisionManager implements AccessDecisionManagerInterface { private $voters; private $strategy; private $allowIfAllAbstainDecisions; private $allowIfEqualGrantedDeniedDecisions;
...
}
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
Affirmative
Consensus
Unanimous
// 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;
}
# app/config/security.yml
security:
access_decision_manager:
strategy: affirmative
allow_if_all_abstain: false
allow_if_equal_granted_denied: true
ACL !== access control lists in security.yml
Custom roles: CREATE, EDIT, DELETE, ...
class scope
class field scope
object scope
object field scope
# app/config/security.yml security: acl: connection: default
php app/console init:acl
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,
),
//...
// 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);
//...
// 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);
{# 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();
}
//...
}
// 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);
//...
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,
);
//....
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)
// 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();
// 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);
{#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 %}
// 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();
// 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/
}
}
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 ]
// 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;
}
ACE => 16 slides
Role voters => 2 slides
A page with an article and 5 comments
(prod environment, without cache in xhprof results)
Role voters
ACE
Unless you need to be completely flexible or your business logic doesn't define precise role management,
Drop ACE, use role voters.