RSS

Neil Crookes

Learnings and Teachings on Web Application Development & CakePHP

Feb

26

Get ALL ACL permissions

Use ACL in your CakePHP application? Ever wanted to load all permissions for a user at login? Ever tried it with Acl->check() for each aco in your system and found your users get bored of waiting for your application to log them in? Then try this solution for loading all permissions in one query, that takes a few milliseconds, all thanks to a young man called Ben Roberts.

Share and Enjoy:

  • Digg
  • del.icio.us
  • StumbleUpon
  • Technorati
  • Slashdot

Over the last couple of months, whilst working on several projects at work, I’ve also been developing our base base framework, which is built on top of CakePHP, that we use as a start point for all our projects.

I finally got to the point where I needed to add in authentication and access control to the CMS for the projects, enter CakePHP Auth component and ACL behavior/component.

All went smoothly until I got to the point that after logging in, I wanted to load all the permissions for the User into the session, so I can do things like check if the user has permission to add a new anything, before displaying a link to that action.

I used Mark Story‘s excellent ACL extras shell to scan my controllers (with some of my own additions to scan plugin controllers, which I’ll add through git hub shortly), and I ended up with 250 ish acos.

After logging in I used acos->find all then iterated through the list doing ACL->check with the logged in user id.

Each check does 4 queries (in my app):

  1. Find the aro for model User, foreign_key <logged in user id>
  2. Find the aco for the alias <controller>/<action>
  3. x2 Checks for permissions (could could be more ore less depending on your acos/aros hierarchies)

Queries 1 and 3 were quick, but the 2nd one to find the aco was painful at 300ms due to 3 self joins on the acos table.

I hunted around for some solutions and found a couple of posts suggesting additional indexes (indices?) on the table, so I tried adding them and it had a positive effect, halving the query time down to ~150ms, but stil not good enough. Logging in was taking a lengthy minute on my development machine, which  is fairly high spec.

Clearly the CakePHP ACL component check method was not a viable solution – it’s fine for checking access to a single aco, but throw any number greater than about 10 into the mix and your starting to notice a significant lag in your login process.

With debug at 2 I sent the problem query to a friend and colleague who is a little bit good at SQL.

He sent it back with some conditions on the joins rather than in WHERE clause, thus reducing the initial size of the result set that the database engine needs to create before applying the WHERE conditions, and this speeded it up by a factor of 5 – returning in 30ms.

But, 30 x 250 is still… a lot of milliseconds, and I couldn’t be bothered to figure out how to integrate this aco finding query into the ACL check without hacking the core, so I promised my friend/colleague a pint of beer if he could figure out how to get all the permissions for a given user out of the database in one go.

And the little diamond only went and bloody did it didn’t he?

Not only that – even with 250 acos it’s lightning quick as well.

Tests so far show it’s working a treat on my aros/acos/aros_acos setup, which is pretty standard.

I’ve got users and groups, where users belong to group, ~250 acos, with “controllers” as my root node, then a list of controller names under that, e.g. Posts, Widgets etc, then a list of actions under each controller, e.g. admin_add, admin_edit etc.

I’ve granted a super user group full access to controllers, i.e. everything, and the client group full access to most controllers, but not ones that are system and admin settings like Groups etc.

The query takes 70ms. Don’t ask me how it works – I ain’t got a scooby, but it does, for this setup anyway. So here it is in a method in my User model:

  function permissions($userId) {
    $this->id = $userId;
    $node = $this->node();
    $aroId = $node[0]['Aro']['id'];

    $sql = "SELECT DISTINCT alias
  FROM
    (
  SELECT
  user_id.aro_id
  ,(
  SELECT
case when controler is null then method WHEN master is null then concat(controler,'/',method) else concat(master,'/',controler,'/',method) end as alias
FROM
  (SELECT id,`alias` as method, (SELECT `alias` FROM acos WHERE id=t.parent_id) as controler, (SELECT `alias` FROM acos WHERE id=(SELECT `parent_id` FROM acos WHERE id=t.parent_id)) as `master` FROM acos t) Aco
WHERE Aco.id=u.aco_id OR Aco.id=p.aco_id
  ) as name,
  CASE WHEN u.aco_id=0  OR u.aco_id is NULL  THEN p.aco_id  ELSE u.aco_id  END as aco_id,
  CASE WHEN u._create=0 OR u._create is NULL THEN p._create ELSE u._create END as _create,
  CASE WHEN u._read=0   OR u._read is NULL   THEN p._read   ELSE u._read   END as _read,
  CASE WHEN u._update=0 OR u._update is NULL THEN p._update ELSE u._update END as _update,
  CASE WHEN u._delete=0 OR u._delete is NULL THEN p._delete ELSE u._delete END as _delete
  FROM
  (SELECT 'a' as flag,$aroId as aro_id) user_id
   LEFT JOIN
  (
  SELECT 'a' as mark1,aco_id,`_create`,`_read`, `_update`, `_delete` FROM `aros_acos` AS `Permission` LEFT JOIN `aros` AS `Aro` ON (`Permission`.`aro_id` = `Aro`.`id`) LEFT JOIN `acos` AS `Aco` ON (`Permission`.`aco_id` = `Aco`.`id`) WHERE `Permission`.`aro_id` = $aroId  ORDER BY `Aco`.`lft` ) u ON u.mark1=user_id.flag
  LEFT JOIN
    (
  SELECT 'a' as mark2,aco_id,`_create`,`_read`, `_update`, `_delete`  FROM `aros_acos` AS `Permission` LEFT JOIN `aros` AS `Aro` ON (`Permission`.`aro_id` = `Aro`.`id`) LEFT JOIN `acos` AS `Aco` ON (`Permission`.`aco_id` = `Aco`.`id`) WHERE `Permission`.`aro_id` = (
              SELECT id
              FROM aros
              WHERE id=(SELECT parent_id from aros WHERE id=$aroId)
            )
 )p ON p.mark2=user_id.flag
WHERE u._create=1 OR u._read=1 or u._update=1 or u._delete=1
OR p._create=1 OR p._read=1 or p._update=1 or p._delete=1
           ) tt
                      LEFT JOIN
           (
SELECT `Aco`.`id` , `Aco`.`parent_id` , `Aco`.`model` , `Aco`.`foreign_key` , `Aco`.rght,`Aco`.lft,
case when controler is null then method WHEN master is null then concat(controler,'/',method) else concat(master,'/',controler,'/',method) end as alias
FROM
  (SELECT *, `alias` as method, (SELECT `alias` FROM acos WHERE id=t.parent_id) as controler, (SELECT `alias` FROM acos WHERE id=(SELECT `parent_id` FROM acos WHERE id=t.parent_id)) as `master` FROM acos t) Aco
           ) tmp_aco
           ON tmp_aco.id=tt.aco_id OR tmp_aco.alias like concat(tt.name,'%')
    ";

    $permissions = $this->query($sql);
    $permissions = Set::extract('/tmp_aco/alias', $permissions);
    return $permissions;
  }

The query my friend/colleague gave me had the ‘*’ (all fields) wildcard instead of the ‘DISTINCT alias’ string in the fields list at the start of the query. This returned all the aros_acos fields including the CRUD flags, so adding this back in might work for you too if you use the CRUD flags in your ACL implementation. But I think you’ll need to add a GROUP BY alias clause as well, because without the DISTINCT keyword I got a few duplicates in the result set.

To add the permissions to your session at login, be sure to set Auth->autoRedirect to false and have something like this in your UsersController:

  function webadmin_login() {
    if ($this->Auth->user()) {
      $this->Session->write('Auth.Permissions', $this->User->permissions($this->Auth->user('id')));
      $this->redirect($this->Auth->redirect());
    }
  }

Now you can add a permission check method to your AppHelper ….

  function hasPermission($url) {

    if (!is_array($url)) {
      return false;
    }

    extract($url);

    if (!isset($controller)) {
      $controller = $this->params['controller'];
    }

    $controller = Inflector::camelize($controller);

    if (!isset($action)) {
      $action = $this->params['action'];
    }

    $_admin = Configure::read('Routing.admin');

    if ((isset(${$_admin}) && ${$_admin}) || $this->params['action'][$_admin]) {
      $action = $_admin.'_'.$action;
    }

    $permission = 'controllers/'.$controller.'/'.$action;

    return in_array($permission, $this->Session->read('Auth.Permissions'));

  }

…and call it in your views, where the $url param is exactly what you’d send to $html->link($title, $url), i.e. an array containing optional keys for ‘controller’, ‘action’ and ‘<whatever your Routing.admin is set to>’.

To simplify the “echo link if permitted” concept, I’ve also written a helper method called, wait for it, $app->linkIfPermitted() ;-)

It accepts the same params as HtmlHelper::link(), but does a hasPermission check before returning the link.

So hey, try it out, if it works for you, and you like it, send love and best wishes to Ben Roberts, and come back and leave me a comment to let me know.

If the SQL part doesn’t work for you, don’t come back and ask me how to get it to work, ‘cus I won’t be able to help you!

Share and Enjoy:

  • Digg
  • del.icio.us
  • StumbleUpon
  • Technorati
  • Slashdot
(3 votes, average: 5.00 out of 5)
Loading ... Loading ...

22 Responses so far

That’s a pretty neat approach Neil. That query is quite the beast, I’m looking forward to the shell patch :)

Neil, Is it always needed to get all ACOs up front? My approach has been to do a quick check for an individual item upon request for that user. I’m a little new to ACL, so I could be totally off course. Any thoughts?

That’s a heck of query.

Question for you. Your User::node() function, does it return the aroId of the user or the group?

I ran the query on my ACL setup and it worked ok for the parent ARO (group in your case), but for the child ARO (user) I got the error: Subquery returns more than 1 row

-Matt

@all, cheers for comments.

@mark, patch onit’s way soon, as soon as I get internet up and running in my new house.

@jason, yeah I think it is important to be able to get all the permissions at once – imagine a dashboard page in the admin area of your app which lists all recent activity with links to edit/delete/publish/disallow item etc. It’s important to only display the links to users that have permission to access them. Low level users will get a bit annoyed if their dashoard is plastered with links to actions they don’t have permission to commit.

@matt, the node method returns an array of the hierarchy of aros, index 0 in the array is the aro for the user, then index 1 is the aro for the group – I think the query will probably only work for this kind of setup.

Hi Neil,

That is one collosal sql query ;). I started looking at how to improve a few things tree/acl related a little while ago (for 1.3) and you might find this query (the select, although in this case I stick it in a table to run once read many) interesting to compare to: http://trac.assembla.com/mi/browser/branches/acl_sandbox/plugins/acl/models/permission_cache.php#L127

Some screenshots for reference:
All permissions defined: http://is.gd/mFZM
Full derived permission list for 1 aro: http://is.gd/mG03
SQL for above: http://is.gd/mG0d
SQL to generate the cache: http://is.gd/mG0n

Although there is only a small dataset in the screenshots, I’ve been testing with > 1000 aros and > 1000 acos. You might notice the cache doesn’t directly account for field level inherited rules – would need some ‘mop up’ logic to take care of those, which I haven’t written/thought about yet.

Btw “but the 2nd one to find the aco was painful at 300ms due to 3 self joins on the acos table” isn’t accurate – it’s not the joins but the conditions used which currently force a full table scan. There’s something coming to resolve that, which would reduce the time for those sort of queries to ~0.

Anyway, nice post,

AD

Hey Andy, cheers for the comment. I’ll check out your query as soon as I get into to work tomorrow. Looks promising though.

I know what you mean about the sentence about the self joins – what you said is what I meant – 3 self joins with full table scan for each, so the result set the where conditions are applied to is essentially, the number of records in the table to the power of the levels in the Aco hierarchy. Ouch. The work done to improve this – does it involve moving the conditions into the joins?

Any other cool stuff coming in 1.3 that you can share exclusively on my blog?

[...] Neil Crookes » Get ALL ACL permissions (tags: cakephp acl) Possibly related posts: (automatically generated)links for 2009-01-15Media types in css serve up tags for print or handheld devicesAdv. Scripting – Week 2My daily readings 05/11/2008 [...]

[...] Crookes posted a query that will grab all the ACL permissions at once. Be warned, this query is not for the faint of heart. It feeds on three whole cows a day [...]

You could just use this to get the permissions:

function permissions($userId) {
return $this->Acl->Aro->find(‘threaded’,array(‘conditions’=>array(‘Aro.foreign_key’=>$userId)));
}

It seems to be a little simpler than that huge statement

Neil,

“@mark, patch onit’s way soon, as soon as I get internet up and running in my new house.”

When will this happen? I need patch desperately too :)

Thanks in advance

Does this require your User model to “actAs” ACL (behaviour)?
I’m asking only because i’m not sure , but i see the node() function call.

Also, if it works based on the node, why do you need the $userId to be passed to the function ?

thanks,
Ken.

@Ken, yes it does require your User model to “actAs” ACL. The node() function requires the id property of the User model set. You could do it outside the permissions() function if you wanted.

@Neil, Thank i got it working.. it works great!

Also, i was wondering if this can somehow be used to remove the ACL checks that are ran at each page load (The ACL SQL queries) – since we loaded all the permissions into the Session.. it is somehow possible to override the check process to try the session first?

I know the main purpose of loading them into the session is for the views, but maybe it can also be used to cut down on those queries that run on each page… thus giving this massive query even more credit

Like to hear your thoughts.

Thanks, Ken.

@Ken, I’m sure it could. The cook book implies you could set the AuthComponent::authorize property to ‘controller’ then have a AppController::isAuthorized() method that could check the session. Let me know how you get on.

@Neil: Yep your lead was right on!
That’s exactly what i did…

AppController::isAuthorized() is much like your “hasPermissions”.. only it returns the “in_array” of your $this->params['controller'] with $this->params['action'] – and not based on parameters sent to it.

This effectively reducded the amount of SQL’s for permissions to a single huge query upon login and eliminated the 3 queries that were ran upon each page load.
Well worth it when someone travels in the site a lot.

Thanks for everything, your blog has been a tremendous source of help!

That query is really impressive! Unfortunately, it returns an empty array on deeper groups hierarchy. I tested it with “Group1 -> Group2 -> User3″ and it returned an empty array. But for “Group1 -> User2″ the query does a perfect job. I’ve never thought it is possible to achieve such amazing results with SQL.

Amazing work.

I tested the case when I have groups of users. When I add the group permissions, all users of that group (with this own aro_id) was resolved. well done.

but, when i tested again its own rights on acos_aros table, when I define it against their fields: _create,_read,_update,_delete, works that way: defining at least one of them with 1, gets its permissions, but allowing and denying each of them(0 on _create, but 1on _read) give me worng results.
so, how can retrive correct permissions from acos_aros definitions?
thanks.

sorry…
some corrections on last lines.
…wrong results…
…retrieve the correct permissions from aros_acos…
sorry.

the query not works too in the situation when I give some permissions on group but deny some cascade permission on the user.
Example: accept Users to group1 but deny group1:user1 to edit in Users. dont works.
thanks.

If we are 2 controllers which names starts with same string, the (big) request return wrong permission.

-> For good results : add “/” on last line just behind % like :

ON tmp_aco.id=tt.aco_id OR tmp_aco.alias like concat(tt.name,’/%’)

Hi,
I tried to implement this in CakePHP 2.0 but I get the following error:
Fatal error: Call to a member function hasPermission() on a non-object in …

In my View I have this:

if ($this->App->hasPermission(‘/admin/groups’) == true) {

}

and my AppHelper looks like this:

App::uses(‘Helper’, ‘View’);
class AppHelper extends Helper {
function hasPermission($url) {

}
}

Can you tell me what I did wrong?

Thanx in advance.

@Neil: I have solved the problem I posted before. I have updated the AppHelper::hasPermission for version 2.0.

@cesar: I see the same problem. When you first allow and then deny on a lower level (controller/action) then it still gives the denied nodes as allowed.
Does anyone have a solution for this.

Regards,

Luc

Leave a comment