Amazium bvba, your online partner
Content-driven Access Control with Zend ACL
  • Share post with Twitter
  • Share post with StumbleUpon
  • Share post with Delicious
  • Share post with Digg
  • Share post with Technorati
  • Share post with Blinklist
2010-06-11 02:20

Content-driven Access Control with Zend ACL

zend_acl, zend framework

In my previous blog post Adding Context to your ACL I talked about adding "state" to your ACL. David Zuelke made some good comments about why this approach was not OK. I went back to the drawing board and came up with a new, cleaner answer to the problem.

Use case

The use case I have in mind at the moment of writing this post, is a news site that offers free and paid articles. An user is either a visitor (registered, but without subscription to the site) or a subscriber. While a subscriber can access all articles, the visitor can only access free articles and articles he bought directly. If he wants to read another article he has to pay.

Initially I explained layer by layer (starting at DB and moving upwards), but I have changed the structure of the blog post to start with the most relevant information: I first explain how the ACL classes look, then move to the demo code to show what happens and after that I explain the models and all other classes you'll need to try it yourself.

ACL classes

I have 2 ACL classes. One is the actual acl class, extending from Zend_Acl and the other is an assertion class. I have located these classes under application/acls and application/acls/Assert respectively. If you want to know how to load these (App_Acl_*) see at the end under "Miscellaneous stuff".

Our ACL class

So let's start with the ACL class. As mentioned before it extends Zend_Acl. And in the constructor we define our roles, resources and access rules. Our user model implements 2 roles: visitor and subscriber. When adding the subscriber we make sure it extends from the visitor role. Our article class implements 2 resources: free-article and charged-article. I added a third resource article that is a parent for both. Last but not least we define our access rules:

1. A subscriber has access to all articles
2. A visitor has access to all free articles
3. A visitor has access to all non-free articles that he paid for

The first 2 rules are quite straight forward. For the third we added an assertion to the rule called App_Acl_Assert_UserOwned. It will basically assert that the resource is owned by the user that is currently logged in. Our ACL and Assertion classes look like this:

class App_Acl_Acl extends Zend_Acl
{
    public function 
__construct()
    {
        
// Roles
        
$this->addRole('visitor');
        
$this->addRole('subscriber''visitor');
        
        
// Resourcess
        
$this->addResource('article');
        
$this->addResource('free-article''article');
        
$this->addResource('charged-article''article');
        
        
// Access rules
        
$this->allow('subscriber''article');
        
$this->allow('visitor''free-article');
        
$this->allow('visitor''charged-article'null, new App_Acl_Assert_UserOwned());
    }
}

class 
App_Acl_Assert_UserOwned implements Zend_Acl_Assert_Interface
{
    public function 
assert(Zend_Acl $acl
                           
Zend_Acl_Role_Interface $role null
                           
Zend_Acl_Resource_Interface $resource null
                           
$privilege null)
    {   
        
// First we need a good Resource type
        
if (!$resource instanceof App_Model_UserOwnedInterface) {
            throw new 
Exception('UserOwnedInterface not implemented');
        }
        
        
// Secondly, we need a authenticated user
        
$auth Zend_Auth::getInstance();
        if (!
$auth->hasIdentity()) {
            return 
false;
        }
        
$user = new App_Model_User($auth->getIdentity());
        
        
// Then do the check
        /** @var App_Model_UserOwnedInterface $resource */
        
return $resource->isOwnedByUser($user);
    }
}

As you can see the assertion class first makes sure that the resource has the App_Model_UserOwnedInterface interface implemented. This allows us to reuse this for other purposes and do a check that this assertion class isn't used on an incompatible resource. Then it checks we have a logged in user and if we do it calls the isOwnedByUser($user) function on the resource. It's that simple!

Demo Code

You can believe my word for it that it works, or you can create a simple test. Below you find an IndexController and index.phtml file where I loop over all the users and articles and draw a small table with who has access on what article. As you will see, the visitor resource has access on one of the non-free articles.

The controller

class IndexController extends Zend_Controller_Action
{

    public function 
indexAction()
    {
        
// Users
        
$repUsers = new App_Model_Repository_User();
        
$users $repUsers->fetchAll();
        
        
// Articles
        
$repArticles = new App_Model_Repository_Article();
        
$articles $repArticles->fetchAll();
        
        
// Get our ACL
        
$acl = new App_Acl_Acl();
        
        
// Loop over our users and articles and see if they have access
        
$access = array();
        for (
$j 0$j count($users); $j++) {
            
// Make ZF think he has authenticated a user
            
Zend_Auth::getInstance()->getStorage()->write($users[$j]->toArray());
            
// Loop over the articles and check access
            
for ($i 0$i count($articles); $i++) {
                
$access[] = array(
                    
'user'    => $users[$j]->getUsername(),
                    
'article' => $articles[$i]->getTitle(),
                    
'allowed' => $acl->isAllowed($users[$j], $articles[$i]) 
                );
            }
        }
        
$this->view->assign('access'$access);
    }
}

The view

<table border="1">
    <tr>
        <th>User</th>
        <th>Article</th>
        <th>Access?</th>
    </tr>
    <?php foreach ($this->access as $access):?>
    <tr>
        <td><?php echo $this->escape($access['user']); ?></td>
        <td><?php echo $this->escape($access['article']); ?></td>
        <td style='background-color:<?php echo $access['allowed'] ? '#0F0' '#F00'?>'>
            <?php echo $access['allowed'] ? 'YES' 'NO'?>
        </td>
    </tr>
    <?php endforeach; ?>
</table>

The Result

Table showing who has access on what article

Now, let's see what else I implemented to get this to work:

Model layer

In our model layer we don't want to think about storing and loading data. We have the repository and datamapper for that (see data layer). As you can see below the models I created only care about what is important for them. They are located in the application/models directory. Let's have a closer look.

User model

The user can have 2 roles: he can either be a visitor or a person subscribed to the news service. If he's subscribed, he can view all articles. If he's a visitor, he can only view the free articles and articles he has paid for. We implement the interface Zend_Acl_Role_Interface on our model, that way we can use it in ACL later on. This simple means adding the getRoleId() function to our model.

I am also keeping track of which articles the user has access to. We can add articles (a.k.a. BUY) or check if he has bought an article in the past. Our datamapper layer will save this information, so we don't care about that now.

Last but not least I am providing function to populate and serialize the model from/to array.

class App_Model_User implements Zend_Acl_Role_Interface
{
    
    const 
SALT 'MySecretKeyword';

    protected 
$_id;
    protected 
$_username;
    protected 
$_password;
    protected 
$_roleId;
    protected 
$_userArticles;
    
    public function 
__construct($options = array())
    {
        if (!empty(
$options)) {
            
$this->populate($options);
        }
    }

    public function 
getId()
    {
        return 
$this->_id;
    }

    public function 
getUsername()
    {
        return 
$this->_username;
    }
    
    public function 
checkPassword($password)
    {
        
$encrypted $this->_encryptPassword($password);
        return (
$this->_password == $encrypted);
    }

    public function 
setPassword($password)
    {
        
$encrypted $this->_encryptPassword($password);
        
$this->_password $encrypted;
        
        
// Return self
        
return $this;
    }

    protected function 
_encryptPassword($password)
    {
        return 
md5(self::SALT '|' $password);
    }
    
    public function 
getRoleId() 
    {
        return 
$this->_roleId;
    }

    public function 
getUserArticles()
    {
        return 
$this->_userArticles;
    }
    
    public function 
addArticleToUser($article)
    {
        if (
is_array($article)) {
            foreach (
$article as $art) {
                
$this->addArticleToUser($art);
            }
        } elseif (
$article instanceof App_Model_Article) {
            
$this->_userArticles[] = $article->getId();
        } elseif (
is_numeric($article)) {
            
$this->_userArticles[] = $article;
        } else {
            throw new 
Exception('Invalid article provided');
        }
        
        
// Return self
        
return $this;
    }
    
    public function 
hasArticle($article)
    {
        if (
$article instanceof App_Model_Article) {
            
$article $article->getId();
        }
        return 
in_array($article$this->getUserArticles());
    }
    
    public function 
populate(array $data)
    {
        
// Populate object
        
$this->_id       = isset($data['id'])       ? $data['id']       : null;
        
$this->_username = isset($data['username']) ? $data['username'] : null;
        
$this->_password = isset($data['password']) ? $data['password'] : null;
        
$this->_roleId   = isset($data['role_id'])  ? $data['role_id']  : null;
        
        
// Add the user articles
        
$this->_userArticles = array();
        
$articles = isset($data['user_articles']) ? $data['user_articles'] : array();
        
$this->addArticleToUser($articles);
        
        
// Return self
        
return $this;
    }
    
    public function 
toArray($forUpdate false$withArticles true)
    {
        
$data = array();
        if (!
$forUpdate) { // ID and Username can't be modified
            
$data['id']       = $this->_id;
            
$data['username'] = $this->_username;
        }
        
$data['password'] = $this->_password;
        
$data['role_id']  = $this->_roleId;
        
        
// The articles the user owns
        
if ($withArticles) {
            
$data['user_articles'] = $this->_userArticles;
        }
        return 
$data;
    }

}

Article model

Our article can either be a free article or requires a payment, so we have 2 resource types: free-article and charged-article. Since we want to check access in our ACL, we implement the interface Zend_Acl_Resource_Interface. This means adding the getResourceId() function to our model. Based on the is_free field from the database, it will determine which resource we have.

You might notice we implement another interface: App_Model_UserOwnedInterface. This is something that will be used later on when we get the the ACL assertion. The interface is very simple. It has one function isOwnedByUser.

interface App_Model_UserOwnedInterface
{
    public function 
isOwnedByUser(App_Model_User $user);
}

The article model is structured the same way as the user model, so no need to repeat myself. The only difference is the function from the App_Model_UserOwnedInterface. As you can see the function gets an App_Model_User object. In our function in the article model, we check on the user if he owns the article. The full article model looks like this:

class App_Model_Article 
implements Zend_Acl_Resource_InterfaceApp_Model_UserOwnedInterface
{
    
    
/** Enums used in is_free */
    
const IS_FREE     'yes';
    const 
IS_NOT_FREE 'no';
    
    protected 
$_id;
    protected 
$_createdAt;
    protected 
$_title;
    protected 
$_titleUrl;
    protected 
$_body;
    protected 
$_isFree true;
    
    public function 
__construct($options = array())
    {
        if (!empty(
$options)) {
            
$this->populate($options);
        } else {
            
$this->_createdAt date('Y-m-d H:i:s');
        }
    }
    
    public function 
getId()
    {
        return 
$this->_id;
    }
    
    public function 
getCreatedAt()
    {
        return 
$this->_createdAt;
    }
    
    public function 
getTitle()
    {
        return 
$this->_title;
    }
    
    public function 
setTitle($title)
    {
        
$this->_title $title;
        
        
// Return self
        
return $this;
    }
    
    public function 
getTitleUrl()
    {
        return 
$this->_titleUrl;
    }
    
    public function 
setTitleUrl($title)
    {
        
$this->_titleUrl $title;
        
        
// Return self
        
return $this;
    }
    
    public function 
getBody()
    {
        return 
$this->_body;
    }
    
    public function 
setBody($body)
    {
        
$this->_body $body;
        
        
// Return self
        
return $this;
    }
    
    public function 
isFree()
    {
        return 
$this->_isFree;
    }
    
    public function 
setIsFree($isFree)
    {
        if (
$isFree === self::IS_NOT_FREE || $isFree === false) {
            
$this->_isFree false;
        } else { 
// Free by default
            
$this->_isFree true;
        }
    
    }

    public function 
populate(array $data)
    {
        
// Populate object
        
$this->_id        = isset($data['id'])          ? $data['id']           : null;
        
$this->_createdAt = isset($data['created_at'])  ? $data['created_at']   : date('Y-m-d H:i:s');
        
$this->_title     = isset($data['title'])       ? $data['title']        : null;
        
$this->_titleUrl  = isset($data['title_url'])   ? $data['title_url']    : null;
        
$this->_body      = isset($data['body'])        ? $data['body']         : null;
        
        
// Use the special setter to set is free (allows string and boolean values)
        
$isFree = isset($data['is_free']) ? $data['is_free']  : null;
        
$this->setIsFree($isFree);
        
        
// Return self
        
return $this;
    }
    
    public function 
toArray($forUpdate false)
    {
        
$data = array();
        if (!
$forUpdate) { // ID and Creation Date can't be modified
            
$data['id']         = $this->_id;
            
$data['created_at'] = $this->_createdAt;
        }
        
$data['title']      = $this->_title;
        
$data['title_url']  = $this->_titleUrl;
        
$data['body']       = $this->_body;
        
$data['is_free']    = $this->_isFree self::IS_FREE self::IS_NOT_FREE;
        return 
$data;
    }

    public function 
isOwnedByUser(App_Model_User $user)
    {
        return 
$user->hasArticle($this);
    }
    
    public function 
getResourceId()
    {
        if (
$this->isFree()) {
            return 
'free-article';
        } else {
            return 
'charged-article';
        }
    }

}

Database Layer

MySQL

The simple use case I designed has 3 tables. A user and article table and a user_articles table that holds articles bought by a user. The table structure (and initial data) look like this:

--  Table structure for `article`

CREATE TABLE `article` (
  `id` int(10) unsigned NOT NULL auto_increment,
  `created_at` datetime NOT NULL,
  `title` varchar(100) NOT NULL,
  `title_url` varchar(100) NOT NULL,
  `body` text NOT NULL,
  `is_free` enum('yes','no') NOT NULL default 'yes',
  PRIMARY KEY  (`id`),
  KEY `idx_title_url` (`title_url`)
);

--  Records of `article`

INSERT INTO `article` VALUES 
('1', now(), 'Free Article 1', 'free-article-1', 'blah blah blah', 'yes'), 
('2', now(), 'Charged Article 1', 'charged-article-1', 'blah blah blah', 'no'), 
('3', now(), 'Free Article 2', 'free-article-2', 'blah blah blah', 'yes'), 
('4', now(), 'Charged Article 2', 'charged-article-2', 'blah blah blah', 'no'), 
('5', now(), 'Free Article 3', 'free-article-3', 'blah blah blah', 'yes'), 
('6', now(), 'Charged Article 3', 'charged-article-3', 'blah blah blah', 'no');

-- Table structure for `user`

CREATE TABLE `user` (
  `id` int(10) unsigned NOT NULL auto_increment,
  `username` varchar(32) NOT NULL,
  `password` varchar(255) NOT NULL,
  `role_id` enum('visitor','subscriber') NOT NULL default 'visitor',
  PRIMARY KEY  (`id`),
  UNIQUE KEY `idx_username` (`username`)
);

-- Records of `user`

INSERT INTO `user` VALUES 
('1', 'jan', '6a701e3c63e0bd47b3f382bc39e4ca72', 'visitor'),
('2', 'jeroen', 'f2f82007c03a27d3c20da4cb18f8184f', 'subscriber');

-- Table structure for `user_articles`

CREATE TABLE `user_articles` (
  `user_id` int(10) unsigned NOT NULL,
  `article_id` int(10) unsigned NOT NULL,
  PRIMARY KEY  (`user_id`,`article_id`)
);

-- Records of `user_articles`

INSERT INTO `user_articles` VALUES ('1', '4');

Dbtable classes

Code wise, this means I have 3 Dbtable classes. They reside in the directory application/models/Dbtable. Please note how I defined the table relationships in case you haven't used those before.

class App_Model_Dbtable_User extends Zend_Db_Table_Abstract
{
    protected 
$_name 'user';
    protected 
$_dependentTables = array('App_Model_Dbtable_UserArticles');
}

class 
App_Model_Dbtable_Article extends Zend_Db_Table_Abstract
{
    protected 
$_name 'article';
    protected 
$_dependentTables = array('App_Model_Dbtable_UserArticles');
}

class 
App_Model_Dbtable_UserArticles extends Zend_Db_Table_Abstract
{
    protected 
$_name 'user_articles';
    
    protected 
$_referenceMap    = array(
        
'Article' => array(
            
'columns'           => array('article_id'),
            
'refTableClass'     => 'App_Model_Dbtable_Article',
            
'refColumns'        => array('id')
        ),
        
'User' => array(
            
'columns'           => array('user_id'),
            
'refTableClass'     => 'App_Model_Dbtable_User',
            
'refColumns'        => array('id')
        )
    );
}

The repository

After hearing Matthew Weier O'Phinney talk about repositories and datamappers at the Dutch PHP Conference I added a repository for loading users and articles. I'm still playing with that, so don't shoot me if it looks weird. This is put in application/models/Repository (note: I cut out the phpdoc for the blog, don't leave it out yourself). Please not that the repository layer returns domain models and not arrays.

class App_Model_Repository_Article
{
    
    public function 
fetchArticleById($id)
    {
        
$dao = new App_Model_Dbtable_Article();
        
$result $dao->find($id);
        if (
$result->count()) {
            
$article $result->current()->toArray();
            return new 
App_Model_Article($article);
        }
    }

    public function 
fetchArticleByUrlTitle($urlTitle)
    {
        
$dao = new App_Model_Dbtable_Article();
        
$where $dao->getAdapter()->quoteInto('title_url = ?'$urlTitle);
        
$result $dao->fetchAll($where);
        if (
$result->count()) {
            
$article $result->current()->toArray();
            return new 
App_Model_Article($article);
        }
    }
    
    public function 
fetchAll()
    {
        
$dao = new App_Model_Dbtable_Article();
        
$result $dao->fetchAll();
        if (
$result->count()) {
            
$articles = array();
            foreach (
$result as $article) {
                
$article $result->current()->toArray();
                
$articles[] = new App_Model_Article($article);
            }
            return 
$articles;
        }
    }

}

class 
App_Model_Repository_User
{

    public function 
fetchUserById($id)
    {
        
$dao = new App_Model_Dbtable_User();
        
$result $dao->find($id);
        if (
$result->count()) {
            
// First get the user data
            
$user $result->current();
            return 
$this->_wrapUpUser($user$asArray);
        }
    }

    public function 
fetchUserByUsername($username)
    {
        
$dao = new App_Model_Dbtable_User();
        
$where $dao->getAdapter()->quoteInto('username = ?'$username);
        
$result $dao->fetchAll($where);
        if (
$result->count()) {
            
// First get the user data
            
$user $result->current();
            return 
$this->_wrapUpUser($user$asArray);
        }
    }
    
    public function 
fetchAll()
    {
        
$dao = new App_Model_Dbtable_User();
        
$result $dao->fetchAll();
        if (
$result->count()) {
            
$users = array();
            foreach (
$result as $user) {
                
$user $result->current();
                
$users[] = $this->_wrapUpUser($user);
            }
            return 
$users;
        }
    }
    
    protected function 
_wrapUpUser(Zend_Db_Table_Row $user)
    {       
        
// Then load the articles
        
$articles $user->findDependentRowset('App_Model_Dbtable_UserArticles');
                         
        
// Serialize our data to an array
        
$data $user->toArray();
        
$data['user_articles'] = array();
        foreach (
$articles as $article) {
            
$data['user_articles'][] = $article->article_id;
        }
        
        
// Determine what to return
        
return new App_Model_User($data);
    }

}

Miscellaneous stuff

This basically concludes this tutorial. Below you can find a few extra things that I did to set up my application, especially for adding the App_* namespaces.

The Bootstrap

In the bootstrap we initialize our autoloader:

class Bootstrap extends Zend_Application_Bootstrap_Bootstrap
{
    protected function 
_initAutoload()
    {
        
$resources $this->getOption('resources');
        
$path dirname($resources['frontController']['controllerDirectory']);
        
        
$autoloader = new Zend_Application_Module_Autoloader(array(
            
'namespace' => 'App',
            
'basePath'  => $path,
        ));
        
$autoloader->addResourceType('acl''acls''Acl');
        return 
$autoloader;
    }
}

Configuration file

The only thing I added in the configuration file were the params to bootstrap the database:

resources.db.adapter = PDO_MYSQL
resources.db.params.dbname = acltest
resources.db.params.username = acltest
resources.db.params.password = secret
resources.db.params.hostname = localhost
resources.db.isDefaultTableAdapter = true

Wrapping up!

So I hope that this was all clear. Personally I like this approach a lot better than the previous attempt. I would like to thank David Zuelke for pushing me to try a different approach.

Enjoy!

Jeroen

Comments and Feedback
can you put up the source code please.

Matthijs

2010-12-04 17:17
Great article Jeroen. Thanks for this write up. This ACL stuff can be confusing, so it really helps to see a possible implementation completely.
Jeroen,

Thank you for the great article! I was taking somewhat similar approach but was having some issues when putting all of these dynamic assertions together. This article was laid out in an awesome format! A lot of the Zend documentation seems to be very unstructured, it was refreshing to have an article in front of me that explained everything in detail.

Thanks!

Felipe Marques

2011-07-26 20:08
Nice post!! Excellent!!