Role-Based Access Control
Installation
The Role-Based Access Control (RBAC) component provides role-based authorization abstraction for the CodefyPHP Framework.
Introduction
Role-Based Access Control (RBAC) is based on the idea of roles rather than permissions as you may find in ACL. In a web application, users will typically have identities defined by username
, email
, token
, etc.
RBAC System:
- An Identity has one or more roles
- A role requests access to a permission
- A permission is given to a role
Thus RBAC has:
- Many-to-many relationship between identities and roles.
- Many-to-many relationship between roles and permissions.
- Roles can have a parent role.
To get started, there are 2 ways to store role and permission settings: persistently by extending BaseStorageResource
or by runtime using ./config/rbac.php
.
BaseStorageResource
Here is a sample code of FileResource
which extends the BaseStorageResource
abstraction:
<?php
declare(strict_types=1);
namespace App\Infrastructure\Services;
use Codefy\Framework\Auth\Rbac\Resource\BaseStorageResource;
final class FileResource extends BaseStorageResource
{
/**
* @var string
*/
protected string $file;
/**
* @param string $file
*/
public function __construct(string $file)
{
$this->file = $file;
}
/**
* @throws SentinelException
* @throws FilesystemException
*/
public function load(): void
{
$this->clear();
if (!file_exists($this->file) || (!$data = LocalStorage::disk()->read(json_decode($this->file, true)))) {
$data = [];
}
$this->restorePermissions($data['permissions'] ?? []);
$this->restoreRoles($data['roles'] ?? []);
}
/**
* @throws FilesystemException
*/
public function save(): void
{
$data = [
'roles' => [],
'permissions' => [],
];
foreach ($this->roles as $role) {
$data['roles'][$role->getName()] = $this->roleToRow($role);
}
foreach ($this->permissions as $permission) {
$data['permissions'][$permission->getName()] = $this->permissionToRow($permission);
}
LocalStorage::disk()->write($this->file, json_encode(value: $data, flags: JSON_PRETTY_PRINT));
}
protected function roleToRow(Role $role): array
{
$result = [];
$result['name'] = $role->getName();
$result['description'] = $role->getDescription();
$childrenNames = [];
foreach ($role->getChildren() as $child) {
$childrenNames[] = $child->getName();
}
$result['children'] = $childrenNames;
$permissionNames = [];
foreach ($role->getPermissions() as $permission) {
$permissionNames[] = $permission->getName();
}
$result['permissions'] = $permissionNames;
return $result;
}
protected function permissionToRow(Permission $permission): array
{
$result = [];
$result['name'] = $permission->getName();
$result['description'] = $permission->getDescription();
$childrenNames = [];
foreach ($permission->getChildren() as $child) {
$childrenNames[] = $child->getName();
}
$result['children'] = $childrenNames;
$result['ruleClass'] = $permission->getRuleClass();
return $result;
}
/**
* @throws SentinelException
*/
protected function restorePermissions(array $permissionsData): void
{
/** @var string[][] $permChildrenNames */
$permChildrenNames = [];
foreach ($permissionsData as $pData) {
$permission = $this->addPermission($pData['name'] ?? '', $pData['description'] ?? '');
$permission->setRuleClass($pData['ruleClass'] ?? '');
$permChildrenNames[$permission->getName()] = $pData['children'] ?? [];
}
foreach ($permChildrenNames as $permissionName => $childrenNames) {
foreach ($childrenNames as $childName) {
$permission = $this->getPermission($permissionName);
$child = $this->getPermission($childName);
if ($permission && $child) {
$permission->addChild($child);
}
}
}
}
/**
* @throws SentinelException
*/
protected function restoreRoles($rolesData): void
{
/** @var string[][] $rolesChildrenNames */
$rolesChildrenNames = [];
foreach ($rolesData as $rData) {
$role = $this->addRole($rData['name'] ?? '', $rData['description'] ?? '');
$rolesChildrenNames[$role->getName()] = $rData['children'] ?? [];
$permissionNames = $rData['permissions'] ?? [];
foreach ($permissionNames as $permissionName) {
if ($permission = $this->getPermission($permissionName)) {
$role->addPermission($permission);
}
}
}
foreach ($rolesChildrenNames as $roleName => $childrenNames) {
foreach ($childrenNames as $childName) {
$role = $this->getRole($roleName);
$child = $this->getRole($childName);
if ($role && $child) {
$role->addChild($child);
}
}
}
}
}
Usage
We can now initiate with our FileResource. The resource can be a file, database, cache or runtime. You can extend the BaseStorageResource
or create an implementation of Codefy\Framework\Auth\Rbac\Resource\StorageResource
.
<?php
use App\Infrastructure\Services\FileResource;
use Codefy\Framework\Auth\Rbac\Rbac;
$resource = new FileResource('rbac.json');
$rbac = new Rbac($resource);
Create Permissions Hierarchy
<?php
$perm1 = $rbac->addPermission('create_post', 'Can create posts');
$perm2 = $rbac->addPermission('moderate_post', 'Can moderate posts');
$perm3 = $rbac->addPermission('update_post', 'Can update posts');
$perm4 = $rbac->addPermission('delete_post', 'Can delete posts');
$perm2->addChild($perm3); // moderator can also update
$perm2->addChild($perm4); // and delete posts
Create Role Hierarchy
<?php
$adminRole = $rbac->addRole('admin');
$moderatorRole = $rbac->addRole('moderator');
$authorRole = $rbac->addRole('author');
$adminRole->addChild($moderatorRole); // admin has all moderator's rights
Important!
Please note that when defining roles and permissions, permissions should be added and loaded before roles.
Bind Roles and Permissions
<?php
...
$moderatorRole->addPermission($perm2);
...
Persist State
<?php
$rbac->save();
Checking Access Rights
<?php
if($rbac->getRole($user->role)->checkAccess('moderate_post') {
... // User can moderate posts
}
// or add to your user's class something like:
$user->can('moderate_post');
Rules
Sometimes you need to perform an extra check. For example, what if you only want authors to edit
, update
or delete
their own content, but not someone else's content? You can do that by setting a rule. You can do so by implementing the AssertionRule
interface with the execute()
method.
<?php
declare(strict_types=1);
namespace App\Domain\Post\Services;
use Codefy\Framework\Auth\Rbac\Entity\AssertionRule;
final class AuthorRule implements AssertionRule
{
/**
* @param array|null $params
*
* @return bool
*/
public function execute(?array $params = null): bool
{
// @var Post $post
if($post = $params['post'] ?? null) {
return $post->authorId === ($params['userId'] ?? null);
}
return false;
}
}
Configure RBAC
<?php
$perm5 = $rbac->addPermission('post:author_update', 'Author can update his posts.');
$perm6 = $rbac->addPermission('post:author_delete', 'Author can delete his posts.');
$perm5->setRuleClass(AuthorRule::class);
$perm6->setRuleClass(AuthorRule::class);
$authorRole->addPermission($perm5);
$authorRole->addPermission($perm6);
Check Rights
<?php
if($rbac->checkAccess('post:author_delete', ['userId' => $userId, 'post' => $post]) {
... // The user is author of the post and can delete it
}
RBAC Config
The alternative to using a resource is setting up a config to be checked during runtime.
<?php
return [
'permissions' => [
'admin' => [
'description' => 'Super Admin',
'permissions' => [
'admin:dashboard' => ['description' => 'Access to the dashboard.'],
'admin:profile' => ['description' => 'Access to profile edit.'],
],
],
],
'roles' => [
'user' => [
'description' => 'Regular user',
'permissions' => [],
],
'manager' => [
'description' => 'Editor',
'permissions' => ['admin:dashboard'],
],
'admin' => [
'description' => 'Administrator',
'permissions' => ['admin'],
],
],
];
If you use the config option, you will need to figure out a way to load them during runtime so that it can be checked against the user. The skeleton app conveniently includes a loader as well as a service provider to load roles
and permissions
during runtime. If you want to change, edit, or add permissions and roles, check out File: ./config/rbac.php
.
Important:
As mentioned previously, permissions need to be defined first, and only once. Permissions from any defined group can be used when you define your roles. To use all the permissions from a group, add the key(s) ['admin']
to your array. If you are only wanting to use a particular defined permission from a group, use the specific key(s) in your array ['admin:dashboard']
.
Authorizing Users
Codefy provides several middlewares for checking and validating user roles, permissions, and session handling.
If you need to check whether a user is authorized to view a certain page, you will need to add
Codefy\Framework\Http\Middleware\Auth\UserAuthorizationMiddleware
or the Injector alias (user.authorization
) to your route:
<?php
declare(strict_types=1);
return (function(\Qubus\Routing\Psr7Router $router) {
$router->get('/admin/dashboard/', 'AdminController@dashboard')->middleware('user.authorization');
});
When a user visits the /admin/dashboard/
route, the middleware will check if the user is logged in. If the user is logged in, the user will continue on, otherwise, the user will be redirected to your login route via the redirect_guests_to
setting in ./config/auth.php
.
A different approach would be to check permissions via the controller. You can use the App\Infrastructure\Services\UserAuth
class as a type-hint in your controllers:
<?php
declare(strict_types=1);
namespace App\Infrastructure\Http\Controllers;
use App\Infrastructure\Services\UserAuth;
use Codefy\Framework\Codefy;
use Codefy\Framework\Http\BaseController;
use Psr\Http\Message\ResponseInterface;
use Qubus\Exception\Data\TypeException;
use Qubus\Http\Factories\HtmlResponseFactory;
use Qubus\Http\ServerRequest;
use Qubus\Http\Session\SessionService;
use Qubus\Routing\Router;
use Qubus\View\Renderer;
final class AdminController extends BaseController
{
public function __construct(
protected SessionService $sessionService,
protected Router $router,
protected UserAuth $user,
protected Renderer $view
) {
parent::__construct($sessionService, $router, $view);
}
public function index(ServerRequest $request): ResponseInterface
{
if (false === $this->user->can(permissionName: 'admin:dashboard', request: $request)) {
Codefy::$PHP->flash->error(
message: 'You must be logged in to access the admin area.'
);
return $this->redirect($this->router->url(name: 'admin.login'));
}
return HtmlResponseFactory::create(
$this->view->render(template: 'framework::backend/index', data: ['title' => 'Dashboard'])
);
}
}
$this->user->can(permissionName: 'admin:dashboard', request: $request)
is what's used to check if a logged-in user has a certain permission to continue. Please note that a ServerRequest
instance is passed into the can()
method so that UserAuth
can check for the existence of a session cookie.
Here is a list of other authentication middlewares along with their aliases:
Codefy\Framework\Http\Middleware\Auth\ExpireUserSessionMiddleware
- Alias:
user.session.expire
- Description: This middleware can be used for a logout route to clear the user's session and cookie.
- Alias:
Codefy\Framework\Http\Middleware\Auth\AuthenticationMiddleware
- Alias:
user.authenticate
- Description: This middleware can be used for a login route which checks the submitted login credentials against the database.
- Alias:
Codefy\Framework\Http\Middleware\Auth\UserSessionMiddleware
- Alias:
user.session
- Description: This middleware should be used with the previous middleware. If authentication is successful, the user session and cookie will be created.
- Alias: