Adding Context to your ACL
Recently I reached the bounderies of what Zend_Acl could do for me. I needed extra levels of access control and ended up cluttering up my controllers. In this article, I investigate and show a clean way to add context to your acl.
IMPORTANT: Based on the comments on this post, I wrote another blog post Content-driven Access Control with Zend ACL. The approach is cleaner and better than the approach displayed here. Please have a look there first.
Two or Three Dimensional ACL
Zend_Acl is quite powerful in itself. But sometimes, in bigger applications it's not always just about giving certain access to a certain resource. In Zend Framework you can have 2D (role & resource) or 3D (role, resource & privilege) ACL rules. While this is good in 99% of the cases, sometimes you need to go further. Sometimes you have 2 levels of access: first on application, then on the context you are in.
Example 1 : the reporter
Suppose you have a news application where you need to pay per article in the archive if you are not a subscriber. You can see all articles you have bought in the past or buy a new article. Your account will have read privileges on the resource articles, but how can the system be sure that you are allowed to see this article?
Example 2 : the reseller
Suppose you have a webhost application where you have multiple resellers that each have their own clients. A reseller can open a client's detail ("read" privilege on "clients"), but only for his own clients. How can the system be sure that the reseller is allowed to view this client?
Four Dimensional ACL
In comes the extra, fourth dimension: context. Context is what defines the state you are currently in. It can be a page you are visiting, a client's details you are requesting, etc... basically it can be anything you want to access.
Zend Framework already has a system that allows you to do extra, real-time checks on ACL, see Conditional ACL Rules with Assertions.
It allows you to do extra checks, like allowing access from certain IPs or denying access outside business hours. All you have to do is create a new class that implements Zend_Acl_Assert_Interface and it's assert function.
There is one big problem though: you can not pass variables from your application to this assertion class. Euhm, correction, you can when you configure your ACL, but most of the time you don't know your context yet at that moment. Your context is known at the time you do your "isAllowed" check on your ACL. This can be for example in your service layer or controller.
Defining the Context
The Dirty Way
So how do we pass the context to the Assertion class? My first idea was to override Zend_Acl's isAllowed() function so I could pass parameters. It would look something like this:
public function isAllowed($role = null,
$resource = null,
$privilege = null,
$assertParams = array());
This would lead to overriding the function _getRuleType swell. It looked dirty and I didn't like it at all.
A Clean Way
Then it hit me... I had seen that Zend_Acl_Assert_Interface::assert() also passes the acl object to the function. So I did not need to pass the params to the function, I could let my ACL class know what his context was. I decided to extend Zend_Acl to add context to it:
/**
* Amazium library
*
* @category Amz
* @package Amz_Acl
* @author Jeroen Keppens; Amazium bvba (http://www.amazium.com)
*/
class Amz_Acl extends Zend_Acl
{
/**
* Array holding the current context.
* This could be the currently loaded page, article, user, etc...
*
* @var array
*/
protected $_context = array();
/**
* Set the context array
*
* @param array $context
*/
public function setContextArray($context = array())
{
$this->_context = $context;
}
/**
* Get the context array
*
* @return array $context
*/
public function getContextArray()
{
return $this->_context;
}
/**
* Set a context value
*
* @param string $key context item name
* @param mixed $value
*/
public function setContextValue($key, $value = null)
{
$this->_context[$key] = $value;
}
/**
* Get the context array
*
* @param string $key context item name
* @return mixed $value
*/
public function getContextValue($key)
{
if (isset($this->_context[$key])) {
return $this->_context[$key];
} else {
throw new Zend_Acl_Exception('Context value [' . $key . '] not set');
}
}
}
Using Context
Setting up ACL
Setting up the ACL is done like you would normally do it:
$acl = new Amz_Acl();
$acl->addRole(new Zend_Acl_Role('client_subscription'));
$acl->addRole(new Zend_Acl_Role('client_payperview'));
$acl->add(new Zend_Acl_Resource('articles'));
// Clients with a subscription have full access
$acl->allow('client_subscription',
'articles',
'read');
// Clients without subscription pay per view
$acl->allow('client_payperview',
'articles',
'read',
new Amz_Acl_Assert_HasArticleAccessAssertion());
Note that we set added the assertion class in the allow.
Assertion class
The assertion class defined above looks like this:
<?php
interface Amz_Acl_Assert_Interface extends Zend_Acl_Assert_Interface
{
/**
* Returns true if and only if the assertion conditions are met
*
* @param Amz_Acl $acl
* @param Zend_Acl_Role_Interface $role
* @param Zend_Acl_Resource_Interface $resource
* @param string $privilege
* @return boolean
*/
public function assert(Amz_Acl $acl,
Zend_Acl_Role_Interface $role = null,
Zend_Acl_Resource_Interface $resource = null,
$privilege = null);
}
class Amz_Acl_Assert_HasArticleAccessAssertion implements Amz_Acl_Assert_Interface
{
/**
* Check if a user has access to the article
*
* @param Amz_Acl $acl
* @param Zend_Acl_Role_Interface $role
* @param Zend_Acl_Resource_Interface $resource
* @param string $privilege
* @return boolean
*/
public function assert(Amz_Acl $acl,
Zend_Acl_Role_Interface $role = null,
Zend_Acl_Resource_Interface $resource = null,
$privilege = null)
{
try {
$article = $acl->getContextValue('article_id');
// Check if user has bought article before
// ...
} catch (Zend_Acl_Exception $e) {
return false;
}
}
}
Meanwhile in our Controller...
Last but not least we check access from our controller like this:
$id = $this->_getParam($id);
$acl->setContextValue('article_id', $id);
if ($acl->isAllowed($user->getRole(), 'articles', 'read')) {
// ...
}
Wrapping up
So, I hope you enjoyed this tutorial. I am sure there are plenty of other ways to accomplish the same thing, but this way you can keep your classes clean and still add this extra depth of access control. Let me know what you guys think!
Enjoy!
Jeroen






+32 475 62.42.64
David Zuelke
2010-06-04 20:56The correct way to do this is to have an object representing your Article resource (typically a domain model object, but could also be some data model object like a Doctrine record, if you absolutely must), and implement Zend_Acl_Resource_Interface in that class*. This object is then passed to the isAllowed() method, and the assertion can perform the necessary checks.
Zend_Acl_Resource_Interface requires you to implement a getResourceId() method*. This method in Zend_Acl_Resource returns whatever you pass into the constructor. Zend_Acl then compares this value for equality. That means that in our case, the method would return "articles" (this should be singular IMO), so your subscriber check works too.
At least that's how I understood Zend_Acl.
*: don't have the source code here, so forgive me if method names are wrong etc