Introduction: What Are Access Control Lists?
The ACL lets you create a hierarchy of users with their respective roles. Here's a quick example.- Super Users
- User #1
- Admins
- User #2
- User #3
- Users
- User #4
- User #5
- User #6
- ....
With this hierarchy, we can assign several permissions for each role:
- The Super Users can create, read, update and delete Posts and Users.
- The Admins can create, read, update and delete Posts.
- The Users can create and read Posts.
- Everyone else can just read Posts.
Now, on the other side, we have the Access Control Objects, or ACOs. These are the objects to be controlled. Above I mentioned Posts and Users. Normally, these objects are directly linked with the models, so if we have a Post model, we will need an ACO for this model.
Each ACO has four basic permissions: create, read, update and delete. You can remember them with the keyword CRUD. There's a fifth permission, the asterisk, that is a shortcut for full access.
We will be using just two ACOs for this tutorial: Post and User, but you can create as many as you need.
The ACL Tables
Let's proceed to create the database tables. You can find this code indb_acl.sql
inside your app's config/sql
directory.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| CREATE TABLE acos ( id INTEGER (10) UNSIGNED NOT NULL AUTO_INCREMENT, parent_id INTEGER (10) DEFAULT NULL , model VARCHAR (255) DEFAULT '' , foreign_key INTEGER (10) UNSIGNED DEFAULT NULL , alias VARCHAR (255) DEFAULT '' , lft INTEGER (10) DEFAULT NULL , rght INTEGER (10) DEFAULT NULL , PRIMARY KEY (id) ); CREATE TABLE aros_acos ( id INTEGER (10) UNSIGNED NOT NULL AUTO_INCREMENT, aro_id INTEGER (10) UNSIGNED NOT NULL , aco_id INTEGER (10) UNSIGNED NOT NULL , _create CHAR (2) NOT NULL DEFAULT 0, _read CHAR (2) NOT NULL DEFAULT 0, _update CHAR (2) NOT NULL DEFAULT 0, _delete CHAR (2) NOT NULL DEFAULT 0, PRIMARY KEY (id) ); CREATE TABLE aros ( id INTEGER (10) UNSIGNED NOT NULL AUTO_INCREMENT, parent_id INTEGER (10) DEFAULT NULL , model VARCHAR (255) DEFAULT '' , foreign_key INTEGER (10) UNSIGNED DEFAULT NULL , alias VARCHAR (255) DEFAULT '' , lft INTEGER (10) DEFAULT NULL , rght INTEGER (10) DEFAULT NULL , PRIMARY KEY (id) ); |
Step 1: A Basic Authentication System
As this tutorial is intended for CakePHP developers with a basic to moderate knowledge of the framework, I will be supplying the code and a brief explanation. However, the authentication system is not the goal of this tutorial.The MySQL table:
1
2
3
4
5
| CREATE TABLE users ( id INTEGER (10) UNSIGNED AUTO_INCREMENT KEY , username TEXT, password TEXT ); |
models/user.php
)
1
2
3
4
5
| <?php class User extends AppModel { var $name = 'User' ; } ?> |
controllers/users_controller.php
)
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
| <?php class UsersController extends AppController { var $name = 'Users' ; var $components = array ( 'Auth' ); function beforeFilter(){ $this ->Auth->userModel = 'User' ; $this ->Auth->allow( '*' ); } function register(){ if (! empty ( $this ->data)){ // Here you should validate the username (min length, max length, to not include special chars, not existing already, etc) // As well as the password if ( $this ->User->validates()){ $this ->User->save( $this ->data); // Let's read the data we just inserted $data = $this ->User->read(); // Use it to authenticate the user $this ->Auth->login( $data ); // Then redirect $this ->redirect( '/' ); } } } function login(){ if (! empty ( $this ->data)){ // If the username/password match if ( $this ->Auth->login( $this ->data)){ $this ->redirect( '/' ); } else { $this ->User->invalidate( 'username' , 'Username and password combination is incorrect!' ); } } } function logout(){ $this ->Auth->logout(); $this ->redirect( '/' ); } } ?> |
$components
variable. This variable includes all the components in the array. We will be needing the Auth
component, which is a core component, as are HTML and Form helpers, but
since it isn't included by default by Cake, we will have to include it
manually. The Auth component handles some basic authentication mechanics: it helps us to login a user and it handles an authenticated user's session for us, as well as handling the log out and basic authorization for guests. Also, it hashes the password automatically. I'll be explaining how to call each function in the following paragraphs.
Next, we are creating a function called
beforeFilter
. This is a callback function
and it lets us set some actions before all the controller logic is
processed. The Auth component requires us to specify a model to be used,
in this case the User. Then, by default, it will deny all the access to
users not logged in. We'll have to overwrite this behaviour with allow()
which requires one parameter. That parameter can be an asterisk,
specifying that all methods inside said controller can be accessed by
unauthenticated users. Or, it can be passed an array with the functions
that can be accessed by unauthenticated users. In this case, since we
have just three functions, the following lines are the same thing.
1
2
| $this ->Auth->allow( '*' ); $this ->Auth->allow( array ( 'register' , 'login' , 'logout' )); |
login()
function, the Auth component will handle
all the login mechanics for us. We will just have to supply the
function with an array with two keys: the username and the password.
These keys can be changed, but by default, both username
and password
fields will be matched against the database and will return true if the user has been authenticated. Finally, the controller's login function will try to match a username/password combination against the database.
Please note that this code is very basic. You'll need to validate the username characters, if the username exists, the minimum length for the password, and so on.
The Register view (
views/users/register.ctp
)
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
| <h2>Register your account</h2> <form method= "POST" action= "<?=$this->here; ?>" > <p> Username <?= $form ->text( 'User.username' ); ?> </p> <p> Password <?= $form ->password( 'User.password' ); ?> </p> <?= $form ->submit( 'Register' ); ?> </form> |
views/users/login.ctp
)
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
| <h2>Log in to your account</h2> <form method= "POST" action= "<?=$this->here; ?>" > <?= $form ->error( 'User.username' ); ?> <p> Username <?= $form ->text( 'User.username' ); ?> </p> <p> Password <?= $form ->password( 'User.password' ); ?> </p> <?= $form ->submit( 'Log in' ); ?> </form> |
/users/register
in your web browser and register a new account. I suggest admin
as username and 123
as password and if your session expires just go to /users/login
and enter the correct username/password combination you just created.Step 2: Denying Access To Unauthenticated Users
We're not even working with ACL, but we already can deny posting, editing and deleting posts. Open your Posts controller and add the Auth component.
1
| var $components = array ( 'Auth' ); |
/posts
in your web browser. If you're logged in, you should see the posts, but if you're not, you will be redirected to /users/login
.
By simply including the Auth component, all the actions are by default,
denied for guests. We need to deny three actions for unauthorized
users: create, edit and delete. In other terms, we'll have to allow
index and view.
1
2
3
4
| function beforeFilter(){ $this ->Auth->userModel = 'User' ; $this ->Auth->allow( array ( 'index' , 'view' )); } |
/users/login
.
Everything seems to be working quite good, but what about the views?
The edit and delete links are being shown to everybody. We should make a
conditional. But before going into that, let's see how Auth's user() function works. Copy and paste these lines into the index function.
1
2
| $user = $this ->Auth->user(); pr( $user ); |
/posts
in your browser and, if logged in, the pr()
will throw something like this.
1
2
3
4
5
6
7
8
| Array ( [User] => Array ( [id] => 1 [username] => admin ) ) |
user()
function returns an array just as a model
would do. If we had more than three fields (password is not included),
they will be shown in the array. If you're not logged in, the array will
be empty, so you can know a user is logged in if Auth's user()
array is not empty. Now, Auth is a component, meant to be used in a controller. We need to know from a view if a user is logged in, preferably via a helper. How can we use a component inside a helper? CakePHP is so awesome and flexible that it is possible.
01
02
03
04
05
06
07
08
09
10
11
12
| <? class AccessHelper extends Helper{ var $helpers = array ( "Session" ); function isLoggedin(){ App::import( 'Component' , 'Auth' ); $auth = new AuthComponent(); $auth ->Session = $this ->Session; $user = $auth ->user(); return ! empty ( $user ); } ?> |
views/helpers
as access.php
. Now let's see the code, line by line. First, we are setting up a $helpers
var. Helpers can include other helpers, just like $components
can. The Session component is required for the Auth component, but we
have no access to this component inside a helper. Fortunately we have a
Session helper, that will help us. Next, we create a function and use
App::import
which will let us import an element that normally we wouldn't have access to. The next line creates the Auth component in a $auth
variable, and now a little dirty hack; since the Auth component reads
the session to know if we are logged or not, it requires the Session
component, but as we are importing it from a place it shouldn't belong
to, we'll have to give it a new Session object. Finally, we are using user()
and setting it to $user
and returning true if the variable is not empty, otherwise false.Let's get back to the posts controller and proceed to add the helper.
1
| var $helpers = array ( 'Access' ); |
index.ctp
in views/posts
and replace this line.
1
| <small><a href= "/posts/edit/<? echo $post['Post']['id'] ?>" >edit</a> | <? echo $html ->link( 'delete' , '/posts/delete/' . $post [ 'Post' ][ 'id' ], NULL, 'Are you sure?' ); ?></small> |
1
| <? if ( $access ->isLoggedin()): ?><small><a href= "/posts/edit/<? echo $post['Post']['id'] ?>" >edit</a> | <? echo $html ->link( 'delete' , '/posts/delete/' . $post [ 'Post' ][ 'id' ], NULL, 'Are you sure?' ); ?></small><? endif ; ?> |
While this would be enough if you have an app with one or two users, it is not enough if you have registrations open.
Step 3: Installing ACL
Open the Users controller and add the ACL component.
1
| var $components = array ( 'Auth' , 'Acl' ); |
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
| function install(){ if ( $this ->Acl->Aro->findByAlias( "Admin" )){ $this ->redirect( '/' ); } $aro = new aro(); $aro ->create(); $aro ->save( array ( 'model' => 'User' , 'foreign_key' => null, 'parent_id' => null, 'alias' => 'Super' )); $aro ->create(); $aro ->save( array ( 'model' => 'User' , 'foreign_key' => null, 'parent_id' => null, 'alias' => 'Admin' )); $aro ->create(); $aro ->save( array ( 'model' => 'User' , 'foreign_key' => null, 'parent_id' => null, 'alias' => 'User' )); $aro ->create(); $aro ->save( array ( 'model' => 'User' , 'foreign_key' => null, 'parent_id' => null, 'alias' => 'Suspended' )); $aco = new Aco(); $aco ->create(); $aco ->save( array ( 'model' => 'User' , 'foreign_key' => null, 'parent_id' => null, 'alias' => 'User' )); $aco ->create(); $aco ->save( array ( 'model' => 'Post' , 'foreign_key' => null, 'parent_id' => null, 'alias' => 'Post' )); $this ->Acl->allow( 'Super' , 'Post' , '*' ); $this ->Acl->allow( 'Super' , 'User' , '*' ); $this ->Acl->allow( 'Admin' , 'Post' , '*' ); $this ->Acl->allow( 'User' , 'Post' , array ( 'create' )); } |
Next, we do the same with ACOs, since we just have two objects to manage (Posts and Users), we'll create two, one for each.
Now, a quick note. The array that we are saving for both ACOs and AROs have four fields: the model name, foreign key (which is useful if you want to give access to one ARO, like a post he created), parent id (which will be used later and is the basic parent-child nodes relationship; we will be creating users below these roles), and the alias (which is a quick form to find the objects).
Finally, we are using ACL's
allow()
function. The first
parameter is the ACO alias; second, the ARO alias, and third, the
permissions given to said ARO. A Superuser has full access to the Post
and User models, an Admin has full access to the Post model, and a User
can just create posts. At the function's beginning, I declared a conditional to check if the Admin role exists in ACOs. You don't want to install the same thing more than once, do you? It would mess up the database quite badly.
Open
/users/install
in your web browser, and, since we
don't have a view, CakePHP will throw an error, but just check the MySQL
dump. All the relationships have been successfully created, it's time
to work with the child nodes.Setting users to roles
Let's clean theusers
table. Open phpMyAdmin, select your database, the users
table and click empty. We'll get back to the register()
function on the Users Controller. Just below this line:
1
| $this ->Auth->login( $data ); |
01
02
03
04
05
06
07
08
09
10
11
| // Set the user roles $aro = new Aro(); $parent = $aro ->findByAlias( $this ->User->find( 'count' ) > 1 ? 'User' : 'Super' ); $aro ->create(); $aro ->save( array ( 'model' => 'User' , 'foreign_key' => $this ->User->id, 'parent_id' => $parent [ 'Aro' ][ 'id' ], 'alias' => 'User::' . $this ->User->id )); |
Then we'll save a new ARO; the model is the one we are working currently on,
User
, the foreign_key
is the last record's id we just created. The parent_id
is the node we began with; we'll just pass it the id and finally the alias. It is a good idea to call it Model_name
, then a separator ::
, and then the identifier. It will be much easier to find it.Now we're done. Create four users: superuser, adminuser, normaluser and suspendeduser. I suggest the same username and password for testing purposes. Don't forget that, until this point, only the superuser has a role of Super; all the remaining will be Users!
Step 4: Reading Permissions
Because ACL is a component, it is accessible only within the controller. Later, it will be also in the view; but first things first. Include the ACL component in the Posts controller, as we did in the Users controller. Now you can begin checking permissions. Let's go to the edit function and do a quick test. In the first line of said method, add this.
1
2
| $user = $this ->Auth->user(); if (! $this ->Acl->check( 'User::' . $user [ 'User' ][ 'id' ], 'Post' , 'update' )) die ( 'you are not authorized' ); |
check()
function needs three parameters: the ARO,
the ACO and the action. Go and edit a post, and, if you're not logged in
as the super user, the script will die. Go to /users/login
and access as the Super user and go back to editing. You should be able
to edit the post. Check below the MySQL dump to see the magic. Four
database queries: that sure isn't scalable. We have two issues. First, two lines for the permissions check. Second, the ACLs aren't being cached. And don't forget all the logged users can see the edit link, even if just the Super user can use it.
Let's create a new component. Let's call it Access.
01
02
03
04
05
06
07
08
09
10
| <?php class AccessComponent extends Object{ var $components = array ( 'Acl' , 'Auth' ); var $user ; function startup(){ $this ->user = $this ->Auth->user(); } } ?> |
controllers/components
as access.php
. The class's $user
var will be initiated as the component is loaded, and startup()
is a callback function, so it's now set in the class. Now let's create our check()
function.
1
2
3
4
5
6
7
| function check( $aco , $action = '*' ){ if (! empty ( $this ->user) && $this ->Acl->check( 'User::' . $this ->user[ 'User' ][ 'id' ], $aco , $action )){ return true; } else { return false; } } |
check()
method will need just two parameters: the
ACO and the action (which is optional). The ARO will be the current user
for each session. The action parameter will default to *
, which is full access for the ARO. The function begins by checking if the $this->user
is not empty (which really tells us if a user is logged in) and then we go to ACL. We have already covered this.We can now include the Access component in our Posts controller and check the permissions with just one line.
1
| if (! $this ->Access->check( 'Post' , 'update' )) die ( 'you are not authorized' ); |
die()
with the CakePHP error handler:
1
| $this ->cakeError( 'error404' ); |
Permissions in views
This might be ugly, but it works. We'll have to create a helper that loads a component with a custom method for use in the helper.Add this function in the Access component (
controllers/components/access.php
).
1
2
3
4
5
| function checkHelper( $aro , $aco , $action = "*" ){ App::import( 'Component' , 'Acl' ); $acl = new AclComponent(); return $acl ->check( $aro , $aco , $action ); } |
views/helpers/access.php
).
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| <?php class AccessHelper extends Helper{ var $helpers = array ( "Session" ); var $Access ; var $Auth ; var $user ; function beforeRender(){ App::import( 'Component' , 'Access' ); $this ->Access = new AccessComponent(); App::import( 'Component' , 'Auth' ); $this ->Auth = new AuthComponent(); $this ->Auth->Session = $this ->Session; $this ->user = $this ->Auth->user(); } function check( $aco , $action = '*' ){ if ( empty ( $this ->user)) return false; return $this ->Access->checkHelper( 'User::' . $this ->user[ 'User' ][ 'id' ], $aco , $action ); } function isLoggedin(){ return ! empty ( $this ->user); } } ?> |
beforeRender()
method is a callback, similar to the component's startup()
.
We are loading two components and since these will be used in most
functions, it's a good idea to start all at once, than to manually start
them each time the method is called.Now on your
index.ctp
view in views/posts
you can replace this line.
1
| <small><a href= "/posts/edit/<? echo $post['Post']['id'] ?>" >edit</a> | <? echo $html ->link( 'delete' , '/posts/delete/' . $post [ 'Post' ][ 'id' ], NULL, 'Are you sure?' ); ?></small> |
1
| <? if ( $access ->check( 'Post' )): ?><small><a href= "/posts/edit/<? echo $post['Post']['id'] ?>" >edit</a> | <? echo $html ->link( 'delete' , '/posts/delete/' . $post [ 'Post' ][ 'id' ], NULL, 'Are you sure?' ); ?></small><? endif ; ?> |
User data
You can access the user data for a logged user with theuser()
method in the Auth component. Then you can access the array and get the
info you want. But there must be a better way. Let's add the following
function in the Access component.
1
2
3
| function getmy( $what ){ return ! empty ( $this ->user) && isset( $this ->user[ 'User' ][ $what ]) ? $this ->user[ 'User' ][ $what ] : false; } |
user_id
relationship.
1
| $this ->data[ 'Post' ][ 'user_id' ] = $this ->Access->getmy( 'id' ); |
1
2
3
| function getmy( $what ){ return ! empty ( $this ->user) && isset( $this ->user[ 'User' ][ $what ]) ? $this ->user[ 'User' ][ $what ] : false; } |
1
| Welcome <?= $access ->isLoggedIn() ? $access ->getmy( 'username' ) : 'Guest' ; ?> |
Step 5: Modifying Permissions
Let's say we need to switch roles for the user #4: he needs to be a Super User. So, User's id is 4 and Aro's id is 1.
1
2
| $user_id = 4; $user_new_group = 1; |
1
2
3
4
5
6
7
8
9
| $aro_user = $this ->Acl->Aro->find( 'first' , array ( 'conditions' => array ( 'Aro.parent_id !=' => NULL, 'Aro.model' => 'User' , 'Aro.foreign_key' => $user_id ) ) ); |
$aro_user
variable is not empty, let's update the Aro.parent_id
field.
1
2
3
4
5
| if (! empty ( $aro_user )){ $data [ 'id' ] = $aro_user [ 'Aro' ][ 'id' ]; $data [ 'parent_id' ] = $user_new_group ; $this ->Acl->Aro->save( $data ); } |