Setting Up User Groups With ACL and Auth in CakePHP 1.2

Update (January 30, 2007): Also see this helpful post on CakePHP 1.2 Auth ACL by a commenter, and make sure to read the comments for more suggestions.
Update (August 26, 2007): While managing to get this set up, I've come to the conclusion that the CakePHP ( 1.2.5... ) db_acl implementation is far too incomplete to be using at this point. Some of the problems I've run into in the past month or so:

  • Changing a users group does not change the ARO parent (effectively making it usless/impossible to change groups). I tried fixing this home-brew style, but since there's no setParent in 1.2's db_acl, it's far harder than it should be.
  • The cake console acl management tool, while nice, is buggy as hell. See the CakePHP trac for more on that (just search for 'acl console').

Since I've already committed myself to using this implementation, I'm going to see if I can limp my way through making it work, but if you haven't already begun using it, I highly recommend staying away from CakePHP's db_acl setup, and instead use one of the many (more lightweight) alternatives found in the Cakeforge.
Hopefully in the near future we'll get some code that's more complete and some more useful documentation.

Introduction
I had an extremely difficult time getting ACL/Authentication with CakePHP. Some of the articles that helped to get me started are listed below.

Special kudos to Geoff (Lemoncake) for helping me identify a bug in the db_acl.php file (information on how to patch it is at the bottom of this tutorial).
Unfortunately I spent a lot of time going back and forth between multiple tutorials to get a grasp of how to set up Users and Groups. Hopefully this tutorial will be helpful in giving you the "big picture" in setting up Auth/ACL with Users/Groups.
The version I'll be using here is CakePHP 1.2.0.5146alpha.
I'm assuming that the reader is already fairly familiar with CakePHP 1.2. I'm going to show you what I did to get user groups working as explicitly as I can, but if you're coming at this with no previous CakePHP familiarity, you may have some trouble.

Initial Setup
To get started, go ahead and use the cake bake console to create the models, views, and controllers for your Groups and Users. Here's what my tables looked like:

CREATE TABLE `users` (
`id` mediumint(8) UNSIGNED NOT NULL AUTO_INCREMENT,
`group_id` smallint(6) NOT NULL,
`firstname` varchar(100) collate latin1_general_ci DEFAULT NULL,
`lastname` varchar(100) collate latin1_general_ci DEFAULT NULL,
`email` varchar(100) collate latin1_general_ci DEFAULT NULL,
`password` varchar(50) collate latin1_general_ci DEFAULT NULL,
`active` tinyint(1) DEFAULT '1',
PRIMARY KEY (`id`)
);

CREATE TABLE `groups` (
`id` smallint(5) UNSIGNED NOT NULL AUTO_INCREMENT,
`name` varchar(50) DEFAULT NULL,
`description` text,
`parent_id` int(11) NOT NULL,
PRIMARY KEY (`id`)
);

Once you create the tables you can use the cake console to create the rest. If you're not familiar with the console, just navigate to your cake/console directory and type ./cake bake. It's pretty self-expanatory. You'll need to make sure that you have the path to your app directory set up properly. If you require further help, I suggest checking out this screencast.

Configuring Your App Controller To Use Auth/ACL
Open up app_controller.php in your application and add:

class AppController extends Controller {

        // This tells the controller to include the ACL/Auth components.

        var $components = array('Acl', 'Auth' );

        // Publicly accessible pages.
        // This works so that you can indicate controllers ( top level ) and actions
        // that are accessible to the public in general and should not require authentication

        // to view. The asterisk ( * ) indicates that ALL actions for that controller should be
        // publicly accessible.
        var $allowed = array ( 'pages' => '*', 'allowed' => array ('page1', 'page2' ) );

        function beforeFilter() {
                $this->checkAuth(  );

        }

        function checkAuth(  ) {

                if (isset($this->Auth)) {

                        // This allows you to use different fienld names ( in this case 'email' ) for the
                        // username and password on the User::login function.

                        $this->Auth->fields = array('username' => 'email', 'password' => 'password');

                        // This is another filter that says 'If the User.active is not one, then they cannot log in.'
                        $this->Auth->userScope = array( 'User.active' => '1' );

                        // This ( obviously ) is the page used to log in. Format the HTML using /views/users/login.ctp
                        $this->Auth->loginAction = '/users/login';

                        // Where do we go after a successful login?
                        $this->Auth->loginRedirect = '/users/menu';

                        $this->Auth->authorize = 'actions';

                        // What to say when the log in was incorrect.
                        $this->Auth->loginError = 'No matching user found. If you have forgotten your password, click the "Forgot Password" link.';

                        // This is my own home-brewed solution to create allowable pages.
                        // There is actually, I believe, an implementation in AuthComponent

                        // that pretty much accomplishes the same thing. I highly recommend
                        // using the CakePHP-based implementation rather than mine, but

                        // since I already wrote this, I'm using it.
                        if (

                                array_key_exists( $this->viewPath, $this->allowed )

                                && ( $this->allowed[$this->viewPath] == '*'

                                || (

                                        is_array( $this->allowed[$this->viewPath] )

                                        && in_array( $this->action, $this->allowed[$this->viewPath] ) ) ) ) { $this->Auth->allow(); }

                        // If for some reason you want to see the User model, you can get it here.
                        $user = $this->Auth->user(); } } }

Now, I know that the Auth compoment is supposed to actually do this for you, but I was unable to get it to work. I'm probably doing something wrong, but the above is what I ended up doing just to "Get 'er Done." If you leave the User::login function empty, the login action will work, but the session wasn't flashing properly on login success or failure. I gave up trying to figure out why.

User and Group Models
You'll need to modify the models for both Groups and Users so that the ACL component knows that they are "Access Request Objects" and creates new Aro records when you create new groups or users.
First, in models/group.php, add the following. This code is from the LemonCake Tutorial on Groups/Users.

class Group extends AppModel {
        var $name = 'Group';

        var $actsAs = array('Acl'=>'requester');

        function parentNode(){

                if (!$this->id) {

                return null;

                }

                $data = $this->read();

                if (!$data['Group']['parent_id']){
                        return null;

                } else {
                        return $data['Group']['parent_id'];

                }
        }
}

This effectively allows you to create a Group Hierarchy. So if you want one group to inherit permissions from another group, you can set the Group.parent_id to a parent Group.id. Pretty neat, eh? Notice the $actsAs variable. Make sure that's included! (And make sure it's "actsAs" not "actAs." I spent hours tearing my hair out over that stupid mistake.)
In models/user.php add a parentNode function to indicate that the user is a child of a group:

class User extends AppModel {

        var $name = 'User';

        var $actsAs = array( 'Acl'=>'requester' );

 
        function parentNode( ) {
                if (!$this->id) {

                        return null;

                }

                $data = $this->read();

                if (!$data['User']['group_id']){

                        return null;

                } else {
                        return array('model' => 'Group', 'foreign_key' => $data['User']['group_id']);

                }
        }}

This basically tells the ACL component that when it creates a new Aro record for this User, set the Aro.parent_id to the Group.id of the user. That allows permissions to be inherited.

Using Cake Console To Generate Your First ACOs
To actually make this work, you have to manually create the first ACOs. To do this, you can use the cake acl console. To view the available commands, use cake acl help.
The ACO setup is a little difficult to grasp at first, but once you figure out how the ACL component expects things to be structured, it's fairly simple. First, create a ROOT level aco. Then create the controller, then the action. Run each of these commands separately.

cake acl initdb
cake acl create aco / ROOT
cake acl create aco ROOT Users
cake acl create aco Users menu

The "initdb" command creates the table structures for the ACL. You only need to run that once. If you run into problems, you can always log into your database and truncate the aros, acos, and aros_acos tables to start with a clean slate.
Now you need to create your first group and user. This is sort of tricky because when the ACL component creates a user, it will automatically encrypt the password with the Security::hash function (see /cake/lib/sanitize.php). By default it's sha1. Update: A reader pointed out that the hash function is prepended with your CAKE_SESSION_STRING. So when you create your first user, you'll need to use the CAKE_SESSION_STRING in /config/core.php and prepend it to the password string. If your CAKE_SESSION_STRING is 'farflagnoogen' then your password 'j03' would be 'farflagnooganj03'.

INSERT INTO groups (name, description) values ('Administration', 'Our first all-powerful group.');
INSERT INTO users (group_id, firstname, lastname, email, password) values (1, 'John', 'Doe', '[email protected]', SHA1('[CAKE_SESSION_STRING]john'));

This is obviously not a secure username and password, but it'll do for now. Make sure you provide the correct group_id.
Now to create the ARO with the cake console. Back to your command line:

cake acl create aro / Group.1
cake acl create aro Group.1 User.1

"Group" is the model and "1" is the Model.id (Group.id or User.id), so change as needed.

Granting Permissions (Some Oddities Arise)
Now you need to grant permissions. This is where we run into another oddity that I wasn't able to figure out easily. It says in the help file that you have to provide the aco_id and the aro_id. At first I thought that this would be the actual primary key in the acos/aros tables. Wrong... doesn't appear to work. What does appear to work is providing an ARO alias. Unfortunately, when we created the aro, no alias was provided. So we have to give it one. Log into mysql and update the aro for Group.1 and set the alias to 'Admin'. The code would look something like this:

UPDATE aros SET alias = 'Admin' where model = 'Group' and foreign_key = 1;

Now your aro has a proper alias, and you can:

cake acl grant Admin ROOT '*'

That should give the Admin user access to ALL controllers and ALL actions. If you get a message that says:

Warning: DB_ACL::allow() - Invalid node in .../cake_1.2.0.5146alpha/cake/libs/model/db_acl.php on line 154

This is the (for the moment inelegant) way of telling you that either your aro 'Admin' was not found or the aco 'ROOT' wasn't found. So go back and make sure you followed all of the steps correctly.

Can You Log In?
Now at this point you SHOULD be able to go to your application via the web, be redirected to the /users/login/ page (you did create the view, didn't you?), enter the username '[email protected]' and the password 'john' and be successfully redirected to the /users/menu page.

Setting Up Future ACOs
If you have DEBUG set to 2, and you navigate to an action that doesn't yet have an ACO record, you'll get a "node lookup failed" error. My solution was to create an ACL management tool that allowed me to grant group permissions and automatically create the ACO record all at the same time. I'm going to post the code, but I highly recommend that you attempt to understand what this does before you attempt to use it.

In a controller (users, perhaps? place these functions:

       function acl ( $group_id = null, $controller = null, $action = null, $permission = null ) {

                if ($group_id == null) {

                        $this->set('groups', $this->Group->findAll(  ));

                } else if ( $controller == null ){

                        // Prevents a heap-load of error messages from coming up if DEBUG = 2.
                        Configure::write( 'debug', '0' );

                        $group = $this->Group->read( null, $group_id );

                        // See http://cakebaker.42dh.com/2006/07/21/how-to-list-all-controllers/
                        $controllerList = $this->ControllerList->get(  );

                        $controllerPerms = '';

                        $aco = new Aco(  );

                        foreach ($controllerList as $controller => $actions) {

                                foreach ($actions as $key => $action)

                                        $controllerPerms[$controller][$action] = $this->Acl->check($group, $controller . '/'. $action, '*');

                        }
                        $this->set('controllerList', $controllerList );

                        $this->set('controllerPerms', $controllerPerms );

                        $this->set('group', $group);

                } else {
                        $group = $this->Group->read( null, $group_id );

                        if ( $action == 'all' ) {

                                $controllerList = $this->ControllerList->get(  );

                                foreach ( $controllerList[$controller] as $action )

                                        $this->setPermissions( $group, $controller, $action, $permission );

                        } else
                                $this->setPermissions( $group, $controller, $action, $permission );

die;
                        $this->Session->setFlash( $group['Group']['name'] . ' has been granted access to ' . $controller .'/'.$action );

                        $this->redirect( '/admin/administrators/acl/'.$group['Group']['id'] );

                }
        }

        private function setPermissions( $group, $controller, $action, $permission ) {

                        // First check to make sure that the controller is already set up as an ACO
                        $aco = new Aco(  );

                        $rootAco = $aco->findByAlias( 'ROOT' );

                        // Set up $controllerAco if it's not present.
                        $controllerAco = $aco->findByAlias( $controller );//$this->Administrator->query( 'SELECT Aco.* From acos AS Aco LEFT JOIN acos AS Aco0 ON Aco0.alias = "'.$controller.'" LEFT JOIN acos AS Aco1 ON Aco1.lft > Aco0.lft && Aco1.rght < Aco0.rght AND Aco1.alias = "ROOT" WHERE Aco.lft <= Aco0.lft AND Aco.rght >= Aco0.rght ORDER BY Aco.lft DESC' ) );

                        if ( empty( $controllerAco ) ) {

                                $aco->create(  );

                                $aco->save( array (

                                        'alias' => $controller,

                                        'parent_id' => $rootAco['Aco']['id'],

                                ));

                                $controllerAco = $aco->findByAlias( $controller );//$this->Administrator->query( 'SELECT Aco.* From acos AS Aco LEFT JOIN acos AS Aco0 ON Aco0.alias = "'.$controller.'" LEFT JOIN acos AS Aco1 ON Aco1.lft > Aco0.lft && Aco1.rght < Aco0.rght AND Aco1.alias = "ROOT" WHERE Aco.lft <= Aco0.lft AND Aco.rght >= Aco0.rght ORDER BY Aco.lft DESC' ) );

                        }

                        // Set up $actionAcoif it's not present.

                        $actionAco = $aco->find( array( 'parent_id' => $controllerAco['Aco']['id'], 'alias' => $action ) );

                        if ( empty( $actionAco ) ) {

                                $aco->create(  );

                                $aco->save( array (

                                        'alias' => $action,

                                        'parent_id' => $controllerAco['Aco']['id'],

                                ));

                                $actionAco = $aco->find( array( 'parent_id' => $controllerAco['Aco']['id'], 'alias' => $action ) );

                        }

                        // Set up perms now.

                        if ( $permission == 'allow' )

                                $this->Acl->allow( array( 'model' => 'Group', 'foreign_key' => $group['Group']['id'] ), $controller . '/' . $action );

                        else
                                $this->Acl->deny( array( 'model' => 'Group', 'foreign_key' => $group['Group']['id'] ), $controller . '/' . $action );

        }

I admit this is something of an ugly hack, but, hey, it works. I leave it to you to prettify it.

Some Tips To Help You
Patch Your db_acl.php file!
It took me hours, no, days, to get all of the above working. It doesn't help that this version of CakePHP actually has a bug that you will need to patch in db_acl.php. Geoff was kind enough to provide a patch to fix this issue, and hopefully it will be included in the next version of CakePHP 1.2.x. Go to line 312 and clear out the whole AclNode::node function and replace it with the following:

<?php
/**
 * Retrieves the Aro/Aco node for this model
 *
 * @param mixed $ref
 * @return array
 */

  function node($ref = null) {

     $db =& ConnectionManager::getDataSource($this->useDbConfig);

     $type = $this->name;

     $prefix = $this->tablePrefix;

     if (!empty($this->useTable)) {

      $table = $this->useTable;

     } else {

      $table = Inflector::pluralize(Inflector::underscore($type));

     }

     if (empty($ref)) {
      return null;

     } elseif (is_string($ref)) {
      $path = explode('/', $ref);

      $start = $path[count($path) - 1];
      unset($path[count($path) - 1]);

      $path = array_reverse( $path );

      $query  = "SELECT {$type}.* From {$prefix}{$table} AS {$type} ";

      $query .=  "INNER JOIN {$prefix}{$table} {$db->alias} {$type}0 ";
      //$query .=  "LEFT JOIN {$prefix}{$table} AS {$type}0 ";

      $query .= "ON {$type}0.alias = " . $db->value($start) . " ";

      foreach ($path as $i => $alias) {

         $j = $i - 1;
         $k = $i + 1;

         //$query .= "LEFT JOIN {$prefix}{$table} AS {$type}{$k} ";
         //$query .= "ON {$type}{$k}.lft > {$type}{$i}.lft &{$k}.rght < {$type}{$i}.rght ";

         $query .= "INNER JOIN {$prefix}{$table} {$db->alias} {$type}{$k} ";
         $query .= "ON {$type}{$i}.parent_id = {$type}{$k}.id ";

         $query .= "AND {$type}{$k}.alias = " . $db->value($alias) . " ";

      }
      $result = $this->query("{$query} WHERE {$type}.lft <= {$type}0.lft AND {$type}.rght >= {$type}0.rght ORDER BY {$type}.lft DESC", $this->cacheQueries);

     } elseif (is_object($ref) && is_a($ref, 'Model')) {
      $ref = array('model' => $ref->name, 'foreign_key' => $ref->id);

     } elseif (is_array($ref) && !(isset($ref['model']) && isset($ref['foreign_key']))) {

      $name = key($ref);
      if (!ClassRegistry::isKeySet($name)) {

         if (!loadModel($name)) {
          trigger_error("Model class '$name' not found in AclNode::node() when trying to bind {$this->name} object", E_USER_WARNING);

          return null;
         }
         $model =& new $name();

      } else {
         $model =& ClassRegistry::getObject($name);

      }
      $tmpRef = null;
      if (method_exists($model, 'bindNode')) {

         $tmpRef = $model->bindNode($ref);
      }

      if (empty($tmpRef)) {
         $ref = array('model' => $name, 'foreign_key' => $ref[$name][$model->primaryKey]);

      } else {
         if (is_string($tmpRef)) {

          return $this->node($tmpRef);
         }

         $ref = $tmpRef;
      }
     }

     if (is_array($ref)) {
      foreach ($ref as $key => $val) {

         if (strpos($key, $type) !== 0) {
          unset($ref[$key]);

          $ref["{$type}0.{$key}"] = $val;
         }

      }
      $query  = "SELECT {$type}.* From {$prefix}{$table} AS {$type} ";
      $query .=  "LEFT JOIN {$prefix}{$table} AS {$type}0 ";

      $query .= "ON {$type}.lft <= {$type}0.lft AND {$type}.rght >= {$type}0.rght ";
      $result = $this->query("{$query} " . $db->conditions($ref) ." ORDER BY {$type}.lft DESC", $this->cacheQueries);

      if (!$result) {
         trigger_error("AclNode::node() - Couldn't find {$type} node identified by \"" . print_r($ref, true) . "\"", E_USER_WARNING);

      }
     }
     return $result;
  }

?>

Take advantage of the debug output!
If you have DEBUG set to "2" you can see what SQL requests are being sent to look up the ACO and ARO records. That helped me greatly in understanding how the tree was expected to be set up.

More Resources