Skip to content

Role-Based Access Control

Installation

composer require codefyphp/codefy

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.

./app/Domain/Post/Services/AuthorRule.php
<?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.

./config/rbac.php
<?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:

./routes/web/web.php
<?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:

./app/Infrastructure/Http/Controllers/AdminController.php
<?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.
  • 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.
  • 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.