diff --git a/docs/LmcRbacMvc.md b/docs/LmcRbacMvc.md index 42eb3ce..f53e406 100644 --- a/docs/LmcRbacMvc.md +++ b/docs/LmcRbacMvc.md @@ -3,4 +3,4 @@ sidebar_position: 2 --- LmcRbacMvc is a role-based access control Laminas MVC module to provide additional features on top of Laminas\Permissions\Rbac -[Documentation](https://lm-commons.github.io/lmc-rbac-mvc) \ No newline at end of file +[Documentation](lmc-rbac-mvc/intro) \ No newline at end of file diff --git a/docs/lmc-rbac-mvc/cookbook.md b/docs/lmc-rbac-mvc/cookbook.md new file mode 100644 index 0000000..4b2551c --- /dev/null +++ b/docs/lmc-rbac-mvc/cookbook.md @@ -0,0 +1,767 @@ +--- +sidebar_position: 8 +--- +# Cookbook + +This section will help you further understand how LmcRbacMvc 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: + +```php +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 + +```php +class Module +{ + public function getConfig() + { + return [ + 'controllers' => [ + '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 have 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\Persistence\ObjectManager` as dependency. + +```php +use Doctrine\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. + +```php +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 + +Ideally, you should not protect your applications using only guards (Route or Controller guards). +This leaves your application open for some undesired side-effects. +As a best practice you should protect all your services or controllers by injecting the authorization service. +But let's go step by step: + +Assuming the application example above we can easily use LmcRbacMvc to protect our route using the following guard: + +```php +return [ + 'lmc_rbac' => [ + 'guards' => [ + 'LmcRbacMvc\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: + +```php +class PostController +{ + protected $postService; + + public function createAction() + { + // this action may have been reached through the "forward" method, hence bypassing guards + $this->postService->deletePost('2'); + } +} +``` + +You see the issue! + +The solution is to inject the `AuthorizationService` into your services and check for the +permissions before doing anything wrong. So let's modify our previously created `PostService` class + +```php +use Doctrine\Persistence\ObjectManager; +use LmcRbacMvc\Service\AuthorizationService; + +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 new 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. + +```php +class Module +{ + // getAutoloaderConfig(), getConfig(), etc... + + public function getServiceConfig() + { + return [ + 'factories' => [ + 'PostService' => function($sm) { + return new PostService( + $sm->get('doctrine.entitymanager.orm_default'), + $sm->get('LmcRbacMvc\Service\AuthorizationService') // This is new! + ); + } + ] + ]; + } +} +``` + +Alternatively, you can also protect your controllers using the `isGranted` helper (you do not need to inject +the AuthorizationService then): + +```php +class PostController +{ + protected $postService; + + public function createAction() + { + if (!$this->isGranted('deletePost')) { + throw new UnauthorizedException('You are not allowed !'); + } + + $this->postService->deletePost('2'); + } +} +``` + +While protecting services is the more defensive way (because services are usually the last part of the logic flow), +it is often complicated to deal with. +If your application is architectured correctly, it is often simpler to protect your controllers. + +### 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: + +```php +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 reject access to all admin routes using the following guard: + +```php +return [ + 'lmc_rbac' => [ + 'guards' => [ + 'LmcRbacMvc\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: + +```php +use Doctrine\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 new 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 we need to write an Assertion. The Assertion will return a boolean statement about the current +identity being the owner of the post. + +```php +namespace Your\Namespace; + +use LmcRbacMvc\Assertion\AssertionInterface; +use LmcRbacMvc\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 only makes 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 how the context gets 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. + +```php +// module.config.php or wherever you configure your RBAC stuff +return [ + 'lmc_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: + +```php +// module.config.php or wherever you configure your RBAC stuff +return [ + 'lmc_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 by simply passing it to the `isGranted()` method. For this we need to modify our Service +one last time. + +```php +use Doctrine\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 new 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: + +```php +namespace Your\Namespace; + +use LmcRbacMvc\Assertion\AssertionInterface; +use LmcRbacMvc\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'); + } +} +``` + +## A Real World Application Part 4 - Checking permissions in the view + +If some part of the view needs to be protected, you can use the shipped ```isGranted``` view helper. + +For example, lets's say that only users with the permissions ```post.manage``` will have a menu item to acces +the adminsitration panel : + +In your template post-index.phtml + +```php +
+``` + +You can even protect your menu item regarding a role, by using the ```hasRole``` view helper : + +```php + +``` + +In this last example, the menu item will be hidden for users who don't have the ```admin``` role. + +## Using LmcRbacMvc with Doctrine ORM + +First your User entity class must implement `LmcRbacMvc\Identity\IdentityInterface` : + +```php +use LmccUser\Entity\User as LmcUserEntity; +use LmcRbacMvc\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 LmcUserEntity 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 a more complex situation by using `Rbac\Role\HierarchicalRoleInterface` so the second step is to create HierarchicalRole entity class + +```php +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 a Permission entity class which is a very simple entity class. You don't have to do specific things! + +You can find all entity examples in this folder : [Example](https://github.com/LM-Commons/LmcRbacMvc/tree/master/data) + +You need one more configuration step. Indeed, how can the RoleProvider retrieve your role and permissions? For this you need to configure `LmcRbacMvc\Role\ObjectRepositoryRoleProvider` in your `lmc_rbac.global.php` file : +```php + /** + * Configuration for role provider + */ + 'role_provider' => [ + 'LmcRbacMvc\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 LmcRbacMvc is very simple. You need to be aware of performance 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 of its permissions are also +loaded in one query (notice the `fetch="EAGER"`): + +```php +/** + * @ORM\ManyToMany(targetEntity="Permission", indexBy="name", fetch="EAGER") + */ +protected $permissions; +``` + +The `hasPermission` method is therefore really simple: + +```php +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: + +```php +/** + * @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 +your application to efficiently filter a collection without loading the whole collection: + +```php +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 LmcRbacMvc and ZF2 Assetic + +To use [Assetic](https://github.com/widmogrod/zf2-assetic-module) with LmcRbacMvc guards, you should modify your +`module.config.php` using the following configuration: + +```php +return [ + 'assetic_configuration' => [ + 'acceptableErrors' => [ + \LmcRbacMvc\Guard\GuardInterface::GUARD_UNAUTHORIZED + ] + ] +]; +``` + +## Using LmcRbacMvc and LmcUser + +To use the authentication service from LmcUser, just add the following alias in your application.config.php: + +```php +return [ + 'service_manager' => [ + 'aliases' => [ + 'Laminas\Authentication\AuthenticationService' => 'lmcuser_auth_service' + ] + ] +]; +``` + +Finally add the LmcUser routes to your `guards`: + +```php +return [ + 'lmc_rbac' => [ + 'guards' => [ + 'LmcRbac\Guard\RouteGuard' => [ + 'lmcuser/login' => ['guest'], + 'lmcuser/register' => ['guest'], // required if registration is enabled + 'lmcuser*' => ['user'] // includes logout, changepassword and changeemail + ] + ] + ] +]; +``` diff --git a/docs/lmc-rbac-mvc/guards.md b/docs/lmc-rbac-mvc/guards.md new file mode 100644 index 0000000..04a6048 --- /dev/null +++ b/docs/lmc-rbac-mvc/guards.md @@ -0,0 +1,478 @@ +--- +sidebar_position: 5 +--- +# Guards + +In this section, you will learn: + +* What guards are +* How to use and configure built-in guards +* How to create custom guards + +## What are guards and when should you use them? + +Guards are listeners that are registered on a specific event of +the MVC workflow. They allow your application to quickly mark a request as unauthorized. + +Here is a simple workflow without guards: + + + +And here is a simple workflow with a route guard: + + + +RouteGuard and ControllerGuard are not aware of permissions but rather only think about "roles". For +instance, you may want to refuse access to each routes that begin by "admin/*" to all users that do not have the +"admin" role. + +If you want to protect a route for a set of permissions, you must use RoutePermissionsGuard. For instance, +you may want to grant access to a route "post/delete" only to roles having the "delete" permission. +Note that in a RBAC system, a permission is linked to a role, not to a user. + +Albeit simple to use, guards should not be the only protection in your application, and you should always +protect your services as well. The reason is that your business logic should be handled by your service. Protecting a given +route or controller does not mean that the service cannot be access from elsewhere (another action for instance). + +### Protection policy + +By default, when a guard is added, it will perform a check only on the specified guard rules. Any route or controller +that is not specified in the rules will be "granted" by default. Therefore, the default is a "blacklist" +mechanism. + +However, you may want a more restrictive approach (also called "whitelist"). In this mode, once a guard is added, +anything that is not explicitly added will be refused by default. + +For instance, let's say you have two routes: "index" and "login". If you specify a route guard rule to allow "index" +route to "member" role, your "login" route will become defacto unauthorized to anyone, unless you add a new rule for +allowing the route "login" to "member" role. + +You can change it in LmcRbacMvc config, as follows: + +```php +use LmcRbacMvc\Guard\GuardInterface; + +return [ + 'lmc_rbac' => [ + 'protection_policy' => GuardInterface::POLICY_DENY + ] +]; +``` + +> NOTE: this policy will block ANY route/controller (so it will also block any console routes or controllers). The +deny policy is much more secure, but it needs much more configuration to work with. + +## Built-in guards + +LmcRbacMvc comes with four guards, in order of priority : + +* RouteGuard : protect a set of routes based on the identity roles +* RoutePermissionsGuard : protect a set of routes based on roles permissions +* ControllerGuard : protect a controllers and/or actions based on the identity roles +* ControllerPermissionsGuard : protect a controllers and/or actions based on roles permissions + +All guards must be added in the `guards` subkey: + +```php +return [ + 'lmc_rbac' => [ + 'guards' => [ + // Guards config here! + ] + ] +]; +``` + +Because of the way Laminas Framework handles config, you can without problem define some rules in one module, and +more rules in another module. All the rules will be automatically merged. + +> For your mental health, I recommend you to use either the route guard OR the controller guard, but not both. If +you decide to use both conjointly, I recommend you to set the protection policy to "allow" (otherwise, you will +need to define rules for every routes AND every controller, which can become quite frustrating!). + +Please note that if your application uses both route and controller guards, route guards are always executed +**before** controller guards (they have a higher priority). + +### RouteGuard + +> The RouteGuard listens to the `MvcEvent::EVENT_ROUTE` event with a priority of -5. + +The RouteGuard allows your application to protect a route or a hierarchy of routes. You must provide an array of "key" => "value", +where the key is a route pattern and the value is an array of role names: + +```php +return [ + 'lmc_rbac' => [ + 'guards' => [ + 'LmcRbacMvc\Guard\RouteGuard' => [ + 'admin*' => ['admin'], + 'login' => ['guest'] + ] + ] + ] +]; +``` + +> Only one role in a rule needs to be matched (it is an OR condition). + +Those rules grant access to all admin routes to users that have the "admin" role, and grant access to the "login" +route to users that have the "guest" role (eg.: most likely unauthenticated users). + +> The route pattern is not a regex. It only supports the wildcard (*) character, that replaces any segment. + +You can also use the wildcard character * for roles: + +```php +return [ + 'lmc_rbac' => [ + 'guards' => [ + 'LmcRbacMvc\Guard\RouteGuard' => [ + 'home' => ['*'] + ] + ] + ] +]; +``` + +This rule grants access to the "home" route to anyone. + +Finally, you can also omit the roles array to completely block a route, for maintenance purpose for example : + +```php +return [ + 'lmc_rbac' => [ + 'guards' => [ + 'LmcRbacMvc\Guard\RouteGuard' => [ + 'route_under_construction' + ] + ] + ] +]; +``` + +This rule will be inaccessible. + +Note : this last example could be (and should be) written in a more explicit way : + +```php +return [ + 'lmc_rbac' => [ + 'guards' => [ + 'LmcRbacMvc\Guard\RouteGuard' => [ + 'route_under_construction' => [] + ] + ] + ] +]; +``` + + +### RoutePermissionsGuard + +> The RoutePermissionsGuard listens to the `MvcEvent::EVENT_ROUTE` event with a priority of -8. + +The RoutePermissionsGuard allows your application to protect a route or a hierarchy of routes. You must provide an array of "key" => "value", +where the key is a route pattern and the value is an array of permission names: + +```php +return [ + 'lmc_rbac' => [ + 'guards' => [ + 'LmcRbacMvc\Guard\RoutePermissionsGuard' => [ + 'admin*' => ['admin'], + 'post/manage' => ['post.update', 'post.delete'] + ] + ] + ] +]; +``` + +> By default, all permissions in a rule must be matched (an AND condition). + +In the previous example, one must have ```post.update``` **AND** ```post.delete``` permissions +to access the ```post/manage``` route. You can also specify an OR condition like so: + +```php +use LmcRbacMvc\Guard\GuardInterface; + +return [ + 'lmc_rbac' => [ + 'guards' => [ + 'LmcRbacMvc\Guard\RoutePermissionsGuard' => [ + 'post/manage' => [ + 'permissions' => ['post.update', 'post.delete'], + 'condition' => GuardInterface::CONDITION_OR + ] + ] + ] + ] +]; +``` + +> Permissions are linked to roles, not to users + +Those rules grant access to all admin routes to roles that have the "admin" permission, and grant access to the +"post/delete" route to roles that have the "post.delete" or "admin" permissions. + +> The route pattern is not a regex. It only supports the wildcard (*) character, that replaces any segment. + +You can also use the wildcard character * for permissions: + +```php +return [ + 'lmc_rbac' => [ + 'guards' => [ + 'LmcRbacMvc\Guard\RoutePermissionsGuard' => [ + 'home' => ['*'] + ] + ] + ] +]; +``` + +This rule grants access to the "home" route to anyone. + +Finally, you can also use an empty array to completly block a route, for maintenance purpose for example : + +```php +return [ + 'lmc_rbac' => [ + 'guards' => [ + 'LmcRbacMvc\Guard\RoutePermissionsGuard' => [ + 'route_under_construction' => [] + ] + ] + ] +]; +``` + +This route will be inaccessible. + + +### ControllerGuard + +> The ControllerGuard listens to the `MvcEvent::EVENT_ROUTE` event with a priority of -10. + +The ControllerGuard allows your application to protect a controller. You must provide an array of arrays: + +```php +return [ + 'lmc_rbac' => [ + 'guards' => [ + 'LmcRbacMvc\Guard\ControllerGuard' => [ + [ + 'controller' => 'MyController', + 'roles' => ['guest', 'member'] + ] + ] + ] + ] +]; +``` + +> Only one role in a rule need to be matched (it is an OR condition). + +Those rules grant access to each actions of the MyController controller to users that have either the "guest" or +"member" roles. + +As for RouteGuard, you can use a wildcard (*) character for roles. + +You can also specify optional actions, so that the rule only apply to one or several actions: + +```php +return [ + 'lmc_rbac' => [ + 'guards' => [ + 'LmcRbacMvc\Guard\ControllerGuard' => [ + [ + 'controller' => 'MyController', + 'actions' => ['read', 'edit'], + 'roles' => ['guest', 'member'] + ] + ] + ] + ] +]; +``` + +You can combine a generic rule and a specific action rule for the same controller, as follows: + +```php +return [ + 'lmc_rbac' => [ + 'guards' => [ + 'LmcRbacMvc\Guard\ControllerGuard' => [ + [ + 'controller' => 'PostController', + 'roles' => ['member'] + ], + [ + 'controller' => 'PostController', + 'actions' => ['delete'], + 'roles' => ['admin'] + ] + ] + ] + ] +]; +``` + +These rules grant access to each controller action to users that have the "member" role, but restrict the +"delete" action to "admin" only. + +### ControllerPermissionsGuard + +> The ControllerPermissionsGuard listens to the `MvcEvent::EVENT_ROUTE` event with a priority of -13. + +The ControllerPermissionsGuard allows your application to protect a controller using permissions. You must provide an array of arrays: + +```php +return [ + 'lmc_rbac' => [ + 'guards' => [ + 'LmcRbacMvc\Guard\ControllerPermissionsGuard' => [ + [ + 'controller' => 'MyController', + 'permissions' => ['post.update', 'post.delete'] + ] + ] + ] + ] +]; +``` + +> All permissions in a rule must be matched (it is an AND condition). + +In the previous example, the user must have ```post.update``` **AND** ```post.delete``` permissions +to access each action of the MyController controller. + +As for all other guards, you can use a wildcard (*) character for permissions. + +The configuration rules are the same as for ControllerGuard. + +### Security notice + +RouteGuard and ControllerGuard listen to the `MvcEvent::EVENT_ROUTE` event. Therefore, if you use the +`forward` method in your controller, those guards will not intercept and check requests (because internally +Laminas MVC does not trigger again a new MVC loop). + +Most of the time, this is not an issue, but you must be aware of it, and this is an additional reason why you +should always protect your services too. + +## Creating custom guards + +LmcRbacMvc is flexible enough to allow you to create custom guards. Let's say we want to create a guard that will +refuse access based on an IP addresses blacklist. + +First create the guard: + +```php +namespace Application\Guard; + +use Laminas\Http\Request as HttpRequest; +use Laminas\Mvc\MvcEvent; +use LmcRbacMvc\Guard\AbstractGuard; + +class IpGuard extends AbstractGuard +{ + const EVENT_PRIORITY = 100; + + /** + * List of IPs to blacklist + */ + protected $ipAddresses = []; + + /** + * @param array $ipAddresses + */ + public function __construct(array $ipAddresses) + { + $this->ipAddresses = $ipAddresses; + } + + /** + * @param MvcEvent $event + * @return bool + */ + public function isGranted(MvcEvent $event) + { + $request = $event->getRequest(); + + if (!$request instanceof HttpRequest) { + return true; + } + + $clientIp = $_SERVER['REMOTE_ADDR']; + + return !in_array($clientIp, $this->ipAddresses); + } +} +``` + +> Guards must implement `LmcRbacMvc\Guard\GuardInterface`. + +By default, guards are listening to the event `MvcEvent::EVENT_ROUTE` with a priority of -5 (you can change the default +event to listen by overriding the `EVENT_NAME` constant in your guard subclass). However, in this case, we don't +even need to wait for the route to be matched, so we overload the `EVENT_PRIORITY` constant to be executed earlier. + +The `isGranted` method simply retrieves the client IP address, and checks it against the blacklist. + +However, for this to work, we must register the newly created guard with the guard plugin manager. To do so, add the +following code in your config: + +```php +return [ + 'zfc_rbac' => [ + 'guard_manager' => [ + 'factories' => [ + 'Application\Guard\IpGuard' => 'Application\Factory\IpGuardFactory' + ] + ] + ] +]; +``` + +The `guard_manager` config follows a conventional service manager configuration format. + +Now, let's create the factory: + +```php +namespace Application\Factory; + +use Application\Guard\IpGuard; +use Laminas\ServiceManager\Factory\FactoryInterface; +use Laminas\ServiceManager\ServiceLocatorInterface; + +class IpGuardFactory implements FactoryInterface +{ + /** + * {@inheritDoc} + */ + public function __invoke(ContainerInterface $container, $requestedName, array $options = null) + { + if (null === $options) { + $options = []; + } + return new IpGuard($options); + } +} +``` + +In a real use case, you would likely fetched the blacklist from a database. + +Now we just need to add the guard to the `guards` option, so that LmcRbacMvc can execute the logic behind this guard. In +your config, add the following code: + +```php +return [ + 'lmc_rbac' => [ + 'guards' => [ + 'Application\Guard\IpGuard' => [ + '87.45.66.46', + '65.87.35.43' + ] + ] + ] +]; +``` +The array of IP addresses will be passed to `IpGuardFactory::__invoke` in the `$options` parameter. diff --git a/docs/lmc-rbac-mvc/images/workflow-with-guards.png b/docs/lmc-rbac-mvc/images/workflow-with-guards.png new file mode 100644 index 0000000..3c3d217 Binary files /dev/null and b/docs/lmc-rbac-mvc/images/workflow-with-guards.png differ diff --git a/docs/lmc-rbac-mvc/images/workflow-without-guards.png b/docs/lmc-rbac-mvc/images/workflow-without-guards.png new file mode 100644 index 0000000..fd49aba Binary files /dev/null and b/docs/lmc-rbac-mvc/images/workflow-without-guards.png differ diff --git a/docs/lmc-rbac-mvc/installation.md b/docs/lmc-rbac-mvc/installation.md new file mode 100644 index 0000000..2599781 --- /dev/null +++ b/docs/lmc-rbac-mvc/installation.md @@ -0,0 +1,42 @@ +--- +sidebar_position: 2 +sidebar_label: Requirements and Installation +--- +# Requirements and Installation +## Requirements + +- PHP 7.4 or higher +- [Zf-fr/Rbac component v1](https://github.com/zf-fr/rbac): this is actually a prototype for the ZF3 Rbac component. +- [Laminas Components 2.x | 3.x or higher](http://www.github.com/laminas) + + +## Optional + +- [DoctrineModule](https://github.com/doctrine/DoctrineModule): if you want to use some built-in role and permission providers. +- [Laminas\DeveloperTools](https://github.com/laminas/Laminas\DeveloperTools): if you want to have useful stats added to + the Laminas Developer toolbar. + + +## Installation + +LmcRbacMvc only officially supports installation through Composer. For Composer documentation, please refer to +[getcomposer.org](http://getcomposer.org/). + +Install the module: + +```sh +$ composer require lm-commons/lmc-rbac-mvc:^3.0 +``` + +Enable the module by adding `LmcRbacMvc` key to your `application.config.php` or `modules.config.php` file. Customize the module by copy-pasting +the `lmc_rbac.global.php.dist` file to your `config/autoload` folder. + +## Upgrade + +LmcRbacMvc introduces breaking changes from zfcrbac v2: +- [BC] The namespace has been changed from `ZfcRbac` to `LmcRbacMvc`. +- [BC] The key `zfc_rbac` in autoload and module config files has been replaced + by the `lmc_rbac` key. +- Requires PHP 7.4 or later +- Requires Laminas MVC components 3.x or later +- Uses PSR-4 autoload diff --git a/docs/lmc-rbac-mvc/intro.md b/docs/lmc-rbac-mvc/intro.md new file mode 100644 index 0000000..1492b68 --- /dev/null +++ b/docs/lmc-rbac-mvc/intro.md @@ -0,0 +1,56 @@ +--- +sidebar_position: 1 +--- + +# Introduction + +LmcRbacMvc is a role-based access control Laminas MVC module to provide additional features on top of Laminas\Permissions\Rbac + +:::tip +**Important Note:** + +If you are migrating from ZfcRbac v2, there are breaking changes to take into account. See the [Upgrade](installation.md#upgrade) section for details. +::: + +## Why should I use an authorization module? + +The authorization part of an application is an essential aspect of securing your application. +While the *authentication* part tells you who is using your website, the *authorization* answers if the given identity has the permission to +perform specific actions. + +## What is the Rbac model? + +Rbac stands for **role-based access control**. We use a very simple (albeit powerful) implementation of this model +through the use of the [zf-fr/rbac](https://github.com/zf-fr/rbac) library. + + +The basic idea of Rbac is to use roles and permissions: + +* **Users** can have one or many **Roles** +* **Roles** request access to **Permissions** +* **Permissions** are granted to **Roles** + +By default, LmcRbacMvc can be used for two kinds of Rbac model: + +* Flat RBAC model: in this model, roles cannot have children. This is ideal for smaller applications, as it is easier + to understand, and the database design is simpler (no need for a join table). +* Hierarchical RBAC model: in this model, roles can have child roles. When evaluating if a given role has a + permission, this model also checks recursively if any of its child roles also have the permission. + + +## How can I integrate LmcRbacMvc into my application? + +LmcRbacMvc offers multiple ways to protect your application: + +* Using **Guards**: these classes act as "firewalls" that block access to routes and/or controllers. Guards are usually + configured using PHP arrays, and are executed early in the MVC dispatch process. Typically this happens right after + the route has been matched. +* Using **AuthorizationService**: a complementary method is to use the `AuthorizationService` class and inject it into your + service classes to protect them from unwanted access. + +While it is advised to use both methods to make your application even more secure, this is completely optional and you +can choose either of them independently. + +To find out about how you can easily make your existing application more secure, please refer to the following section: + +* [Cookbook: A real world example](cookbook.md#a-real-world-application) diff --git a/docs/lmc-rbac-mvc/quick-start.md b/docs/lmc-rbac-mvc/quick-start.md new file mode 100644 index 0000000..f3f9c1e --- /dev/null +++ b/docs/lmc-rbac-mvc/quick-start.md @@ -0,0 +1,145 @@ +--- +sidebar_position: 3 +--- + +# Quick Start + +In this section, you will learn: + +* How to set up the module +* How to specify an identity provider +* How to add a simple role provider + +Before starting the quick start, make sure you have properly installed the module by following the instructions in +the README file. + +## Specifying an identity provider + +By default, LmcRbacMvc internally uses the `Laminas\Authentication\AuthenticationService` service key to retrieve the user (logged or +not). Therefore, you must implement and register this service in your application by adding these lines in your `module.config.php` file: + +```php +return [ + 'service_manager' => [ + 'factories' => [ + 'Laminas\Authentication\AuthenticationService' => function($sm) { + // Create your authentication service! + } + ] + ] +]; +``` +:::tip +If you are also using the [LmcUser](https://github.com/lm-commons/lmcuser) package, then the `Laminas\Authentication\AuthenticationService` will be provided for you and there is no need to implement your own. +::: + +The identity given by `Laminas\Authentication\AuthenticationService` must implement `LmcRbacMvc\Identity\IdentityInterface`. +:::warning +Note that the default identity provided with Laminas does not implement this interface, neither does the LmcUser suite. +::: + +LmcRbacMvc is flexible enough to use something other than the built-in `AuthenticationService`, by specifying custom +identity providers. For more information, refer [to this section](role-providers.md#identity-providers). + +## Adding a guard + +A guard allows your application to block access to routes and/or controllers using a simple syntax. For instance, this configuration +grants access to any route that begins with `admin` (or is exactly `admin`) to the `admin` role only: + +```php +return [ + 'lmc_rbac' => [ + 'guards' => [ + 'LmcRbacMvc\Guard\RouteGuard' => [ + 'admin*' => ['admin'] + ] + ] + ] +]; +``` + +LmcRbacMvc has several built-in guards, and you can also register your own guards. For more information, refer +[to this section](guards.md#built-in-guards). + +## Adding a role provider + +RBAC model is based on roles. Therefore, for LmcRbacMvc to work properly, it must be aware of all the roles that are +used inside your application. + +This configuration creates an *admin* role that has a child role called *member*. The *admin* role automatically +inherits the *member* permissions. + +```php +return [ + 'lmc_rbac' => [ + 'role_provider' => [ + 'LmcRbacMvc\Role\InMemoryRoleProvider' => [ + 'admin' => [ + 'children' => ['member'], + 'permissions' => ['delete'] + ], + 'member' => [ + 'permissions' => ['edit'] + ] + ] + ] + ] +]; +``` + +In this example, the *admin* role has two permissions: `delete` and `edit` (because it inherits the permissions from +its child), while the *member* role only has the `edit` permission. + +LmcRbacMvc has several built-in role providers, and you can also register your own role providers. For more information, +refer [to this section](role-providers.md#built-in-role-providers). + +## Registering a strategy + +When a guard blocks access to a route/controller, or if you throw the `LmcRbacMvc\Exception\UnauthorizedException` +exception in your service, LmcRbacMvc automatically performs some logic for you depending on the view strategy used. + +For instance, if you want LmcRbacMvc to automatically redirect all unauthorized requests to the "login" route, add +the following code in the `onBootstrap` method of your `Module.php` class: + +```php +public function onBootstrap(MvcEvent $e) +{ + $app = $e->getApplication(); + $sm = $app->getServiceManager(); + $em = $app->getEventManager(); + + $listener = $sm->get(\LmcRbacMvc\View\Strategy\RedirectStrategy::class); + $listener->attach($em); +} +``` + +By default, `RedirectStrategy` redirects all unauthorized requests to a route named "login" when the user is not connected +and to a route named "home" when the user is connected. This is, of course, entirely configurable. + +> For flexibility purposes, LmcRbacMvc **does not** register any strategy for you by default! + +For more information about built-in strategies, refer [to this section](strategies.md#built-in-strategies). +[to this section](strategies.md) + +## Using the authorization service + +Now that LmcRbacMvc is properly configured, you can inject the authorization service into any class and use it to check +if the current identity is granted to do something. + +The authorization service is registered inside the service manager using the following key: `LmcRbacMvc\Service\AuthorizationService`. +Once injected, you can use it as follows: + +```php +use LmcRbacMvc\Exception\UnauthorizedException; + +class ActionController extends \Laminas\Mvc\Controller\AbstractActionController { +public function delete() +{ + if (!$this->authorizationService->isGranted('delete')) { + throw new UnauthorizedException(); + } + + // Delete the post +} +} +``` diff --git a/docs/lmc-rbac-mvc/role-providers.md b/docs/lmc-rbac-mvc/role-providers.md new file mode 100644 index 0000000..c98e447 --- /dev/null +++ b/docs/lmc-rbac-mvc/role-providers.md @@ -0,0 +1,198 @@ +--- +sidebar_position: 4 +--- +# Role providers + +In this section, you will learn: + +* What are role providers +* What are identity providers +* How to use and configure built-in providers +* How to create custom role providers + +## What are role providers? + +A role provider is an object that returns a list of roles. Each role provider must implement the +`LmcRbacMvc\Role\RoleProviderInterface` interface. The only required method is `getRoles`, and must return an array +of `Rbac\Role\RoleInterface` objects. + +Roles can come from one of many sources: in memory, from a file, from a database... However, please note that +you can specify only one role provider per application. The reason is that having multiple role providers makes +the workflow harder and can lead to security problems that are very hard to spot. + +## Identity providers? + +Identity providers return the current identity. Most of the time, this means the logged in user. LmcRbacMvc comes with a +default identity provider (`LmcRbacMvc\Identity\AuthenticationIdentityProvider`) that uses the +`Laminas\Authentication\AuthenticationService` service. + +### Create your own identity provider + +If you want to implement your own identity provider, create a new class that implements +`LmcRbacMvc\Identity\IdentityProviderInterface` class. Then, change the `identity_provider` option in LmcRbacMvc config, +as shown below: + +```php +return [ + 'lmc_rbac' => [ + 'identity_provider' => 'MyCustomIdentityProvider' + ] +]; +``` + +The identity provider is automatically pulled from the service manager. + +## Built-in role providers + +LmcRbacMvc comes with two built-in role providers: `InMemoryRoleProvider` and `ObjectRepositoryRoleProvider`. A role +provider must be added to the `role_provider` subkey: + +```php +return [ + 'lmc_rbac' => [ + 'role_provider' => [ + // Role provider config here! + ] + ] +]; +``` + +### InMemoryRoleProvider + +This provider is ideal for small/medium sites with few roles/permissions. All the data is specified in a simple +PHP file, so you never hit a database. + +Here is an example of the format you need to use: + +```php +return [ + 'lmc_rbac' => [ + 'role_provider' => [ + 'LmcRbacMvc\Role\InMemoryRoleProvider' => [ + 'admin' => [ + 'children' => ['member'], + 'permissions' => ['article.delete'] + ], + 'member' => [ + 'children' => ['guest'], + 'permissions' => ['article.edit', 'article.archive'] + ], + 'guest' => [ + 'permissions' => ['article.read'] + ] + ] + ] + ] +]; +``` + +The `children` and `permissions` subkeys are entirely optional. Internally, the `InMemoryRoleProvider` creates +either a `Rbac\Role\Role` object if the role does not have any children, or a `Rbac\Role\HierarchicalRole` if +the role has at least one child. + +If you are more confident with flat RBAC, the previous config can be re-written to remove any inheritence between roles: + +```php +return [ + 'lmc_rbac' => [ + 'role_provider' => [ + 'LmcRbacMvc\Role\InMemoryRoleProvider' => [ + 'admin' => [ + 'permissions' => [ + 'article.delete', + 'article.edit', + 'article.archive', + 'article.read' + ] + ], + 'member' => [ + 'permissions' => [ + 'article.edit', + 'article.archive', + 'article.read' + ] + ], + 'guest' => [ + 'permissions' => ['article.read'] + ] + ] + ] + ] +]; +``` + +### ObjectRepositoryRoleProvider + +This provider fetches roles from the database using `Doctrine\Common\Persistence\ObjectRepository` interface. + +You can configure this provider by giving an object repository service name that is fetched from the service manager +using the `object_repository` key: + +```php +return [ + 'lmc_rbac' => [ + 'role_provider' => [ + 'LmcRbacMvc\Role\ObjectRepositoryRoleProvider' => [ + 'object_repository' => 'App\Repository\RoleRepository', + 'role_name_property' => 'name' + ] + ] + ] +]; +``` + +Or you can specify the `object_manager` and `class_name` options: + +```php +return [ + 'lmc_rbac' => [ + 'role_provider' => [ + 'LmcRbacMvc\Role\ObjectRepositoryRoleProvider' => [ + 'object_manager' => 'doctrine.entitymanager.orm_default', + 'class_name' => 'App\Entity\Role', + 'role_name_property' => 'name' + ] + ] + ] +]; +``` + +In both cases, you need to specify the `role_name_property` value, which is the name of the entity's property +that holds the actual role name. This is used internally to only load the identity roles, instead of loading +the whole table every time. + +Please note that your entity fetched from the table MUST implement the `Rbac\Role\RoleInterface` interface. + +## Creating custom role providers + +To create a custom role provider, you first need to create a class that implements the `LmcRbacMvc\Role\RoleProviderInterface` +interface. + +Then, you need to add it to the role provider manager: + +```php +return [ + 'lmc_rbac' => [ + 'role_provider_manager' => [ + 'factories' => [ + 'Application\Role\CustomRoleProvider' => 'Application\Factory\CustomRoleProviderFactory' + ] + ] + ] +]; +``` + +You can now use it like any other role provider: + +```php +return [ + 'lmc_rbac' => [ + 'role_provider' => [ + 'Application\Role\CustomRoleProvider' => [ + // Options + ] + ] + ] +]; +``` + diff --git a/docs/lmc-rbac-mvc/strategies.md b/docs/lmc-rbac-mvc/strategies.md new file mode 100644 index 0000000..950a941 --- /dev/null +++ b/docs/lmc-rbac-mvc/strategies.md @@ -0,0 +1,152 @@ +--- +sidebar_position: 6 +--- +# Strategies + +In this section, you will learn: + +* What strategies are +* How to use built-in strategies +* How to create custom strategies + +## What are strategies? + +A strategy is an object that listens to the `MvcEvent::EVENT_DISPATCH_ERROR` event. It is used to describe what +happens when access to a resource is unauthorized by LmcRbacMvc. + +LmcRbacMvc strategies all check if an `LmcRbacMvc\Exception\UnauthorizedExceptionInterface` has been thrown. + +By default, LmcRbacMvc does not register any strategy for you. The best place to register it is in your `onBootstrap` +method of the `Module.php` class: + +```php +public function onBootstrap(MvcEvent $e) +{ + $app = $e->getApplication(); + $sm = $app->getServiceManager(); + $em = $app->getEventManager(); + + $listener = $sm->get(\LmcRbacMvc\View\Strategy\UnauthorizedStrategy::class); + $listener->attach($em); +} +``` + +## Built-in strategies + +LmcRbacMvc comes with two built-in strategies: `RedirectStrategy` and `UnauthorizedStrategy`. + +### RedirectStrategy + +This strategy allows your application to redirect any unauthorized request to another route by optionally appending the previous +URL as a query parameter. + +To register it, copy-paste this code into your Module.php class: + +```php +public function onBootstrap(MvcEvent $e) +{ + $app = $e->getApplication(); + $sm = $app->getServiceManager(); + $em = $app->getEventManager(); + + $listener = $sm->get(\LmcRbacMvc\View\Strategy\RedirectStrategy::class); + $listener->attach($em); +} +``` + +You can configure the strategy using the `redirect_strategy` subkey: + +```php +return [ + 'lmc_rbac' => [ + 'redirect_strategy' => [ + 'redirect_when_connected' => true, + 'redirect_to_route_connected' => 'home', + 'redirect_to_route_disconnected' => 'login', + 'append_previous_uri' => true, + 'previous_uri_query_key' => 'redirectTo' + ], + ] +]; +``` + +If users try to access an unauthorized resource (eg.: http://www.example.com/delete), they will be +redirected to the "login" route if is not connected and to the "home" route otherwise (it must exist in your route configuration +of course) with the previous URL appended : http://www.example.com/login?redirectTo=http://www.example.com/delete + +You can prevent redirection when a user is connected (i.e. so that the user gets a 403 page) by setting `redirect_when_connected` to `false`. + +### UnauthorizedStrategy + +This strategy allows your application to render a template on any unauthorized request. + +To register it, copy-paste this code into your Module.php class: + +```php +public function onBootstrap(MvcEvent $e) +{ + $app = $e->getApplication(); + $sm = $app->getServiceManager(); + $em = $app->getEventManager(); + + $listener = $sm->get(\LmcRbacMvc\View\Strategy\UnauthorizedStrategy::class); + $listener->attach($em); +} +``` + +You can configure the strategy using the `unauthorized_strategy` subkey: + +```php +return [ + 'lmc_rbac' => [ + 'unauthorized_strategy' => [ + 'template' => 'error/custom-403' + ], + ] +]; +``` + +> By default, LmcRbacMvc uses a template called `error/403`. + +## Creating custom strategies + +Creating a custom strategy is rather easy. Let's say we want to create a strategy that integrates with +the [ApiProblem](https://github.com/laminas-api-tools/api-tools-api-problem) Laminas Api Tools module: + +```php +namespace Application\View\Strategy; + +use Laminas\Http\Response as HttpResponse; +use Laminas\Mvc\MvcEvent; +use Laminas\ApiTools\ApiProblem\ApiProblem; +use Laminas\ApiTools\ApiProblem\ApiProblemResponse; +use LmcRbacMvc\View\Strategy\AbstractStrategy; +use LmcRbacMvc\Exception\UnauthorizedExceptionInterface; + +class ApiProblemStrategy extends AbstractStrategy +{ + public function onError(MvcEvent $event) + { + // Do nothing if no error or if response is not HTTP response + if (!($exception = $event->getParam('exception') instanceof UnauthorizedExceptionInterface) + || ($result = $event->getResult() instanceof HttpResponse) + || !($response = $event->getResponse() instanceof HttpResponse) + ) { + return; + } + + return new ApiProblemResponse(new ApiProblem($exception->getMessage())); + } +} +``` + +Register your strategy: + +```php +public function onBootstrap(EventInterface $e) +{ + $e->getTarget() + ->getEventManager() + ->attach(new ApiProblemStrategy()); +} +``` diff --git a/docs/lmc-rbac-mvc/support.md b/docs/lmc-rbac-mvc/support.md new file mode 100644 index 0000000..0f6119e --- /dev/null +++ b/docs/lmc-rbac-mvc/support.md @@ -0,0 +1,16 @@ +--- +sidebar_position: 20 +title: Support +--- + +- File issues at https://github.com/LM-Commons/LmcRbacMvc/issues. +- Ask questions in the [LM-Commons Gitter](https://gitter.im/LM-Commons/community) chat. + + +##### Notices and Disclaimers +This is not an official Laminas Project organization. + +Issues and questions related to the Laminas MVC and components +should be addressed to the Laminas Project organisation. + +Laminas is a trademark of the Laminas Project, a Series of LF Projects, LLC. diff --git a/docs/lmc-rbac-mvc/using-the-authorization-service.md b/docs/lmc-rbac-mvc/using-the-authorization-service.md new file mode 100644 index 0000000..7943400 --- /dev/null +++ b/docs/lmc-rbac-mvc/using-the-authorization-service.md @@ -0,0 +1,240 @@ +--- +sidebar_position: 7 +--- +# Using the Authorization Service + +This section will teach you how to use the AuthorizationService to its full extent. + +## Injecting the Authorization Service + +### Using initializers + +To automatically inject the authorization service into your classes, you can implement the +`AuthorizationServiceAwareInterface` and use the trait, as shown below: + +```php +namespace YourModule; + +use LmcRbacMvc\Service\AuthorizationServiceAwareInterface; +use LmcRbacMvc\Service\AuthorizationServiceAwareTrait; + +class MyClass implements AuthorizationServiceAwareInterface +{ + use AuthorizationServiceAwareTrait; + + public function doSomethingThatRequiresAuth() + { + if (! $this->getAuthorizationService()->isGranted('deletePost')) { + throw new UnauthorizedException('You are not allowed !'); + } + + return true; + } +} +``` + +Then, register the initializer in your config (it is not registered by default): + +```php +class Module +{ + // ... + + public function getServiceConfig() + { + return [ + 'initializers' => [ + 'LmcRbacMvc\Initializer\AuthorizationServiceInitializer' + ] + ]; + } +} +``` + +> While initializers allow rapid prototyping, their use can lead to more fragile code. We'd suggest using factories. + +### Using delegator factory + +LmcRbacMvc is shipped with a `LmcRbacMvc\Factory\AuthorizationServiceDelegatorFactory` [delegator factory](https://docs.laminas.dev/laminas-servicemanager/delegators/) +to automatically inject the authorization service into your classes. + +As for the initializer, the class must implement the `AuthorizationServiceAwareInterface`. + +You just have to add your classes to the right delegator : + +```php +class Module +{ + // ... + + public function getServiceConfig() + { + return [ + 'invokables' => [ + 'Application\Service\MyClass' => 'Application\Service\MyClassService', + ], + 'delegators' => [ + 'Application\Service\MyClass' => [ + 'LmcRbacMvc\Factory\AuthorizationServiceDelegatorFactory', + // eventually add more delegators here + ], + ], + ]; + } +} +``` + +> While they need a little more configuration, delegator factories have better performances than initializers. + +### Using Factories + +You can inject the AuthorizationService into your factories by using Laminas' ServiceManager. The AuthorizationService +is known to the ServiceManager as `'LmcRbacMvc\Service\AuthorizationService'`. Here is a classic example for injecting +the AuthorizationService: + +*YourModule/Module.php* + +```php +class Module +{ + // getAutoloaderConfig(), etc... + + public function getServiceConfig() + { + return [ + 'factories' => [ + 'MyService' => function($sm) { + $authService = $sm->get('LmcRbacMvc\Service\AuthorizationService'); + return new MyService($authService); + } + ] + ]; + } +} +``` + + +## Permissions and Assertions + +Since you now know how to inject the AuthorizationService, let's use it! + +One of the great things the AuthorizationService brings are **assertions**. Assertions get executed *if the identity +in fact holds the permission you are requesting*. A common example is a blog post, which only the author can edit. In +this case, you have a `post.edit` permission and run an assertion checking the author afterwards. + +### Defining assertions + +The AssertionPluginManager is a great way for you to use assertions and IOC. You can add new assertions quite easily +by adding this to your `module.config.php` file: + +```php +return [ + 'lmc_rbac' => [ + 'assertion_manager' => [ + 'factories' => [ + 'MyAssertion' => 'MyAssertionFactory' + ] + ] + ] +]; +``` + +### Defining the assertion map + +The assertion map can automatically map permissions to assertions. This means that every time you check for a +permission with an assertion map, you'll include the assertion in your check. You can define the assertion map by +adding this to your `module.config.php` file: + +```php +return [ + 'lmc_rbac' => [ + 'assertion_map' => [ + 'myPermission' => 'myAssertion' + ] + ] +]; +``` + +Now, every time you check for `myPermission`, `myAssertion` will be checked as well. + +### Checking permissions in a service + +So let's check for a permission, shall we? + +```php +$authorizationService->isGranted('myPermission'); +``` + +That was easy, wasn't it? + +`isGranted` checks if the current identity is granted the permission and additionally runs the assertion that is +provided by the assertion map. + +### Checking permissions in controllers and views + +LmcRbacMvc comes with both a controller plugin and a view helper to check permissions. + +#### In a controller : + +```php + public function doSomethingAction() + { + if (!$this->isGranted('myPermission')) { + // redirect if not granted for example + } + } +``` + +#### In a view : + +```php + isGranted('myPermission')): ?> +Display only if granted
+