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.
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.
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:
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:
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":
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.
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:
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:
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():
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:
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:
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:
$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:
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.
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#
- Authentication — establishing who the role belongs to in the first place
- Middleware — where an authorization check belongs on an HTTP request
- Records & the ORM — the rows an ownership assertion compares against
- pop-acl README — the full API surface