Uso de Voters para el control del acceso de usuarios en zonas

Acceso denegado - 403
Acceso denegado – 403

Los voters son un elemento de Symfony para darle una vuelta de tuerca al las listas de acceso – ACL (Access Control List) de Symfony donde podemos validar con mas profundidad si un recurso esta disponible para un usuario.

Aqui la documentacion oficial.

A continuacion explicare un ejemplo mas concreto donde lo he usado.

La problematica era la siguiente:

-Un usuario puede realizar ciertas acciones en las zonas, segun el tipo de rol y los permisos definidos para cada rol y zona. Es decir que para mi zona de “Gestion de Usuarios” existen las opciones de listar, crear, editar, visualizar y eliminar usuarios. Por ejemplo para un usuario con “rol admin” podra hacerlas todas, pero un usuario con “rol guest” solo podra listar y visualizar información de los usuarios.

Todo esto va gestionado a traves de relaciones entre enitdades que representan tablas en la BD. Esto lo explicare en otro post con mas detalle para que veais como he resuelto la gestion del acceso de usuarios con roles, permisos y zonas en una aplicacion con FOSUserBundle de por medio.

Empezamos:

1- He definido en el firewall que el rol minimo para poder acceder a esa ruta debe ser algo parecido a esto, de esa forma otros usuarios que no tengan ese rol “minimo” no podran acceder a esa/s ruta/s:

(security.yml)
access_control:
    - { path: ^/login$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
    - ... //seguridad de acceso en otras rutas
    - { path: ^/users, roles: ROLE_GUEST }

2 – En mi bundle he creado una carpeta llamada “Security” donde metere los Voters para revisar la seguridad de las acciones sobre las entidades.

3 – Como en la gran mayoria de mis Voters van a hacerse las mismas (o parecidas) comprobaciones respecto a si puede acceder a ese recurso o no, me he hecho un “GenericVoter” abstracto donde se incluye la logica de la seguridad, el cual herederan el resto de Voters de mis zonas:

<?php
namespace efor\AppBundle\Security;

use Doctrine\Common\Collections\Criteria;
use efor\AppBundle\Entity\Role;
use efor\AppBundle\Entity\RoleZone;
use efor\UsuarioBundle\Entity\User;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Authorization\Voter\AbstractVoter;
use Symfony\Component\Security\Core\User\UserInterface;

// La clase AbstractVoter está disponible a partir de Symfony 2.6
abstract class GenericVoter extends AbstractVoter
{
    const READ = 'read';
    const CREATE = 'create';
    const EDIT = 'edit';
    const DELETE = 'delete';
    
    protected function getSupportedAttributes()
    {
        return array(self::CREATE, self::EDIT, self::DELETE, self::READ);
    }

    abstract protected function getSecureZone();

    /**
     * @param string $action
     * @param object $object
     * @param null $user
     *
     * @return bool
     */
    protected function isGranted($action, $object, $user = null)
    {
        if(!$user instanceof User){
            return false;
        }

        //Buscamos todos los roles del usuario(>= 1 role)
        $roleUser = $user->getRoleUser();

        //recorremos los posibles roles que tenga el usuario
        /** @var Role $role */
        foreach($roleUser as $role){ 
            //buscamos las zonas que tenga disponibles ese role
            $securedZones = $role->getRoleZone(); 

            /** @var RoleZone $securedZone */
            foreach($securedZones as $securedZone)
            {
                if($securedZone->getZone()->getName() === $this->getSecureZone()){
                    switch($action){
                        case self::READ:
                                return $securedZone->getAllowedToRead();
                            break;
                        case self::CREATE:
                                return $securedZone->getAllowedToAdd();
                            break;
                        case self::EDIT:
                                return $securedZone->getAllowedToEdit();
                            break;
                        case self::DELETE:
                                return $securedZone->getAllowedToDelete();
                            break;
                        default: return false;
                            break;
                    }
                }
            }
        }

        return true; //dejamos pasar siempre que se pueda
    }
}

En esta clase hay que destacar que hereda de AbstractVoter que definde una serie de metodos publicos implementados, y otros abstractos. De esa forma, asi nosotros añadimos las posibles acciones sobre un “objeto (entidad)” y una funcion abstracta getSecureZone() donde cada Voter especifico definira la “zona” donde se revisara la seguridad. Dentro de la funcion isGranted() comprobamos que esa zona este definida en alguno de los roles del usuario que la intenta acceder para ver si puede leer, crear, editar o eliminar un objeto de ese tipo.

4 – Definimos nuestro Voter especifico para comprobar la seguridad cuando se accede a los usuarios tal que así:

class UserVoter extends GenericVoter
{
    protected function getSupportedClasses()
    {
        return array('efor\UsuarioBundle\Entity\User');
    }

    protected function getSecureZone()
    {
        //en mi caso apunta a una clase donde tengo definidas 
        //mis rutas seguras - user
        return ZoneMapping::USERS_ROUTE_NAME;
    }
}

Este Voter comprobara para la entidad User (es la clase donde se podran hacer las 4 operaciones posibles – CRUD) cuando acceda a la zona ‘user’ si es posible conceder el acceso.

5 – Definimos un servicio en nuestro ‘services.yml’ que atendera para garantizar la seguridad:

services:
    ...
    user_voter:
        class: efor\AppBundle\Security\UserVoter
        public: false
        tags:
            - { name: security.voter }

6 – Ahora tenemos que usar las funciones que proporciona Symfony de chequeo del acceso para que este Voter se ejecute como responsable. Para ello en nuestro UserController deberemos añadir la/s siguiente/s comprobaciones a traves de anotaciones(cuando se pueda) o condicionales:

class UserController
 {
/**
 * @Route("/create", name="user_create")
 */
public function createUserAction(Request $request)
{
     $newUser = new User();

     if(!$this->isGranted(GenericVoter::CREATE, $newUser)){
         throw $this->createAccessDeniedException();
     }
     $form = ...
}
/**
 * @Route("/list", name="user_list")
 */
public function listUserAction()
{
    if(!$this->isGranted(GenericVoter::READ, new User())){
        throw $this->createAccessDeniedException();
    }
    ...
}

/**
* @Route("/show/{id}", name="user_show")
* @ParamConverter("user", class="efor\UsuarioBundle\Entity\User")
* @Security("is_granted('read', user)")
*/
public function showUserAction(User $user)
{
    if($user === $this->getUser()){
        return $this->redirectToRoute('profile_show');
    }

    return $this->render('UsuarioBundle:User:showUser.html.twig', array(
        'user' => $user
    ));
}

//IDEM con el resto de acciones del CRUD o Actions 
//que queramos proteger

Por una parte destacamos la anotacion @Security donde le decimos que ejecute la funcion isGranted y le pasamos la accion y un objeto(que se obtiene con el @ParamConverter previo).

De la misma forma pero sin usar anotacion, es llamando dentro de nuestro Action a $this->isGranted() pasandole la misma informacion ( a veces necesitamos instanciar un nuevo objeto para que se pueda comprobar) y lanzando una excepcion en caso negativo.

7 – Si queremos revisar la seguridad en las vistas para mostrar u ocultar botones podemos hacer uso de la funcion is_granted() en TWIG de la siguiente forma:

{% set editAction = constant('efor\\AppBundle\\Security\\GenericVoter::EDIT') %}
{% if is_granted(editAction, user) %}
    <a href={{ url('user_edit', { 'id': user.id } }}>Editar</a>
{% endif %}

De esta forma mostrar un link a la ruta de editar usuario solo si mi Voter lo permite validando los permisos sobre ese objeto.


 

Y eso es todo, podeis encontrar mas informacion sobre los Voters en el link que os deje al principio del post para que se ajuste a vuestras necesidades.

Si te ha gustado el articulo, no dudes en compartirlo en las redes sociales para que llegue a toda la comunidad Symfony Hispana. Si tienes otras formas de usarlo, o crees que harias algo distinto y que aportara valor a los demas, no dudes en dejar tu comentario al respecto, y lo revisare lo antes que pueda.

Saludos ¡