Developing a Doctrine-backed ACL helper TDD-style, part 1
I figured my admin module needed to be unit tested since the code changes a lot and I’m constantly worried about breaking things in certain places while I add new features or factor out some common code. Writing tests at this point though does not follow the TDD way. I’m trying to get into this philosophy, so I’m gonna rebuild the ACL helper, this time guided by unit tests right from the start. All those code I have now aren’t fully trashed yet as I’m sure I’m going to copy-pasta a lot of those back in.
Note that I’m no expert on TDD. I’m a newbie, in fact. Don’t take anything in this post as a guideline on unit testing. I’m probably not even doing it properly. If you’re not interested in the TDD walkthrough, jump to the end and grab the (half-working) package containing a sample project.
Goals
The ultimate goal is to make an ACL helper with a flexible storage back-end, but for now I will focus on just the Doctrine back-end. This ACL helper is invoked during pre-dispatch and checks if the current user is allowed to access a controller action. Not every rule and role is loaded into the ACL object. Only the ones pertinent to the current user and request will be pulled from the back-end and pushed into the ACL object.
Requirements
- ZF 1.8+. Setup Zend_Tool properly.
- PHPUnit 3.3+
- Doctrine 1.1+ (in the second part)
Setting up
Creating the project
Fire up the console, go to the document root and create a new project with Zend_Tool.
D:\>yep, i'm on windows. bite me. 'yep' is not recognized as an internal or external command, operable program or batch file. D:\>cd www D:\www>zf create project acl Creating project at D:/www/acl D:\www>
Edit the file application/configs/application.ini and make it look like this. Changed/added lines are in bold.
[production] phpSettings.display_startup_errors = 0 phpSettings.display_errors = 0 phpSettings.date.timezone = "Asia/Manila" includePaths.library = APPLICATION_PATH "/../library" autoloaderNamespaces[] = "App_" bootstrap.path = APPLICATION_PATH "/Bootstrap.php" bootstrap.class = "Bootstrap" resources.frontController.controllerDirectory = APPLICATION_PATH "/controllers" [staging : production] [testing : production] phpSettings.display_startup_errors = 1 phpSettings.display_errors = 1 phpSettings.error_reporting = E_ALL|E_STRICT [development : production] phpSettings.display_startup_errors = 1 phpSettings.display_errors = 1 phpSettings.error_reporting = E_ALL|E_STRICT
I’m not touching the bootstrap yet. I just want to setup the autoloader to make it work with PHPUnit.
Setting up the test suite
Zend_Tool kindly creates for us a tests directory under the project path. But it’s totally empty. Let’s remedy that. Edit the file tests/phpunit.xml.
<phpunit>
<testsuite name="App Library">
<directory>library/App</directory>
</testsuite>
</phpunit>
We need to edit the file tests/library/bootstrap.php and bootstrap the application there. This allows us to use the loader(s) registered by Zend_Application and the application bootstrap in the test cases. This is mostly a simplified version of public/index.php, except we’re not calling the run() method of the application.
<?php
define('TEST_INITIALIZED', true);
define('APPLICATION_PATH', realpath(dirname(__FILE__) . '/../../application'));
define('APPLICATION_ENV', 'testing');
set_include_path(
APPLICATION_PATH . '/library' .
PATH_SEPARATOR .
get_include_path()
);
require_once 'Zend/Application.php';
$app = new Zend_Application(
APPLICATION_ENV,
APPLICATION_PATH . '/configs/application.ini'
);
$app->bootstrap();
This file needs to be included in every test class. I set the global constant TEST_INITIALIZED so that we’d know if we have to load the bootstrap or not. This might not seem useful now since we only have one test class, but once we have more test classes, this is absolutely required since we wouldn’t know if the test class was invoked via a test suite or individually.
Creating the test class
I have decided to name the helper class App_Helper_AccessManager. We’re following the PEAR convention, so this class will be inside the file library/App/Helper/AccessManager.php. Our test classes will mirror this structure. Under the path tests/library create the directory App and under that, the directory Helper. Then create the file tests/library/App/Helper/AccessManagerTest.php. We’ll create a temporary test to know if the autoloader works.
<?php
defined('TEST_INITIALIZED') or require dirname(__FILE__) . '/../../bootstrap.php';
class App_Helper_AccessManagerTest extends PHPUnit_Framework_TestCase
{
public function testTest()
{
$helper = new App_Helper_AccessManager();
}
}
Now, create the file library/App/Helper/AccessManager.php:
<?php
class App_Helper_AccessManager extends Zend_Controller_Action_Helper_Abstract
{}
Go to the tests directory and run phpunit. If it doesn’t work, make sure your include paths are configured correctly and that the Zend and PHPUnit libraries are in the include path. If it works, remove the testTest() method and we’ll start writing the real tests.
Building the class
Let’s first think about what exactly we want our class to do. In the end, we want our helper class to check if the current user is allowed to access an action before it is executed. We need to break this down:
- The helper would have an associated Zend_Acl object where the roles, resources and rules would be registered. The helper uses this object to determine whether a user should be allowed in.
- The helper uses the username of the current user when querying the ACL object. Therefore, the username is registered as a role, and the user’s roles are used as its parent roles. The parent roles would have to be registered as well, as well as the parent roles of those parents, etc. The parent role information would be pulled from the database through Doctrine, but we will make it explicitly settable first to make testing easier. The username and user roles are pulled from the identity stored by Zend_Auth. If there is no stored identity, some username is used as the role and the guest role is set as the lone parent role.
- A resource ID and privilege may also be specified when querying the ACL object. Like the roles, the resource must also be registered with the ACL object. We won’t implement resource inheritance for now. There’s no need to store these in the database.
- Of course, the ACL object needs access rules to know if a user can access a resource. These rules are stored in the database, but like the role information, we will make them explicitly settable first. Only the rules for the resource being queried will be registered.
- This check would be automatically done during preDispatch. The resource ID and privilege will be derived by the helper from the request object (the module/controller is the resource ID and the action is the privilege), and the username and user roles will be pulled from Zend_Auth. If the user is denied, he will be redirected either to the login page or to an error page.
First step
Begin with the first point. We will write a test that verifies that the isAllowed() method of a Zend_Acl object is invoked by the helper.
// tests/library/App/Helper/AccessManagerTest.php
public function setUp()
{
$this->helper = new App_Helper_AccessManager();
}
public function testHelperUsesAnAclObjectToDetermineIfUserIsPermitted()
{
$acl = $this->getMock('Zend_Acl', array('isAllowed'));
$acl->expects($this->once())
->method('isAllowed')
->with($this->equalTo('foo'), $this->equalTo('default/index'), $this->equalTo('index'));
$this->helper->setAcl($acl);
$this->helper->isAllowed('foo', 'default/index', 'index');
}
One test, one assertion. We only assert that the isAllowed method of Zend_Acl is called. We’re not interested in the return value yet. We then write the implementation:
// library/App/Helper/AccessManager.php
class App_Helper_AccessManager extends Zend_Controller_Action_Helper_Abstract
{
protected $_acl;
public function isAllowed($role, $resource = null, $privilege = null)
{
$acl = $this->getAcl();
return $acl->isAllowed($role, $resource, $privilege);
}
public function setAcl(Zend_Acl $acl)
{
$this->_acl = $acl;
return $this;
}
public function getAcl()
{
if (null === $this->_acl) {
$this->_acl = new Zend_Acl();
}
return $this->_acl;
}
}
It might seem like a very trivial facet to test. Never forget that even the greatest things start from small beginnings. I find it easier to write the subsequent tests once I have a simple thing going.
Even if we already passed that particular test, it doesn’t mean that we’re done writing the isAllowed() method. TDD is not about testing methods; it is about implementing features specified by the tests. Thus the class is built one feature at a time, and for the most part we would be rewriting or adding code to existing methods.
On to the next couple tests. We will now ensure that the role and resource being queried are registered in the ACL object. Instead of mocking, we will instead use a real Zend_Acl object this time.
// tests/library/App/Helper/AccessManagerTest.php
public function testRoleIsAddedIfItIsntRegisteredYet()
{
$this->helper->isAllowed('foo', 'default/index', 'index');
$acl = $this->helper->getAcl();
$this->assertTrue($acl->hasRole('foo'));
}
public function testResourceIsAddedIfItIsntRegisteredYet()
{
$this->helper->isAllowed('foo', 'default/index', 'index');
$acl = $this->helper->getAcl();
$this->assertTrue($acl->has('default/index'));
}
And the implementation:
// library/App/Helper/AccessManager.php
public function isAllowed($role, $resource = null, $privilege = null)
{
$acl = $this->getAcl();
$acl->hasRole($role) or $acl->addRole(new Zend_Acl_Role($role));
$acl->has($resource) or $acl->add(new Zend_Acl_Resource($resource));
return $acl->isAllowed($role, $resource, $privilege);
}
Still very straightforward. We will now implement the registration of parent roles. It starts to get tricky from here.
Registering roles
// tests/library/App/Helper/AccessManagerTest.php
public function testParentRolesAreAddedIfTheyArentRegisteredYet()
{
$acl = $this->getMock('Zend_Acl', array('addRole', 'isAllowed'));
$acl->expects($this->exactly(3))
->method('addRole')
->with($this->isInstanceOf('Zend_Acl_Role'));
$acl->expects($this->once())
->method('isAllowed');
$this->helper
->setParents('foo', array('bar', 'baz'))
->setAcl($acl)
->isAllowed('foo');
}
public function testParentRolesAreSpecifiedWhenAddingTheRole()
{
$this->helper
->setParents('foo', array('bar'))
->isAllowed('foo');
$acl = $this->helper->getAcl();
$this->assertTrue($acl->inheritsRole('foo', 'bar', true));
}
Note that at this point, we’re only working with roles in the context of Zend_Acl. The Zend_Auth identity will not come into play until we get to the preDispatch logic.
The foo role has two parents, so we expect the $acl->addRole() to be called three times. We’re not testing the arguments passed to isAllowed() anymore since that’s already been tested. It would be preferable to not mock that method at all, but we have to since the subsequent call to the isAllowed() method would fail because we didn’t actually add the roles (it was captured by the mocked method). The next test verifies that the parent roles are passed when the role is added. And here’s the implementation:
// library/App/Helper/AccessManager.php
protected $_roles = array();
public function isAllowed($role, $resource = null, $privilege = null)
{
$acl = $this->getAcl();
$parents = $this->getParents($role);
foreach ($parents as $parent) {
$acl->hasRole($parent) or $acl->addRole(new Zend_Acl_Role($parent));
}
$acl->hasRole($role) or $acl->addRole(new Zend_Acl_Role($role), $parents);
$acl->has($resource) or $acl->add(new Zend_Acl_Resource($resource));
return $acl->isAllowed($role, $resource, $privilege);
}
public function setParents($role, $parents)
{
$this->_roles[$role] = $parents;
return $this;
}
public function getParents($role)
{
if (!isset($this->_roles[$role])) {
$this->_roles[$role] = array();
}
return (array) $this->_roles[$role];
}
Notice the getParents() method which looks somewhat like the lazy loading pattern. The if block in that method is where we will place the Doctrine integration code later.
Here’s the tricky part. We will now verify that all the ancestor roles are added to the role registry. Meaning all the parents of the parent roles, plus the parents of those parents, etc. until we reach a role with no parents. Circular dependencies must be checked here since a role which inherits from itself makes no sense.
// tests/library/App/Helper/AccessManagerTest.php
public function testAllAncestorRolesAreRegistered()
{
$acl = $this->getMock('Zend_Acl', array('addRole', 'isAllowed'));
$acl->expects($this->exactly(4))
->method('addRole');
$acl->expects($this->once())
->method('isAllowed');
$this->helper
->setRoles(array(
'foo' => array('bar'),
'bar' => array('baz'),
'baz' => array('bat'),
))
->setAcl($acl)
->isAllowed('foo');
}
public function testRoleInheritsFromAncestors()
{
$this->helper
->setRoles(array(
'foo' => array('bar'),
'bar' => array('baz'),
'baz' => array('bat'),
))
->isAllowed('foo');
$acl = $this->helper->getAcl();
$this->assertTrue($acl->inheritsRole('foo', 'baz', false));
}
public function testThrowsAnExceptionIfARoleInheritsFromItself()
{
$this->setExpectedException('App_Exception');
$this->helper
->setRoles(array(
'foo' => array('bar'),
'bar' => array('baz'),
'baz' => array('bat'),
'bat' => array('bar'),
))
->isAllowed('foo');
}
We will factor out the adding of parent roles into its own function since we’ll be doing recursion. While looping through the parents, it checks if that parent role inherits from other roles. If not, the parent is added with an empty array as its parent. If it is, it loops through all the parent roles of the parent and performs the same check. The loop terminates when we reach a parent role with no parent roles. That’s why it’s important to ensure that a role does not appear twice in a single line of hierarchy. The loop will never end in that case.
// create a new file library/App/Exception.php
class App_Exception extends Exception {}
// library/App/Helper/AccessManager.php
public function isAllowed($role, $resource = null, $privilege = null)
{
$acl = $this->getAcl();
$parents = $this->_addAllAncestorRoles($role);
$acl->hasRole($role) or $acl->addRole(new Zend_Acl_Role($role), $parents);
$acl->has($resource) or $acl->add(new Zend_Acl_Resource($resource));
return $acl->isAllowed($role, $resource, $privilege);
}
protected function _addAllAncestorRoles($role, &$path = array())
{
if (in_array($role, $path)) {
throw new App_Exception('Circular role inheritance detected.');
}
array_push($path, $role);
$acl = $this->getAcl();
$parents = $this->getParents($role);
foreach ($parents as $parent) {
$grandParents = $this->_addAllAncestorRoles($parent, $path);
$acl->hasRole($parent) or $acl->addRole(new Zend_Acl_Role($parent), $grandParents);
}
array_pop($path);
return $parents;
}
public function setRoles($roles)
{
$this->_roles = $roles;
return $this;
}
That just about covers the business with roles. Nothing special needs to be done with resources since we decided not to implement resource inheritance. So on to the permissions.
Registering access rules
First, we ensure that only the rules for a particular resource gets loaded into the ACL.
// tests/library/App/Helper/AccessManagerTest.php
public function testAccessRulesForResourceAreRegistered()
{
$acl = $this->getMock('Zend_Acl', array('allow'));
$acl->expects($this->once())
->method('allow')
->with('foo', 'default/index');
$this->helper
->setRules(array(
'default/index' => array(
array('role_id' => 'foo', 'privilege' => null, 'allowed' => true)
),
'default/test' => array(
array('role_id' => 'foo', 'privilege' => null, 'allowed' => true)
),
))
->setAcl($acl)
->isAllowed('foo', 'default/index');
}
We specified rules for two resources, but only one of those should be actually added to the ACL object.
// library/App/Helper/AccessManager.php
protected $_rules = array();
public function isAllowed($role, $resource = null, $privilege = null)
{
$acl = $this->getAcl();
$parents = $this->_addAllAncestorRoles($role);
$acl->hasRole($role) or $acl->addRole(new Zend_Acl_Role($role), $parents);
$acl->has($resource) or $acl->add(new Zend_Acl_Resource($resource));
$this->_buildPermissions($resource);
return $acl->isAllowed($role, $resource, $privilege);
}
protected function _buildPermissions($resource)
{
$acl = $this->getAcl();
$acl->add(new Zend_Acl_Resource($resource));
$rules = $this->getRulesFor($resource);
foreach ($rules as $rule) {
$role = $rule['role_id'];
$priv = $rule['privilege'];
$allowed = $rule['allowed'];
$action = $allowed ? 'allow' : 'deny';
$acl->$action($role, $resource, $priv);
}
}
public function setRules($rules)
{
$this->_rules = $rules;
return $this;
}
public function getRulesFor($resource)
{
if (!isset($this->_rules[$resource])) {
$this->_rules[$resource] = array();
}
return $this->_rules[$resource];
}
You might be wondering: why setRules but not getRules? A method named getRules implies that you want to get all rules. We only want to get rules for a single resource, hence it is named as such. Eventually, we might write a getRules method that returns all rules, but at the moment there is no need for that.
There’s a problem with this. Since all the rules for that resource are pulled, there might be rules included which aren’t relevant to the role we are querying. We only registered the roles which are related to our target role, thus calling allow or deny with an unrelated role would throw an exception. We write a test to prevent this:
// tests/library/App/Helper/AccessManagerTest.php
public function testRulesForUnrelatedRolesAreSkipped()
{
$acl = $this->getMock('Zend_Acl', array('allow'));
$acl->expects($this->exactly(2))
->method('allow');
$this->helper
->setParents('foo', array('bar'))
->setRules(array(
'default/index' => array(
array('role_id' => 'baz', 'privilege' => null, 'allowed' => true),
array('role_id' => 'bar', 'privilege' => null, 'allowed' => true),
array('role_id' => 'foo', 'privilege' => null, 'allowed' => true),
),
))
->setAcl($acl)
->isAllowed('foo', 'default/index');
}
Then we add a check in our _buildPermissions method:
// library/App/Helper/AccessManager.php
protected function _buildPermissions($resource)
{
$acl = $this->getAcl();
$rules = $this->getRulesFor($resource);
foreach ($rules as $rule) {
$role = $rule['role_id'];
$priv = $rule['privilege'];
$allowed = $rule['allowed'];
$action = $allowed ? 'allow' : 'deny';
if (!$acl->hasRole($role)) {
continue;
}
$acl->$action($role, $resource, $priv);
}
}
Consider the case of an admin account. Typically, you’d want to allow administrators access to any resource. We could add an allow rule for every resource to the admin role, but that is unmaintainable if we have lots of resources. Zend_Acl provides another way to accomplish this: by allowing the admin role access to the null resource. Instead of a hundred allow rules for the admin role, we could have a single global rule that covers every resource. Therefore, in addition to the rules specifically for our target resource, we should also add the rules for the null resource.
// tests/library/App/Helper/AccessManagerTest.php
public function testGlobalRulesAreAlwaysAdded()
{
$this->helper
->setRules(array(
null => array(
array('role_id' => 'foo', 'privilege' => null, 'allowed' => true),
)
));
$this->assertTrue($this->helper->isAllowed('foo', 'some resource'));
}
We’ll factor out the actual adding of rules into its own function since we now have to do it twice per resource.
// library/App/Helper/AccessManager.php
protected function _buildPermissions($resource)
{
$globalRules = $this->getRulesFor(null);
$this->_addRules($globalRules);
$rules = $this->getRulesFor($resource);
$this->_addRules($rules, $resource);
}
protected function _addRules($rules, $resource = null)
{
$acl = $this->getAcl();
foreach ($rules as $rule) {
$role = $rule['role_id'];
$priv = $rule['privilege'];
$allowed = $rule['allowed'];
$action = $allowed ? 'allow' : 'deny';
if (!$acl->hasRole($role)) {
continue;
}
$acl->$action($role, $resource, $priv);
}
}
By default, Zend_Acl denies access to all resources unless you add an allow rule. I do not want this behavior. If I don’t have any rules for a resource, I want everyone to be able to access it. Only when I want to restrict access to it will I specify which roles I want to give access to.
// tests/library/App/Helper/AccessManagerTest.php
public function testEveryoneIsAllowedIfThereAreNoRulesForTheResource()
{
$this->assertTrue($this->helper->isAllowed('foo', 'default/index'));
}
public function testDenyByDefaultIfThereIsAtLeastOneRuleForTheResource()
{
$this->helper
->setRules(array(
'some resource' => array(
array('role_id' => 'foo', 'privilege' => null, 'allowed' => true),
),
));
$this->assertFalse($this->helper->isAllowed('bar', 'some resource'));
}
We implement this by calling $acl->allow(null, $resource) if the getRulesFor($resource) returns an empty array but only for non-global rules. We won’t satisfy the second condition if we call $acl->allow(null, null).
// library/App/Helper/AccessManager.php
protected function _addRules($rules, $resource)
{
$acl = $this->getAcl();
// wordpress source highlighter sucks. there should be only one 'empty' below
if (null !== $resource && empty($rules)) {
$acl->allow(null, $resource);
return;
}
foreach ($rules as $rule) {
$role = $rule['role_id'];
$priv = $rule['privilege'];
$allowed = $rule['allowed'];
$action = $allowed ? 'allow' : 'deny';
if (!$acl->hasRole($role)) {
continue;
}
$acl->$action($role, $resource, $priv);
}
}
One final feature for the access rules. I want to have an alias for the null role and privilege, and also make them optional in the rule row. If they are not specified, the null value is assumed.
// tests/library/App/Helper/AccessManagerTest.php
public function testAliasesForTheNullRoleAndPrivilege()
{
$acl = $this->getMock('Zend_Acl', array('allow'));
$acl->expects($this->exactly(3))
->method('allow')
->with(null, 'resource', null);
$this->helper
->setAcl($acl)
->setRules(array(
'resource' => array(
array('role_id' => '', 'privilege' => '', 'allowed' => true),
array('role_id' => '*', 'privilege' => '*', 'allowed' => true),
array('allowed' => true),
),
))
->isAllowed('foo', 'resource');
}
// library/App/Helper/AccessManager.php
protected function _addRules($rules, $resource)
{
$acl = $this->getAcl();
if (null !== $resource && empty($rules)) {
$acl->allow(null, $resource);
return;
}
$all = array('', '*');
foreach ($rules as $rule) {
$role = !isset($rule['role_id']) || in_array($rule['role_id'], $all)
? null : $rule['role_id'];
$priv = !isset($rule['privilege']) || in_array($rule['privilege'], $all)
? null : $rule['privilege'];
$action = $rule['allowed'] ? 'allow' : 'deny';
if (null !== $role && !$acl->hasRole($role)) {
continue;
}
$acl->$action($role, $resource, $priv);
}
}
Now we’re done messing with the ACL object. We will now automate the building of rules and access check during the helper preDispatch.
Autocheck on preDispatch
While we now have a functional ACL builder, the helper still needs to do a couple of things to be able to perform access check on preDispatch:
- Pull the user’s username and his roles from the stored Zend_Auth identity. The username will be used as a role and the user roles will become the parent roles. If there is no identity stored, the guest username and role are used. The username must be transformed so that they won’t collide with actual role IDs. For example, if we have an admin user who belongs to the admin role, we’d get a circular relation right off the bat. Worse still, say he have a sneaky user who chose the name moderator. If we also have a role named moderator, that user will be able to access all moderator-only pages even if he is just a regular member.
- Generate a resource ID and privilege from the current request. The resource ID should be of the format module/controller and the privilege would simply be action.
If the user fails the access check, he will be redirected to the login page if he is not logged in and to an error page if he is logged in but denied access.
I will now write all the tests and implementation in one go since this post is getting long.
// tests/library/App/Helper/AccessManagerTest.php
protected function _prepareCollaborators()
{
$this->authStorage = new Zend_Auth_Storage_NonPersistent();
$auth = Zend_Auth::getInstance();
$auth->setStorage($this->authStorage);
$this->request = new Zend_Controller_Request_Simple();
$this->response = new Zend_Controller_Response_HttpTestCase();
$controller = $this->getMock(
'Zend_Controller_Action',
array('getRequest'),
array($this->request, $this->response, array())
);
$controller->expects($this->any())
->method('getRequest')
->will($this->returnValue($this->request));
$this->helper->setActionController($controller);
}
public function testUserAndRolesArePulledFromTheStoredAuthIdentityDuringPredispatch()
{
$this->_prepareCollaborators();
$this->authStorage->write(array('username' => 'foo', 'roles' => array('bar')));
$this->helper->preDispatch();
$acl = $this->helper->getAcl();
$this->assertTrue($acl->inheritsRole('user:foo', 'bar', true));
}
public function testGuestUserAndRoleAreUsedIfThereIsNoStoredIdentityDuringPredispatch()
{
$this->_prepareCollaborators();
$this->helper->preDispatch();
$acl = $this->helper->getAcl();
$this->assertTrue($acl->inheritsRole('user:guest', 'guest', true));
}
public function testResourceAndPrivilegeAreGeneratedDuringPredispatch()
{
$this->_prepareCollaborators();
$this->request
->setModuleName('foo')
->setControllerName('bar')
->setActionName('baz');
$acl = $this->getMock('Zend_Acl', array('isAllowed'));
$acl->expects($this->once())
->method('isAllowed')
->with('user:guest', 'foo/bar', 'baz');
$this->helper->setAcl($acl)->preDispatch();
}
public function testAuthenticatedUserIsRedirectedToErrorPageIfAccessIsDenied()
{
$this->_prepareCollaborators();
$this->authStorage->write(array('username' => 'foo', 'roles' => array()));
$this->request->setParams(array(
'module' => 'foo',
'controller' => 'bar',
'action' => 'baz'
));
$this->helper
->setRules(array(
'foo/bar' => array(
array('role_id' => 'user:foo', 'allowed' => false)
),
))
->preDispatch();
$page = implode('/', array(
$this->request->getModuleName(),
$this->request->getControllerName(),
$this->request->getActionName()
));
$this->assertEquals('default/error/denied', $page);
}
public function testGuestIsRedirectedToLoginPageIfAccessIsDenied()
{
$this->_prepareCollaborators();
$this->request->setParams(array(
'module' => 'foo',
'controller' => 'bar',
'action' => 'baz'
));
$this->helper
->setRules(array(
'foo/bar' => array(
array('role_id' => 'guest', 'allowed' => false)
),
))
->preDispatch();
$page = implode('/', array(
$this->request->getModuleName(),
$this->request->getControllerName(),
$this->request->getActionName()
));
$this->assertEquals('default/user/login', $page);
}
Since the helper abstract doesn’t have a setRequest method, we need to stub the controller (done in the _prepareCollaborators method) so that the helper would have a request object to work with. This has the unfortunate effect of incrementing the assertion count.
// library/App/Helper/AccessManager.php
public function preDispatch()
{
$auth = Zend_Auth::getInstance();
$loggedIn = $auth->hasIdentity();
if ($loggedIn) {
$identity = $auth->getIdentity();
$user = $this->_usernameAsRole($identity['username']);
$roles = $identity['roles'];
} else {
$user = $this->_usernameAsRole('guest');
$roles = array('guest');
}
$this->setParents($user, $roles);
list($resource, $privilege) = $this->_generateResource();
if (!$this->isAllowed($user, $resource, $privilege)) {
if ($loggedIn) {
$this->_forward('denied', 'error', 'default');
} else {
$this->_forward('login', 'user', 'default');
}
}
}
protected function _usernameAsRole($username)
{
return 'user:' . $username;
}
protected function _generateResource()
{
$req = $this->getRequest();
$module = $req->getModuleName();
$controller = $req->getControllerName();
$action = $req->getActionName();
return array($module . '/' . $controller, $action);
}
protected function _forward($action, $controller, $module)
{
$req = $this->getRequest();
$req->setModuleName($module)
->setControllerName($controller)
->setActionName($action)
->setDispatched(false);
}
And the base is done. Doctrine needs additional configuration and this post is nearing 3500 words according to wordpress, so the Doctrine stuff will be covered in part 2. What we have now is usable but it has to be fed all the role and rule information before the preDispatch stage. I’ve put together a simple project which demonstrates this. Get it here. The rules and roles are stored in an ini file and fed into the helper during bootstrap. Try hitting the index, foo and bar actions of the test controller. foo can access test/foo only, bar can access test/foo and test/bar while admin can access all three actions.
In the next part, we will modify the helper class to automatically fetch the roles and rules from the database if they aren’t set yet.
public function setRoles($roles)
{
$this->_roles = roles;
return $this;
}
Line 35 missing $
good catch, thanks!