2010-10-14: Updated array structure in controller.
There are several articles elsewhere covering this, as well as some (claimed) working solutions. I'm not claiming this is necessarily usable for your project, but it works here under light testing for an app with light user requirements.
Problem: How do I provide only access-permitted links to users in various views? Mark Story released a component a couple of years ago that is supposed to handle it, but either I didn't know what I was doing with it (quite possible) or it just doesn't work under the 1.3 system. I didn't spend much time on it. A search on associated topics revealed Neil Crookes talking about getting and caching all ACOs. That could be useful for checking access, but that's some scary code. The lecterror blog has a greatly simplified version of ACO caching, but after staring at and playing with that code, I found that it won't quite work with my ACO/ARO structure, so it doesn't suit my needs either. Result? Build your own!
Checking out all of those solutions helped me to get a better idea of Cake's ACL handling and even a better idea of handling permissions in my own application. My desired permissions handling is to pigeon-hole users in one of 6 groups, which is fairly easy, yet still allow for user-specific permissions and be able to provide edit-own/view-own/etc. privileges under certain controllers. For a CakePHP noob, that's quite a bit to wrap my mind around. I'm going with a solution including prefix routing, controller tweaks, a custom component, and a custom helper. Nasty, eh?
I should mention early here that I'm using
$this->Auth->authorize = 'actions';rather than crud or anything else. The crud option could be better, but I think the number of groups and controllers and methods I'm using would be prohibitive (6 x 43 x ??).
First, the prefix routing (which takes over admin routing in the 1.3 series). A helpful assistant on Stack Overflow recommended against using this method, as well as my own aversion at first (and second, and third, and...). However, prefix routing just seems like the easiest and "cheapest" way for me to handle privilege separation. Consider the classic ACL-controlled application from the documentation. Even something that simple requires edit-own restrictions on the Post controller, for example (which isn't described in the docs, by the way). How do you do that?
Sure, you can just toss a snippet into the posts/edit() function that will restrict edits to only the Post owner, but that will break your managers and admins. That suggests a use for Acl->check() for the group's privileges, but you only have the one ACO action (edit() function) to test against. Try as hard as I might, I can't figure out how to allow everyone access to edit() without giving everyone access to edit().
Another possible solution involved some extra controller methods and ACL setup along with custom routing to hide the background work. All this to differentiate users and permissions. After playing with that for a couple of days and creating a working system, I realized that I was only (sloppily) re-inventing a bad version of what Cake can do with prefix routing and ACL. So I gave up and embraced the "dark side". Here's what one section of the Posts controller looks like:
function _edit($id = null)
{
$this->availableActions = array(
// posts
'posts' => array(
'index' => array(
'title' => 'List Posts'
),
'add' => array(
'title' => 'Add Post'
),
'delete' => array(
'title' => 'Delete Post',
'action_params' => array($id),
'link_params' => array(null, sprintf(__('Are you sure you want to delete # %
s?', true), $id))
)
),
// users
'users' => array(
'index' => array(
'title' => 'List Users'
),
'add' => array(
'title' => 'Add User'
)
)
);
if (!$id && empty($this->data))
{
$this->Session->setFlash(sprintf(__('Invalid %s', true), 'post'));
$this->redirect(array('action' => 'index'));
}
if (!empty($this->data))
{
/**
* new stuff here: compare URL ID with data ID
*/
if ($this->data['Post']['id'] != $id)
{
$this->Session->setFlash(sprintf(__('The %s could not be saved. Please, try
again.', true), 'post'));
}
if ($this->Post->save($this->data))
{
$this->Session->setFlash(sprintf(__('The %s has been saved', true), 'post'));
$this->redirect(array('action' => 'index'));
} else
{
$this->Session->setFlash(sprintf(__('The %s could not be saved. Please, try
again.', true), 'post'));
}
}
if (empty($this->data))
{
$this->data = $this->Post->read(null, $id);
}
$users = $this->Post->User->find('list');
$this->set(compact('users'));
}
function user_edit($id = null)
{
// Users can only edit own.
$post_user_id = $this->Post->findById($id);
$post_user_id = $post_user_id['Post']['user_id'];
if ($this->Auth->user('id') == $post_user_id)
{
$this->_edit($id);
}
else
{
$this->Session->setFlash('You are not authorized to edit that post.');
$this->redirect(array('action' => 'index'));
}
}
function manager_edit($id = null)
{
// Managers can edit all.
$this->_edit($id);
}
function admin_edit($id = null)
{
// Admins can edit all.
$this->_edit($id);
}
The core edit function is private and inaccessible. Prefix routing handles the separation of privileges through the standard "auto-magic" ACL component. Note here that the prefixed functions all reference the core function after their local filtering. In the case above, the "users" group is restricted to edit-own privileges. That's nice from a code re-use and maintenance perspective. And if widely varying funcitonality is required for different groups, this handles it with no problems at all - as opposed to a monolith function that would need to handle all users.
Of course, the whole
$this->availableActionsthing is distracting, so let's look at that now.
The availableActions variable is one of the tweaks. Here are the "non-standard" controller items:
var $components = array('AclPermittedActions');
var $availableActions = array();
The AclPermittedActions component is coming up shortly. The availableActions class variable is being used to store actions that I want to build links for if the user has access. For example, if the user is on the posts/index page, it's meaningless to provide and edit or delete link (not on the post itself, but rather from the "main" menu). Those links would be good to have on the view page, though. So, each controller action that will result in a page gets its own availableActions set and the component picks that up for access testing.
Here's the structure that the component expects:
$this->availableActions = array(
'controllerName' => array(
'actionName' => array(
'title' => 'Link Title Text',
'action_params' => array(),
'link_params' => array(
'options' => null,
'confirm' => 'Your desired confirmation string',
),
'skip_prefix' => false
)
)
);
controllerName is the name of the controller and actionName is the name of the action to be used in construction of the permissions test and, if permitted, link building through the helper. They are required to be the array keys. The link title is self-explanatory, but the other three inner elements might need clarification.
The action_params array gets looped through by the component after permissions are tested against the controller/action pair and are added to what the helper will turn into the URL. Consider deleting the Post with ID 123. We'll be Acl->check()ing against only the delete() method, but we need the ID for the function argument. That's where action_params would come into play. Since it's an array (with a loop in the component), it could be used for passing more parameters than just the ID.
The link_params array is similar to action_params, but it's for the rest of the parameters that may be used to build a link with the HTML helper. Consider again the delete() function. The bake process generates a confirmation message for the delete function. That goes into the link_params array. Essentially, anything that needs to go to Html->link (past the first two arguments) goes into this array.
The skip_prefix is a bool and is used to turn off prefix routing for an access check and, if permitted, the associated link. Set to true to turn it off.
Here's the component code:
<?php
class AclPermittedActionsComponent extends Object
{
var $name = 'AclPermittedActions';
var $components = array('Auth', 'Acl');
var $permitted = array(); // Permitted actions
// CONFIG
var $groupModel = 'Group';
var $groupModelNameField = 'name';
var $groupModelWeightField = 'weight';
// END CONFIG
//called before Controller::beforeFilter()
function initialize(&$controller, $settings = array())
{
// saving the controller reference for later use
$this->controller =& $controller;
}
//called after Controller::beforeFilter()
function startup(&$controller)
{
}
//called after Controller::beforeRender()
function beforeRender(&$controller)
{
$this->filterActions($controller->availableActions);
$controller->set('permittedActions', $this->permitted);
}
//called after Controller::render()
function shutdown(&$controller)
{
}
//called before Controller::redirect()
function beforeRedirect(&$controller, $url, $status=null, $exit=true)
{
}
function redirectSomewhere($value)
{
// utilizing a controller method
$this->controller->redirect($value);
}
/**
* 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
if ($this->Auth && $this->Acl && $uid = $this->Auth->user('id') && is_array
($availableActions))
{
// 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
$acoPath .= $controller;
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 .= '/'.$action;
$acoArray = array('controller' => $controller, 'action' => $action);
}
else
{
$aAcoPath .= '/'.$groupName.'_'.$action;
$acoArray = array($groupName => true, 'controller' => $controller,
'action' => $action);
}
// TODO: REPLACE ACL->CHECK WITH A CHECK AGAINST CACHE/SESSION
// Only checks against actions, not specific params passed to an action
if ($this->Acl->check($aroArray, $aAcoPath) || ($controller == $this-
>controller->name && in_array($action, $this->Auth->allowedActions)))
{
// 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']))
{
// default values are from the HTML->link docs
$permittedLinkData[] = isset($params['link_params']['options']) && !empty($params['link_params']['options']) ? $params['link_params']['options'] : array();
$permittedLinkData[] = isset($params['link_params']['confirm']) && !empty($params['link_params']['confirm']) ? $params['link_params']['confirm'] : false;
}
$this->permitted[$groupName][$controller][] = $permittedLinkData;
}
}
}
reset($availableActions);
}
}
}
}
?>
Most of the methods are blank because it's build on the template provided in the manual.
Note the config section at the top. I had considered turning this whole thing into a plugin, but I don't want to take the time to make it that generic. The config section is just one of the relics of that consideration. The meat is beforeRender and filterActions.
beforeRender happens before the view is generated. That's just where we want to run code that handles links in the views. And since we set availableActions as a class variable in our controller, we can easily grab our array here and pass it to the processor function filterActions.
filterActions crosses all system groups with all incoming availableActions to check permissions. ACL provides the means to allow/deny an action for a single user (in addition to group-level access). Since we can do that, then we need to check all group levels for the current ARO's (the current user's) permissions for a given action. For permitted actions, the component builds another array in a class variable. The associated helper expects the format of:
array(
'groupName' => array(
'controllerName' => array(
array()
)
)
)
The innermost array is structured as the array used by the Html helper's link method.
There is actually a little views work to do as well. Here's and example of how to use the helper in views files. Just replace the actions div contents with a simple call to the AclPermittedLink helper:
<div class="actions">
<h3><?php __('Actions'); ?></h3>
<?php
$this->AclPermittedLink->renderLinks($permittedActions);
?>
</div>
Note that an implication of using prefix routing is creating appropriate views files for each new controller method. Keeping code re-use and maintainability in mind, I just created mine like this:
//user_edit.ctp
<?php
require_once('edit.ctp');
?>
Just like the prefixed functions in the controller, if the requirements vary between groups, the foundation is there to handle it. In this case, just create the custom view. Otherwise, the only drawback is extra files.
Finally, here's the AclPermittedLink helper file:
<?php
class AclPermittedLinkHelper extends AppHelper
{
var $helpers = array('Html');
function renderLinks($permittedActions)
{
$ulStarted = false;
$output = '';
if (!empty($permittedActions))
{
foreach ($permittedActions as $groupName => $controllers)
{
if (empty($permittedActions[$groupName]))
{
continue;
}
$oGroup = Inflector::humanize($groupName);
$output .= '<h3 class="AclPermittedLinkGroup"
id="AclPermittedLinkGroup_'.$oGroup.'">'.$oGroup.' Access</h3>'.PHP_EOL;
// loop $groupName's $controllers
foreach ($controllers as $controller => $links)
{
if (empty($controllers[$controller]))
{
continue;
}
$oController = Inflector::humanize($controller);
$output .= '<h4 class="AclPermittedLinkController"
id="AclPermittedLinkController_'.$oController.'">'.$oController.'</h4>'.PHP_EOL;
$output .= '<ul>'.PHP_EOL;
// loop $controller's $links
foreach ($links as $link)
{
$output .= '<li>'. call_user_func_array(array($this->Html, 'link'),
$link) .'</li>'.PHP_EOL;
}
$output .= '</ul>'.PHP_EOL;
}
}
echo $output;
}
}
}
?>
The helper simply loops through the array created by the AclPermittedLink component and dumps it to the view. I didn't provide options (yet), but it could easily be extended to toggle the "GroupName Access" grouping and the controller name grouping. Now that I think of it, I may go ahead and do that just for fun. A couple of extra paramters should fix that right up, but I'll probably go ahead and use the current groupings.
The most noticeable drawback for me is that this seems a bit of a kludge. Certainly, during testing on a sandbox here, it's a working kludge, but a kludge nonetheless. Could stand to be a bit more elegant.
More controller/action pairs means more queries. My posts/index page on the sandbox, which provides 3 links to test against 3 groups, runs 42 queries. Granted, it's quick, but that's still quite a number of queries. This could be mitigated with caching, but I haven't found a system that I like that also works. The closest was Phally's CachedAclComponent, but I've yet to figure out how to get my component to play with it. I've also considered creating custom caching, but that's a step beyond what I'm looking to do. Still, it might be a fun exercise in learning another aspect of CakePHP.
If this is useful, drop a line and let me know.
Add new comment