Limit Data by User with CakePHP
Through the years, I have realized there are two types of web applications.
- Community
- Single User
While there are certainly some hybrid combination’s of these classifications, all applications can be categorized into these two categories. For example PHPNuke. It’s a single login to a community. As a user, you can use all of the functionality in the application AND you can see other users, etc. While something like FaceBook for example is a Hybrid of community and single user. While you can allow other users to see your data and content, they cannot modify your data or settings. You own that and nobody else has access to it without your username and password.
So comes the question. How can I limit data to a specific user with CakePHP? I love the CakePHP framework. But I have never been able to get a straight answer from anyone on the proper “cakeish” way to limit data to a specific user. For example. Let’s say I want to build a check book balancing application. I want it to be available to multiple subscribers. While all subscribers have access to the same functionality, they do not all see or modify the same data. While their data should be limited, they may all have access to the same Bank. This means that any user should be able to see all the banks we currently support… for example.
I have searched the internet and posted to stackoverflow.com trying to find the answer to this question. It is apparent that I am not the only one trying to figure this out. Add in the potential complexity to provide ADMIN routing and what you potentially have is a complicated mess of code if it is not done properly.
Well, search no more my Internet Friends! I think I have figured out the mess. I have been able to use a combination of things I have learned from here and here. But ultimately, I had to build this with good old ingenuity and a lot of trial and error. Keep Reading!
The Code
To start out with, there has to be a way to determine if the user is an ADMIN or just a standard USER. This will require that we put a ‘roll’ field in the users table. Here is what my table looks like.
--
-- Table structure for table `users`
--
CREATE TABLE `users` (
`id` char(36) NOT NULL,
`username` varchar(255) NOT NULL,
`password` varchar(40) NOT NULL,
`first_name` varchar(35) NOT NULL,
`last_name` varchar(35) NOT NULL,
`role` enum('admin','user') NOT NULL DEFAULT 'user',
`active` tinyint(4) NOT NULL DEFAULT '0',
`created` datetime NOT NULL,
`modified` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
There is nothing really new about this table. It’s fairly straight forward and a typical users table. I use an email address for the username.
Now, the thing to keep in mind is I want to limit all of the data a USER sees to her own data while allowing any global data. This means there are a few checks that must occur each time the user accesses the site. Now for the “magic” piece that will accomplish this.
<?php
class AppController extends Controller {
beforeFilter() {
if ($this->isAdmin()) {
$this->beforeAdminFilter();
} else {
$this->beforeUserFilter();
}
}
function beforeAdminFilter() {
if ($this->Auth->user('role') !== 'admin') {
$this->Session->setFlash(sprintf(__('%s is not authorized!', true), 'User'));
$this->redirect('/users');
}
}
function beforeUserFilter() {
Configure::write('user_id', $this->Session->read('Auth.User.id'));
}
function isAdmin() {
return (isset($this->params['admin']) && $this->params['admin']) ? true : false;
}
}
A few things I need to point out here. I use the StudioCanaria method for ACL. This is a great solution in my opinion. But I am not showing all of the code for that solution. It would make it more obscure and difficult to understand. This is just the vanilla portions I added in for my “limiting data” purposes.
Now for the more complicated part.
<?php
class AppModel extends Model {
var $user_id;
function beforeFind($queryData) {
if($this->user_id = Configure::read('user_id')){ // must be a user (not admin)
if(isset($this->_schema['user_id'])) {
if(isset($this->user_id)) {
$queryData['conditions'][$this->name.'.user_id'] = $this->user_id;
}
}
if($this->name == 'User') {
$queryData['conditions'][$this->name.'.id'] = $this->user_id;
}
}
return $queryData;
}
function beforeSave() {
if($this->user_id = Configure::read('user_id')){ // must be a user (not admin)
if(isset($this->_schema['user_id'])) {
$this->data[$this->name]['user_id'] = $this->user_id;
}
}
return true;
}
}
?>
Isn’t this how it is suppose to be? The heaviest part of the code should be in the models? Well this is the part that does the heavy lifting. Basically this will restrict anything that is associated to a USER. Ok, let’s dive in and I will explain how it works.
beforeFind
This is the function that is called before any search is done on the data. Now, keep in mind the two caveats to this configuration. 1- Not all data is associated to a USER and 2) ADMIN users are not limited. With that in mind, let’s analyze this code.
if($this->user_id = Configure::read('user_id')){ // must be a user (not admin)
This line establishes that we are in fact dealing with a USER. Notice I am using a “global variable”? This is so I can easily pass the data back and forth between the APP_CONTROLLER and MODEL_CONTROLLER.
Now comes the first part of the magic. Does this schema (Model) require a user_id field?
if(isset($this->_schema['user_id'])) {
This will determine if we need to actually “limit” the data. It will prevent the user from seeing anything accept the users data. How? Because if we detect a USER_ID, we set the condition to limit the data by the current user_id of the user looking at the data.
$queryData['conditions'][$this->name.'.user_id'] = $this->user_id;
The line before it is just because I am paranoid about my code breaking. So feel free to flame me about this:
if(isset($this->user_id)) {
But I will still leave it in. If you are comfortable taking it out, feel free. So there you have it. You are now limiting your $this->{MODEL}->find queries to a specific user. You will note that for the USER model, the user id is actually ID so I account for that in the next few lines. It is also important to note I use the standard foreign-key ID configuration common to good SQL practices. Any table associated to a user has the USER_ID as the foreign-key. I wont go into the horror stories of some of the things I have seen. (Including some of the work I have seen at Yahoo! while I have been consulting there.)
One more thing. If you have a beforeFilter in your model, it will override your app_model.beforeFilter. so you need to add the following:
<?php
class YourModel extends Model {
// your code
function beforeFilter(){
parent::beforeFilter(); // <==== this is the important part
// your filter code here
}
// your code
}
?>
Let’s continue.
beforeSave
This function is basically the same thing. However, as you may have guessed, it is to prevent a user from saving over another users data by chance they were able to randomly guess the ID.
Conclusion
There you have it! The only “user control” process for CakePHP that exists on the entire internet, at least that I am aware of. If you happen to know of another one that is more elegant, or you have a way to better improve this one, please drop a note in the comments. I find that many times I tend to overlook even some of the more simple solutions.
Happy Coding!
16 Comments
Chuck Burgess on August 12th, 2010
Yes. There should be no issue with it. ACL will control what actually has rights to CRUD (and custom functions) within each controller. This only manages the data that is queried and saved. It will limit it by the user. So if ACL does not allow them access, this functionality will not even be executed. I hope that makes sense.
jdrummey on September 2nd, 2010
Thanks for writing this, I used the code almost exactly as written and it works well.
The one change I made is that I wanted admin users to get those rights from the admin group and not from a flag in the users table. Therefore, the login code checks if the user is in the admin group and sets an “admin_flag” Configure variable. Then that flag is looked for in the beforeAdminFilter() and before beforeUserFilter() functions.
Chuck Burgess on September 2nd, 2010
That’s what makes this so flexible. That is also a great approach to the situation. Glad you liked it. I will soon be putting out my custom user management app that can be put into any cakephp app. I am still fine tuning the way it works. But it is fairly comprehensive. Keep an eye on the blog, I suspect it will be out in another few weeks.
moflint on September 28th, 2010
I’m fairly new to cakephp: In your beforeUserFilter() you use configure::write() … is there some reason you can’t get this value, at any future time in your app, from $this->Session->read(‘Auth.User.id’)) ?
moflint on September 28th, 2010
I think I just found the answer a little lower down … ‘Notice I am using a “global variable”? This is so I can easily pass the data back and forth between the APP_CONTROLLER and MODEL_CONTROLLER’ … but does that mean that the sesssion, $this->Session->read(), is not available at the same time in app_controller and model_controller ?
moflint on September 28th, 2010
So I’ve read the whole thing (really ought to do that before commenting!) and I like your solution
I’m wondering what solution you have for menus/UI. What logic do you use to make visible/hide options from unauthorised users? I am thinking of a single ‘centralized’ method for both the SQL limiting and the UI options.
Chuck Burgess on September 28th, 2010
I am still trying to put together a good solution on the UI side of things for navigation. However, I simple set up some elements and call them based on the Auth.User.role in the layout something like this:
Sean on January 10th, 2011
Hi Chuck, thanks for this great explanation, the code works well.
However, I’m wondering if you could help as I’m a bit confused with how the beforeFilter() works in this case.
In the Posts Controller there is, for example:
function beforeFilter() {
parent::beforeFilter();
$this->Auth->allowedActions = array(‘index’);
}
When not logged in, a user can see all posts in the index but when logged in, they can only see their own posts.
I’d like them to still see all the posts in index but only be able to edit their own eg. $this->Auth->deny(‘delete’,'edit’,'add’).
How does this work with this function? Should the beforeFilter be in the Model?
Sorry if I’ve missed something glaringly obvious!
Chuck Burgess on January 10th, 2011
The beforeFind will limit what the user will see after they log in. Keep in mind the idea behind the way this was written was to limit the user to only see their own data. If you want everyone to see ALL data, then remove the beforeFind in app_model.
lyba on March 29th, 2011
This is exactly what I set to find on internet today. Took me 5 minutes, great. I like it very much.
Now, I have one concern. Above solution is assuming admin/no admin roles. And assumes authentication does not allow non users to access models in question. Although these assumptions make the solution safe, they make it limited.
In systems where access should be restricted to owner data (e.g. medical data should not be available to admins, non doctors) and is rather role based then an admin/no admin attribute above solution is missing prevent access feature. If user_id is not set filter will return all rows. Imagine, you have not set up properly login/non login access to a certain model. The same goes for save. If user_id is not set it will allow saving any hacked id.
The simple solution is to add a line to both methods:
else (user_id not set)
impossible to meet condition (- to return empty set)
Still the idea is great and I will expand it to my needs. I thought I will point the above out so if you intend to expand the solution you have it in mind.
Chuck Burgess on March 29th, 2011
Thank you for your feedback Iyba. I am glad you find it useful. I also appreciate the perspective of others. Your perspective helps improve this code. Thank you for posting.
lyba on March 29th, 2011
There is a major problem with this approach – it only works for the main model. Callbacks are not executed for dependent models.
If you display dependent models on your forms they will be displayed regardless of the user_id set on them.
I need to look for another solution or continue with updating all controllers. No quick wins so far.
Thanks anyway
Chuck Burgess on March 29th, 2011
They should be. If they are not, you can add the callback to your independent model with the following:
function {callback}() {
parent::{callback}();
}
Just replace {callback} with the callback function you are performing: beforeSave, beforeFind, etc.
tagman on June 19th, 2011
hi chunck
I got problem when login as admin
I see data same user see
My app use acl plugin to manage 4 permission admin,mod,expert,user
can you help me to implement your code to my app
thank
Chuck Burgess on June 19th, 2011
This code is designed to limit users only and allow the admin to see ALL users. If you want to limit the ADMIN to only see ADMIN information, you can remove the corresponding code from the beforeSave and beforeFind call backs (if($this->user_id = Configure::read(‘user_id’)){).















>
Kim Pomares on August 12th, 2010
Can this be used along with CakePHP’s ACL implementation as described in their ACL tutorial.
http://book.cakephp.org/view/1543/Simple-Acl-controlled-Application