.. Copyright (C) 2015 Champs Libres Cooperative SCRLFS Permission is granted to copy, distribute and/or modify this document under the terms of the GNU Free Documentation License, Version 1.3 or any later version published by the Free Software Foundation; with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. A copy of the license is included in the section entitled "GNU Free Documentation License". Access controle model ********************** .. contents:: Table of content :local: Concepts ======== Every time an entity is created, viewed or updated, the software check if the user has the permission to make this action. The decision is made with three parameters : - the type of entity ; - the entity's center ; - the entity'scope The user must be granted access to the action on this particular entity, with this scope and center. TL;DR ===== Resolve scope and center ------------------------ In a service, resolve the center and scope of an entity .. code-block:: php use Chill\MainBundle\Security\Resolver\CenterResolverDispatcher; use Chill\MainBundle\Security\Resolver\ScopeResolverDispatcher; class MyService { private ScopeResolverDispatcher $scopeResolverDispatcher; private CenterResolverDispatcher $centerResolverDispatcher; public function myFunction($entity) { /** @var null|Center[]|Center $center */ $center = $this->centerResolverDispatcher->resolveCenter($entity); // $center may be null, an array of center, or an instance of Center if ($this->scopeResolverDispatcher->isConcerned($entity) { /** @var null|Scope[]|Scope */ $scope = $this-scopeResolverDispatcher->resolveScope($entity); // $scope may be null, an array of Scope, or an instance of Scope } } } In twig template, resolve the center: .. code-block:: twig {# resolve a center #} {% if person|chill_resolve_center is not null%} {% if person|chill_resolve_center is iterable %} {% set centers = person|chill_resolve_center %} {% else %} {% set centers = [ person|chill_resolve_center ] %} {% endif %} {{ 'Center'|trans|upper}} : {% for c in centers %} {{ c.name|upper }} {% if not loop.last %}, {% endif %} {% endfor %} {%- endif -%} In twig template, resolve the scope: .. code-block:: twig {% if entity|chill_is_scope_concerned %} {% if entity|chill_resolve_scope is iterable %} {% set scopes = entity|chill_resolve_scope %} {% else %} {% set scopes = [ entity|chill_resolve_scope ] %} {% endif %} Scopes : {% for s in scopes %} {{ c.name|localize_translatable_string }} {% if not loop.last %}, {% endif %} {% endfor %} {%- endif -%} Build a ``Voter`` ----------------- .. code-block:: php security = $security; // we build here a voter helper. This will ease the operations below. // when the authorization model is changed, it will be easy to make a different implementation // of the helper, instead of writing all Voters $this->voterHelper = $voterHelperFactory // create a builder with some context ->generate(self::class) // add the support of given roles for given class: ->addCheckFor(Person::class, [self::SEE, self::CREATE]) ->addCheckFor(PersonDocument::class, $this->getRoles()) ->build(); } protected function supports($attribute, $subject) { return $this->voterHelper->supports($attribute, $subject); } protected function voteOnAttribute($attribute, $subject, TokenInterface $token) { // basic check if (!$token->getUser() instanceof User) { return false; } // we first check the acl for associated elements. // here, we must be able to see the person associated to the document: if ($subject instanceof PersonDocument && !$this->security->isGranted(PersonVoter::SEE, $subject->getPerson())) { // not possible to see the associated person ? Then, not possible to see the document! return false; } // the voter helper will implements the logic: return $this->voterHelper->voteOnAttribute($attribute, $subject, $token); } // all the method below are used to register roles into the admin part public function getRoles() { return [ self::CREATE, self::SEE, self::SEE_DETAILS, self::UPDATE, self::DELETE ]; } public function getRolesWithoutScope() { return array(); } public function getRolesWithHierarchy() { return ['PersonDocument' => $this->getRoles() ]; } } From an user point of view ========================== The software is design to allow fine tuned access rights for complicated installation and team structure. The administrators may also decide that every user has the right to see all resources, where team have a more simple structure. Here is an overview of the model. Chill can be multi-center ------------------------- Chill is designed to be installed once for social center who work with multiple teams separated, or for social services's federation who would like to share the same installation of the software for all their members. This was required for cost reduction, but also to ease the generation of statistics agregated across federation's members, or from the central unit of the social center with multiple teams. Otherwise, it is not required to create multiple center: Chill can also work for one center. Obviously, users working in the different centers are not allowed to see the entities (_persons_, _reports_, _activities_) of other centers. But users may be attached to multiple centers: consequently they will be able to see the entities of the multiple centers they are attached to. Inside center, scope divide team -------------------------------- Users are attached to one or more center and, inside to those center, there may exists differents scopes. The aim of those _scopes_ is to divide the whole team of social worker amongst different departement, for instance: the social team, the psychologist team, the nurse team, the administrative team, ... Each team is granted of different rights amongst scope. For instance, the social team may not see the _activities_ of the psychologist team. The administrative team may see the date & time's activities, but is not allowed to see the detail of those entities (the personal notes, ...). The administrator is responsible of creating those scopes and team. *He may also decide to not define a division inside his team*: he creates only one scope and all entities will belong to this scope, all users will be able to see all entities. As entities have only one scopes, if some entities must be shared across two different teams, the administrator will have to create a scope *shared* by two different team, and add the ability to create, view, or update this scope to those team. Example: if some activities must be seen and updated between nurses and psychologists, the administrator will create a scope "nurse and psy" and add the ability for both team "nurse" and "psychologist" to "create", "see", and "update" the activities belonging to scope "nurse and psy". Where does the ``scope`` and ``center`` comes from ? ==================================================== Most often, scope and center comes from user's input: * when person is created, Chill asks the associated center to the user. Then, every entity associated to the user (Activity, ...) is associated to this center; * when an entity is created, Chill asks the associated scope. The UI check the model before adding those input into form. If the user hae access to only one center or scope, this scope or center is filled automatically, and the UI does not ask the user. Most of the times, the user does not see "Pick a scope" and "Pick a center" inputs. Scope and Center are associated to entities through ``ManyToOne`` properties, which are then mapped to ``FOREIGN KEY`` in tables, ... But sometimes, this implementation does not fits the needs: * persons are associated to center *geographically*: the address of each person contains lat/lon coordinates, and the center is resolved from this coordinated; * some would like to associated persons to multiple center, or one center; * entities are associated to scope through the job reached by "creator" (an user); * some would like not to use scope at all; * … For this reasons, associated center and scopes must be resolved programmatically. The default implementation rely on the model association, as described above. But it becomes possible to change the behaviour on different implementations. Is my entity "concerned" by scopes ? ------------------------------------ Some entities are concerned by scope, some not. This is also programmatically resolved. The concepts translated into code =================================== .. figure:: /_static/access_control_model.png Schema of the access control model Chill handle **entities**, like *persons*, *reports* (associated to *persons*), *activities* (also associated to *_persons*), ... On creation, those entities are linked to one center and, eventually, to one scope. They implements the interface `HasCenterInterface`. .. note:: Somes entities are linked to a center through the entity they are associated with. For instance, *activities* or *reports* are associated to a *person*, and the person is associated to a *center*. The *report*'s *center* is always the *person*'s *center*. Entities may be associated with a scope. In this case, they implement the `HasScopeInterface`. .. note:: Currently, only the *person* entity is not associated with a scope. At each step of his lifetime (creation, view of the entity and eventually of his details, update and, eventually, deletion), the right of the user are checked. To decide wether the user is granted right to execute the action, the software must decide with those elements : - the entity's type; - the entity's center ; - the entity's scope, if it exists, - and, obviously, a string representing the action All those action are executed through symfony voters and helpers. How to check authorization ? ============================ Just use the symfony way-of-doing, but do not forget to associate the entity you want to check access. For instance, in controller : .. code-block:: php class MyController extends Controller { public function viewAction($entity) { $this->denyAccessUnlessGranted('CHILL_ENTITY_SEE', $entity); //... go on with this action } } And in template : .. code-block:: twig {{ if is_granted('CHILL_ENTITY_SEE', entity) %}print something{% endif %} Retrieving reachable scopes and centers for a user -------------------------------------------------- The class :class:`Chill\\MainBundle\\Security\\Authorization\\AuthorizationHelperInterface` helps you to get centers and scope reachable by a user. Those methods are intentionnaly build to give information about user rights: - getReachableCenters: to get reachable centers for a user - getReachableScopes : to get reachable scopes for a user Adding your own roles --------------------- Extending Chill will requires you to define your own roles and rules for your entities. You will have to define your own voter to do so. To create your own roles, you should: * implement your own voter. This voter will have to extends the :class:`Chill\\MainBundle\\Security\\AbstractChillVoter`. As defined by Symfony, this voter must be declared as a service and tagged with `security.voter`; * declare the role through implementing a service tagged with `chill.role` and implementing :class:`Chill\\MainBundle\\Security\\ProvideRoleInterface`. .. note:: Both operation may be done through a simple class: you can implements :class:`Chill\\MainBundle\\Security\\ProvideRoleInterface` and :class:`Chill\\MainBundle\\Security\\AbstractChillVoter` on the same class. See live example: :class:`Chill\\ActivityBundle\\Security\\Authorization\\ActivityVoter`, and similar examples in the `PersonBundle` and `ReportBundle`. .. seealso:: `How to Use Voters to Check User Permissions `_ From the symfony cookbook `New in Symfony 2.6: Simpler Security Voters `_ From the symfony blog Declare your role ^^^^^^^^^^^^^^^^^^ To declare new role, implement the class :class:`Chill\\MainBundle\\Security\\ProvideRoleInterface`. .. code-block:: php interface ProvideRoleInterface { /** * return an array of role provided by the object * * @return string[] array of roles (as string) */ public function getRoles(); /** * return roles which doesn't need * * @return string[] array of roles without scopes */ public function getRolesWithoutScope(); } Then declare your service with a tag `chill.role`. Example : .. code-block:: yaml your_service: class: Chill\YourBundle\Security\Authorization\YourVoter tags: - { name: chill.role } Example of an implementation of :class:`Chill\\MainBundle\\Security\\ProvideRoleInterface`: .. code-block:: php namespace Chill\PersonBundle\Security\Authorization; use Chill\MainBundle\Security\ProvideRoleInterface; class PersonVoter implements ProvideRoleInterface { const CREATE = 'CHILL_PERSON_CREATE'; const UPDATE = 'CHILL_PERSON_UPDATE'; const SEE = 'CHILL_PERSON_SEE'; public function getRoles() { return array(self::CREATE, self::UPDATE, self::SEE); } public function getRolesWithoutScope() { return array(self::CREATE, self::UPDATE, self::SEE); } } Adding role hierarchy ^^^^^^^^^^^^^^^^^^^^^ You should prepend Symfony's security component directly from your code. .. code-block:: php namespace Chill\ReportBundle\DependencyInjection; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\Config\FileLocator; use Symfony\Component\HttpKernel\DependencyInjection\Extension; use Symfony\Component\DependencyInjection\Loader; use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Chill\MainBundle\DependencyInjection\MissingBundleException; /** * This is the class that loads and manages your bundle configuration * * To learn more see {@link http://symfony.com/doc/current/cookbook/bundles/extension.html} */ class ChillReportExtension extends Extension implements PrependExtensionInterface { public function prepend(ContainerBuilder $container) { $this->prependRoleHierarchy($container); } protected function prependRoleHierarchy(ContainerBuilder $container) { $container->prependExtensionConfig('security', array( 'role_hierarchy' => array( 'CHILL_REPORT_UPDATE' => array('CHILL_REPORT_SEE'), 'CHILL_REPORT_CREATE' => array('CHILL_REPORT_SEE') ) )); } } Implement your voter ^^^^^^^^^^^^^^^^^^^^ Most of the time, Voter will check that: 1. The given role is reachable (= ``$attribute``) 2. for the given center, 3. and, if any, for the given role 4. if the entity is associated to another entity, this entity should be, at least, viewable by the user. Thats what we call the "autorization logic". But this logic may be replace by a new one, and developers should take care of it. Then voter implementation should take care of: * check the access to associated entities. For instance, if an ``Activity`` is associated to a ``Person``, the voter should first check that the user can show the associated ``Person``; * as far as possible, delegates the check for associated center, scopes, and check for authorization using the authorization logic. VoterHelper will ease the most common operation of this logic. This is an example of implementation: .. code-block:: php security = $security; // we build here a voter helper. This will ease the operations below. // when the authorization model is changed, it will be easy to make a different implementation // of the helper, instead of writing all Voters $this->voterHelper = $voterHelperFactory // create a builder with some context ->generate(self::class) // add the support of given roles for given class: ->addCheckFor(Person::class, [self::SEE, self::CREATE]) ->addCheckFor(PersonDocument::class, $this->getRoles()) ->build(); } protected function supports($attribute, $subject) { return $this->voterHelper->supports($attribute, $subject); } protected function voteOnAttribute($attribute, $subject, TokenInterface $token) { // basic check if (!$token->getUser() instanceof User) { return false; } // we first check the acl for associated elements. // here, we must be able to see the person associated to the document: if ($subject instanceof PersonDocument && !$this->security->isGranted(PersonVoter::SEE, $subject->getPerson())) { // not possible to see the associated person ? Then, not possible to see the document! return false; } // the voter helper will implements the logic of checking: // 1. that the center is reachable // 2. for this given entity // 3. for this given scope // 4. and for the given role return $this->voterHelper->voteOnAttribute($attribute, $subject, $token); } public function getRoles() { // ... } public function getRolesWithoutScope() { // ... } public function getRolesWithHierarchy() { // ... } } Then, you will have to declare the service and tag it as a voter : .. code-block:: yaml services: chill.report.security.authorization.report_voter: class: Chill\ReportBundle\Security\Authorization\ReportVoter arguments: - "@chill.main.security.authorization.helper" tags: - { name: security.voter } How to resolve scope and center programmatically ? ================================================== In a service, resolve the center and scope of an entity .. code-block:: php use Chill\MainBundle\Security\Resolver\CenterResolverDispatcher; use Chill\MainBundle\Security\Resolver\ScopeResolverDispatcher; class MyService { private ScopeResolverDispatcher $scopeResolverDispatcher; private CenterResolverDispatcher $centerResolverDispatcher; public function myFunction($entity) { /** @var null|Center[]|Center $center */ $center = $this->centerResolverDispatcher->resolveCenter($entity); // $center may be null, an array of center, or an instance of Center if ($this->scopeResolverDispatcher->isConcerned($entity) { /** @var null|Scope[]|Scope */ $scope = $this-scopeResolverDispatcher->resolveScope($entity); // $scope may be null, an array of Scope, or an instance of Scope } } } In twig template, resolve the center: .. code-block:: twig {# resolve a center #} {% if person|chill_resolve_center is not null%} {% if person|chill_resolve_center is iterable %} {% set centers = person|chill_resolve_center %} {% else %} {% set centers = [ person|chill_resolve_center ] %} {% endif %} {{ 'Center'|trans|upper}} : {% for c in centers %} {{ c.name|upper }} {% if not loop.last %}, {% endif %} {% endfor %} {%- endif -%} In twig template, resolve the scope: .. code-block:: twig {% if entity|chill_is_scope_concerned %} {% if entity|chill_resolve_scope is iterable %} {% set scopes = entity|chill_resolve_scope %} {% else %} {% set scopes = [ entity|chill_resolve_scope ] %} {% endif %} Scopes : {% for s in scopes %} {{ c.name|localize_translatable_string }} {% if not loop.last %}, {% endif %} {% endfor %} {%- endif -%} What is the default implementation of Scope and Center resolver ? ----------------------------------------------------------------- By default, the implementation rely on association into entities. * implements ``Chill\MainBundle\Entity\HasCenterInterface`` on entities which have one or any center; * implements ``Chill\MainBundle\Entity\HasCentersInterface`` on entities which have one, multiple or any centers; * implements ``Chill\MainBundle\Entity\HasScopeInterface`` on entities which have one or any scope; * implements ``Chill\MainBundle\Entity\HasScopesInterface`` on entities which have one or any scopes; Then, the default implementation will resolve the center and scope based on the implementation in your model. How to change the default behaviour ? ------------------------------------- Implements those interface into services: * ``Chill\MainBundle\Security\Resolver\CenterResolverInterface``; * ``Chill\MainBundle\Security\Resolver\ScopeResolverInterface``; Authorization into lists and index pages ======================================== Due to the fact that authorization model may be overriden, "list" and "index" pages should not rely on center and scope from controller. This must be delegated to dedicated service, which will be aware of the authorization model. We call them ``ACLAwareRepository``. This service must implements an interface, in order to allow to change the implementation. The controller **must not** performs any DQL or SQL query. Example in a controller: .. code-block:: php namespace Chill\TaskBundle\Controller; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Chill\TaskBundle\Repository\SingleTaskAclAwareRepositoryInterface; final class SingleTaskController extends AbstractController { private SingleTaskAclAwareRepositoryInterface $singleTaskAclAwareRepository; /** * * @Route( * "/{_locale}/task/single-task/list", * name="chill_task_singletask_list" * ) */ public function listAction( Request $request ) { $this->denyAccessUnlessGranted(TaskVoter::SHOW, null); $nb = $this->singleTaskAclAwareRepository->countByAllViewable( '', // search pattern [] // search flags ); $paginator = $this->paginatorFactory->create($nb); if (0 < $nb) { $tasks = $this->singleTaskAclAwareRepository->findByAllViewable( '', // search pattern [] // search flags $paginator->getCurrentPageFirstItemNumber(), $paginator->getItemsPerPage(), // ordering: [ 'startDate' => 'DESC', 'endDate' => 'DESC', ] ); } else { $tasks = []; } return $this->render('@ChillTask/SingleTask/List/index.html.twig', [ 'tasks' => $tasks, 'paginator' => $paginator, 'filter_order' => $filterOrder ]); } } Writing ``ACLAwareRepository`` ------------------------------ The ACLAwareRepository should rely on interfaces ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ As described above, the ACLAwareRepository will perform the query for listing entities, and take care of authorization. Those "ACLAwareRepositories" must be described into ``interfaces``. The service must rely on this interface, and not on the default implementation. Example: at first, we design an interface for listing ``SingleTask`` entities: .. code-block:: php buildQuery($criterias); return $this->addAuthorizations($qb)->select("COUNT(e)")->getQuery()->getResult()->getSingleScalarResult(); } public function findByAuthorized(array $criteria, int $start, int $limit, array $orderBy): array { $qb = $this->buildQuery($criterias); return $this->getResult($this->addAuthorizations($qb), $start, $limit, $orderBy); } public function getResult(QueryBuilder $qb, int $start, int $limit, array $orderBy): array { $qb ->setFirstResult($start) ->setMaxResults($limit) ; // add order by logic return $qb->getQuery()->getResult(); } public function buildQuery(array $criterias): QueryBuilder { $qb = $this->em->createQueryBuilder(); // implement you logic with search criteria here return $qb; } private function addAuthorizations(QueryBuilder $qb): QueryBuilder { // add authorization logic here return $qb; } } Once this logic is executed, it becomes easy to make a new implementation of the repository: .. code-block:: php namespace Chill\MyOtherBundle\Repository; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\QueryBuilder; use Chill\MyBundle\Repository\MyEntityACLAwareRepository final class AnotherEntityACLAwareRepository implements MyEntityACLAwareRepositoryInterface { private EntityManagerInterface $em; private \Chill\MyBundle\Repository\MyEntityACLAwareRepository $initial; public function __construct( EntityManagerInterface $em, \Chill\MyBundle\Repository\MyEntityACLAwareRepository $initial ) { $this->em = $em; $this->initial = $initial; } public function countByAuthorized(array $criterias): int { $qb = $this->initial->buildQuery($criterias); return $this->addAuthorizations($qb)->select("COUNT(e)")->getQuery()->getResult()->getSingleScalarResult(); } public function findByAuthorized(array $criteria, int $start, int $limit, array $orderBy): array { $qb = $this->initial->buildQuery($criterias); return $this->initial->getResult($this->addAuthorizations($qb), $start, $limit, $orderBy); } private function addAuthorizations(QueryBuilder $qb): QueryBuilder { // add a different authorization logic here return $qb; } } Then, register this service and decorates the old one: .. code-block:: yaml services: Chill\MyOtherBundle\Repository\AnotherEntityACLAwareRepository: autowire: true autoconfigure: true decorates: Chill\MyBundle\Repository\MyEntityACLAwareRepositoryInterface: