Extending Zend_Acl to support custom roles and resources

Date November 29, 2007

Few days ago I played a little with the Zend_Acl package, which provides lightweight and flexible access control list functionality and privileges management. It was very easy to use, but I found that the base Zend_Acl package has some limitation/problem if you want to use it in a bigger real life project. Zend_Acl supports only logical roles, resources so I decided to extend it to allow using custom roles and resources which can represent existing entities (for example users/groups and topics in a database). You will find the full source code and sample sql dump in the end of the post.

Zend_Acl

The package provides classical ACL functionality. There are roles, resources and privileges, and you can assign them to each other via deny and allow rules. For example you can say that XY user(role) is allowed to moderate(privilege) the PHP topic(resource) in a forum. You don’t have to always specify all the three part, for example you can say, that administrator can moderate any topic.

I won’t introduce the Zend_Acl package, it has an easy to understand manual page. Some observation which could be interesting related to this post:

  • There is no separate user and group in Zend_Acl, the concept of role includes both of them. A role can have multiple parents, so roles compose a directed graph.
  • Resources can have only one parent so they compose a tree.

Zend_Acl limitations/problems

Consider a forum where you have a lot of users/groups and a lot of resources (topics). You have have to add all of them (manually one at a time) to the Acl object which is very memory and time consuming task. Of course once you build the Acl object you can save it (in a serialized form) and reuse it next time but it will eat a lot of memory too. To make this clear check the following code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
$acl = new Zend_Acl();

$acl->addRole(new Zend_Acl_Role('guest'))
    ->addRole(new Zend_Acl_Role('member'))
    ->addRole(new Zend_Acl_Role('admin'));

$parents = array('guest', 'member', 'admin');
$acl->addRole(new Zend_Acl_Role('someUser'), $parents);

$acl->add(new Zend_Acl_Resource('someResource'));

$acl->deny('guest', 'someResource');
$acl->allow('member', 'someResource');

echo $acl->isAllowed('someUser', 'someResource') ? 'allowed' : 'denied';
?>

You can see that if I want to reference a role or a resource in the allow/deny method, first I have to add it to the Acl object.

My other problem is that the Zend_Acl doesn’t support different type of roles/resources. Of course you can use a prefix, but maybe different roles/resources should be handled in a different way.

I wanted to come up with a solution where I have to define only the access rules and the Acl automatically checks if the referenced entity exists and automatically handles its relations (parent[s]), something like this:

1
2
3
4
5
6
7
8
<?php
$acl = new Zend_Acl();

$acl->deny('guest', 'someResource');
$acl->allow('members', 'otherResource');

echo $acl->isAllowed('someUser', 'someResource') ? 'allowed' : 'denied';
?>

You may have immeditaley a problem with this code: how can we distinguish the different roles/resources? Yes, we need one more step to have enough felxibility:
$acl = new Zend_Acl();

$acl->deny(‘group:guest’, ‘topic:membersonly’);

echo $acl->isAllowed(‘user:someUser’, ‘topic:membersonly’) ? ‘allowed’ : ‘denied’;
?>
With this notation we can have any type of roles and resources. In a typical application you will have a user and a group roles, but you can have for example an ldapuser too.

Foo_Acl

After a little planning I find out the following structure:
Foo_Acl class diagram

Zend_Acl has a RoleRegistry but I needed more functionality so I extended it. In Zend_Acl there is no ResourceRegistry, so I added to the Foo_Acl package, its functionality is very similar to the RoleRegistry. For both of them I defined an interface, they can handle any role/resource which implements it. This interfaces extends the proper interfaces in the Zend_Acl package, and defines some more basic functionality: getting the relation(s) of the role/resource and loading it. For example the Foo_Acl_Role_Interface is the following:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php
/**
 * Interface of custom roles, Foo_Acl_Role_Registry can handle an object which implements it.
 *  
 * @category   Foo
 * @package    Foo_Acl
 */

interface Foo_Acl_Role_Interface extends Zend_Acl_Role_Interface
{
    /**
     * Returns the array of the identifier of th parent roles
     *
     * @return array
     */

    public function getParents();
   
    /**
     * Returns the specified role if it exists or null.
     *
     * @return Foo_Acl_Role_Interface
     */

    public static function load($resourceId);
}
?>

The functionality of different roles/resources is very similar, so I created a default implementation for both of this interfaces, and you can easily create custom ones by extending it. The default implementation of the Foo_Acl_Role_Interface is the following:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
<?php
/**
 * The base implementation of Foo_Acl_Role_Interface, custom roles can extend it.
 *
 * @category   Foo
 * @package    Foo_Acl
 */

class Foo_Acl_Role_CustomBase implements Foo_Acl_Role_Interface
{
    /**
     * Unique id of Role
     *
     * @var string
     */

    protected $_roleType;
   
    /**
     * Unique id of Role
     *
     * @var string
     */

    protected $_roleId;
   
    /**
     * Array of the identifiers of the Role's parents
     */

    protected $_parents;

    /**
     * Sets the Role data
     *
     * @param  string $id
     * @param  array $parents
     *
     * @return void
     */

    public function __construct($roleId, $parents = array())
    {
        $this->_roleId = (string) $roleId;
        $this->_parents = $parents;
       
        $className = get_class($this);
        $this->_roleType = strtolower(substr($className, strrpos($className, '_')+1));
    }

    /**
     * Defined by Zend_Acl_Role_Interface; returns the Role identifier
     *
     * @return string
     */

    public function getRoleId()
    {
        return $this->_roleType.':'.$this->_roleId;
    }

    /**
     * Defined by Foo_Acl_Role_Interface; returns the Role's parents
     *
     * @return array
     */

    public function getParents()
    {
        return $this->_parents;
    }
   
    /**
     * Returns the specified role if it exists or null.
     *
     * @return Foo_Acl_Role_Interface
     */

    public static function load($roleId)
    {
        return null;
    }
}
?>

If you want to create a custom role you should extend this class and overwrite the load function which responsibility is to check if the specified role exists and to instantiate the proper role object. I wrote some sample custom rules, the following is for representing users:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
<?php
/**
 * Custom role representing users in the forum.
 *
 * @category   Foo
 * @package    Foo_Acl
 */

class Foo_Acl_Role_Custom_User extends Foo_Acl_Role_CustomBase
{
    /**
     * Returns the specified role if it exists or null.
     *
     * @return Foo_Acl_Role_Interface
     */

    public static function load($roleId)
    {
        // For more flexibility we support to refer a role with its name.
        $lookUpByName = !ctype_digit($roleId);

//        if ($cache = Foo_Cache_Factory::getInstance()) {
//            if ($lookUpByName) {
//                if ($storedRoleId = $cache->fetch('acl.role.user.name2id.'.$roleId)) {
//                    $roleId = $storedRoleId;
//                }
//            }
//            
//            if ($storedRole = $cache->fetch('acl.role.user.obj.'.$roleId)) {
//                return  $storedRole;
//            }
//        }

        $query = $lookUpByName ?
            'SELECT u.user_id, g.group_id FROM user u LEFT JOIN user2group g ON u.user_id = g.user_id WHERE u.nickname = ?'
            :
            'SELECT u.user_id, g.group_id FROM user u LEFT JOIN user2group g ON u.user_id = g.user_id WHERE u.user_id = ?';
           
        $rows = $GLOBALS['DB']->getAll($query, array($roleId), DB_FETCHMODE_ASSOC);
       
        if ($rows) {
            $parents = array();
            // If the user belongs to some groups we set them as its parents.
            if (!is_null($rows[0]['group_id'])) {
                foreach ($rows as $row) {
                    $parents[] = Foo_Acl_Role_Custom_Group::load($row['group_id']);
                }
            }
           
            if ($lookUpByName) {
//                if ($cache) {
//                    $cache->store('acl.role.user.name2id.'.$roleId, $row['user_id'], 1800);
//                }

                $roleId = $row['user_id'];
            }
           
            $role = new self($roleId, $parents);
           
//            if ($cache) {
//                $cache->store('acl.role.user.obj.'.$roleId, $role, 1800);
//            }
           
            return $role;
        } else {
            return null;
        }
    }
}
?>

You can see it is very straightforward to create a new role. I commented out the caching codes because I am in changing the cache implementations and I could not put it to sample code.

The Foo_Acl package provide an effective and convenient way to handle a huge number of entity:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<?php

//insert into topic values(1, null);
//insert into topic values(2, null);
//insert into topic values(3, null);
//insert into topic values(4, 1);
//insert into topic values(5, 2);
//insert into topic values(6, 4);
//insert into user_group values(1, 'group1');
//insert into user_group values(2, 'group2');
//insert into user values(1, 'user1');
//insert into user values(2, 'user2');
//insert into user values(3, 'user3');
//insert into user values(4, 'user4');
//insert into user2group values (1,1);
//insert into user2group values (2,1);
//insert into user2group values (3,2);
//insert into user2group values (4,2);

$acl = new Foo_Acl();

$acl->allow('user:1', 'dummy:foo');
var_dump($acl->isAllowed('user:1', 'dummy:foo')); // true
var_dump($acl->isAllowed('user:1', 'dummy:bar')); // flase

$acl->allow('group:group1', 'dummy:coverpage');
var_dump($acl->isAllowed('user:user2', 'dummy:coverpage')); // true
var_dump($acl->isAllowed('user:user3', 'dummy:coverpage')); // flase

$acl->allow('group:group1', 'hierarchy:1');
var_dump($acl->isAllowed('user:1', 'hierarchy:4')); // true
var_dump($acl->isAllowed('user:2', 'hierarchy:6')); // true
var_dump($acl->isAllowed('user:1', 'hierarchy:2')); // flase
?>

I tested it on the database of a bigger forum and it worked very well. You can download the sample code here.

12 Responses to “Extending Zend_Acl to support custom roles and resources”

  1. developercast.com » Gergely Hodicska’s Blog: Extending Zend_Acl to support custom roles and resources said:

    [...] Hodicska has posted about some hacking he’s down with the Zend_Acl package in the Zend Framework to make support for [...]

  2. Wim Godden said:

    Although this does help in offering more features, I think the biggest problem is still that you have to manually do
    $acl->allow(‘user:1′, ‘dummy:foo’);

    which means defining a lot of roles and resources that might not even apply to the user that’s currently accessing the site.

  3. Joshua Ross said:

    I find a lot of problems with your assessment of Zend_Acl. I personally found it to be a robust enough tool to handle a very complicated ACL at my previous company. Through the use of an Override Assertion I was able to provide the same granular access control as you show without mixing and intermingling the concept of the controller layer with the model layer. I think you have subverted all the power of the inheritance that is native to Zend_Acl. The sacrifice of which, I would contend, is a performance hit for which it appears you are implementing extensive caching.

    I would additionally point out that you are incorrect about no resource registry, Zend_Acl itself is a resource registry which contains a role registry object.

    Using an override assertion you only load your Zend_Acl object with roles and resources. Cache/Serialize that object all you want. This keeps the object very lightweight and utilizes its inheritance capabilities. The only time you need granular control is when a user is denied access to a resource. At that point is when the override assertion would be called and you perform a query to an overrides table using the user’s id instead of the role that was checked against the ACL. You of course cache the result.

    Using this approach you eliminate all the extensions you wrote and you instead query a model for an override and then check that against the ACL. All of which can be done in about 10 lines of code.

    I would further contend that most forums are comprised of boards containing topics and access control is normally granted to a group(role) to a board(resource) and not at a topic level.

    Although it looks like you put some thought into your approach, I think the better approach is using the built in assertion capability of Zend_Acl. None the less, I enjoy your blog so keep up the posts! =]

  4. Psychic Advice said:

    Thanks for the great info. I hope you’ll follow this with some more great content.

  5. ang said:

    Very good article, I will try your code into my app that is being developed. Thanx for sharing.

  6. Import from China said:

    I came across this blog the other day and you got some great info here – thanks.

  7. Cesar B. aka the Mover said:

    This Blog reminds me the reason I like bloging so much, the interaction is very important with readers and you guys have it right. Looks great too, will be back for more posts, David the mover. : – )

  8. Tim Reynolds said:

    Nice post. Thank you for the info. Keep it up.

  9. Willem Luijk said:

    Hi Gergely,

    You know roles times resources can grow quite large. Is it in your solution possible to read only the needed combinations when testing if access to a resource need to be evaluated?

  10. Snef said:

    Hi. I know, it is an old entry but i just got to start playing with it. Solution looks nice but i’m having some trouble to alter the code so that is can work with ‘group-parenting’.

    I want to be able to define groups that inherit from other groups. When a user/group has been found, the parents should also be entered in the acl (like Zend_Acl will work). Ever thought of such a solution?
    (It would be nice if it could do both… assigning users to different groups and that the groups could inherit from other groups..)

  11. Terra said:

    you make a good point -

  12. Snef said:

    Also, should i only use $acl->allow and ‘forget’ the $acl->deny?
    Imagine a user (user:1) is bound to group1 and 3.

    $acl->allow(‘group:1′, ‘dummy:foo’);
    $acl->deny(‘group:3′, ‘dummy:foo’);

    $this->isAllowed(‘user:1′, ‘dummy:foo’); // returns false?

    Yes, it was denied because of membership of group3, but the user is allowed by membership of group1. So the user should be allowed, isn’t it?

    I know, in this example I just could remove the deny rule, but it is just an example!

Leave a Reply

XHTML: You can use these tags: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>