Pop PHP
The Toolkit

Authorization

Authorization is the question that follows a login: this person is who they say they are, but may they do this? pop-acl answers it with roles, resources and permissions, and then with assertions and policies for the cases where a role name is not enough — where the answer depends on who owns the row.

BASH
composer require popphp/pop-acl

Roles, Resources and Permissions#

Three nouns. A role is who is asking — admin, editor, reader. A resource is what they are asking about — page, invoice, user. A permission is the verb — read, edit, delete. Rules connect the three, and Pop\Acl\Acl holds them.

PHP
use Pop\Acl\Acl;
use Pop\Acl\AclResource;
use Pop\Acl\AclRole;

$acl = new Acl();

$admin  = new AclRole('admin');
$editor = new AclRole('editor');
$reader = new AclRole('reader');
$page   = new AclResource('page');

$acl->addRoles([$admin, $editor, $reader]);
$acl->addResource($page);

$acl->allow('admin', 'page')
    ->allow('editor', 'page', 'edit')
    ->allow('reader', 'page', 'read');

allow() with no permission means every permission on that resource. With one, it means that one; pass an array for several. deny() takes the same arguments and always wins over an allow() covering the same ground.

Roles and resources go into the constructor as well, individually or in arrays, in any order — the constructor sorts them by type:

PHP
use Pop\Acl\Acl;
use Pop\Acl\AclResource;
use Pop\Acl\AclRole;

$acl = new Acl(
    [new AclRole('admin'), new AclRole('editor')],
    new AclResource('page')
);

Both objects double as data carriers, and that matters more than it looks: assertions and policies read those extra properties to answer ownership questions. Set them at construction or afterward:

PHP
use Pop\Acl\AclResource;
use Pop\Acl\AclRole;

$editor = new AclRole('editor');
$editor->user_id = 1002;

$page = new AclResource('page', ['id' => 2001, 'user_id' => 1002]);

'*' is reserved and means "any permission". It combines with a narrower deny(), which is how you express "everything except one thing":

PHP
use Pop\Acl\Acl;
use Pop\Acl\AclResource;
use Pop\Acl\AclRole;

$acl   = new Acl();
$admin = new AclRole('admin');
$page  = new AclResource('page');

$acl->addRole($admin)->addResource($page);

$acl->allow($admin, $page, '*')
    ->deny($admin, $page, 'delete');

removeAllowRule() and removeDenyRule() revoke rules without unregistering the role or resource, narrowing from one permission, to a whole resource, to every rule a role holds, depending on how many arguments you pass. removeRole() and removeResource() go further and purge the rules, assertions and policies that referenced them.

Checking Access#

isAllowed() takes a role, a resource and a permission, and returns a boolean. Roles and resources go in as objects or as their string names — the two forms are interchangeable.

PHP
use Pop\Acl\Acl;
use Pop\Acl\AclResource;
use Pop\Acl\AclRole;

$acl = new Acl();
$acl->setStrict();

$editor = new AclRole('editor');
$page   = new AclResource('page');

$acl->addRole($editor)->addResource($page);
$acl->allow($editor, $page, 'edit');

$acl->isAllowed('editor', 'page', 'edit');   // true
$acl->isAllowed('editor', 'page', 'delete'); // false

That setStrict() is the most important line on this page.

Call setStrict() to deny anything without an explicit allow rule. A default Acl is permissive, so a check for which no rule exists returns true.

isDenied() is the mirror image, for the times a rule set is expressed as prohibitions.

A user commonly holds several roles at once. isAllowedMulti() takes an array of them, and by default passes if any one of them allows the action:

PHP
use Pop\Acl\Acl;
use Pop\Acl\AclResource;
use Pop\Acl\AclRole;

$acl = new Acl();
$acl->setStrict();

$admin  = new AclRole('admin');
$editor = new AclRole('editor');
$page   = new AclResource('page');

$acl->addRoles([$admin, $editor])->addResource($page);
$acl->allow($admin, $page)->allow($editor, $page, 'edit');

$acl->isAllowedMulti([$admin, $editor], $page, 'add');       // true — admin allows it
$acl->isAllowedMultiStrict([$admin, $editor], $page, 'add'); // false — editor does not

isAllowedMultiStrict() requires every role to allow the action. It sets the multi-strict flag and calls isAllowedMulti(), and the flag persists — call setMultiStrict(false) to clear it.

To see the rule set rather than one verdict, getAllowedPermissions() and getDeniedPermissions() return the effective permission list for a role on a resource, merged across inherited roles. They report ['*'] for unrestricted access and [] when no rule exists.

getAllowedResources() and getDeniedResources() report the explicit rules, without applying the strict, multi-strict or parent-strict fallbacks.

Assertions#

Role names run out fast. "An editor may edit a page" is a rule about a class of thing; "an editor may edit their own page" is about one row. An assertion is a callable condition attached to a rule.

An assertion implements Pop\Acl\Assertion\AssertionInterface — one method:

PHP
use Pop\Acl\Acl;
use Pop\Acl\AclResource;
use Pop\Acl\AclRole;
use Pop\Acl\Assertion\AssertionInterface;

class UserOwnsPage implements AssertionInterface
{

    public function assert(
        Acl $acl,
        AclRole $role,
        ?AclResource $resource = null,
        mixed $permission = null
    ): bool
    {
        return (($resource !== null) && ($resource->user_id == $role->user_id));
    }

}

$resource is nullable, so guard it. This is where those extra properties on the role and resource earn their keep — the assertion compares them.

Register it as the fourth argument to allow() or deny():

PHP
use Pop\Acl\Acl;
use Pop\Acl\AclResource;
use Pop\Acl\AclRole;

$acl = new Acl();
$acl->setStrict();

$admin  = new AclRole('admin');
$editor = new AclRole('editor');
$page   = new AclResource('page');

$admin->user_id  = 1001;
$editor->user_id = 1002;
$page->user_id   = 1001;

$acl->addRoles([$admin, $editor])->addResource($page);

$acl->allow('admin', 'page', 'edit', new UserOwnsPage())
    ->allow('editor', 'page', 'edit', new UserOwnsPage());

$acl->isAllowed('admin', 'page', 'edit');  // true — 1001 owns the page
$acl->isAllowed('editor', 'page', 'edit'); // false — 1002 does not

Both roles hold an identical rule and an identical assertion. The verdicts differ because the assertion reads the data, not the rule.

An assertion narrows a rule; it never widens one. It runs only where a rule already covers the role, resource and permission, so an assertion returning true for a combination with no rule behind it changes nothing. Think of it as a second condition on an existing grant.

Assertions are keyed by the role, resource and permission they were registered against, separately for allow and deny. hasAssertionKey(), getAssertionKey() and deleteAssertion() work on that registry.

Policies#

A policy puts the rules on the role itself, one method per action. Where an assertion is one condition bolted onto one rule, a policy is a class answering several questions about one kind of actor.

The role class extends Pop\Acl\AclRole and uses Pop\Acl\Policy\PolicyTrait. Each method is named after the permission it decides:

PHP
use Pop\Acl\AclResource;
use Pop\Acl\AclRole;
use Pop\Acl\Policy\PolicyTrait;

class User extends AclRole
{

    use PolicyTrait;

    public function __construct($name, $id, $isAdmin)
    {
        parent::__construct($name, ['id' => $id, 'isAdmin' => $isAdmin]);
    }

    public function create(User $user, AclResource $page): bool
    {
        return (($user->isAdmin) && ($page->getName() == 'page'));
    }

    public function update(User $user, AclResource $page): bool
    {
        return ($user->id === $page->user_id);
    }

}

Register each role-resource-action combination with addPolicy(), and the policy is consulted by the ordinary isAllowed() call:

PHP
use Pop\Acl\Acl;
use Pop\Acl\AclResource;

$page   = new AclResource('page', ['id' => 2001, 'user_id' => 1002]);
$admin  = new User('admin', 1001, true);
$editor = new User('editor', 1002, false);

$acl = new Acl();
$acl->addRoles([$admin, $editor])->addResource($page);

$acl->addPolicy('create', $admin, $page)
    ->addPolicy('create', $editor, $page)
    ->addPolicy('update', $admin, $page)
    ->addPolicy('update', $editor, $page);

$acl->isAllowed('admin', 'page', 'create');  // true — is an admin
$acl->isAllowed('editor', 'page', 'create'); // false — is not
$acl->isAllowed('admin', 'page', 'update');  // false — does not own the page
$acl->isAllowed('editor', 'page', 'update'); // true — does

Nothing else about the check changes: the call site stays isAllowed() whether the verdict comes from a rule, an assertion or a policy. Underneath, isAllowed() calls evaluatePolicy(), which calls can() on the role — both are public, and either is useful when you want the policy's answer without the surrounding ACL.

can() also takes a comma-separated list, evaluated in order and stopping at the first false:

PHP
$admin->can('create,update', $page);  // false — create passes, update fails
$editor->can('create,update', $page); // false — create fails, update never runs

Implement every policy method you register on the role class — a missing one raises Pop\Acl\Policy\Exception at check time.

Role Inheritance#

Real role sets are hierarchies. An editor can do everything a reader can, plus more, and writing the reader's rules out twice is how the two drift apart. addChild() makes one role inherit another's rules:

PHP
use Pop\Acl\Acl;
use Pop\Acl\AclResource;
use Pop\Acl\AclRole;

$editor = new AclRole('editor');
$reader = new AclRole('reader');

$editor->addChild($reader);
// $reader->setParent($editor); is the same relationship, stated from the other end

$acl  = new Acl();
$page = new AclResource('page');

$acl->addRoles([$editor, $reader])->addResource($page);

$acl->deny('editor', 'page', 'add');
$acl->allow('editor', 'page', 'edit');
$acl->allow('editor', 'page', 'read');

$acl->deny('reader', 'page', 'edit');

$acl->isAllowed('reader', 'page', 'read'); // true — inherited from editor
$acl->isAllowed('reader', 'page', 'edit'); // false — the child's own deny overrides
$acl->isAllowed('reader', 'page', 'add');  // false — inherited deny

The child inherits both grants and denials, and a rule set directly on the child overrides the inherited one. Read the direction carefully: $editor->addChild($reader) makes the reader the one that inherits. The child is the narrower role, and the rules flow downhill from the parent.

Inheritance interacts with strict mode in a way worth knowing before you rely on it.

Inherited rules match loosely even under setStrict(): any explicit rule a parent holds on a resource satisfies a check against it. Pass true to setStrict()'s parent argument for exact matching.

PHP
use Pop\Acl\Acl;
use Pop\Acl\AclResource;
use Pop\Acl\AclRole;

$acl = new Acl();
$acl->setStrict()
    ->setParentStrict();

$editor = new AclRole('editor');
$reader = new AclRole('reader');
$editor->addChild($reader);

$page = new AclResource('page');

$acl->addRoles([$editor, $reader])->addResource($page);
$acl->allow($editor, $page, 'edit');

$acl->isAllowed($reader, $page, 'edit');   // true
$acl->isAllowed($reader, $page, 'delete'); // false

removeRole() reparents a removed role's children onto its own parent, or makes them root roles if it had none, so pulling a middle role out of a hierarchy does not orphan what sat below it.

pop-acl has more surface than this page covers — the full inspection API, and every shape of rule removal — see the pop-acl README.

See Also#