Row-level Model Access Control for CakePHP

UPDATED!

Core Developer says “You Don’t Need It”

it seems that for as long as i have been using cake, i’ve been hearing people asking “how do i control access to an individual record with ACL?”.  the usual response is “you aren’t doing it right if you need to restrict access to a specific row”.  while this may be correct 90% of the time (due to the php/development greenhorns who use cake, but that’s a different blog post), i think it’s a bit narrowminded to think in this way.  who’s to say whether or not i or my product owner want to restrict access to certain db rows?  and how much of that response is due to the fact that cake’s generic ACL implemenation makes doing row level permissions a nightmare?  and so, remembering the “good old days” when i used phpGACL in projects, i wondered if there was a better way to do this in cake, that doesn’t require backporting an old clunky library like phpGACL (which i’ve seen done elsewhere).

Enter the “RMAC” dragon

awhile back, i ran across this post on by Baron Schwartz, author of the excellent O’reilly book “High Performance MySQL” and various handy mysql tools.  his two-part article on how to implement role based access in just sql is very intriguing, to say the least.  while i like his implementation, it’s has a lot of bells and whistles that are more specific to his requirements that i would need.

around a year ago, i tasked one of my developers with adapting Baron’s implementation to fit our requirements.  in the end, while it was a good stab at it, i think we still tried to make it do too much, and there were various problems with our implementation (accessing $_SESSION in a model behavior gives me shivers), but it did what we needed it to do and all was good.

for a new project we are working on i thought i would try to do this from scratch.  i’ll leave off describing all the exhaustive implemenation/testing that was done to verify whether or not this can be done with cake’s built in ACL behavior/component.  in a nutshell, the answer is yes, but to put it how Mark Story said “you have to know the answer to the question before you even ask it”, in that you have to query the model in question, along with the aros/acos/aros_acos table to find a list of row ids that you are able to access, and then use that as a filter for your original query.  this becomes even more tedious when dealing with a tree behaviored model, because you will run into the scenario where someone may be allowed to access a node several levels deep in the tree, but disallowed access to it’s parent.  you then have to decide to bypass the permissions and give the user access to read the parent node, or else change the way you save permission for each user/group to implicitly allow paths.  this sucks, to say the least.  after all this “research” (read: unsatisfactory implementations that made my head hurt to try to keep straight), i scrapped all of this.  i have come to the conclusion that cake’s ACL is a very good generic implementation that never was explicitly intended for use with users/groups, much less roles.  that is not to say you can’t, but that you probably shouldn’t.  turns out the cake core developers were right (but i still think that blithely ignoring the fact that it’s just not suited for this sort of implementation is kind of naughty).

so where does this leave us?  after scrapping the implementations that used cake’s generic ACL, i returned to Baron’s method and stripped it down.  while i don’t have it in a “plug-n-play” state just yet, here she is, the Permissionable behavior! </trumpet flourish> (apologies for not having syntax highlighting!)

/**
 * Permission Model
 *
 * Model for manipulating permissions data
 *
 * @author      Joshua McNeese
 */
final class PermissionModel extends AppModel {

    /**
     * Permissions table
     *
     * @access  public
     * @var     string
     */
    public $useTable = 'permissions';

}

/**
 * Permissionable Behavior
 *
 * @author      Joshua McNeese
 * @since       1.0
 * @internal    $Info: $
 * @version     $Rev: $
 */
final class PermissionableBehavior extends ModelBehavior {

    public $settings    = array();
    private $__defaults = array(
        'userModel'     => 'User',
        'groupModel'    => 'Group',
        'defaultBits'   => 416      // owner_read (256) +
                                    // owner_write (128) +
                                    // group_read (32)
    );
    private $__enabled  = true;

    /**
     * Bits for various permissions.  Don't touch!
     *
     * @access  private
     * @var     array
     */
    private $__permissions = array(
        'owner_read'   => 256,
        'owner_write'  => 128,
        'owner_delete' => 64,
        'group_read'   => 32,
        'group_write'  => 16,
        'group_delete' => 8,
        'other_read'   => 4,
        'other_write'  => 2,
        'other_delete' => 1
    );

    public function setup(&$model, $config = array()) {
        $model->Permission =& ClassRegistry::init('PermissionModel');

        $config = (is_array($config) and !empty($config))
                ? am($this->__defaults, $config)
                : $this->__defaults;

        foreach($config as $k=>$v) {
            if(isset($model->{$k})) {
                $config[$k] = $model->{$k};
            }
        }

        $this->settings[$model->alias] = $config;
	}

	public function afterDelete(&$model) {
	    if(!$this->isEnabled()) {
	        return true;
	    }

        $model->Permission->deleteAll(array(
            'model'         => $model->alias,
            'foreign_key'   => $model->id
        ));
	}

	public function afterSave(&$model, $created) {
	    if(!$this->isEnabled()) {
	        return true;
	    }

	    if(
	       !defined('PERMISSION_GROUP_BITS') or
	       !defined('PERMISSION_USER_ID')
        ) {
	        return $this->backout($model);
	    }

	    $data = array();

	    if($created) {
	        $data = array(
                'model'         => $model->alias,
                'foreign_key'   => $model->id,
                'user_id'       => PERMISSION_USER_ID,
                'group_bits'    => PERMISSION_GROUP_BITS,
                'permission'    => $this->getPermissionBits($model)
	        );

	        $groupModelName = $this->settings[$model->alias]['groupModel'];
	        $userModelName  = $this->settings[$model->alias]['userModel'];

	        if($model->alias == $userModelName) {
	            $data['user_id'] = $model->id;

	            if(
	               !isset($model->data[$groupModelName]) or
	               empty($model->data[$groupModelName])
                ) {
                    return $this->backout($model);
                }

                $groupIds = array_unique(am(
                    Set::extract(
                        "/$groupModelName/$groupModelName/.",
                        $model->data
                    ),
                    Set::extract(
                        "/$groupModelName/{$model->{$groupModelName}->primaryKey}",
                        $model->data
                    )
                ));

                if(empty($groupIds)) {
                    return $this->backout($model);
                }

                $groupModel= $this->getModel($model, $groupModelName);

                if(empty($groupModel)) {
                    return $this->backout($model);
                }

                $groupBits = 0;

                foreach($groupIds as $groupId) {
                    $bitPath    = $groupModel->getpath($groupId, array('bit'));
                    $bitArray   = array_sum(Set::extract("/$groupModelName/bit", $bitPath));
                    $groupBits  = (int) $groupBits | (int) $pathBits;
                }

                if(empty($groupBits)) {
                    return $this->backout($model);
                }

                $data['group_bits'] = (int) $groupBits;
	        } elseif($model->alias == $groupModelName) {
	            $data['group_bits'] = $model->data[$groupModelName]['bit'];
	        }

	        $model->Permission->create();
	    } elseif(
	       isset($model->data['Permission']) and
	       !empty($model->data['Permission'])
        ) {
	        $data = $model->data['Permission'];

	        if(!isset($data['id']) or empty($data['id'])) {
	            $oldPermission = $model->Permission->find('first', array(
                    'conditions' => array(
                        'model'         => $model->alias,
                        'foreign_key'   => $model->id
    	            )
	            ));

	            if(empty($oldPermission)) {
	                $model->Permission->create();
	                $data['permission'] = $this->getPermissionBits($model);
	            } else {
	                $data = am($oldPermission['Permission'], $data);
	            }
	        }

	        $data = am($data, array(
	           'model'         => $model->alias,
               'foreign_key'   => $model->id
	        ));
	    }

	    if(!empty($data)) {
	        $model->Permission->save($data);
	    }
	}

	public function beforeDelete(&$model) {
	    if(!$this->isEnabled()) {
	        return true;
	    }

	    return $this->hasPermission($model, 'delete');
	}

	public function beforeFind(&$model, $queryData) {
	    if((
            isset($queryData['permissions']) and
	        $queryData['permissions'] == false
        ) or (
            isset($queryData['conditions']['permissions']) and
            $queryData['conditions']['permissions'] == false
        )) {
	        unset($queryData['conditions']['permissions']);
	        unset($queryData['permissions']);
	        return $queryData;
	    } elseif(!$this->isEnabled()) {
	        return true;
	    } elseif(!defined('PERMISSION_GROUP_BITS') or !defined('PERMISSION_USER_ID')) {
	        $queryData['conditions'] = '1=0';
	    } elseif((PERMISSION_GROUP_BITS & 1) <> 0) {
	        return true;
	    } else {
	        $alias = $model->Permission->alias;

	        $queryData['joins'][]  = array(
                'table'     => $model->Permission->table,
                'alias'     => $alias,
                'type'      => 'INNER',
                'foreignKey'=> false,
                'conditions'=> array(
                    "$alias.model = '{$model->alias}'",
                    "$alias.foreign_key = {$model->alias}.{$model->primaryKey}",
                    'or' => $this->getPermissionQuery($model)
                )
            );
	    }

	    return $queryData;
	}

	public function beforeSave(&$model) {
	    if(!$this->isEnabled()) {
	        return true;
	    }

	    $id = null;

	    if(!empty($model->id)) {
	        $id = $model->id;
	    } elseif(
	       !empty($model->data) and
	       isset($model->data[$model->alias]) and
           isset($model->data[$model->alias]['id']) and
           !empty($model->data[$model->alias]['id'])) {
	        $id = $model->data[$model->alias]['id'];
	    }

            if($model->alias == $this->settings[$model->alias]['groupModel'] and empty($id)) {
                $next_bit   = 1;
                $max_bit    = $model->field('bit', null, "{$model->alias}.bit DESC");

                if(!empty($max_bit)) {
                    $next_bit = $max_bit * 2;
                }

                $this->set('bit', $next_bit);
            }

            return (!empty($id))
	           ? $this->hasPermission($model, 'write', $id)
	           : true;
	}

	private function backout(&$model) {
	    $model->del();
	    return false;
	}

	private function getModel(&$model, $name = null) {

	    if(empty($name)) {
	        return null;
	    }

	    if(isset($model->{$name})) {
	        $theModel =& $model->{$name};
	    } else {
	        $theModel =& ClassRegistry::init($name);
	    }

	    return $theModel;
	}

	private function getPermissionBits(&$model) {
	    return (isset($model->data['permission']) and
	            !empty($model->data['permission']))
                    ? $model->data['permission']
                    : (isset($model->data[$model->alias]) and
                       isset($model->data[$model->alias]['permission']) and
                       !empty($model->data[$model->alias]['permission']))
                        ? $model->data[$model->alias]['permission']
                        : $this->settings[$model->alias]['defaultBits'];
	}

	private function getPermissionQuery(&$model, $action = 'read') {
	    $alias = $model->Permission->alias;
	    $perms = $this->__permissions;

	    return array(
            "$alias.permission & {$perms['other_'.$action]} <> 0",
            array(
                "$alias.permission & {$perms['group_'.$action]} <> 0",
                "$alias.group_bits & ". PERMISSION_GROUP_BITS ." <> 0"
            ),
            array(
                "$alias.permission & {$perms['owner_'.$action]} <> 0",
                "$alias.user_id = '". PERMISSION_USER_ID ."'"
            )
        );
	}

	public function hasPermission(&$model, $action = 'read', $id = null) {
	    if(!$this->isEnabled()) {
	        return true;
	    }

	    if((PERMISSION_GROUP_BITS & 1) <> 0) {
	        return true;
	    } elseif(empty($id) and empty($model->id)) {
	        return false;
	    }

	    $permission = $model->Permission->find('count', array(
            'recursive'     => -1,
            'conditions'    => array(
                'model'         => $model->alias,
                'foreign_key'   => (!empty($id)
                                ?  $id
                                :  $model->id),
                'or'            => $this->getPermissionQuery($model, $action)
            )
	    ));

	    return (!empty($permission) and $permission > 0);
	}

	private function isEnabled() {
	    return $this->__enabled;
	}

	private function isTree(&$model) {
	    return $model->Behaviors->attached('Tree');
	}

	public function setUserData(&$model, $data = array()) {
	    if(empty($data)) {
	        return;
	    }

	    $modelNames = am(array_values($model->tableToModel), array($model->alias));

	    foreach($modelNames as $modelName) {
	        if(!isset($this->settings[$modelName])) {
	            $this->settings[$modelName] = $this->__defaults;
	        }

	        $this->settings[$modelName]['userData'] = $data;
	    }
	}

}

How To Use

first off, there are a couple of caveats, as with anything that’s not 100% ready for release and built primarily for my use.

  • my app uses UUIDs, so you might need to adjust sql/code if you don’t
  • groups are hierarchical, and thus tree-behaviored
  • there must be a “root” group with a bit of 1, and a “root” user in that group.  any other groups must be created as siblings to the root group (not children!)
  • if you use the Auth component to control your app, you’ll need to set the scope for Auth, ala: $this->Auth->userScope = array(‘permissions’ => false);
  • if you use this on a tree-behaviored model besides the group model, you will need to handle inherited permissions (if you want that)
  • there are other missing knicknacks, specifically updating the user’s group_bits when you add/remove groups, or if you add/remove the user from groups.  actually, now that i think about it, removing a group won’t cause a problem, so long as you skip the removed group’s bit when adding a new group.  i’ll explain why in a bit.  and there isn’t a way to override the owner or group_bit for a record as of the moment.  i’ll add that soon.

you will need to create the appropriate tables:

CREATE TABLE `groups` (
  `id` char(36) collate utf8_unicode_ci NOT NULL,
  `parent_id` char(36) collate utf8_unicode_ci default NULL,
  `lft` int(3) NOT NULL,
  `rght` int(3) NOT NULL,
  `name` varchar(100) collate utf8_unicode_ci NOT NULL,
  `bit` int(11) NOT NULL,
  PRIMARY KEY  (`id`),
  KEY `parent_id` (`parent_id`),
  KEY `lft` (`lft`,`rght`),
  KEY `bit` (`bit`)
) DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

CREATE TABLE `groups_users` (
  `id` int(11) NOT NULL auto_increment,
  `group_id` char(36) collate utf8_unicode_ci NOT NULL,
  `user_id` char(36) collate utf8_unicode_ci NOT NULL,
  PRIMARY KEY  (`id`),
  UNIQUE KEY `group_id` (`group_id`,`user_id`)
)  DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

CREATE TABLE `permissions` (
  `id` char(36) collate utf8_unicode_ci NOT NULL,
  `model` varchar(25) collate utf8_unicode_ci NOT NULL,
  `foreign_key` char(36) collate utf8_unicode_ci NOT NULL,
  `user_id` char(36) collate utf8_unicode_ci NOT NULL,
  `group_bits` int(11) NOT NULL default '0',
  `permission` int(3) unsigned zerofill NOT NULL default '000',
  PRIMARY KEY  (`id`),
  KEY `user_id` (`user_id`),
  KEY `group_bit` (`group_bits`),
  KEY `model` (`model`,`foreign_key`)
) DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

CREATE TABLE `users` (
  `id` char(36) collate utf8_unicode_ci NOT NULL,
  `password` char(40) collate utf8_unicode_ci NOT NULL,
  `email` varchar(255) collate utf8_unicode_ci NOT NULL,
  PRIMARY KEY  (`id`)
) DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

and that’s it! well, not really. you will need to add the behavior to each model you want to be permissioned:

public $actsAs = array('Permissionable');

you will need to put this code in AppController::beforeFilter(). this will inform the behavior who the user in question is, as well as what the group_bit is for the user (we’ll explain how this works later).

if you put this in some other controller’s beforeFilter you run the risk of permissioned associated models not knowing who the logged-in user is, i don’t recommend it. i’ll be moving this to a component in all likelihood, so it doesn’t have to live directly in any controller(s).

        $modelName  = Inflector::classify($this->name);
        $user_id    = $this->Auth->user('id');

        if(
            empty($user_id) or !isset($this->{$modelName}) or
            $this->{$modelName}->Behaviors->attached('Permissionable') == false
        ) {
            return;
        }

        $mainModel  = $this->{$modelName};
        $group_bits = $this->Session->read('Permission.group_bits');

        if(empty($group_bits)) {
            $permissionModel    =& $mainModel->Permission;
            $permissionBehavior = $mainModel->Behaviors->Permissionable;
            $group_bits         = $permissionModel->field('group_bits', array(
                'model'         => $permissionBehavior->settings[$mainModel->alias]['userModel'],
                'foreign_key'   => $user_id
            ));

            if(empty($group_bits)) {
                return;
            }

            $this->Session->write('Permission.group_bits', $group_bits);
        }

        define('PERMISSION_USER_ID',    $user_id);
        define('PERMISSION_GROUP_BITS', $group_bits);

after that, any new record that is created will have a permission set, the creating user being the owner, and the groups that the user is in as the group_bit for the permission.  this means that every group the user is in will have the same group level permissions that the creating user has.  for example:  if the record is created with the permissions of (user read/write/delete, group read/write, others read), then any member of any group that the creator is in will also have read/write permissions on that record, while the author will additionally have delete permission, and all others will have read.

now, if you want to override the default permissions, you can do so in the model by setting the $defaultBits property to the desired permission for that model.  additionally, you can set $model->data[‘permission’] or $model->data[$modelName][‘permission’] to the desired permission for the row being saved.

So, how does it work, Mr. Wizard?

well, the simple answer is “bitwise”-ly.  without going into tedious detail, we make use of bitwise operators in php (and sql) to determine what type of operation we are trying to accomplish (selects are reads, updates are writes and deletes are deletes), we check whether or not the user is either the owner of the row, in a group that the record in question is also in, or if “other” permissions allow them to do what they are asking.  some quick examples:

  • group A has bit of 1 (root)
  • group B has bit of 2
  • group C has bit of 4
  • group D has a bit of 8
  • user 1 has a group_bit of 1 (root)
  • user 2 has a group_bit of 6 (group B + group C)
  • user 3 has a group_bit of 8 (group D)
  • record is owned by user 2, has a group_bit of 6 (group B + group C) and a permission of 416 (owner read (256) + owner write (128) + group read (32) = 416)

user 1 (root) tries to select the record.  since he’s root (bit 1), he’s allowed.

user 2 tries to select record. he’s not the record owner, so permissions are checked to see if it contains the “group read” bit: (416 & 32 <> 0) == true, and then the record’s group_bit is checked to see if the user has a matching group bit: (6 & 6 <> 0) == true.  both checks are true, so the user can read the row.

user 3 tries to select record. he’s not the record owner, so group permissions are checked: (416 & 32 <> 0) == true, and then group_bit is checked: (6 & 8 <> 0) == false, then other_read permissions are checked (416 & 4 <> 0) == false, so the user is denied access to the row.

probably the nicest thing about this method is that instead of having to do separate queries in a beforeFind to determine if the query should continue or how to filter the original query, all the behavior does is add a join to the query on the permissions table.  all of your single queries to find matching records stay single queries.  there still are pre-checks when trying to update or delete a record, but they are quick and relatively painless.  there’s nothing else to really change.  all your find calls will work like before, except they will only return allowed rows.  save()/delete() calls will return false if the row doesn’t have write access for the user trying to do so.  inserting isn’t covered by this method though, that’s where ACLs come into play… and that’s what the follow-up article will cover.

so there you have it.  since this is only mostly finished, i welcome fixes, comment, critique and/or praise.


About this entry