Developing a Doctrine-backed ACL helper TDD-style, part 2

2009 June 20

In this second part, we will integrate Doctrine into our AccessManager helper. The database will be hit at least thrice per request: once for the rules for the requested resource, once for the global rules and at least once for the user roles. No additional tests here since I’m not sure how to approach mocking or stubbing with Doctrine and databases.

Schema and fixtures

We begin by setting up the directory for the files needed by the Doctrine importer.

-project
+-- application
|  +-- models
|  `-- data
|     +-- fixtures
|     +-- schema
|     `-- migrations
+-- library
`-- public

It should be obvious which files go where. Make the data and models directory writeable by the web server. Now create the file application/data/schema/schema.yml:

detect_relations: false
options:
    type: INNODB
    charset: utf8

User:
    tableName: users
    columns:
        id:
            type: integer(4)
            autoincrement: true
            primary: true
        username:
            type: string(32)
            unique: true
        password: string(128)
    relations:
        Roles:
            class: Role
            local: user_id
            foreign: role_id
            refClass: UserRole

UserRole:
    tableName: user_roles
    columns:
        user_id:
            type: int(4)
            primary: true
        role_id:
            type: string(32)
            primary: true

Role:
    tableName: roles
    columns:
        id:
            type: string(32)
            primary: true
        description: string(128)
    relations:
        Members:
            class: User
            local: role_id
            foreign: user_id
            refClass: UserRole
        Parents:
            class: Role
            local: child_id
            foreign: parent_id
            refClass: RoleGraph
            foreignAlias: Children

RoleGraph:
    tableName: role_graph
    columns:
        child_id:
            type: string(32)
            primary: true
        parent_id:
            type: string(32)
            primary: true

Permission:
    tableName: permissions
    columns:
        id:
            type: integer(4)
            autoincrement: true
            primary: true
        resource_id: string(128)
        privilege: string(128)
        role_id: string(32)
        allowed: boolean
    indexes:
        resource_index:
            fields: [resource_id]

Then create application/data/fixtures/data.yml:

User:
    user_1:
        username: admin
        password: admin
        Roles: [admin]
    user_2:
        username: foo
        password: foo
        Roles: [member]
    user_3:
        username: bar
        password: bar
        Roles: [mod]

Role:
    guest:
        id: guest
        description: Unauthenticated visitor
    admin:
        id: admin
        description: Administrator
    member:
        id: member
        description: Registered member
        Parents: [guest]
    mod:
        id: mod
        description: Moderator
        Parents: [member]

Permission:
    perm_1:
        role_id: admin
        allowed: true
    perm_2:
        resource_id: default/test
        privilege: foo
        role_id: member
        allowed: true
    perm_3:
        resource_id: default/test
        privilege: bar
        role_id: mod
        allowed: true

Note that the filenames aren’t really important. If you want, you can put the schema and fixtures for each table in their own files and name them whatever you want. Doctrine will iterate through all the files in the schema and fixtures directory when importing them into the database.

Setting up the CLI runner

Our CLI runner will utilize the application bootstrap to initialize Doctrine.We first open the file application/Bootstrap.php and add the Doctrine stuff:

    public function _initAutoloader()
    {
        set_include_path(
            get_include_path() . PATH_SEPARATOR .
            realpath(APPLICATION_PATH . '/models')
        );
        $loader = $this->getApplication()->getAutoloader();
        $loader->setFallbackAutoloader(true);
    }

    protected function _initDoctrine()
    {
        // register the Doctrine namespace. Note the lack of trailing underscore, since the Doctrine facade
        // class needs to be discoverable also
        $loader = $this->getApplication()->getAutoloader();
        $loader->registerNamespace('Doctrine');

        $manager = Doctrine_Manager::getInstance();
        $dsn = 'sqlite:' . APPLICATION_PATH . '/data/acl.sqlite';

        $manager->openConnection($dsn);
        $manager->setAttribute(Doctrine::ATTR_QUOTE_IDENTIFIER, true);
        $manager->setAttribute(Doctrine::ATTR_AUTO_ACCESSOR_OVERRIDE, true);
        $manager->setAttribute(Doctrine::ATTR_MODEL_LOADING,
            Doctrine::MODEL_LOADING_CONSERVATIVE);

        return $manager;
    }

We will put the CLI-specific options into its own resource method since they aren’t used by the application.

    protected function _initDoctrineCli()
    {
        // return early if the bootstrap is not invoked through the CLI
        // blame the wordpress source highlighter for the 'emptyempty' below
        if ('testing' == APPLICATION_ENV || empty($_SERVER['argv'])) {
            return;
        }

        $options = array(
            'data_fixtures_path' => APPLICATION_PATH . '/data/fixtures',
            'models_path'        => APPLICATION_PATH . '/models',
            'migrations_path'    => APPLICATION_PATH . '/data/migrations',
            'sql_path'           => APPLICATION_PATH . '/data',
            'yaml_schema_path'   => APPLICATION_PATH . '/data/schema',
            'generate_models_options'  => array(
                'generateTableClasses' => true,
                'baseClassPrefix'      => 'Base_',
                'baseClassesDirectory' => 'Base',
                'phpDocName'           => 'Mon Zafra',
                'phpDocEmail'          => 'monzee at gmail',
                'phpDocPackage'        => 'app',
                'phpDocSubpackage'     => 'acl'
            )
        );

        $manager = $this->bootstrap('doctrine')->getResource('doctrine');
        $manager->setAttribute(Doctrine::ATTR_MODEL_LOADING,
            Doctrine::MODEL_LOADING_AGGRESSIVE);

        return new Doctrine_Cli($options);
    }

A few things to note here:

  • Aside from the paths, we specify additional options for the model generator. By default, the base generated classes are stored in <models_path>/generated and are named like BaseUser, which utterly disregards the PEAR naming convention (which I find odd since they advocate its use in their own CS) and thus makes the generated classes undiscoverable by the autoloader. With the changes above, the generated base classes are now named like Base_User and are stored in application/models/Base.
  • But this is in violation of the ZF naming standards, which says that model classes should be prefixed with Model_. We could have added another Doctrine generator option to add a prefix to all generated classes, but the problem is that the filenames of the classes will also contain that prefix. If we go down that path, we should either rename all the classes Doctrine generates or patch the Doctrine generator to not add the prefix on filenames. Either way is bothersome so I say screw the ZF standards, use non-namespaced models and just add the models path to the include paths. Hence the _initAutoloader resource method.
  • So, why did I manually call set_include_path when I could have just simply added includePaths.models = APPLICATION_PATH "/models" in application.ini? Remember that if you do require_once "path/to/foo.php", the interpreter loops through your include path, appends path/to/foo.php to it and checks if it exists. If it does, then the file is loaded. Otherwise, the next path is checked. Because the models path would be hit very infrequently compared to the other paths like the library, it isn’t reasonable to put it near the top. It would lead to lots of missed stat calls which slows down your application. Adding paths to your include path should be avoided as much as possible, but if you really have to, the ones hit the most should be at the top. That is the reason why I changed the naming scheme of the Doctrine-generated classes because the only other option is to add application/models/generated to the include path. It’s probably also one of the motivations of the Zend_Loader_Autoloader class and its namespace-based file discovery which only does a stat call if the class prefix matches a registered namespace (if the fallback flag is not set).
  • We override the model loading set by the _initDoctrine method in the CLI resource. Importing through CLI fails if model loading is set to conservative.

Like the PHPUnit bootstrap, the Doctrine CLI runner is mostly the same as the public/index.php file, except we don’t bootstrap the whole application. We only bootstrap the DoctrineCLI resource (and indirectly, the Doctrine resource). Create the file application/data/doctrine.php:

#!/usr/bin/php
<?php
define('APPLICATION_PATH', realpath(dirname(__FILE__) . '/..'));
define('APPLICATION_ENV', 'development');

set_include_path(
    realpath(APPLICATION_PATH . '/../library') .
    PATH_SEPARATOR .
    get_include_path()
);

require_once 'Zend/Application.php';

$app = new Zend_Application(
    APPLICATION_ENV,
    APPLICATION_PATH . '/configs/application.ini'
);

$cli = $app->getBootstrap()
           ->bootstrap('doctrineCli')
           ->getResource('doctrineCli');

$cli->run($_SERVER['argv']);

If you’re in Linux, make this file executable. If you’re on Windows, you might want to make a batch file for convenience:

D:\www\acl\application\data>echo @php doctrine.php %* >doctrine.bat

Building the database

We first import the schema into the database and generate the model files. Again, make sure that the data directory is writable since the SQLite database will be written there, and the models directory where the generated classes will be written.

D:\www\acl\application\data>doctrine build-all
build-all - Generated models successfully from YAML schema
build-all - Successfully created database for connection "0" at path "D:/www/acl
/application/data/acl.sqlite"
build-all - Created tables successfully

Check the models directory and see that a bunch of classes has been written there. We must edit the User class and define a setPassword method so that the imported passwords would be automatically hashed before being written to the db.

<?php

/**
 * User
 *
 * This class has been auto-generated by the Doctrine ORM Framework
 *
 * @package    app
 * @subpackage acl
 * @author     Mon Zafra <monzee at gmail>
 * @version    SVN: $Id: Builder.php 5441 2009-01-30 22:58:43Z jwage $
 */
class User extends Base_User
{
    public function setPassword($password)
    {
        $this->_set('password', $this->hash($password));
    }

    public function hash($password)
    {
        // implement your hashing here!
        return md5($password . 'Tris Acetate EDTA');
    }
}

Doctrine is smart enough to not overwrite this class if you re-import the schema. You should write a cleverer hashing method to make life harder for attackers.

Now we can import the fixtures:

D:\www\acl\application\data>doctrine load-data
load-data - Data was successfully loaded

The database is now filled with values specified in fixtures.yml. If you use Firefox, you can install the extension SQLite Manager and confirm that the data has been loaded. Also, see that the passwords are hashed even if the fixtures only had plain text passwords.

Pulling the role information

Currently in our helper, we return an empty array if the role is not found in the helper’s registry. We will modify the getParents method to try fetching the role information from Doctrine and cache the result. If there is no matching row in the database, an empty array is returned as before.

// library/App/Helper/AccessManager.php
    public function getParents($role)
    {
        if (!isset($this->_roles[$role])) {
            $q= Doctrine_Query::create();
            $q->select('r.id, p.id')
              ->from('Role r')
              ->leftJoin('r.Parents p')
              ->where('r.id = ?');
            $res = $q->fetchArray($role);
            $parents = array();
            if ($row = current($res)) {
                foreach ($row['Parents'] as $parent) {
                    $parents[] = $parent['id'];
                }
            }

            $this->_roles[$role] = $parents;
        }
        return (array) $this->_roles[$role];
    }

Note that this query would be run for every role that is not in the registry, and since the registry is initially empty, there would be one query for every role in the hierarchy for every request. This is far from ideal, but I just can’t see a way to pull every related role in a single query. It might be a good idea to serialize and cache the ACL object built by the helper after preDispatch and use that for the subsequent requests.

Pulling permissions data

This one’s a bit simpler than above because no joins are used. The query is slightly different if we’re requesting for the null resource, so we add a check just before the query. The same caveat above applies here, although it’s not such a problem here since you typically would only get the permissions for a single resource. Had we implemented resource inheritance then it would be a concern.

// library/App/Helper/AccessManager.php
    public function getRulesFor($resource)
    {
        if (!isset($this->_rules[$resource])) {
            $q = Doctrine_Query::create();
            $q->select('p.role_id, p.resource_id, p.privilege, p.allowed')
              ->from('Permission p');
            if (null === $resource) {
                $q->where('p.resource_id IS NULL');
            } else {
                $q->where('p.resource_id = ?', $resource);
            }

            $this->_rules[$resource] = $q->fetchArray();
        }
        return $this->_rules[$resource];
    }

…and you’re done! It’s elementary. As a bonus, I’ll show how one might implement user authentication against the Doctrine back-end.

class UserController extends Zend_Controller_Action implements Zend_Auth_Adapter_Interface
{
    public function indexAction()
    {
        $auth = Zend_Auth::getInstance();
        if ($auth->hasIdentity()) {
            $user = $auth->getIdentity();
            $this->view->welcome = 'Hey there, ' . $user['username'];
            $this->view->user = $user;
        } else {
            $this->view->welcome = 'Who goes there?';
        }
    }

    public function loginAction()
    {
        $form = new Form_Login(array(
                'action' => $this->_helper->url('login')
            ));
        $req = $this->_request;

        if ($req->isPost() && $form->isValid($req->getPost())) {
            $auth = Zend_Auth::getInstance();
            $res = $auth->authenticate($this);
            if ($res->isValid()) {
                $this->_redirect('user');
            } else {
                $this->view->error = current($res->getMessages());
            }
        }
        $this->view->form = $form;
    }

    public function logoutAction()
    {
        Zend_Auth::getInstance()->clearIdentity();
        $this->_redirect('user');
    }

    public function authenticate()
    {
        $req = $this->getRequest();
        $username = $req->getPost('username');

        $q = Doctrine_Query::create();
        $q->select('u.id, u.username, u.password, r.id')
          ->from('User u')
          ->leftJoin('u.Roles r')
          ->where('username = ?');
        $user = $q->fetchOne($username);

        if (false !== $user) {
            $password = $user->hash($req->getPost('password'));
            if ($user['password'] == $password) {
                $user = $user->toArray();
                // flatten roles
                $user['roles'] = array();
                foreach ($user['Roles'] as $role) {
                    $user['roles'][] = $role['id'];
                }
                unset($user['password'], $user['Roles']);

                return new Zend_Auth_Result(
                        Zend_Auth_Result::SUCCESS,
                        $user,
                        array('Authentication successful.')
                    );
            } else {
                return new Zend_Auth_Result(
                        Zend_Auth_Result::FAILURE_CREDENTIAL_INVALID,
                        $username,
                        array('Wrong password.')
                    );
            }
        }
        return new Zend_Auth_Result(
                Zend_Auth_Result::FAILURE_IDENTITY_NOT_FOUND,
                $username,
                array('No such user.')
            );
    }

}

Since I’m lazy, I didn’t create a separate class for authentication, but instead implemented the authenticate method directly in the controller. You can’t just copy-paste the above code because it needs a couple of view scripts and a form which requires a module autoloader to be setup. I created another project with all of these. Get it here. Use SQLite Manager to mess around with the permissions table then see what happens.

No more part 3 for this tutorial, but there’s lots more you can do to improve this class, for example:

  • Implement the adapter pattern for the rules and resource to pull the ACL data from various stores.
  • Cache the ACL object to minimize database hits.
  • Figure out a better way to pull all the ancestors of a role from the database.
  • Implement resource inheritance, e.g. a controller resource may inherit permissions from a module resource.
  • Make some parts of the helper configurable, like the login and 403 pages.
  • Allow a Zend_Config object to be passed to the constructor to set various options.

I plan to (re-)implement some of these in my refactored admin module. Watch for it.

No comments yet

Leave a Reply

Note: You can use basic XHTML in your comments. Your email address will never be published.

Subscribe to this comment feed via RSS