Skip to content
Alex Shelkovskiy edited this page Jul 22, 2020 · 4 revisions
Please help improve docs. Feel free to edit wiki.

Why you need an access control list (ACL)

When you want to control access to certain protected objects by other requesting objects you can use ACL.

ACL — is a list of permissions attached to an object. An ACL specifies which users are granted access to objects, as well as what operations are allowed on given objects.

How its works

For example you have a blog and you want users create posts and leave comments in it. But you want restrict access and allow create posts only for registered users. Also you want that anybody can view posts in the blog.

Roles and resources

In the case of the blog you have Post as a resource and User (registered user) and Guest (not registered user) as role. Lets create few rules.

First we want allow view blog post to anybody:

<?php
$acl = new Acl(); // create an acl

$user = new Role('User'); // create role for our registered user or just user
$guest = new Role('Guest'); // same for not registered or guest user
$guest->addChild($user); // our user will inherits all privileges from guest

$post = new Resource('Post'); // our post becomes resource

$acl->addRule($guest, $post, 'View', true); // Rule #1, allow access for guest and all of his children

Note that parent object span rules on their children, so everything allowed for Guest becomes allowed for User too (later you will know how you can change it by adding new rules).

Now we can check who is allowed to view posts:

<?php
var_dump($acl->isAllowed('Guest', 'Post', 'View')); // true
var_dump($acl->isAllowed('User', 'Post', 'View')); // true

Here is rules for creating posts:

<?php
$acl->addRule($user, $post, 'Create', true); // Rule #2

var_dump($acl->isAllowed('Guest', 'Post', 'Create')); //  false, guest not allowed to create posts
var_dump($acl->isAllowed('User', 'Post', 'Create')); // true

Imagine now that your blog becomes popular and you want limit access to certain post (StarredPost) for only premium users.

<?php
$premiumUser = new Role('PremiumUser'); // add new role
$user->addChild($premiumUser);

$starredPost = new Resource('StarredPost'); // and new post type
$post->addChild($starredPost);

$acl->addRule($premiumUser, $starredPost, 'View', true); // Rule #3, allow access to StarredPost for PremiumUser
$acl->addRule($guest, $starredPost, 'View', false); // Rule #4, and deny for anybody else

var_dump($acl->isAllowed('Guest', 'StarredPost', 'View')); // false
var_dump($acl->isAllowed('User', 'StarredPost', 'View')); // false
var_dump($acl->isAllowed('PremiumUser', 'StarredPost', 'View')); // true

As for now we have following ACL structure:

http://yuml.me/edit/b233636d

As you remember for our blog we already add Rule #1 which allow View Post for Guest and because Guest has User as a child User is also allowed to View Post (same works for StarredPost who is child of Post). So if we don't add Rule #4 everybody was allowed to View StarredPost.

Rules priorities

So how to know what rules would be used when checking access? Why Rule #4 overwrite Rule #1 when we checking access for View StarredPost for Guest? Lets talk about priorities.

Every rule added to ACL have default priority equals to 0. If rules have same priority then last added win. You can change the order of applying of rules using Rule::setPriority, by default all rules have 0 rule priority. Don't be confused, there 2 types of priority: (1) "rule priority" -- control the order in which rule are applied and (2) "priority" -- control rule hierarchy. Each times rules goes deeper into hierarchy (remember that Roles and Resources can expand rules to their children using addChild() method) its priority decreases by 1.

Lets leave our blog and see how it works on more simple example:

<?php
$acl = new Acl();

$guest = new Role('Guest');
$user = new Role('User');
$guest->addChild($user);

$post = new Resource('Post');

$view1 = new Rule('View');
$view1->setId('Rule #6'); // by default ID generated automatically using uniqid()
$view2 = new Rule('View');
$view2->setId('Rule #7');
$view3 = new Rule('View');
$view3->setId('Rule #5');

$acl->addRule($user, $post, $view3, false); // Rule #5
$acl->addRule($guest, $post, $view1, false); // Rule #6
$acl->addRule($guest, $post, $view2, true); // Rule #7

var_dump($acl->isAllowed('Guest', 'Post', 'View')); // Check #1, true
var_dump($acl->isAllowed('User', 'Post', 'View')); // Check #2, false

Same on the diagram:

http://yuml.me/edit/04452e8b

For Check #1 Rule #6 and Rule #7 was inspected and as Rule #6 was added later it wins and access is allowed. In Check #2 Rule #6 and #7 are also used, but as they was created not for User role, but for Guest its priority equals -1.

We can check what rules are applied and priority used:

<?php
foreach ($acl->isAllowedReturnResult('User', 'Post', 'View') as $result) {
    echo "Priority: {$result->getPriority()}\n";
    echo "Rule ID: {$result->getRule()->getId()}\n\n";
}

// Output:

// Priority: 0
// Rule ID: Rule #5

// Priority: -1
// Rule ID: Rule #7

// Priority: -1
// Rule ID: Rule #6

We learn how change priorities later, now lets go back to our blog.

Role and Resource aggregate

It is good that now we can check access to resource for role according to rule, but in your application you more likely not have role and resources, you would have your domain objects. So how connect your domain objects with Roles and Resources? For this case we have RoleAggregateInterface and ResourceAggregateInterface. Lets imagine that we have UserModel and PostModel in our blog application.

<?php
class UserModel implements RoleAggregateInterface
{
    public function __construct($who)
    {
        $this->who = $who;
    }

    protected $who;

    /**
     * Return array of names for registered roles.
     *
     * @return array
     */
    public function getRolesNames()
    {
        return array($this->who);
    }
}

class PostModel implements ResourceAggregateInterface
{
    public function __construct($who)
    {
        $this->who = $who;
    }

    protected $who;    

    /**
     * Return array of names for registered resources.
     *
     * @return array
     */
    public function getResourcesNames()
    {
        return array($this->who);
    }
}

Now we can use UserModel and PostModel when checking access, more importantly your different domains models (different instances of UserModel for example) may return different roles. Thus it is possible create any type of relationship between domain models and roles (or resources): one User role may have many UserModels, and one UserModel may be User role and Some_other_thing role at the same time.

Lets check it on example:

<?php
var_dump($acl->isAllowed(new UserModel('Guest'), new PostModel('Post'), 'View')); // true
var_dump($acl->isAllowed(new UserModel('Guest'), new PostModel('Post'), 'Create')); // false
var_dump($acl->isAllowed(new UserModel('PremiumUser'), new PostModel('StarredPost'), 'View')); // true

Using callbacks

Everything looks good now, but what if your users want to edit their posts? And you want restrict post editing only for owners of the post or administrators. In that case we need to use callbacks. But first we change our models a bit.

<?php
class UserModel implements RoleAggregateInterface
{
	public $name;

	public function __construct($name, $who)
	{
		$this->name = $name;
		$this->who = $who;
	}

        // ... the remaining code stays the same
}

class PostModel implements ResourceAggregateInterface
{
	// used to identify who wrote the post
	public $writer;

	public function __construct($writer, $who)
	{
		$this->writer = $writer;
		$this->who = $who;
	}
 
        // ... the remaining code stays the same
}

Now lets added new role:

<?php
$admin = new Role('Admin');
$premiumUser->addChild($admin); // as admin child of $premiumUser it have all access which was available to 
                                // it, so he can view and create both Post and StarredPost

Next we need create new rule, which allow to edit all posts for admins:

<?php
$acl->addRule($admin, $post, 'Edit', true);

var_dump($acl->isAllowed(new UserModel('Alex', 'Admin'), new PostModel('Jon', 'Post'), 'Edit')); // true
var_dump($acl->isAllowed(new UserModel('Alex', 'Admin'), new PostModel('Jon', 'StarredPost'), 'Edit')); // true

But what we need to do next? We want allow Users edit post only if they own it. We can tell who is the owner of the post by looking at $writer property of our post model. But how we can use this knowledge when creating the rules? Here the callbacks and some of their abilities comes into play.

<?php
$acl->addRule($user, $post, 'Edit', function(\SimpleAcl\RuleResult $r){
        // with help of \SimpleAcl\RuleResult we can get access to our models when creating the rules
        // and decide is access allowed or not
	if ( ($u = $r->getRoleAggregate()) instanceof UserModel && ($p = $r->getResourceAggregate()) instanceof PostModel ) {
		return $u->name == $p->writer;
	}

	// rule not works
	return null;
});

Callbacks works just the same as simple boolean conditions, but they can deal with more complex situations, in which you need somehow to get access to your models (or other things, for example, you can create rule which allow something in 50% of the cases:).

Changing rule priority and more on callbacks

Callbacks make it possible to have access not only to models, but also for other things which related to Acl itself. Lets see it on other example.

<?php
$acl = new Acl();

$user = new Role('User');
$guest = new Role('Guest');
$guest->addChild($user);

$post = new Resource('Post');

$view = new Rule('View');

$acl->addRule($guest, $post, $view, function() {
    return true;
});

// or this is also possible

$view->setAction(function() {
    return true;
});

var_dump($acl->isAllowed('User', 'Post', 'View')); // true

$acl->addRule($guest, $post, 'View', function (\SimpleAcl\RuleResult $ruleResult) {
	echo $ruleResult->getNeedRoleName() . "\n";
	echo $ruleResult->getNeedResourceName() . "\n";
	echo $ruleResult->getPriority() . "\n";
	echo $ruleResult->getRule()->getRole()->getName() . "\n";
	echo $ruleResult->getRule()->getResource()->getName() . "\n";
});

var_dump($acl->isAllowed('User', 'Post', 'View')); // true

// Output: 

// User
// Post
// -1
// Guest
// Post

Small note here: if callback or action itself return null it removed from the final result and not count when checking access, that is why last check return true.

With the help of callbacks is possible to change priorities of the rules, but you should be careful because rule may work deeper in the hierarchy, and you should remember about it.

<?php
$acl->addRule($user, $post, 'View', false);

var_dump($acl->isAllowed('Guest', 'Post', 'View')); // true
var_dump($acl->isAllowed('User', 'Post', 'View')); // false

$view->setAction(function(\SimpleAcl\RuleResult $ruleResult) {
    $ruleResult->setPriority(1);
    return true;
});

var_dump($acl->isAllowed('Guest', 'Post', 'View')); // true
var_dump($acl->isAllowed('User', 'Post', 'View')); // true

It is better to check if rule applied to rule's roles (or resources) or one of their children. It is possible to do that easily:

<?php
$view->setAction(function (\SimpleAcl\RuleResult $ruleResult) {
    // if priority less then zero then rule match one of the children
    if ( $ruleResult->getPriority() == 0 ) {
        $ruleResult->setPriority(1);
    }
    // or as an equivalent
    // if ( $ruleResult->getNeedRoleName() == '...' &&
    //     $ruleResult->getNeedResourceName() == '...' ) {
    //     $ruleResult->setPriority(1);
    // }
    return true;
});