Skip to content

Latest commit

 

History

History
719 lines (592 loc) · 21.4 KB

07. Cookbook.md

File metadata and controls

719 lines (592 loc) · 21.4 KB

Cookbook

This section will help you to further understand how ZfcRbac works by providing more concrete examples. If you have any other recipe you'd like to add, please open an issue!

A Real World Application

In this example we are going to create a very little real world application. We will create a controller PostController that interacts with a service called PostService. For the sake of simplicity we will only cover the delete-methods of both parts.

Let's start by creating a controller that has the PostService as dependency:

class PostController
{
    protected $postService;

    public function __construct(PostService $postService)
    {
        $this->postService = $postService;
    }

    // addAction(), editAction(), etc...

    public function deleteAction()
    {
        $id = $this->params()->fromQuery('id');

        $this->postService->deletePost($id);

        return $this->redirect()->toRoute('posts');
    }
}

Since we have a dependency, let's inject it using the ControllerManager, we will do this inside our Module-Class

class Module
{
    // getAutoloaderConfig(), getConfig(), etc...

    public function getControllerConfig()
    {
        return [
            'factories' => [
                 'PostController' => function($cpm) {
                     // We assume a Service key 'PostService' here that returns the PostService Class
                     return new PostController(
                         $cpm->getServiceLocator()->get('PostService')
                     );
                 }
            ]
        ];
    }
}

Now that we got this in place let us quickly define our PostService. We will be using a Service that makes use of Doctrine, so we require a Doctrine\Common\Persistence\ObjectManager as dependency.

use Doctrine\Common\Persistence\ObjectManager;

class PostService
{
    protected $objectManager;

    public function __construct(ObjectManager $objectManager)
    {
        $this->objectManager = $objectManager;
    }

    public function deletePost($id)
    {
        $post = $this->objectManager->find('Post', $id);
        $this->objectManager->remove($post);
        $this->objectManager->flush();
    }
}

And for this one, too, let's quickly create the factory, again within our Module class.

class Module
{
    // getAutoloaderConfig(), getConfig(), etc...

    public function getServiceConfig()
    {
        return [
            'factories' => [
                 'PostService' => function($sm) {
                     return new PostService(
                         $sm->get('doctrine.entitymanager.orm_default')
                     );
                 }
            ]
        ];
    }
}

With this set up we can now cover some best practices.

Best practices

One mistake most beginners do is protecting their applications using guards only. This leaves your application open for some undesired side-effects. As a best practice you should protect all your services by injecting the authorization service. But let's go step by step:

Assuming the application example above we can easily use ZfcRbac to protect our route using the following guard:

return [
    'zfc_rbac' => [
        'guards' => [
            'ZfcRbac\Guard\RouteGuard' => [
                'post/delete' => ['admin']
            ]
        ]
    ]
];

Now, any users that do not have the "admin" role will receive a 403 error (unauthorized) when trying to access the "post/delete" route. However, this does not prevent the service (which should contain the actual logic in a properly design application) to be injected and used elsewhere in your code. For instance:

class PostController
{
    protected $postService;

    public function createAction()
    {
        // MUHAHAHA DOING SOMETHING EVIL!!!
        $this->postService->deletePost('2');
    }
}

You see the issue! The solution is to inject the AuthorizationService into your services, and checking for the permissions before doing anything wrong. So let's modify our previously created PostService-class

use Doctrine\Common\Persistence\ObjectManager;

class PostService
{
    protected $objectManager;

    protected $authorizationService;

    public function __construct(
        ObjectManager        $objectManager,
        AuthorizationService $autorizationService
    ) {
        $this->objectManager        = $objectManager;
        $this->authorizationService = $autorizationService;
    }

    public function deletePost($id)
    {
        // First check permission
        if (!$this->authorizationService->isGranted('deletePost')) {
            throw UnauthorizedException('You are not allowed !');
        }

        $post = $this->objectManager->find('Post', $id);
        $this->objectManager->remove($post);
        $this->objectManager->flush();
    }
}

Since we now have an additional dependency we should inject it through our factory, again within our Module class.

class Module
{
    // getAutoloaderConfig(), getConfig(), etc...

    public function getServiceConfig()
    {
        return [
            'factories' => [
                 'PostService' => function($sm) {
                     return new PostService(
                         $sm->get('doctrine.entitymanager.orm_default'),
                         $sm->get('ZfcRbac\Service\AuthorizationService') // This is new!
                     );
                 }
            ]
        ];
    }
}

When using guards then?

In fact, you should see guards as a very efficient way to quickly reject access to a hierarchy of routes or a whole controller. For instance, assuming you have the following route config:

return [
    'router' => [
        'routes' => [
            'admin' => [
                'type'    => 'Literal',
                'options' => [
                    'route' => '/admin'
                ],
                'may_terminate' => true,
                'child_routes' => [
                    'users' => [
                        'type' => 'Literal',
                        'options' => [
                            'route' => '/users'
                        ]
                    ],
                    'invoices' => [
                        'type' => 'Literal',
                        'options' => [
                            'route' => '/invoices'
                        ]
                    ]
                ]
            ]
        ]
    ]
};

You can quickly unauthorized access to all admin routes using the following guard:

return [
    'zfc_rbac' => [
        'guards' => [
            'ZfcRbac\Guard\RouteGuard' => [
                'admin*' => ['admin']
            ]
        ]
    ]
];

A Real World Application Part 2 - Only delete your own Posts

If you jumped straight to this section please notice, that we assume you have the knowledge that we presented in the previous example. In here we will cover a very common use-case. Users of our Application should only have delete permissions to their own content. So let's quickly refresh our PostService class:

use Doctrine\Common\Persistence\ObjectManager;

class PostService
{
    protected $objectManager;

    protected $authorizationService;

    public function __construct(
        ObjectManager        $objectManager,
        AuthorizationService $autorizationService
    ) {
        $this->objectManager        = $objectManager;
        $this->authorizationService = $autorizationService;
    }

    public function deletePost($id)
    {
        // First check permission
        if (!$this->authorizationService->isGranted('deletePost')) {
            throw UnauthorizedException('You are not allowed !');
        }

        $post = $this->objectManager->find('Post', $id);
        $this->objectManager->remove($post);
        $this->objectManager->flush();
    }
}

As we can see, we check within our Service if the User of our Application is allowed to delete the post with a check against the deletePost permission. Now how can we achieve that only a user who is the owner of the Post to be able to delete his own post, but other users can't? We do not want to change our Service with more complex logic because this is not the task of such service. The Permission-System should handle this. And we can, for this we have the AssertionPluginManager and here is how to do it:

First of all things we need to write an Assertion. The Assertion will return a boolean statement about the current identity being the owner of the post.

namespace Your\Namespace;

use ZfcRbac\Assertion\AssertionInterface;
use ZfcRbac\Service\AuthorizationService;

class MustBeAuthorAssertion implements AssertionInterface
{
    /**
     * Check if this assertion is true
     *
     * @param  AuthorizationService $authorization
     * @param  mixed                $post
     *
     * @return bool
     */
    public function assert(AuthorizationService $authorization, $post = null)
    {
        return $authorization->getIdentity() === $post->getAuthor();
    }
}

This simple MustBeAuthorAssertion will check against the current $authorization if it equals the identity of the current context Author. The second parameter is called the "context". A context can be anything (an object, a scalar, an array...) and makes only sense in the context of the assertion.

Imagine a user calls http://my.dom/post/delete/42, so obviously he wants to delete the Post-Entity with ID#42. In this case Entity#42 is our Context! If you're wondering of how the context get there, bare with me, we will get to this later.

Now that we have written the Assertion, we want to make sure that this assertion will always be called, whenever we check for the deletePost permission. We don't want others to delete our previous content! For this we have the so- called assertion_map. In this map we glue assertions and permissions together.

// module.config.php or wherever you configure your RBAC stuff
return [
    'zfc_rbac' => [
        'assertion_map' => [
            'deletePost' => 'Your\Namespace\MustBeAuthorAssertion'
        ]
    ]
];

Now, whenever some test the deletePost permission, it will automatically call the MustBeAuthorAssertion from the AssertionPluginManager. This plugin manager is configured to automatically add unknown classes to an invokable. However, some assertions may need dependencies. You can manually configure the assertion plugin manager as shown below:

// module.config.php or wherever you configure your RBAC stuff
return [
    'zfc_rbac' => [
        // ... other rbac stuff
        'assertion_manager' => [
            'factories' => [
                'AssertionWithDependency' => 'Your\Namespace\AssertionWithDependencyFactory'
            ]
        ]
    ]
];

Now we need to remember about the context. Somehow we need to let the AssertionPluginManager know about our context. This is done as simple as to passing it to the isGranted() method. For this we need to modify our Service one last time.

use Doctrine\Common\Persistence\ObjectManager;

class PostService
{
    protected $objectManager;

    protected $authorizationService;

    public function __construct(
        ObjectManager        $objectManager,
        AuthorizationService $autorizationService
    ) {
        $this->objectManager        = $objectManager;
        $this->authorizationService = $autorizationService;
    }

    public function deletePost($id)
    {
        // Note, we now need to query for the post of interest first!
        $post = $this->objectManager->find('Post', $id);

        // Check the permission now with a given context
        if (!$this->authorizationService->isGranted('deletePost', $post)) {
            throw UnauthorizedException('You are not allowed !');
        }

        $this->objectManager->remove($post);
        $this->objectManager->flush();
    }
}

And there you have it. The context is injected into the isGranted() method and now the AssertionPluginManager knows about it and can do its thing. Note that in reality, after you have queried for the $post you would check if $post is actually a real post. Because if it is an empty return value then you should throw an exception earlier without needing to check against the permission.

A Real World Application Part 3 - Admins can delete everything

Often, you want users with a specific role to be able to have full access to everything. For instance, admins could delete all the posts, even if they don't own it.

However, with the previous assertion, even if the admin has the permission deletePost, it won't work because the assertion will evaluate to false.

Actually, the answer is quite simple: deleting my own posts and deleting others' posts should be treated like two different permissions (it makes sense if you think about it). Therefore, admins will have the permission deleteOthersPost (as well as the permission deletePost, because admin could write posts, too).

The assertion must therefore be modified like this:

namespace Your\Namespace;

use ZfcRbac\Assertion\AssertionInterface;
use ZfcRbac\Service\AuthorizationService;

class MustBeAuthorAssertion implements AssertionInterface
{
    /**
     * Check if this assertion is true
     *
     * @param  AuthorizationService $authorization
     * @param  mixed                $context
     *
     * @return bool
     */
    public function assert(AuthorizationService $authorization, $context = null)
    {
        if ($authorization->getIdentity() === $context->getAuthor()) {
            return true;
        }

        return $authorization->isGranted('deleteOthersPost');
    }
}

Using ZfcRbac with Doctrine ORM

First your User entity class must implement ZfcRbac\Identity\IdentityInterface :

use ZfcUser\Entity\User as ZfcUserEntity;
use ZfcRbac\Identity\IdentityInterface;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;

/**
 * @ORM\Entity
 * @ORM\Table(name="user")
 */
class User extends ZfcUserEntity implements IdentityInterface
{
    /**
     * @var Collection
     * @ORM\ManyToMany(targetEntity="HierarchicalRole")
     */
    private $roles;
    
    public function __construct()
    {
        $this->roles = new ArrayCollection();
    }

    /**
     * {@inheritDoc}
     */
    public function getRoles()
    {
        return $this->roles->toArray();
    }

    /**
     * Set the list of roles
     * @param Collection $roles
     */
    public function setRoles(Collection $roles)
    {
        $this->roles->clear();
        foreach ($roles as $role) {
            $this->roles[] = $role;
        }
    }

    /**
     * Add one role to roles list
     * @param \Rbac\Role\RoleInterface $role
     */
    public function addRole(RoleInterface $role)
    {
        $this->roles[] = $role;
    }
}

For this example we will use the more complex situation by using Rbac\Role\HierarchicalRoleInterface so the second step is to create HierarchicalRole entity class

class HierarchicalRole implements HierarchicalRoleInterface
{
    /**
     * @var HierarchicalRoleInterface[]|\Doctrine\Common\Collections\Collection
     *
     * @ORM\ManyToMany(targetEntity="HierarchicalRole")
     */
    protected $children;

    /**
     * @var PermissionInterface[]|\Doctrine\Common\Collections\Collection
     *
     * @ORM\ManyToMany(targetEntity="Permission", indexBy="name", fetch="EAGER", cascade={"persist"})
     */
    protected $permissions;

    /**
     * Init the Doctrine collection
     */
    public function __construct()
    {
        $this->children    = new ArrayCollection();
        $this->permissions = new ArrayCollection();
    }

    /**
     * {@inheritDoc}
     */
    public function addChild(HierarchicalRoleInterface $child)
    {
        $this->children[] = $child;
    }

    /*
     * Set the list of permission
     * @param Collection $permissions
     */
    public function setPermissions(Collection $permissions)
    {
        $this->permissions->clear();
        foreach ($permissions as $permission) {
            $this->permissions[] = $permission;
        }
    }

    /**
     * {@inheritDoc}
     */
    public function addPermission($permission)
    {
        if (is_string($permission)) {
            $permission = new Permission($permission);
        }

        $this->permissions[(string) $permission] = $permission;
    }

    /**
     * {@inheritDoc}
     */
    public function hasPermission($permission)
    {
        // This can be a performance problem if your role has a lot of permissions. Please refer
        // to the cookbook to an elegant way to solve this issue

        return isset($this->permissions[(string) $permission]);
    }

    /**
     * {@inheritDoc}
     */
    public function getChildren()
    {
        return $this->children->toArray();
    }

    /**
     * {@inheritDoc}
     */
    public function hasChildren()
    {
        return !$this->children->isEmpty();
    }
}

And the last step is to create Permission entity class which is a very simple entity class, you don't have to do specific things !

You can find all entity example in this folder : Example

You need one more configuration step. Indeed, how can the RoleProvider retrieve your role and permissions ? For this you need to configure ZfcRbac\Role\ObjectRepositoryRoleProvider in your zfc_rbac.global.php file :

        /**
         * Configuration for role provider
         */
        'role_provider' => [
            'ZfcRbac\Role\ObjectRepositoryRoleProvider' => [
                'object_manager'     => 'doctrine.entitymanager.orm_default', // alias for doctrine ObjectManager
                'class_name'         => 'User\Entity\HierarchicalRole', // FQCN for your role entity class
                'role_name_property' => 'name', // Name to show
            ],
        ],

Using DoctrineORM with ZfcRbac is very simple. You need to be aware from performances where there is a lot of permissions for roles.

How to deal with roles with lot of permissions?

In very complex applications, your roles may have dozens of permissions. In the [/data/FlatRole.php.dist] entity we provide, we configure the permissions association so that whenever a role is loaded, all its permissions are also loaded in one query (notice the fetch="EAGER"):

/**
  * @ORM\ManyToMany(targetEntity="Permission", indexBy="name", fetch="EAGER")
  */
protected $permissions;

The hasPermission method is therefore really simple:

public function hasPermission($permission)
{
    return isset($this->permissions[(string) $permission]);
}

However, with a lot of permissions, this method will quickly kill your database. What you can do is modfiy the Doctrine mapping so that the collection is not actually loaded:

/**
  * @ORM\ManyToMany(targetEntity="Permission", indexBy="name", fetch="LAZY")
  */
protected $permissions;

Then, modify the hasPermission method to use the Criteria API. The Criteria API is a Doctrine 2.2+ API that allows to efficiently filter a collection without loading the whole collection:

use Doctrine\Common\Collections\Criteria;

public function hasPermission($permission)
{
    $criteria = Criteria::create()->where(Criteria::expr()->eq('name', (string) $permission));
    $result   = $this->permissions->matching($criteria);

    return count($result) > 0;
}

NOTE: This is only supported starting from Doctrine ORM 2.5!

Using ZfcRbac and ZF2 Assetic

To use Assetic with ZfcRbac guards, you should modify your module.config.php using the following configuration:

return [
    'assetic_configuration' => [
        'acceptableErrors' => [
            \ZfcRbac\Guard\GuardInterface::GUARD_UNAUTHORIZED
        ]
    ]
];

Using ZfcRbac and ZfcUser

To use the authentication service from ZfcUser, just add the following alias in your application.config.php:

return [
    'service_manager' => [
        'aliases' => [
            'Zend\Authentication\AuthenticationService' => 'zfcuser_auth_service'
        ]
    ]
];

Finally add the ZfcUser routes to your guards:

return [
    'zfc_rbac' => [
        'guards' => [
            'ZfcRbac\Guard\RouteGuard' => [
                'zfcuser/login' => ['guest'],
                'zfcuser/register' => ['guest'], // required if registration is enabled
                'zfcuser*' => ['user'] // includes logout, changepassword and changeemail
            ]
        ]
    ]
];

Navigation

  • Back to [the Using the Authorization Service](/docs/06. Using the Authorization Service.md)
  • Back to the Index