Content-driven Access Control with Zend ACL
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

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_Interface, App_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






+32 475 62.42.64
sam
2010-10-05 14:31