Developing a Doctrine-backed ACL helper TDD-style, part 2
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.