Friday, September 28, 2012

Kris Wallsmith - Symfony2 Security Voters

Kris Wallsmith - Symfony2 Security Voters


I answered this question on StackOverflow today that is probably worth repeating here. The poster was asking how to implement subscription-based authorization logic in Symfony2. I imagine he models his problem something like this:
class Subscription
{
    const SECURED_AREA_FOO = 'FOO';
    const SECURED_AREA_BAR = 'BAR';

    // ...

    /** @ManyToOne(targetEntity="User", inversedBy="subscriptions") */
    public $user;

    /** @Column */
    public $securedArea;

    /** @Column(type="date") */
    public $start;

    /** @Column(type="date") */
    public $end;

    public function isActive()
    {
        $now = new DateTime();

        return $now >= $this->start && $now < $this->end;
    }

    // ...
}

class User implements UserInterface
{
    // ...

    public function getRoles()
    {
        $roles = $this->roles;

        foreach ($this->subscriptions as $subscription) {
            if ($subscription->isActive()) {
                $roles[] = 'ROLE_SUBSCRIPTION_'.$subscription->securedArea;
            }
        }

        return $roles;
    }

    // ...
}
He may then configure access control in his security.yml:
access_control:
    - { path: "^/sections/foo", roles: [ ROLE_SUBSCRIPTION_FOO ] }
    - { path: "^/sections/bar", roles: [ ROLE_SUBSCRIPTION_BAR ] }
This solution is nice enough, but we can do better. As the poster pointed out when someone suggested this solution, it doesn’t make sense to hydrate all of a user’s subscription objects (some of which may have expired years ago) just to fetch a string value. On top of that, fetching all of these objects every request is a waste of resources because the system will only be checking for the existence of one of them in any given request.
This authorization logic can be implemented much more lightly by encapsulating it in a custom security voter.

Voters

When questions about authorization are asked in Symfony2, the answer is arrived at by a process of voting. For example, when someone requests an URL that matches a configuredaccess_control rule, a vote is held to decide whether to allow or deny access to that resource.
This voting process is similar in some ways to a US appeals court. There are a certain numbers of judges presiding over any given proceeding and in the end a decision is rendered. Occasionally judges recuse themselves from a case for this or that reason.
The authorization portion of the Symfony2 security component also includes a panel of judges called voters. These voters each have a say whenever questions of authorization come up in your application. Not every voter will have an opinion on every decision; some will abstain.
There are two basic types of votes: whether a user is granted a certain security attribute (i.e. theROLE_USER attribute) and whether a user is granted a certain security attribute for a certain object (i.e. the EDIT attribute on blog post X). Each voter can be tuned to only chime in when certain votes are held. For example, you could write a voter that only participates when considering users with Gmail addresses.
In this particular case, we want to create a voter that only chimes in when questions regarding access to areas secured by subscriptions are raised. We can signify this by creating a new set of attributes that start with the string SUBSCRIPTION_.
access_control:
    - { path: "^/sections/foo", roles: [ SUBSCRIPTION_FOO ] }
This configuration proposes that only users with access to the security attributeSUBSCRIPTION_FOO should be granted access to any URL that starts with /sections/foo. Since there are no voters configured to evaluate this particular set of attributes, access with always be denied. But that is solved easy enough by creating a custom security voter.
This voter will look something like this:
class SubscriptionVoter implements VoterInterface
{
    private $em;

    public function __construct(EntityManager $em)
    {
        $this->em = $em;
    }

    public function supportsAttribute($attribute)
    {
        return 0 === strpos($attribute, 'SUBSCRIPTION_');
    }

    public function supportsClass($class)
    {
        return true;
    }

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

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

            $securedArea = substr($attribute, strlen('SUBSCRIPTION_'));

            // use the entity manager to query for active
            // subscriptions that connect the current user to the
            // requested secured area
            // $success = ...

            if ($success) {
                return VoterInterface::ACCESS_GRANTED;
            }

            $result = VoterInterface::ACCESS_DENIED;
        }

        return $result;
    }
}
And be configured in the service container something like this:
services:
    subscription_voter:
        class: SubscriptionVoter
        public: false
        arguments:
            - @doctrine.orm.entity_manager
        tags:
            - { name: security.voter }
And that’s all there is to it. You have encapsulated your custom authorization logic in one clean class and added it to the Symfony2 security layer.

Other Applications

This is an example of one specific application of security voters, but there are many more. If you are struggling with how to implement some special access control logic that doesn’t fit nicely into either security roles or the security component’s ACL, consider creating a custom voter.

PS:
1,
there is another solution at 
http://stackoverflow.com/questions/11288293/how-to-use-the-accessdecisionmanager-in-symfony2-for-authorization-of-arbitrary
2,
make sure, you have setup
access_control:
    - { path: "^/sections/foo", roles: [ SUBSCRIPTION_FOO ] }


otherwise this is not going to work, and the reason is:

the service pointed by access_denied_handler is only called if the user has unsufficient privilege to access the resource. If the user is not authenticated at all access_dened_handler is never called. Providing a service to entry_point in security.yml did actually solve the problem

from 
http://stackoverflow.com/questions/11968354/symfony2-why-access-denied-handler-doesnt-work

More than 3 requests, I'll translate this to Chinese.
超过3个请求,我就会把这篇文章翻译成中文。

No comments: