Browse Source

Merge pull request #417 from 0x20h/php-acl

PHP config file based ACL implementation
Mark Story 14 years ago
parent
commit
9e8152f949

+ 134 - 0
app/Config/acl.php

@@ -0,0 +1,134 @@
+<?php
+/**
+ * This is the PHP base ACL configuration file.
+ *
+ * Use it to configure access control of your Cake application.
+ *
+ * PHP 5
+ *
+ * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
+ * Copyright 2005-2011, Cake Software Foundation, Inc. (http://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright 2005-2011, Cake Software Foundation, Inc. (http://cakefoundation.org)
+ * @link          http://cakephp.org CakePHP(tm) Project
+ * @package       app.Config
+ * @since         CakePHP(tm) v 2.1
+ * @license       MIT License (http://www.opensource.org/licenses/mit-license.php)
+ */
+
+/**
+ * Example
+ * -------
+ * 
+ * Assumptions:
+ *
+ * 1. In your application you created a User model with the following properties: 
+ *    username, group_id, password, email, firstname, lastname and so on.
+ * 2. You configured AuthComponent to authorize actions via 
+ *    $this->Auth->authorize = array('Actions' => array('actionPath' => 'controllers/'),...) 
+ * 
+ * Now, when a user (i.e. jeff) authenticates successfully and requests a controller action (i.e. /invoices/delete)
+ * that is not allowed by default (e.g. via $this->Auth->allow('edit') in the Invoices controller) then AuthComponent 
+ * will ask the configured ACL interface if access is granted. Under the assumptions 1. and 2. this will be 
+ * done via a call to Acl->check() with 
+ *
+ *    array('User' => array('username' => 'jeff', 'group_id' => 4, ...))
+ *
+ * as ARO and
+ *
+ *    '/controllers/invoices/delete'
+ *
+ * as ACO.
+ * 
+ * If the configured map looks like
+ *
+ *    $config['map'] = array(
+ *       'User' => 'User/username',
+ *       'Role' => 'User/group_id',
+ *    );
+ *
+ * then PhpAcl will lookup if we defined a role like User/jeff. If that role is not found, PhpAcl will try to 
+ * find a definition for Role/4. If the definition isn't found then a default role (Role/default) will be used to 
+ * check rules for the given ACO. The search can be expanded by defining aliases in the alias configuration.
+ * E.g. if you want to use a more readable name than Role/4 in your definitions you can define an alias like
+ *
+ *    $config['alias'] = array(
+ *       'Role/4' => 'Role/editor',
+ *    );
+ * 
+ * In the roles configuration you can define roles on the lhs and inherited roles on the rhs:
+ * 
+ *    $config['roles'] = array(
+ *       'Role/admin' => null,
+ *       'Role/accountant' => null,
+ *       'Role/editor' => null,
+ *       'Role/manager' => 'Role/editor, Role/accountant',
+ *       'User/jeff' => 'Role/manager',
+ *    );
+ * 
+ * In this example manager inherits all rules from editor and accountant. Role/admin doesn't inherit from any role.
+ * Lets define some rules:
+ *
+ *    $config['rules'] = array(
+ *       'allow' => array(
+ *       	'*' => 'Role/admin',
+ *       	'controllers/users/(dashboard|profile)' => 'Role/default',
+ *       	'controllers/invoices/*' => 'Role/accountant',
+ *       	'controllers/articles/*' => 'Role/editor',
+ *       	'controllers/users/*'  => 'Role/manager',
+ *       	'controllers/invoices/delete'  => 'Role/manager',
+ *       ),
+ *       'deny' => array(
+ *       	'controllers/invoices/delete' => 'Role/accountant, User/jeff',
+ *       	'controllers/articles/(delete|publish)' => 'Role/editor',
+ *       ),
+ *    );
+ *
+ * Ok, so as jeff inherits from Role/manager he's matched every rule that references User/jeff, Role/manager, 
+ * Role/editor, Role/accountant and Role/default. However, for jeff, rules for User/jeff are more specific than 
+ * rules for Role/manager, rules for Role/manager are more specific than rules for Role/editor and so on.
+ * This is important when allow and deny rules match for a role. E.g. Role/accountant is allowed 
+ * controllers/invoices/* but at the same time controllers/invoices/delete is denied. But there is a more
+ * specific rule defined for Role/manager which is allowed controllers/invoices/delete. However, the most specific
+ * rule denies access to the delete action explicitly for User/jeff, so he'll be denied access to the resource.
+ *
+ * If we would remove the role definition for User/jeff, then jeff would be granted access as he would be resolved
+ * to Role/manager and Role/manager has an allow rule.
+ */
+
+/**
+ * The role map defines how to resolve the user record from your application
+ * to the roles you defined in the roles configuration. 
+ */
+$config['map'] = array(
+	'User' => 'User/username',
+	'Role' => 'User/group_id',
+);
+
+/**
+ * define aliases to map your model information to
+ * the roles defined in your role configuration.
+ */
+$config['alias'] = array(
+	'Role/4' => 'Role/editor',
+);
+
+/**
+ * role configuration
+ */
+$config['roles'] = array(
+	'Role/admin' => null,
+);
+
+/**
+ * rule configuration
+ */
+$config['rules'] = array(
+	'allow' => array(
+		'*' => 'Role/admin',
+	),
+	'deny' => array(),
+);

+ 540 - 0
lib/Cake/Controller/Component/Acl/PhpAcl.php

@@ -0,0 +1,540 @@
+<?php
+/**
+ * PHP configuration based AclInterface implementation
+ *
+ * PHP 5
+ *
+ * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
+ * Copyright 2005-2011, Cake Software Foundation, Inc. (http://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright 2005-2011, Cake Software Foundation, Inc. (http://cakefoundation.org)
+ * @link          http://cakephp.org CakePHP(tm) Project
+ * @package       Cake.Controller.Component.Acl
+ * @since         CakePHP(tm) v 2.1
+ * @license       MIT License (http://www.opensource.org/licenses/mit-license.php)
+ */
+
+/**
+ * PhpAcl implements an access control system using a plain PHP configuration file. 
+ * An example file can be found in app/Config/acl.php
+ *
+ * @package Cake.Controller.Component.Acl
+ */
+class PhpAcl extends Object implements AclInterface {
+
+	const DENY = false;
+	const ALLOW = true;
+
+/**
+ * Options:
+ *  - policy: determines behavior of the check method. Deny policy needs explicit allow rules, allow policy needs explicit deny rules
+ *  - config: absolute path to config file that contains the acl rules (@see app/Config/acl.php)
+ *
+ * @var array
+ */
+	public $options = array();
+
+
+/**
+ * Aro Object
+ *
+ * @var PhpAro
+ */
+	public $Aro = null;
+
+/**
+ * Aco Object
+ * 
+ * @var PhpAco
+ */
+	public $Aco = null;
+
+
+	public function __construct() {
+		$this->options = array(
+			'policy' => self::DENY,
+			'config' => APP . 'Config' . DS . 'acl.php',
+		);
+	}
+/**
+ * Initialize method
+ * 
+ * @param AclComponent $Component Component instance 
+ * @return void
+ */
+	public function initialize($Component) {
+		if (!empty($Component->settings['adapter'])) {
+			$this->options = array_merge($this->options, $Component->settings['adapter']);
+		}
+		
+		App::uses('PhpReader', 'Configure');
+		$Reader = new PhpReader(dirname($this->options['config']) . DS);
+		$config = $Reader->read(basename($this->options['config']));
+		$this->build($config);
+		$Component->Aco = $this->Aco;
+		$Component->Aro = $this->Aro;
+	}
+
+/**
+ * build and setup internal ACL representation
+ *
+ * @param array $config configuration array, see docs
+ * @return void
+ */
+	public function build(array $config) {
+		if (empty($config['roles'])) {
+			throw new AclException(__d('cake_dev','"roles" section not found in configuration.'));
+		}
+
+		if (empty($config['rules']['allow']) && empty($config['rules']['deny'])) {
+			throw new AclException(__d('cake_dev','Neither "allow" nor "deny" rules were provided in configuration.'));
+		}
+
+		$rules['allow'] = !empty($config['rules']['allow']) ? $config['rules']['allow'] : array();
+		$rules['deny'] = !empty($config['rules']['deny']) ? $config['rules']['deny'] : array();
+		$roles = !empty($config['roles']) ? $config['roles'] : array();
+		$map = !empty($config['map']) ? $config['map'] : array();
+		$alias = !empty($config['alias']) ? $config['alias'] : array();
+
+		$this->Aro = new PhpAro($roles, $map, $alias);
+		$this->Aco = new PhpAco($rules);
+	}
+
+/**
+ * No op method, allow cannot be done with PhpAcl
+ *
+ * @param string $aro ARO The requesting object identifier.
+ * @param string $aco ACO The controlled object identifier.
+ * @param string $action Action (defaults to *)
+ * @return boolean Success
+ */
+	public function allow($aro, $aco, $action = "*") {
+		return $this->Aco->access($this->Aro->resolve($aro), $aco, $action, 'allow');
+	}
+
+/**
+ * deny ARO access to ACO
+ *
+ * @param string $aro ARO The requesting object identifier.
+ * @param string $aco ACO The controlled object identifier.
+ * @param string $action Action (defaults to *)
+ * @return boolean Success
+ */
+	public function deny($aro, $aco, $action = "*") {
+		return $this->Aco->access($this->Aro->resolve($aro), $aco, $action, 'deny');
+	}
+
+/**
+ * No op method
+ *
+ * @param string $aro ARO The requesting object identifier.
+ * @param string $aco ACO The controlled object identifier.
+ * @param string $action Action (defaults to *)
+ * @return boolean Success
+ */
+	public function inherit($aro, $aco, $action = "*") {
+		return false;
+	}
+
+/**
+ * Main ACL check function. Checks to see if the ARO (access request object) has access to the
+ * ACO (access control object).
+ *
+ * @param string $aro ARO
+ * @param string $aco ACO
+ * @param string $aco_action Action
+ * @return boolean true if access is granted, false otherwise
+ */
+	public function check($aro, $aco, $aco_action = "*") {
+		$allow = $this->options['policy'];
+		$prioritizedAros = $this->Aro->roles($aro);
+
+		if ($aco_action && $aco_action != "*") {
+			$aco .= '/' . $aco_action;
+		}
+
+		$path = $this->Aco->path($aco);	
+		
+		if (empty($path)) {
+			return $allow;
+		}
+
+		foreach ($path as $depth => $node) {
+			foreach ($prioritizedAros as $aros) {
+				if (!empty($node['allow'])) {
+					$allow = $allow || count(array_intersect($node['allow'], $aros)) > 0;
+				}
+
+				if (!empty($node['deny'])) {
+					$allow = $allow && count(array_intersect($node['deny'], $aros)) == 0;
+				}
+			}
+		}
+
+		return $allow;
+	}
+}
+
+/**
+ * Access Control Object
+ *
+ */
+class PhpAco {
+
+/**
+ * holds internal ACO representation
+ *
+ * @var array
+ */
+	protected $tree = array();
+
+/**
+ * map modifiers for ACO paths to their respective PCRE pattern
+ * 
+ * @var array
+ */
+	public static $modifiers = array(
+		'*' => '.*',
+	);
+
+	public function __construct(array $rules = array()) {
+		foreach (array('allow', 'deny') as $type) {
+			if (empty($rules[$type])) {
+				$rules[$type] = array();
+			}
+		}
+
+		$this->build($rules['allow'], $rules['deny']);
+	}
+
+/**
+ * return path to the requested ACO with allow and deny rules attached on each level
+ *
+ * @return array
+ */
+	public function path($aco) {
+		$aco = $this->resolve($aco);
+		$path = array();
+		$level = 0;
+		$root = $this->tree;	
+		$stack = array(array($root, 0));
+		
+		while (!empty($stack)) {
+			list($root, $level) = array_pop($stack);
+
+			if (empty($path[$level])) {
+				$path[$level] = array();
+			}
+
+			foreach ($root as $node => $elements) {
+				$pattern = '/^'.str_replace(array_keys(self::$modifiers), array_values(self::$modifiers), $node).'$/';
+				
+				if ($node == $aco[$level] || preg_match($pattern, $aco[$level])) {
+					// merge allow/denies with $path of current level
+					foreach (array('allow', 'deny') as $policy) {
+						if (!empty($elements[$policy])) {
+							if (empty($path[$level][$policy])) {
+								$path[$level][$policy] = array();
+							}
+
+							$path[$level][$policy] = array_merge($path[$level][$policy], $elements[$policy]);
+						}
+					}
+
+					// traverse
+					if (!empty($elements['children']) && isset($aco[$level + 1])) {
+						array_push($stack, array($elements['children'], $level + 1));
+					}
+				}	
+			}
+		}
+
+		return $path;
+	}
+
+
+/**
+ * allow/deny ARO access to ARO
+ *
+ * @return void 
+ */
+	public function access($aro, $aco, $action, $type = 'deny') {
+		$aco = $this->resolve($aco);
+		$depth = count($aco);
+		$root = $this->tree;
+		$tree = &$root;
+
+		foreach ($aco as $i => $node) {
+			if (!isset($tree[$node])) {
+				$tree[$node]  = array(
+					'children' => array(),
+				);
+			}
+
+			if ($i < $depth - 1) {
+				$tree = &$tree[$node]['children'];
+			} else {
+				if (empty($tree[$node][$type])) {
+					$tree[$node][$type] = array();
+				}
+				
+				$tree[$node][$type] = array_merge(is_array($aro) ? $aro : array($aro), $tree[$node][$type]);
+			}
+		}
+
+		$this->tree = &$root;
+	}
+
+/**
+ * resolve given ACO string to a path
+ *
+ * @param string $aco ACO string
+ * @return array path
+ */
+	public function resolve($aco) {
+		if (is_array($aco)) {
+			return array_map('strtolower', $aco);
+		}
+
+		// strip multiple occurences of '/'
+		$aco = preg_replace('#/+#', '/', $aco);
+		// make case insensitive
+		$aco = ltrim(strtolower($aco), '/');
+		return array_filter(array_map('trim', explode('/', $aco)));
+	}
+
+/**
+ * build a tree representation from the given allow/deny informations for ACO paths
+ *
+ * @param array $allow ACO allow rules
+ * @param array $deny ACO deny rules
+ * @return void 
+ */
+	public function build(array $allow, array $deny = array()) {
+		$stack = array();
+		$this->tree = array();
+		$tree = array();
+		$root = &$tree;
+
+		foreach ($allow as $dotPath => $aros) {
+			if (is_string($aros)) {
+				$aros = array_map('trim', explode(',', $aros));
+			}
+
+			$this->access($aros, $dotPath, null, 'allow');
+		}
+	
+		foreach ($deny as $dotPath => $aros) {
+			if (is_string($aros)) {
+				$aros = array_map('trim', explode(',', $aros));
+			}
+
+			$this->access($aros, $dotPath, null, 'deny');
+		}
+	}
+
+
+}
+
+/**
+ * Access Request Object
+ *
+ */
+class PhpAro {
+
+/**
+ * role to resolve to when a provided ARO is not listed in 
+ * the internal tree
+ *
+ * @var string
+ */
+	const DEFAULT_ROLE = 'Role/default';
+
+/**
+ * map external identifiers. E.g. if
+ *
+ * array('User' => array('username' => 'jeff', 'role' => 'editor')) 
+ *
+ * is passed as an ARO to one of the methods of AclComponent, PhpAcl 
+ * will check if it can be resolved to an User or a Role defined in the
+ * configuration file. 
+ * 
+ * @var array
+ * @see app/Config/acl.php
+ */
+	public $map = array(
+		'User' => 'User/username',
+		'Role' => 'User/role',
+	);
+
+/**
+ * aliases to map
+ * 
+ * @var array
+ */
+	public $aliases = array();
+
+/**
+ * internal ARO representation
+ *
+ * @var array
+ */
+	protected $tree = array();
+
+	public function __construct(array $aro = array(), array $map = array(), array $aliases = array()) {
+		if (!empty($map)) {
+			$this->map = $map;
+		}
+
+		$this->aliases = $aliases;
+		$this->build($aro);
+	}
+
+
+/**
+ * From the perspective of the given ARO, walk down the tree and
+ * collect all inherited AROs levelwise such that AROs from different
+ * branches with equal distance to the requested ARO will be collected at the same
+ * index. The resulting array will contain a prioritized list of (list of) roles ordered from 
+ * the most distant AROs to the requested one itself.
+ * 
+ * @param mixed $aro An ARO identifier
+ * @return array prioritized AROs
+ */
+	public function roles($aro) {
+		$aros = array();
+		$aro = $this->resolve($aro);
+		$stack = array(array($aro, 0));
+
+		while (!empty($stack)) {
+			list($element, $depth) = array_pop($stack);
+			$aros[$depth][] = $element;
+
+			foreach ($this->tree as $node => $children) {
+				if (in_array($element, $children)) {
+					array_push($stack, array($node, $depth + 1));
+				}
+			}
+		}
+
+		return array_reverse($aros);
+	}
+
+
+/**
+ * resolve an ARO identifier to an internal ARO string using
+ * the internal mapping information. 
+ *
+ * @param mixed $aro ARO identifier (User.jeff, array('User' => ...), etc)
+ * @return string internal aro string (e.g. User/jeff, Role/default)
+ */
+	public function resolve($aro) {
+		foreach ($this->map as $aroGroup => $map) {
+			list ($model, $field) = explode('/', $map, 2);
+			$mapped = '';
+
+			if (is_array($aro)) {
+				if (isset($aro['model']) && isset($aro['foreign_key']) && $aro['model'] == $aroGroup) {
+					$mapped = $aroGroup .  '/' . $aro['foreign_key'];
+				} elseif (isset($aro[$model][$field])) {
+					$mapped = $aroGroup . '/' . $aro[$model][$field];
+				} elseif (isset($aro[$field])) {
+					$mapped = $aroGroup . '/' . $aro[$field];
+				}
+			} elseif (is_string($aro)) {
+				$aro = ltrim($aro, '/');
+
+				if (strpos($aro, '/') === false) {
+					$mapped = $aroGroup . '/' . $aro;
+				} else {
+					list($aroModel, $aroValue) =  explode('/', $aro, 2);
+
+					$aroModel = Inflector::camelize($aroModel);
+
+					if ($aroModel == $model || $aroModel == $aroGroup) {
+						$mapped = $aroGroup . '/' . $aroValue;
+					}
+				}
+			}
+			
+			if (isset($this->tree[$mapped])) {
+				return $mapped;
+			}
+			
+			// is there a matching alias defined (e.g. Role/1 => Role/admin)?
+			if (!empty($this->aliases[$mapped])) {
+				return $this->aliases[$mapped];
+			}
+
+		}
+
+		return self::DEFAULT_ROLE;
+	}
+
+
+/**
+ * adds a new ARO to the tree
+ *
+ * @param array $aro one or more ARO records
+ * @return void
+ */
+	public function addRole(array $aro) {
+		foreach ($aro as $role => $inheritedRoles) {
+			if (!isset($this->tree[$role])) {
+				$this->tree[$role] = array();
+			}
+
+			if (!empty($inheritedRoles)) {
+				if (is_string($inheritedRoles)) {
+					$inheritedRoles = array_map('trim', explode(',', $inheritedRoles));
+				} 
+				
+				foreach ($inheritedRoles as $dependency) {
+					// detect cycles
+					$roles = $this->roles($dependency);
+				
+					if (in_array($role, Set::flatten($roles))) {
+						$path = '';
+
+						foreach ($roles as $roleDependencies) {
+							$path .= implode('|', (array)$roleDependencies) . ' -> ';
+						}
+
+						trigger_error(__d('cake_dev', 'cycle detected when inheriting %s from %s. Path: %s', $role, $dependency, $path.$role));
+						continue;
+					}
+					
+					if (!isset($this->tree[$dependency])) {
+						$this->tree[$dependency] = array();
+					}
+					
+					$this->tree[$dependency][] = $role;
+				}
+			}
+		}
+	}
+
+/**
+ * adds one or more aliases to the internal map. Overwrites existing entries.
+ *
+ * @param array $alias alias from => to (e.g. Role/13 -> Role/editor)
+ * @return void
+ */
+	public  function addAlias(array $alias) {
+		$this->aliases = array_merge($this->aliases, $alias);
+	}
+
+/**
+ * build an ARO tree structure for internal processing
+ *
+ * @param array $aros array of AROs as key and their inherited AROs as values
+ * @return void 
+ */
+	public function build(array $aros) {
+		$this->tree = array();
+		$this->addRole($aros);
+	}
+}

+ 7 - 0
lib/Cake/Error/exceptions.php

@@ -386,6 +386,13 @@ class MissingPluginException extends CakeException {
 }
 
 /**
+ * Exception class for AclComponent and Interface implementations. 
+ *
+ * @package       Cake.Error
+ */
+class AclException extends CakeException { }
+
+/**
  * Exception class for Cache.  This exception will be thrown from Cache when it
  * encounters an error.
  *

+ 347 - 0
lib/Cake/Test/Case/Controller/Component/Acl/PhpAclTest.php

@@ -0,0 +1,347 @@
+<?php
+/**
+ * PhpAclTest file.
+ *
+ * PHP 5
+ *
+ * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
+ * Copyright 2005-2011, Cake Software Foundation, Inc. (http://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright 2005-2011, Cake Software Foundation, Inc. (http://cakefoundation.org)
+ * @link          http://cakephp.org CakePHP(tm) Project
+ * @package       Cake.Test.Case.Controller.Component.Acl
+ * @since         CakePHP(tm) v 2.1
+ * @license       MIT License (http://www.opensource.org/licenses/mit-license.php)
+ */
+
+App::uses('AclComponent', 'Controller/Component');
+App::uses('PhpAcl', 'Controller/Component/Acl');
+class_exists('AclComponent');
+
+/**
+ * Test case for the PhpAcl implementation
+ *
+ * @package       Cake.Test.Case.Controller.Component.Acl
+ */
+class PhpAclTest extends CakeTestCase {
+
+	public function setUp() {
+		Configure::write('Acl.classname', 'PhpAcl');
+		$Collection = new ComponentCollection();
+		$this->PhpAcl = new PhpAcl();
+		$this->Acl = new AclComponent($Collection, array(
+			'adapter' => array(
+				'config' => CAKE . 'Test' . DS . 'test_app' . DS . 'Config'. DS . 'acl.php',
+			),
+		));
+	}
+
+
+	public function testRoleInheritance() {
+		$roles = $this->Acl->Aro->roles('User/peter');
+		$this->assertEquals(array('Role/accounting'), $roles[0]);
+		$this->assertEquals(array('User/peter'), $roles[1]);
+
+		$roles = $this->Acl->Aro->roles('hardy');
+		$this->assertEquals(array('Role/database_manager', 'Role/data_acquirer'), $roles[0]);
+		$this->assertEquals(array('Role/accounting', 'Role/data_analyst'), $roles[1]);
+		$this->assertEquals(array('Role/accounting_manager', 'Role/reports'), $roles[2]);
+		$this->assertEquals(array('User/hardy'), $roles[3]);
+	}
+
+
+	public function testAddRole() {
+		$this->assertEquals(array(array(PhpAro::DEFAULT_ROLE)), $this->Acl->Aro->roles('foobar'));
+		$this->Acl->Aro->addRole(array('User/foobar' => 'Role/accounting'));
+		$this->assertEquals(array(array('Role/accounting'), array('User/foobar')), $this->Acl->Aro->roles('foobar'));
+	}
+
+
+	public function testAroResolve() {
+		$map = $this->Acl->Aro->map;
+		$this->Acl->Aro->map = array(
+			'User' => 'FooModel/nickname',
+			'Role' => 'FooModel/role',
+		);
+
+		$this->assertEquals('Role/default', $this->Acl->Aro->resolve('Foo.bar'));
+		$this->assertEquals('User/hardy', $this->Acl->Aro->resolve('FooModel/hardy'));
+		$this->assertEquals('User/hardy', $this->Acl->Aro->resolve('hardy'));
+		$this->assertEquals('User/hardy', $this->Acl->Aro->resolve(array('FooModel' => array('nickname' => 'hardy'))));
+		$this->assertEquals('Role/admin', $this->Acl->Aro->resolve(array('FooModel' => array('role' => 'admin'))));
+		$this->assertEquals('Role/admin', $this->Acl->Aro->resolve('Role/admin'));
+		
+		$this->assertEquals('Role/admin', $this->Acl->Aro->resolve('admin'));
+		$this->assertEquals('Role/admin', $this->Acl->Aro->resolve('FooModel/admin'));
+		$this->assertEquals('Role/accounting', $this->Acl->Aro->resolve('accounting'));
+
+		$this->assertEquals(PhpAro::DEFAULT_ROLE, $this->Acl->Aro->resolve('bla'));
+		$this->assertEquals(PhpAro::DEFAULT_ROLE, $this->Acl->Aro->resolve(array('FooModel' => array('role' => 'hardy'))));
+	}
+
+/**
+ * test correct resolution of defined aliases
+ */
+	public function testAroAliases() {
+		$this->Acl->Aro->map = array(
+			'User' => 'User/username',
+			'Role' => 'User/group_id',
+		);
+
+		$this->Acl->Aro->aliases = array(
+			'Role/1' => 'Role/admin',
+			'Role/24' => 'Role/accounting',	
+		);
+
+		$user = array(
+			'User' => array(
+				'username' => 'unknown_user',
+				'group_id' => '1',
+			),
+		);
+		// group/1
+		$this->assertEquals('Role/admin', $this->Acl->Aro->resolve($user));
+		// group/24
+		$this->assertEquals('Role/accounting', $this->Acl->Aro->resolve('Role/24'));
+		$this->assertEquals('Role/accounting', $this->Acl->Aro->resolve('24'));
+
+		// check department
+		$user = array(
+			'User' => array(
+				'username' => 'foo',
+				'group_id' => '25',
+			),
+		);
+
+		$this->Acl->Aro->addRole(array('Role/IT' => null));
+		$this->Acl->Aro->addAlias(array('Role/25' => 'Role/IT'));
+		$this->Acl->allow('Role/IT', '/rules/debugging/*');
+
+		$this->assertEquals(array(array('Role/IT', )), $this->Acl->Aro->roles($user));
+		$this->assertTrue($this->Acl->check($user, '/rules/debugging/stats/pageload'));
+		$this->assertTrue($this->Acl->check($user, '/rules/debugging/sql/queries'));
+		// Role/default is allowed users dashboard, but not Role/IT
+		$this->assertFalse($this->Acl->check($user, '/controllers/users/dashboard'));
+
+		$this->assertFalse($this->Acl->check($user, '/controllers/invoices/send'));
+		// wee add an more specific entry for user foo to also inherit from Role/accounting 
+		$this->Acl->Aro->addRole(array('User/foo' => 'Role/IT, Role/accounting'));
+		$this->assertTrue($this->Acl->check($user, '/controllers/invoices/send'));
+	}
+
+
+/**
+ * test check method
+ *
+ * @return void
+ */
+	public function testCheck() {
+		$this->assertTrue($this->Acl->check('jan', '/controllers/users/Dashboard'));
+		$this->assertTrue($this->Acl->check('some_unknown_role', '/controllers/users/Dashboard'));
+		$this->assertTrue($this->Acl->check('Role/admin', 'foo/bar'));
+		$this->assertTrue($this->Acl->check('role/admin', '/foo/bar'));
+		$this->assertTrue($this->Acl->check('jan', 'foo/bar'));
+		$this->assertTrue($this->Acl->check('user/jan', 'foo/bar'));
+		$this->assertTrue($this->Acl->check('Role/admin', 'controllers/bar'));
+		$this->assertTrue($this->Acl->check(array('User' => array('username' =>'jan')), '/controlers/bar/bll'));
+		$this->assertTrue($this->Acl->check('Role/database_manager', 'controllers/db/create'));
+		$this->assertTrue($this->Acl->check('User/db_manager_2', 'controllers/db/create'));
+		$this->assertFalse($this->Acl->check('db_manager_2', '/controllers/users/Dashboard'));
+
+		// inheritance: hardy -> reports -> data_analyst -> database_manager
+		$this->assertTrue($this->Acl->check('User/hardy', 'controllers/db/create'));
+		$this->assertFalse($this->Acl->check('User/jeff', 'controllers/db/create'));
+
+		$this->assertTrue($this->Acl->check('Role/database_manager', 'controllers/db/select'));
+		$this->assertTrue($this->Acl->check('User/db_manager_2', 'controllers/db/select'));
+		$this->assertFalse($this->Acl->check('User/jeff', 'controllers/db/select'));
+
+		$this->assertTrue($this->Acl->check('Role/database_manager', 'controllers/db/drop'));
+		$this->assertTrue($this->Acl->check('User/db_manager_1', 'controllers/db/drop'));
+		$this->assertFalse($this->Acl->check('db_manager_2', 'controllers/db/drop'));
+
+		$this->assertTrue($this->Acl->check('db_manager_2', 'controllers/invoices/edit'));
+		$this->assertFalse($this->Acl->check('database_manager', 'controllers/invoices/edit'));
+		$this->assertFalse($this->Acl->check('db_manager_1', 'controllers/invoices/edit'));
+
+		// Role/manager is allowed /controllers/*/*_manager
+		$this->assertTrue($this->Acl->check('stan', 'controllers/invoices/manager_edit'));
+		$this->assertTrue($this->Acl->check('Role/manager', 'controllers/baz/manager_foo'));
+		$this->assertFalse($this->Acl->check('User/stan', 'custom/foo/manager_edit'));
+		$this->assertFalse($this->Acl->check('stan', 'bar/baz/manager_foo'));
+		$this->assertFalse($this->Acl->check('Role/accounting', 'bar/baz/manager_foo'));
+		$this->assertFalse($this->Acl->check('accounting', 'controllers/baz/manager_foo'));
+
+		$this->assertTrue($this->Acl->check('User/stan', 'controllers/articles/edit'));
+		$this->assertTrue($this->Acl->check('stan', 'controllers/articles/add'));
+		$this->assertTrue($this->Acl->check('stan', 'controllers/articles/publish'));
+		$this->assertFalse($this->Acl->check('User/stan', 'controllers/articles/delete'));
+		$this->assertFalse($this->Acl->check('accounting', 'controllers/articles/edit'));
+		$this->assertFalse($this->Acl->check('accounting', 'controllers/articles/add'));
+		$this->assertFalse($this->Acl->check('role/accounting', 'controllers/articles/publish'));
+	}
+
+
+/**
+ * lhs of defined rules are case insensitive
+ */
+	public function testCheckIsCaseInsensitive() {
+		$this->assertTrue($this->Acl->check('hardy', 'controllers/forms/new'));
+		$this->assertTrue($this->Acl->check('Role/data_acquirer', 'controllers/forms/new'));
+		$this->assertTrue($this->Acl->check('hardy', 'controllers/FORMS/NEW'));
+		$this->assertTrue($this->Acl->check('Role/data_acquirer', 'controllers/FORMS/NEW'));
+	}
+
+
+/**
+ * allow should work in-memory
+ */
+	public function testAllow() {
+		$this->assertFalse($this->Acl->check('jeff', 'foo/bar'));
+
+		$this->Acl->allow('jeff', 'foo/bar');
+
+		$this->assertTrue($this->Acl->check('jeff', 'foo/bar'));
+		$this->assertFalse($this->Acl->check('peter', 'foo/bar'));
+		$this->assertFalse($this->Acl->check('hardy', 'foo/bar'));
+
+		$this->Acl->allow('Role/accounting', 'foo/bar');
+
+		$this->assertTrue($this->Acl->check('peter', 'foo/bar'));
+		$this->assertTrue($this->Acl->check('hardy', 'foo/bar'));
+
+		$this->assertFalse($this->Acl->check('Role/reports', 'foo/bar'));
+	}
+
+
+/**
+ * deny should work in-memory
+ */
+	public function testDeny() {
+		$this->assertTrue($this->Acl->check('stan', 'controllers/baz/manager_foo'));
+
+		$this->Acl->deny('stan', 'controllers/baz/manager_foo');
+
+		$this->assertFalse($this->Acl->check('stan', 'controllers/baz/manager_foo'));
+		$this->assertTrue($this->Acl->check('Role/manager', 'controllers/baz/manager_foo'));
+		$this->assertTrue($this->Acl->check('stan', 'controllers/baz/manager_bar'));
+		$this->assertTrue($this->Acl->check('stan', 'controllers/baz/manager_foooooo'));
+	}
+
+
+/**
+ * test that a deny rule wins over an equally specific allow rule
+ */
+	public function testDenyRuleIsStrongerThanAllowRule() {	
+		$this->assertFalse($this->Acl->check('peter', 'baz/bam'));
+		$this->Acl->allow('peter', 'baz/bam');
+		$this->assertTrue($this->Acl->check('peter', 'baz/bam'));
+		$this->Acl->deny('peter', 'baz/bam');
+		$this->assertFalse($this->Acl->check('peter', 'baz/bam'));
+
+		$this->assertTrue($this->Acl->check('stan', 'controllers/reports/foo'));
+		// stan is denied as he's sales and sales is denied /controllers/*/delete
+		$this->assertFalse($this->Acl->check('stan', 'controllers/reports/delete'));
+		$this->Acl->allow('stan', 'controllers/reports/delete');
+		$this->assertFalse($this->Acl->check('Role/sales', 'controllers/reports/delete'));
+		$this->assertTrue($this->Acl->check('stan', 'controllers/reports/delete'));
+		$this->Acl->deny('stan', 'controllers/reports/delete');
+		$this->assertFalse($this->Acl->check('stan', 'controllers/reports/delete'));
+
+		// there is already an equally specific deny rule that will win
+		$this->Acl->allow('stan', 'controllers/reports/delete');
+		$this->assertFalse($this->Acl->check('stan', 'controllers/reports/delete'));
+	}
+
+
+/**
+ * test that an invalid configuration throws exception
+ */
+	public function testInvalidConfigWithAroMissing() {
+		$this->setExpectedException(
+			'AclException',
+			'"roles" section not found in configuration'
+		);
+		$config = array('aco' => array('allow' => array('foo' => '')));
+		$this->PhpAcl->build($config);
+	}
+
+	
+	public function testInvalidConfigWithAcosMissing() {
+		$this->setExpectedException(
+			'AclException',
+			'Neither "allow" nor "deny" rules were provided in configuration.'
+		);
+
+		$config = array(
+			'roles' => array('Role/foo' => null),
+		);
+
+		$this->PhpAcl->build($config);
+	}
+
+/**
+ * test resolving of ACOs
+ */
+	public function testAcoResolve() {
+		$this->assertEquals(array('foo', 'bar'), $this->Acl->Aco->resolve('foo/bar'));
+		$this->assertEquals(array('foo', 'bar'), $this->Acl->Aco->resolve('foo/bar'));
+		$this->assertEquals(array('foo', 'bar', 'baz'), $this->Acl->Aco->resolve('foo/bar/baz'));
+		$this->assertEquals(array('foo', '*-bar', '?-baz'), $this->Acl->Aco->resolve('foo/*-bar/?-baz'));
+		
+		$this->assertEquals(array('foo', 'bar', '[a-f0-9]{24}', '*_bla', 'bla'), $this->Acl->Aco->resolve('foo/bar/[a-f0-9]{24}/*_bla/bla'));
+
+		// multiple slashes will be squashed to a single, trimmed and then exploded
+		$this->assertEquals(array('foo', 'bar'), $this->Acl->Aco->resolve('foo//bar'));
+		$this->assertEquals(array('foo', 'bar'), $this->Acl->Aco->resolve('//foo///bar/'));
+		$this->assertEquals(array('foo', 'bar'), $this->Acl->Aco->resolve('/foo//bar//'));
+		$this->assertEquals(array('foo', 'bar'), $this->Acl->Aco->resolve('/foo // bar'));
+		$this->assertEquals(array(), $this->Acl->Aco->resolve('/////'));
+	}
+
+/**
+ * test that declaring cyclic dependencies should give an error when building the tree
+ */
+	public function testAroDeclarationContainsCycles() {
+		$config = array(
+			'roles' => array(
+				'Role/a' => null,
+				'Role/b' => 'User/b',
+				'User/a' => 'Role/a, Role/b',
+				'User/b' => 'User/a',
+
+			),
+			'rules' => array(
+				'allow' => array(
+					'*' => 'Role/a',
+				),
+			),	
+		);
+
+		$this->expectError('PHPUnit_Framework_Error', 'cycle detected' /* ... */);
+		$this->PhpAcl->build($config);
+	}
+
+
+/**
+ * test that with policy allow, only denies count
+ */
+	public function testPolicy() {
+		// allow by default
+		$this->Acl->settings['adapter']['policy'] = PhpAcl::ALLOW;
+		$this->Acl->adapter($this->PhpAcl);
+
+		$this->assertTrue($this->Acl->check('Role/sales', 'foo'));
+		$this->assertTrue($this->Acl->check('Role/sales', 'controllers/bla/create'));
+		$this->assertTrue($this->Acl->check('Role/default', 'foo'));
+		// undefined user, undefined aco
+		$this->assertTrue($this->Acl->check('foobart', 'foo/bar'));
+
+		// deny rule: Role.sales -> controllers.*.delete
+		$this->assertFalse($this->Acl->check('Role/sales', 'controllers/bar/delete'));
+		$this->assertFalse($this->Acl->check('Role/sales', 'controllers/bar', 'delete'));
+	}
+}

+ 74 - 0
lib/Cake/Test/test_app/Config/acl.php

@@ -0,0 +1,74 @@
+<?php
+/*
+ * Test App PHP Based Acl Config File
+ *
+ *
+ * PHP 5
+ *
+ * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
+ * Copyright 2005-2011, Cake Software Foundation, Inc. (http://cakefoundation.org)
+ *
+ *  Licensed under The MIT License
+ *  Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright 2005-2011, Cake Software Foundation, Inc. (http://cakefoundation.org)
+ * @link          http://cakephp.org CakePHP(tm) Project
+ * @package       Cake.Test.test_app.Config
+ * @since         CakePHP(tm) v 0.10.0.1076
+ * @license       MIT License (http://www/opensource/org/licenses/mit-license.php)
+ */
+
+
+// -------------------------------------
+// Roles
+// -------------------------------------
+$config['roles'] = array(
+	'Role/admin' 				=> null,
+	'Role/data_acquirer' 		=> null,
+	'Role/accounting' 			=> null,
+	'Role/database_manager'		=> null,
+	'Role/sales' 				=> null,
+	'Role/data_analyst' 		=> 'Role/data_acquirer, Role/database_manager',
+	'Role/reports' 				=> 'Role/data_analyst',
+	// allow inherited roles to be defined as an array or comma separated list
+	'Role/manager' 				=> array(
+		'Role/accounting',
+		'Role/sales',
+	),
+	'Role/accounting_manager' 	=> 'Role/accounting',
+	// managers
+	'User/hardy'				=> 'Role/accounting_manager, Role/reports',
+	'User/stan' 				=> 'Role/manager',
+	// accountants
+	'User/peter' 				=> 'Role/accounting',
+	'User/jeff' 				=> 'Role/accounting',
+	// admins
+	'User/jan'					=> 'Role/admin',
+	// database
+	'User/db_manager_1' 		=> 'Role/database_manager',
+	'User/db_manager_2'			=> 'Role/database_manager',
+);
+
+//-------------------------------------
+// Rules
+//-------------------------------------
+$config['rules']['allow'] = array(
+	'/*' 						=> 'Role/admin',
+	'/controllers/*/manager_*'	=> 'Role/manager',
+	'/controllers/reports/*' 	=> 'Role/sales',
+	'/controllers/invoices/*' 	=> 'Role/accounting',
+	'/controllers/invoices/edit'=> 'User/db_manager_2',
+	'/controllers/db/*'			=> 'Role/database_manager',
+	'/controllers/*/(add|edit|publish)'		=> 'User/stan',
+	'/controllers/users/dashboard' => 'Role/default',
+	// test for case insensitivity
+	'controllers/Forms/NEW' 	=> 'Role/data_acquirer',
+);
+$config['rules']['deny'] = array(
+	// accountants and sales should not delete anything
+	'/controllers/*/delete' 	=> array(
+		'Role/sales',
+		'Role/accounting'
+	),
+	'/controllers/db/drop'  	=> 'User/db_manager_2',
+);