Atelier Zend Framework : Autorisations : lier les ACLs aux contrôleurs et objets métiers

Date de publication : 19/03/2009 , Date de mise à jour : 30/07/2009

Par Julien Pauli (Tutoriels, article et conférences PHP et developpement web) (Blog)
 

Les ACLs sont souvent un casse-tête à gérer, du moins si on ne prend pas la peine de réfléchir à créer sa propre structure de gestion de celles-ci. Souvent dans nos applications, c'est toujours la même chose. Ici je vais présenter un système de gestion automatisé des ACLs.
Attention comme tout atelier : il donne une idée que j'ai et que j'utilise sur le coup, mais qui n'est pas forcément adapté à tous, et possède un certain degré de couplage qui pourra peut-être déplaire. Ca donne en revanche quelques idées de manipulations avec le Zend Framework. 23 commentaires · Donner une note à l'article (5)

               Version PDF (Miroir)   Version hors-ligne (Miroir)

I. Introduction
II. Créer son objet ACL
III. Lier son métier à ses ACLs
IV. Gérer les ressources
V. Automatiser le processus d'interrogation des ACLs
VI. Conclusions


I. Introduction

#Zend_Acl est un composant servant à lier des rôles (en général "qui?") à des ressources (en général "pour qui/quoi?"), via des droits ( en général "quoi?").
En fait on répond à la question "qui peut faire quoi avec qui/quoi?". Identifier les ressources, les rôles, et les droits... Tel est le problème.
La réponse est : tout dépend de votre application :-).
Mes choix pour cet atelier ont été les suivants :

  1. Le rôle est un objet métier issu de Zend_Db_Table_Row
  2. La ressource est un contrôleur issu de Zend_Controller_Action
  3. Le droit correspond à une action du contrôleur
Evidemment, libre à vous de l'adapter à vos propres objets, avec votre propre structure.


II. Créer son objet ACL

Nous allons ici dériver la classe Zend_Acl. Les buts sont multiples :

  1. Faire en sorte que l'objet ACL persiste en session de lui-même
  2. Ajouter un rôle de plus bas niveau (rôle par défaut) contenant les droits miniums : "guest"
Créer l'objet ACL est facile, mais encore faut-il le faire persister entre chaque requête HTTP de manière à éviter de le recréer à chaque requête, et aussi de manière à pouvoir le modifier, le peupler de nouveaux droits, et faire persister ces nouveaux droits entre chaque requête.
Plusieurs systèmes de persistances peuvent être choisis, et une interface permettrait de découpler le tout. La solution retenue dans cet atelier sera cependant imposée : la session.
Notre nouvel objet ACL sera un singleton. L'appel au singleton persistera automatiquement en session, dans un Zend_Session_Namespace configurable. Celui-ci devra pouvoir être remis à zéro, et évidemment tout changement de droit doit être reflété dans l'objet en session, de manière là aussi automatique.
Attention : veillez à ce que la création du namespace de session n'influe pas sur la session, vous devrez peut-être la démarrer manuellement si son options 'strict' est à 'on'. Je vous renvoie au manuel pour plus d'infos.

Vous allez voir que nous allons être joliment concernés par le fr Late Static Binding, introduit dans PHP5.3 (cet article concerne donc PHP5.2)
Nous allons devoir utiliser une gymnastique d'héritage du contexte statique dans PHP 5.2, qui va nous obliger à dupliquer un bout de code. Ce problème n'apparait pas dans PHP5.3.
<?php
class JP_Acl extends Zend_Acl
{
     /**
     * Rôle par défaut
     * 
     * @var string
     */
    protected static $_defaultRole = 'guest';
    
    /**
     * Nom du namespace de session
     * 
     * @var string
     */
    protected static $_sessionNamespace = 'acl';
    
    /**
     * Instance singleton
     * 
     * @var JP_Acl
     */
    protected static $_instance;
    
    /**
     * Objet namespace
     * 
     * @var Zend_Session_Namespace
     */
    protected static $_session;
    
    /**
     * Injection de paramètres
     * 
     * @var array $params
     */
    public static function setParams(array $params)
    {
        if (isset($params['defaultRole'])) {
            self::$_defaultRole = $params['defaultRole'];
        }
        if (isset($params['sessionNamespace'])) {
            self::$_sessionNamespace = $params['sessionNamespace'];
        }
    }
    
    /**
     * Récupère l'instance du singleton en gèrant la persistance
     * /!\ LSB : *doit* être redéfini dans les filles
     * 
     * @param bool $clearSession détruit la session auparavant
     * @return JP_Acl
     */
    public static function getInstance($clearSession = false)
    {
        function getInstance()
	    {
	        if (self::$_instance == null) {
                self::$_instance = new self;
            }
            return self::$_instance;
        }
       
		if (self::$_sessionNamespace !== null) {
            if ($clearSession) {
                Zend_Session::namespaceUnset(self::$_sessionNamespace);
            }
            self::$_session = new Zend_Session_Namespace(self::$_sessionNamespace);
            if (isset(self::$_session->acl)) {
                self::$_instance = self::$_session->acl;
            } else {
                self::$_instance = getInstance();
            }
        } else {
            self::$_instance = getInstance();
        }
        return self::$_instance;
    }
}
Tout singleton possède une méthode statique getInstance() (ou dans le même style), permettant de créer l'instance de la classe elle-même.
La différence ici, c'est que l'instance doit pouvoir être récupérée de la session si elle s'y trouve, ou être stockée en session dans le cas contraire. Le tout si le mécanisme de persistance est activé (si $_sessionNamespace est non null).
Pour régler la persistance, il faut utiliser la méthode statique setParams()
Tout singleton déclare un constructeur non public. Le notre doit pouvoir être hérité, il sera donc protégé, mais il sera aussi déclaré final : le constructeur doit ajouter un rôle minimum par défaut (guest par défaut), obligatoirement.
Nous allons donc couper le constructeur en empêchant sa redéfinition, mais en permettant son extension, via un simple proxy de méthode :
Suite de JP_Acl
<?php
    /**
     * Constructeur
     * 
     * final : ne peut être redéfinie, le rôle par défaut doit exister
     */
    final protected function __construct()
    {
        $this->addRole(new Zend_Acl_Role($this->getDefaultRole()));
        $this->_init();
    }
    
    /**
     * A redéfinir dans les filles : constructeur
     */
    protected function _init() { }
    
    /**
     * Récupère le rôle par défaut
     * 
     * @return string
     */
    public function getDefaultRole()
    {
        return self::$_defaultRole;
    }
On notera qu'à ce stade, si l'objet est crée de manière vierge (et non depuis la session), il n'y est pas sauvegardé. Nous allons faire d'une pierre deux coups :
Comme à chaque modification de l'objet il faut le refaire persister en session de manière automatique, nous allons redéfinir allow() et deny(), et comme notre constructeur utilise une de ses méthodes, l'objet sera sauvé en session dès sa construction depuis un état vierge :
sauvegarde en session de l'objet s'il est modifié
<?php
    public function allow($roles = null, $resources = null, $privileges = null, Zend_Acl_Assert_Interface $assert = null)
    {
        $return = parent::allow($roles, $resources, $privileges, $assert);
        if (self::$_session !== null) {
            self::$_session->acl = $this;
        }
        return $return;
    }
    
    public function deny($roles = null, $resources = null, $privileges = null, Zend_Acl_Assert_Interface $assert = null)
    {
        $return = parent::deny($roles, $resources, $privileges, $assert);
        if (self::$_session !== null) {
            self::$_session->acl = $this;
        }
        return $return;
    }
Notons que le sous-espace de nom de session "acl" n'est pas modifiable ici.
Voila, notre objet est terminé, il est prêt à être redéfini mais vu qu'il peut servir comme objet "de base", vierge, nous ne passerons pas sa classe en abstrait.
Voyons comment dériver l'objet :
My_Acl pour mon application spécifique
<?php
class MyAcl extends JP_Acl
{
    /**
     * /!\ LSB : *doit* être redéfini dans les filles
     * 
     * @param bool $clearSession détruit la session auparavant
     * @return JP_Acl
     */
    public static function getInstance($clearSession = false)
    {
        function getInstance()
	    {
	        if (self::$_instance == null) {
                self::$_instance = new self;
            }
            return self::$_instance;
        }
       
		if (self::$_sessionNamespace !== null) {
            if ($clearSession) {
                Zend_Session::namespaceUnset(self::$_sessionNamespace);
            }
            self::$_session = new Zend_Session_Namespace(self::$_sessionNamespace);
            if (isset(self::$_session->acl)) {
                self::$_instance = self::$_session->acl;
            } else {
                self::$_instance = getInstance();
            }
        } else {
            self::$_instance = getInstance();
        }
        return self::$_instance;
    }
    
    protected function _init()
    {
        $this->add(new Zend_Acl_Resource('livres'))
             ->addRole(new Zend_Acl_Role('libraire'), 'guest')
             ->addRole(new Zend_Acl_Role('admin'));
                     
        $this->allow('admin');
        $this->allow('libraire', 'livres', 'add');
        $this->allow('guest','livres', 'liste');
    }
}
Le problème du LSB est très clair dans le cas de l'héritage d'un singleton : dans le code "new self;" , "self" représente la classe dans laquelle le mot "self" lui-même est écrit. En d'autres termes, "new self" n'est pas hérité, et "self" représente JP_Acl si on ne réécrit pas TOUT le code de getInstance() dans la classe fille (ici MyAcl)
Ce problème connu, qui gène les conceptions objets avancées, est résolu dans PHP5.3 avec l'apparition du mot "static" pour la syntaxe "static::$quelqueChose", ou encore "new static". "static" est ici un "self hérité", dont la portée est résolue tardivement ("Late Static Binding").

Pour conclure, si vous ne réécrivez pas toute la méthode getInstance(), vous obtiendrez un objet JP_Acl père, et votre méthode _init() fille sera ignorée : tout tombe à l'eau.
La méthode init(), ici, gère les ACLs de MyAcl : mes ACLs pour mon application d'exemple. Mon objet ACL hérite donc de tout le mécanisme de persistance en session implémenté dans JP_Acl.

Une ressource "livres", qui on le verra plus tard interragira avec un "LivresController". Un rôle libraire héritant du rôle par défaut définit en amont dans JP_Acl, enfin un rôle admin.

  1. L'admin à le droit de tout faire
  2. Le libraire peut ajouter (add) des livres
  3. L'invité, donc le libraire qui en hérite, peut lister les livres
Chacun des droits ('liste' et 'add') correspondra plus tard à des méthodes addAction() et listeAction() du contrôleur Livres.


III. Lier son métier à ses ACLs

Ok, j'ai des rôles. Voyons comment les gérer facilement. J'ai décidé quelque chose de simple :

  1. Je possède une table de membres
  2. La table possède une colonne "rôle" qui définit le même type de rôle que ceux des ACLs
  3. Mes objets Zend_Db_Table_Row de membres doivent pouvoir donner leur rôle
Définition de JP_Db_Table_Row : classe support des objets identifiables via les ACLs
<?php
abstract class JP_Db_Table_Row extends Zend_Db_Table_Row_Abstract implements Zend_Acl_Role_Interface 
{
    /**
     * @var JP_Acl
     */
    protected static $_acl;

    /**
     * Passage de l'objet ACL au métier
     * Il en a besoin pour extraire le rôle par défaut
     * à utiliser dans le cas ou l'objet métier hérité
     * ne comporte pas de rôle particulier
     */
    public static function setAcl(JP_Acl $acl)
    {
        self::$_acl = $acl;
    }
    
     /**
     * Retourne l'attribut dans l'objet en cours
     * à considérer comme représentant le rôle de l'objet concrêt
     * dans les ACLS
     * 
     * @return string 
     */
    protected function _getRoleKey() { }
    
     /**
     * Zend_Acl_Role_Interface
     * 
     * @return string
     */
    public function getRoleId()
    {
        if ($this->_getRoleKey() === null) {
            return self::$_acl->getDefaultRole();
        }
        return $this->_getRoleKey();
    }
}
<?php
class Membres extends JP_Db_Table_Row
{
    /**
    * Un membre est authentifiable via sa propre table
    * Elle comporte donc aussi des colonnes 'nom' et 'password'
    *
    * @var int $sessionDuration durée de vie de la persistance
    * @return Zend_Auth_Result
    */
    public function authenticate(sessionDuration = 120)
    {
        $db      = $this->getTable()->getAdapter();
        $table   = $this->getTable()->info('name');
        $adapter = new Zend_Auth_Adapter_DbTable($db, $table, 'nom', 'password');
        $adapter->setIdentity($this->nom);
        $adapter->setCredential($this->password);
        
        $result = $adapter->authenticate();
        if ($result->isValid()) {
            $this->setFromArray((array)$adapter->getResultRowObject());
            $session = new Zend_Session_Namespace('Zend_Auth');
            $session->setExpirationSeconds(abs((int)$sessionDuration));
            Zend_Auth::getInstance()->getStorage()->write($this);
            }
        return $result;
    }
    
    /**
    * La table Membres comporte une colonne "role"
    */
    public function _getRoleKey()
    {
        return $this->role;
    }
}
Cette classe JP_Db_Table_Row est facilement injectable dans la table supposée Membres :
Injection du support d'objet Row à la table Membres
<?php
class TMembres extends Zend_Db_Table_Abstract
{
// la configuration classique ici, puis  :

    protected $_rowClass = 'Membres';
}
Ce framework est décidément très souple!
Notez la méthode authenticate() qui permet d'authentifier une personne depuis la table Membres, et d'éventuellement faire persister l'identité en session.
Voila, je vais pouvoir interroger mon ACL avec un objet Membres_Db_Table_Row issu de la table des Membres, car celui-ci implémente bien Zend_Acl_Role_Interface
exemple simple d'utilisation
<?php
$acl = MyAcl::getInstance();
JP_Db_Table_Row::setAcl($acl);

$TMembres = new TMembres();
$membre   = $TMembres->createRow();
$membre->login    = 'developpez'; // ceci pourrait provenir d'un formulaire
$membre->password = 'secret'; // ceci pourrait provenir d'un formulaire

$result = $membre->authenticate(1800); // si OK : persistance 30min en session
if (!$result->isValid()) {
    exit('login ou mot de passe incorrect');
}

if ($acl->isAllowed($membre, 'une ressource, par exemple "livres"', 'un doit, par exemple "liste"')) {
    echo "Ok autorisé";
} else {
    echo "KO non autorisé";
}
Voyons maintenant comment gérer les ressources.


IV. Gérer les ressources

J'ai indiqué plus tôt que pour cet atelier, tout contrôleur est une ressource. Tout contrôleur va donc implémenter Zend_Acl_Resource_Interface, il convient donc de créer l'étage (la classe abstraite) convenable pour cela :
Contrôleurs ressources génériques
<?php
class JP_Controller_AclAction extends Zend_Controller_Action implements Zend_Acl_Resource_Interface
{
    /**
     * Nom de la ressource (nom du contrôleur)
     * 
     * @var string
     */
    protected $_resourceId;
    
    /**
     * Zend_Acl_Resource_Interface
     * 
     * @return string
     */
    public function getResourceId()
    {
        return $this->_resourceId;
    }
    
    public function __construct(Zend_Controller_Request_Abstract $request, Zend_Controller_Response_Abstract $response, array $invokeArgs = array())
    {
        $this->_resourceId = strtolower($request->getControllerName());
        parent::__construct($request, $response, $invokeArgs);
    }
Le nom du futur contrôleur qui héritera de cette classe sera considéré comme un ressource. Ainsi, LivresController sera la ressource 'livres'.
Elle devra exister dans les ACLs qu'il faudra préparer auparavant, dans le cas où la ressource n'existe pas, on verra que l'on choisira d'interdir l'accès plutôt que de générer une erreur.

Nous verrons aussi dans la partie suivante que le droit demandé correspondra à l'action. Un contrôleur de livres pourra ressembler à ceci:
Un contrôleur 'livres'
<?php
class LivresController extends JP_Controller_AclAction
{
    // une action retournant une liste de livres
    public function listeAction()
    {
       // les acl possèdent bien un droit 'liste'
    }
    
    // une action qui gère l'ajout d'un livre
    public function addAction()
    {
       // les acl possèdent bien un droit 'add'
    }
}

V. Automatiser le processus d'interrogation des ACLs

A ce stade, j'ai un objet JP_Acl générique qui s'auto-stocke en session et ajoute un rôle par défaut appelé 'guest'.
Un objet MyAcl spécifique à mon application d'exemple (héritant de JP_Acl), qui ajoute 3 rôles et une ressource.
J'ai aussi des rôles qui sont liés à une table Membres et notamment à une colonne 'role' dans celle-ci, interrogée via un objet de table (TMembres) et un objet métier (Membres_Db_Table_Row). L'objet métier possède une méthode authenticate() qui identifie le membre représenté vis à vis de sa table, et fait persister son identitié en session un certain temps. Les objets métiers membres héritent d'un conteneur plus générique gèrant les rôles des objets métiers en héritant (JP_Db_Table_Row).
Enfin j'ai des contrôleurs génériques JP_Controller_AclAction qui représentent dans les acls des ressources determinées via le nom des contrôleurs spécifiques en héritant (LivresController par exemple)

Il manque une pièce au puzzle : automatiser l'interrogation des acls pour tous les contrôleurs traités. Comme nous l'avons vus dans fr cet atelier rien n'est plus approprié pour cela qu'une aide d'action. En effet, le contrôleur en cours de traitement devra être connu, et seules les aides d'action peuvent faire cela.
L'aide d'action qui automatise le processus de vérification des ACLs
<?php
class JP_Controller_Action_Helper_Acl extends Zend_Controller_Action_Helper_Abstract 
{
    /**
     * @var JP_Acl
     */
    protected $_acl;
    
    public function __construct(JP_Acl $acl = null)
    {
        $this->_acl = $acl;
    }
    
    /**
     * Désactivation temporaire des acls
     * 
     * @var bool
     */
    protected $_disabled = false;
    
    public function init()
    {
        if ($this->_acl == null) {
            if (!$this->getFrontController()->getParam('acl') instanceof JP_Acl) {
                throw new Zend_Controller_Action_Exception('acl object not shared');
            }
            $this->_acl = $this->_frontController->getParam('acl');
        }
    }
    
    /**
     * Appelé avant chaque preDispatch() du contrôleur actuel ($_actionController)
     */
    public function preDispatch()
    {
        if (!$this->_actionController instanceof Zend_Acl_Resource_Interface ||
		    $this->_disabled) {
            return;
        }
        
        $action = $this->_actionController->getRequest()->getActionName();
       
        try {
            $result = $this->_acl->isAllowed($this->_getActualRole(), $this->_actionController, $action);
        } catch (Zend_Acl_Exception $e) {
            $result = false;
        }

        if ($result === false) {
            throw new ForbiddenException();
            // ou encore $this->_actionController->getHelper('redirector')->gotoUrlAndExit('/forbidden');
        }        
    }
     /**
     * Retourne le rôle actuellement stocké en session
     * sinon le rôle par défaut
     */
    protected function _getActualRole()
    {
        $auth = Zend_Auth::getInstance();
        if (!$auth->hasIdentity()) {
            return $this->_acl->getDefaultRole();
        }
        return $auth->getIdentity()->getRoleId();
    }
    
    /**
     * Désactive temporairement le traitement des ACLs
     * Appelé depuis la méthode init() des contrôleurs le désirant
     */
    public function disable()
    {
        $this->_disabled = true;
    }
}
Cette aide d'action a besoin de mon objet acl. Soit je le lui passe en construction, soit je le propage via le contrôleur frontal, l'aide d'action essayant alors de le récupérer.
En preDispatch(), donc avant toute action traitée, on vérifie que le contrôleur est bien utilisable directement avec l'objet acl. Si ce n'est pas le cas, par défaut on choisit d'ignorer tout le processus des acls et donc d'ouvrir l'accès. Une petite refactorisation permet ici de faire un choix.
Aussi, chaque contrôleur pourra choisir de désactiver temporairement le contrôle des acls pour lui-même. Nous allons voir un exemple.
Ensuite on interroge l'objet acl : le rôle est issu de la session, sinon le rôle par défaut est utilisé. La ressource est bien le contrôleur lui-même, et le droit à vérifier est bien l'action qu'on tente de lancer.
utilisation de l'aide d'action
<?php
$acl = MyAcl::getInstance();
Zend_Controller_Action_HelperBroker::addHelper(new JP_Controller_Action_Helper_Acl($acl));

// ou
Zend_Controller_Action_HelperBroker::addHelper(new JP_Controller_Action_Helper_Acl());
$frontController->setParam('acl', $acl);
Et voilà :-)

Encore une chose, si un contrôleur veut désactiver le processus des acls, il n'a qu'à l'indiquer à l'aide d'action. On voit bien la facilité de communication entre les actions et leurs aides, mutuellement :
désactivation temporaire de la vérification des acls pour ce contrôleur complet
<?php
class LivresController extends JP_Controller_AclAction
{
    public function init()
    {
        $this->_helper->acl->disable();
    }
}

VI. Conclusions

Comme nous vennons de le voir, la fléxibilité du ZendFramework offre une fois de plus la possibilité de monter des structures très intéréssantes. Pourquoi ceci n'est-il pas implémenté de base dans le framework ? Car ZendFramework propose, et n'impose pas.
Les idées, c'est à chacun de les trouver, en analysant et en comprenant les objets du ZendFramework.
Cette idée d'ACL en raviera certainement plus d'un, on peut lui trouver quelques inconvénients, libres à vous alors de la modifier ;-)



               Version PDF (Miroir)   Version hors-ligne (Miroir)

Valid XHTML 1.0 TransitionalValid CSS!

Copyright © 2009 Julien Pauli. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à 3 ans de prison et jusqu'à 300 000 E de dommages et intérêts. Cette page est déposée à la SACD.

Vos questions techniques : forum d'entraide Zend Framework - Publiez vos articles, tutoriels et cours
et rejoignez-nous dans l'équipe de rédaction du club d'entraide des développeurs francophones
Nous contacter - Hébergement - Participez - Copyright © 2000-2010 www.developpez.com - Legal informations.