UPDATE: I've switched over to using Phally's namespace caching and have updated the relevant lines in the code below.
Note the two previous stories on this subject:
Specifically, I've changed the way I'm caching from "fake" caching with sessions to using CakePHP's core cache utility library. The session thing looked nice on a sandbox, but when I tested it with my dev app's permissions tree, things got ugly. The problem is that on user login, the app would need to generate a fresh permission "cache". That's expected. What wasn't expected was the time it would take: about 2 minutes.
The app in question has about 40 controllers with several actions in each. That requires a lot of entries in the aros_acos table, which means there's quite a bit of db thrashing for Cake to use my fake-caching technique. Indexing the ACL tables helped quite a bit (access times down to sub-40 seconds), but not enough. So I started digging through the CakePHP docs and figured out that caching was easier than I had originally thought.
Here's the replacement functions I'm using in AppController. Note that they're almost the same code as used in the original _sessioncachePerms() function in the second link above.
/**
* File caches accessible ACL paths for every user (individually)
*/
function _cacheAllUserPerms()
{
set_time_limit(0);
$uids = ClassRegistry::init('User')->find('list', array('fields' => 'id'));
foreach ($uids as $uid)
{
$this->_cacheUserPerms($uid);
}
flush(); // with set_time_limit()
}
/**
* File caches accessible ACL paths for a user with the ID $uid
*
* @param int $uid User ID
*/
function _cacheUserPerms($uid)
{
set_time_limit(0);
Cache::delete('ns_permissions.AclPerms.User'.$uid, 'ns_permissions');
$gid = ClassRegistry::init('User')->findById($uid);
$gid = $gid['User']['group_id'];
// group perms
$pArr = array();
$allPerms = $this->Acl->Aro->find('threaded', array('conditions' => array('Aro.foreign_key' => $gid)));
foreach ($allPerms[0]['Aco'] as $action)
{
$pArr[] = $this->Acl->Aco->getpath($action['id']);
}
$perms = array();
$permsCounter = 0;
foreach ($pArr as $permStruct)
{
$perms[$permsCounter] = '';
foreach ($permStruct as $permChunk)
{
$perms[$permsCounter] .= $permChunk['Aco']['alias'] .'/';
}
$perms[$permsCounter] = rtrim($perms[$permsCounter], '/');
if (!$this->Acl->check(array('model' => 'User', 'foreign_key' => $uid), $perms[$permsCounter]))
{
unset($perms[$permsCounter]);
}
$permsCounter++;
}
// $perms contains permitted ACOs, but not Auth->allowedActions
// user perms
$pArr = array();
if (!empty($allPerms[0]['children']))
{
foreach ($allPerms[0]['children'][0]['Aco'] as $action)
{
$pArr[] = $this->Acl->Aco->getpath($action['id']);
}
foreach ($pArr as $permStruct)
{
$perms[$permsCounter] = '';
foreach ($permStruct as $permChunk)
{
$perms[$permsCounter] .= $permChunk['Aco']['alias'] .'/';
}
$perms[$permsCounter] = rtrim($perms[$permsCounter], '/');
if (!$this->Acl->check(array('model' => 'User', 'foreign_key' => $uid), $perms[$permsCounter]))
{
unset($perms[$permsCounter]);
}
$permsCounter++;
}
}
// $perms contains permitted ACOs, but not Auth->allowedActions
Cache::write('ns_permissions.AclPerms.User'.$uid, $perms, 'ns_permissions');
}
Very little difference from the original besides using Cache instead of Session. I also chose to make a variable name change in the filterActions() function of the AclPermittedActions component (first post). Since I've also made a couple of other tweaks since the first, I'll post the updated version here:
/**
* Filters available actions (links) from this controller/action based upon ACL. Stores in class variable.
*
* @param array $actions Possible links
* @return bool False on failure
*/
function filterActions($availableActions)
{
// Auth and Acl are required
$uid = $this->Auth->user('id');
if ($this->Auth && $this->Acl && $uid && is_array($availableActions))
{
// require cached permissions
$cachedPerms = Cache::read('ns_permissions.AclPerms.User'.$uid, 'ns_permissions');
if (empty($cachedPerms))
{
return false;
}
// This ARO
$aroArray = array('model' => $this->Auth->userModel, 'foreign_key' => $this->Auth->user('id'));
// Get system groups
$params = '';
$groups = array();
if (isset($this->groupModel) && !empty($this->groupModel))
{
$params = array('recursive' => 0, 'fields' => $this->groupModelNameField);
if (isset($this->groupModelWeightField) && !empty($this->groupModelWeightField))
{
$params['order'] = array($this->groupModelWeightField.' ASC');
}
$groups = ClassRegistry::init($this->groupModel)->find('all', $params);
}
else
{
return false;
}
// Outer loop groups (group permitted actions by group name)
foreach ($groups as $group)
{
$groupName = Inflector::singularize($group[$this->groupModel][$this->groupModelNameField]);
// Loop through the incoming actions
foreach ($availableActions as $controller => $actions)
{
$acoPath = $this->Auth->actionPath;
if (!is_array($availableActions[$controller]))
{
continue;
}
// Build the ACO path
$controllerName = Inflector::camelize($controller);
$acoPath .= $controllerName;
foreach ($actions as $action => $params)
{
$aAcoPath = $acoPath;
if (!is_array($actions[$action]))
{
continue;
}
if ($action == '' || is_numeric($action))
{
continue;
}
$skip_prefix = isset($params['skip_prefix']) && $params['skip_prefix'] === true;
if ($skip_prefix)
{
$aAcoPath .= '/'.strtolower($action);
$acoArray = array('controller' => $controller, 'action' => $action);
}
else
{
$aAcoPath .= '/'.$groupName.'_'.strtolower($action);
$acoArray = array($groupName => true, 'controller' => $controller, 'action' => $action);
}
// Only checks against actions, not specific params passed to an action
if (in_array($aAcoPath, $cachedPerms) // explicit permission
|| ($controller == $this->controller->name && in_array($action, $this->Auth->allowedActions)) // action allowed in controller
|| in_array(rtrim($this->Auth->actionPath,'/'), $cachedPerms)) // global access
{
// Loop through remaining URL parameters passed to the action
if (isset($params['action_params']) && !empty($params['action_params']))
{
foreach ($params['action_params'] as $actionParam)
{
$aAcoPath .= '/'. $actionParam;
$acoArray[] = $actionParam;
}
}
$permittedLinkData = array($params['title'], $acoArray);
if (isset($params['link_params']) && !empty($params['link_params']))
{
foreach($params['link_params'] as $linkParam)
{
$permittedLinkData[] = $linkParam;
}
}
$this->permitted[$groupName][$controller][] = $permittedLinkData;
}
}
}
reset($availableActions);
}
}
}
The helper (AclPermittedLinks) required no changes. My initAro() function, which I manually run to modify group and user permissions, now calls
Cache::delete('ns_permissions.AclPerms', 'ns_permissions'); and $this->_cacheAllUserPerms();instead of making the old call. And I've modified the UsersController add and edit functions to call
_cacheUserPerms()as needed. Here are those mods:
// add method; standard baked code except for one line
if ($this->User->save($this->data))
{
// build permissions cache for this user (see similar code in _edit)
$this->_cacheUserPerms($this->User->id);
$this->Session->setFlash(sprintf(__('The %s has been saved', true), 'user'));
$this->redirect(array('action' => 'index'));
}
// edit method; standard baked code except for a couple of lines
$original_gid = $this->User->field('group_id');
if ($this->User->save($this->data))
{
// rebuild permissions cache if the group has changed (see similar code in _add)
if ($original_gid !== $this->data['User']['group_id'])
{
$this->_cacheUserPerms($this->User->id);
}
$this->Session->setFlash(sprintf(__('The %s has been saved', true), 'user'));
$this->redirect(array('action' => 'index'));
}
Now the slowness is quite a bit more isolated. I've set the cache for a long expiration and am considering using a maintenance cron job to update the perms cache during off-hours occasionally. Doing that should make it so that the only times the system would bog down is on permissions changes (or the subsequent running of my initAro function), user additions, and user edits where the group is changed. I can mitigate the impact from permissions changes by going into maintenance mode before a permissions update. The other two could be troublesome for an admin that doesn't expect the performance hit. Need some more review here.
What I don't like about this method is that I have a file for each user on the system. While the app won't ever have many users under expected circumstances, it's still unattractive (to me) to have those few files and possibly many more. I may go back and look at some namespace caching in order to help me reduce the file count, at least.
Add new comment