Zend_Acl is a very powerful tool to help manage access control logic, but it can be a bit difficult to determine exactly where and how to use it.

In previous Zend Framework apps I've written, I often handled access control at the level of the controller action. Each action was represented in the ACL as a resource, and the ACL logic was applied by a custom plugin just prior to any action dispatch.

This is a common enough solution, but I'm not sure it's always the best one. The thing is, an "action" doesn't really sound like a "resource" …it's not a thing you need, it's a thing you do. Doing access control in the controller layer is also kind of fragile; all you're really restricting is the user interface, even though what you want to restrict is the application itself: the data and its business.

Access control in the model layer

As a result of these concerns, I decided on a lower-level, model-centric approach for this blog: my models are my resources. Each model class implements Zend_Acl_Resource_Interface, and the ACL specifies "create," "read," "update" and "destroy" privileges for each class (more or less). This is a nice paradigm, because it allows for access control at a much lower level: instead of restricting access to the user interface (the controller layer), we restrict access to the business logic (the model layer). (It also makes dynamic assertions a lot more useful, for reasons I may explain some other time.)

In code, checking privileges is as simple as this:

<?php
$post = new Blahg_Model_Post();
if (!$acl->isAllowed($role, $post, 'create')) {
    // Handle permission failure somehow.
}

Posting the guard: record listeners

However, doing this manually can get a bit tedious, and is a particularly big problem if you're aiming for a thin, lightweight controller layer. Ideally, we'd want these access control checks to happen automatically; fortunately, Doctrine has provided a way with its record listeners. Doctrine_Record_Listener is to Doctrine_Record what Zend_Controller_Plugin is to Zend_Controller_Action: it specifies a set of pre- and post-operation hooks in which you can extend the underlying framework's base behavior.

For example, since I know that I never want a record inserted into the database if the current user doesn't have "create" privileges for that model, I can do something along these lines:

<?php
class Blahg_Plugin_Doctrine_Record_Listener_Acl 
    extends Doctrine_Record_Listener
{
    public function __construct(Zend_Acl $acl, $currentRole);
 
    public function preInsert(Doctrine_Event $event)
    {
        $resource = $event->getInvoker();
        if ($resource instanceof Zend_Acl_Resource_Interface) {
            if (!$this->getAcl()->isAllowed($this->getCurrentRole(), $resource, 'create')) {
                throw new Blahg_Model_Exception_Forbidden('You are not authorized to create new records of this type.');
            }
        } else {
            // Not intended as an ACL resource; do nothing.
        }
    }
 
    public function getAcl();
    public function getCurrentRole();
}

Implement the same types of checks on preUpdate() and preDelete(), and you've got yourself a working record listener. All that's left at that point is to register it with Doctrine at the appropriate level. You can take care of it easily enough at the manager level, like so:

<?php
$manager->addRecordListener(new Blahg_Plugin_Doctrine_Record_Listener_Acl(
    $acl, 
    $currentRole
));

However, if you specify any other listeners at the connection or table level (which is especially true if your model has any "actAs" behaviors), your manager-level listeners will be overridden (see DC-248 on the Doctrine issue tracker for more information). As a result, if you're going to be using behaviors on your model, you'll need to add the listener explicitly to each of your tables, like so:

<?php
$recordListener = new Blahg_Plugin_Doctrine_Record_Listener_Acl($acl, $currentRole);
$table1->addRecordListener($recordListener);
$table2->addRecordListener($recordListener);

But what about reads?

So far so good, but we haven't done anything for the "read" privilege yet. As it happens, "read" operations need to be treated a bit differently. There are two situations in which read operations might take place:

  1. Fetching a single record from the database (e.g., find(), fetchOne(), etc.).
  2. Fetching a collection of records from the database (e.g., findAll(), etc.).

Now, if we were to throw our usual Blahg_Model_Exception_Forbidden whenever an impermissible record was retrieved, case #1 would work just as expected; the user wouldn't get to see the record. However, in case #2, things are more problematic; we don't want to halt the entire operation just because of a single impermissible record—after all, the user might still have legitimate access to other records in the collection.

Instead, we're going to make Doctrine behave as though impermissible records don't exist—any impermissible records should simply be removed from the return values of any method that returns records.

There currently isn't a listener method available that allows for the modification of a result set (see DC-280); however, Doctrine 1.2 allows you to write your own hydrator classes, which with a little ingenuity can handle this kind of logic easily enough. Take a look:

<?php
class Blahg_Plugin_Doctrine_Hydrator_Acl extends Doctrine_Hydrator_RecordDriver
{
    public function hydrateResultSet($stmt)
    {   
        $data = parent::hydrateResultSet($stmt);
 
        foreach ($data as $key => $record) {
            if ($record instanceof Zend_Acl_Resource_Interface) {
                if (!$this->getAcl()->isAllowed($this->getCurrentRole(), $record, 'read')) {
                    $data->remove($key);
                }
            } else {
                // Not intended as an ACL resource; do nothing.
            }
        }
 
        return $data;
    }
 
    public function getAcl();
    public function getCurrentRole();
}

Once you've got this put together, you'll need to register the new hydrator with Doctrine_Manager, like so:

<?php
$manager->registerHydrator("Blahg_Hydrator_Acl", "Blahg_Plugin_Doctrine_Hydrator_Acl");

…and then remember to use it whenever you retrieve records (there may be a way to set it as the default, but I haven't found it yet):

<?php
$records = $table->findAll("Blahg_Hydrator_Acl");

I commented on a few of the downsides of this approach in DC-280, but until some of those issues can be remedied, I think it's a pretty effective solution. Hopefully this post helps someone who's trying to do the same thing.

Categories: