Claudio D'Alicandro
(@ClaudioSThought on twitter)
Backend Dev @Terravision
The Symfony security layer
# 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...
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!
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);
}
}
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:
The owner identity is defined in the User Security Identity
(there is also a Role Security Identity)
and there are three levels of ownership
There is an easier way?
RBAC
class RoleVoter implements VoterInterface
{
// ...
}
ACL
class AclVoter implements VoterInterface
{
// ...
}
Maybe there is a solution!
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);
}
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);
}
// ...
}
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!
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);
}
}
<?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);
}
}
<?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';
}
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;
}
}
<!-- [...] -->
<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>
<!-- [...] -->
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 !!
// 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;
}
}
on Joind!
https://joind.in/talk/view/12218