Cómo usar las listas para el control de acceso (ACL)

En aplicaciones complejas, a menudo te enfrentas al problema de que las decisiones de acceso no se pueden basar únicamente en la persona (Token) que está solicitando el acceso, sino también implica un objeto dominio al cual se está solicitando acceso. Aquí es donde entra en juego el sistema ACL.

Imagina que estás diseñando un sistema de blog donde los usuarios pueden comentar tus entradas. Ahora, deseas que un usuario pueda editar sus propios comentarios, pero no los de otros usuarios. Además, como usuario admin, quieres tener la posibilidad de editar todos los comentarios. En este escenario, Comentario sería nuestro objeto dominio al cual deseas restringir el acceso. Podrías tomar varios enfoques para lograr esto usando Symfony2, dos enfoques básicos (no exhaustivos) son:

  • Reforzar la seguridad en los métodos de tu negocio: Básicamente, significa mantener una referencia dentro de cada comentario a todos los usuarios que tienen acceso, y luego comparar estos usuarios al Token provisto.
  • Reforzar la seguridad con roles: En este enfoque, debes agregar un rol a cada objeto comentario, es decir, ROLE_COMMENT_1, ROLE_COMMENT_2, etc.

Ambos enfoques son perfectamente válidos. Sin embargo, su pareja lógica de autorización a tu código del negocio lo hace menos reutilizable en otros lugares, y también aumenta la dificultad de las pruebas unitarias. Además, posiblemente tengas problemas de rendimiento si muchos usuarios tuvieran acceso a un único objeto dominio.

Afortunadamente, hay una manera mejor, de la cual hablaremos ahora.

Proceso de arranque

Ahora, antes de que finalmente puedas entrar en acción, necesitas hacer un poco del proceso de arranque (bootstrapping). En primer lugar, tienes que configurar la conexión al sistema ACL que se supone vas a usar:

  • YAML
    # app/config/security.yml
    security:
        acl:
            connection: default
    
  • XML
    <!-- app/config/security.xml -->
    <acl>
        <connection>default</connection>
    </acl>
    
  • PHP
    // app/config/security.php
    $container->loadFromExtension('security', 'acl', array(
        'connection' => 'default',
    ));
    

Nota

El sistema ACL requiere que configures cuando menos una conexión DBAL con Doctrine (utilizable de manera predeterminada) o MongoDB de Docrine (utilizable con MongoDBAclBundle). Sin embargo, eso no significa que tengas que utilizar Doctrine para asociar tus objetos del dominio. Puedes usar cualquier asignador de objetos que gustes, ya sea el ORM de Doctrine, ODM de MongoDB, Propel, o SQL crudo, etc. La elección es tuya

Después de configurar la conexión, tienes que importar la estructura de la base de datos. Afortunadamente, existe una tarea para eso precisamente. Basta con ejecutar la siguiente orden:

$ php app/console init:acl

Cómo empezar

Volviendo al pequeño ejemplo desde el principio, implementarás ACL para ello.

Creando una ACL, y añade una ACE

// src/Acme/DemoBundle/Controller/BlogController.php
namespace Acme\DemoBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Acl\Domain\ObjectIdentity;
use Symfony\Component\Security\Acl\Domain\UserSecurityIdentity;
use Symfony\Component\Security\Acl\Permission\MaskBuilder;

class BlogController
{
    // ...

    public function addCommentAction(Post $post)
    {
        $comment = new Comment();

        // ... configura $form, y vincula datos

        if ($form->isValid()) {
            $entityManager = $this->getDoctrine()->getManager();
            $entityManager->persist($comment);
            $entityManager->flush();

            // creando la ACL
            $aclProvider = $this->get('security.acl.provider');
            $objectIdentity = ObjectIdentity::fromDomainObject($comment);
            $acl = $aclProvider->createAcl($objectIdentity);

            // recupera la identidad de seguridad del usuario
            // registrado actualmente
            $securityContext = $this->get('security.context');
            $user = $securityContext->getToken()->getUser();
            $securityIdentity = UserSecurityIdentity::fromAccount($user);

            // otorga permiso de propietario
            $acl->insertObjectAce($securityIdentity, MaskBuilder::MASK_OWNER);
            $aclProvider->updateAcl($acl);
        }
    }
}

Hay un par de decisiones de implementación importantes en este fragmento de código. Por ahora, sólo quiero destacar dos:

En primer lugar, te habrás dado cuenta de que ->createAcl() no acepta objetos de dominio directamente, sino sólo implementaciones de ObjectIdentityInterface. Este paso adicional de desvío te permite trabajar con ACL, incluso cuando no tienes a mano ninguna instancia real del objeto dominio. Esto será muy útil si deseas comprobar los permisos de un gran número de objetos sin tener que hidratar estos objetos.

La otra parte interesante es la llamada a ->insertObjectAce(). En el ejemplo, estas otorgando acceso de propietario al comentario al usuario que ha iniciado sesión. MaskBuilder::MASK_OWNER es una máscara de bits enteros predefinida; no te preocupe que el constructor de la máscara abstraiga la mayoría de los detalles técnicos, pero gracias a esta técnica puedes almacenar muchos permisos diferentes en la fila de la base de datos, lo cual te dá un considerable impulso en rendimiento.

Truco

El orden en que las ACE son revisadas es significativo. Como regla general, debes poner las entradas más específicas al principio.

Comprobando el acceso

// src/Acme/DemoBundle/Controller/BlogController.php

// ...

class BlogController
{
    // ...

    public function editCommentAction(Comment $comment)
    {
        $securityContext = $this->get('security.context');

        // verifica el acceso para edición
        if (false === $securityContext->isGranted('EDIT', $comment))
        {
            throw new AccessDeniedException();
        }

        // recupera el objeto comentario actual, y realiza tu edición aquí
    }
}

En este ejemplo, comproebas si el usuario tiene el permiso de EDICIÓN. Internamente, Symfony2 asigna el permiso a varias máscaras de bits enteros, y comprueba si el usuario tiene alguno de ellos.

Nota

Puedes definir hasta 32 permisos base (dependiendo de tu sistema operativo, PHP puede variar entre 30 a 32). Además, también puedes definir permisos acumulados.

Permisos acumulados

En el primer ejemplo de arriba, sólo concediste al usuario el permiso OWNER base. Si bien este además permite efectivamente al usuario realizar cualquier operación, como ver, editar, etc., sobre el objeto dominio, hay casos en los que deseas conceder explícitamente estos permisos.

Puedes utilizar el MaskBuilder para crear máscaras de bits combinando fácilmente varios permisos base:

$builder = new MaskBuilder();
$builder
    ->add('view')
    ->add('edit')
    ->add('delete')
    ->add('undelete')
;
$mask = $builder->get(); // int(29)

Esta máscara de bits de enteros, luego se puede utilizar para conceder a un usuario los permisos base añadidos anteriormente:

$identity = new UserSecurityIdentity('johannes', 'Acme\UserBundle\Entity\User');
$acl->insertObjectAce($identity, $mask);

El usuario ahora puede ver, editar, borrar, y recuperar objetos eliminados.

Bifúrcame en GitHub