Skip to content

Role Based Access

The Role-Based Access Control (RBAC) component provides role-based authorization abstraction for Devflow.

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 relationshiop 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 of code of FileResource which extends the BaseStorageResource abstraction:

<?php

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 it by implementing Codefy\Framework\Auth\Rbac\Resource\BaseStorageResource.

<?php

use Codefy\Framework\Auth\Rbac\Rbac;

$resource = new FileResource('rbac.json');
$rbac = new Rbac($resource);

Create Permissions Hierarchy

Please note that permissions should be added and loaded before roles.

<?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

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 posts their own content but not someone elses content? You can do that by setting a rule. You can do so by implementing the AssertionRule interface with the execute method.

<?php

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. This is how Devflow is set up. The permissions and roles are defined in config/rbac.php. Please note, that permissions are loaded first and then the the role is checked. Also, a permission needs to only be defined one time regardless of the role(s) it will be assigned to.

Below are the roles/permissions defined for Devflow:

<?php

return [
    /** Named or grouped permissions. */
    'permissions' => [
        'admin' => [
            'description' => 'All system permissions',
            'permissions' => [
                'access:admin' => ['description' => 'Access to the dashboard.'],
                'create:content' => ['description' => 'Create content.'],
                'create:product' => ['description' => 'Create product.'],
                'create:users' => ['description' => 'Create users.'],
                'manage:content' => ['description' => 'Manage content.'],
                'manage:product' => ['description' => 'Manage product.'],
                'manage:users' => ['description' => 'Manage users.'],
                'manage:media' => ['description' => 'Manage media.'],
                'manage:options' => ['description' => 'Manage options.'],
                'manage:settings' => ['description' => 'Manage settings.'],
                'manage:products' => ['description' => 'Manage product inventory.'],
                'update:content' => ['description' => 'Update content.'],
                'update:product' => ['description' => 'Update product.'],
                'update:users' => ['description' => 'Update users.'],
                'delete:content' => ['description' => 'Delete content.'],
                'delete:product' => ['description' => 'Delete product.'],
                'delete:users' => ['description' => 'Delete users.'],
                'switch:user' => ['description' => 'Switch user'],
                'publish:content' => ['description' => 'Publish content.'],
                'publish:product' => ['description' => 'Publish product.'],
            ],
        ],
        'sites' => [
            'description' => 'All site permissions',
            'permissions' => [
                'manage:sites' => ['description' => 'Manage sites.'],
                'create:sites' => ['description' => 'Create sites.'],
                'update:sites' => ['description' => 'Update sites.'],
                'delete:sites' => ['description' => 'Delete sites.'],
            ]
        ],
    ],

    'roles' => [
        'super' => [
            'description' => 'Super administrator',
            'permissions' => ['admin','sites'],
        ],
        'admin' => [
            'description' => 'Administrator',
            'permissions' => [
                'access:admin',
                'create:content',
                'manage:content',
                'update:content',
                'delete:content',
                'publish:content',
                'create:product',
                'manage:product',
                'update:product',
                'delete:product',
                'publish:product',
                'manage:media',
                'manage:options',
                'manage:settings',
            ],
        ],
        'editor' => [
            'description' => 'Site editor',
            'permissions' => [
                'access:admin',
                'create:content',
                'manage:content',
                'update:content',
                'delete:content',
                'publish:content',
                'create:product',
                'manage:product',
                'update:product',
                'delete:product',
                'publish:product',
                'manage:media',
            ],
        ],
    ],
];

You can use the function App\Shared\Helpers\current_user_can() to check for user permission.

Or you can do it the OOP way by adding App\Infrastructure\Services\UserAuth as a dependency:

<?php

declare(strict_types=1);

namespace Cms\Http\Controllers;

use App\Application\Devflow;
use App\Infrastructure\Services\UserAuth;
use Codefy\Framework\Http\BaseController;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use Psr\Http\Message\ResponseInterface;
use Qubus\Exception\Data\TypeException;
use Qubus\Exception\Exception;
use Qubus\Http\ServerRequest;
use Qubus\Http\Session\SessionException;
use Qubus\Http\Session\SessionService;
use Qubus\Routing\Exceptions\RouteParamFailedConstraintException;
use Qubus\Routing\Router;
use Qubus\View\Renderer;
use ReflectionException;

use function App\Shared\Helpers\site_url;
use function Qubus\Security\Helpers\t__;

final class FrontendController extends BaseController
{
    public function __construct(
        SessionService $sessionService,
        Router $router,
        protected UserAuth $user,
        ?Renderer $view = null
    ) {
        parent::__construct($sessionService, $router, $view);
    }

    /**
    * @param ServerRequest $request
    * @return ResponseInterface|string
    * @throws ContainerExceptionInterface
    * @throws Exception
    * @throws InvalidArgumentException
    * @throws NotFoundExceptionInterface
    * @throws ReflectionException
    * @throws RouteParamFailedConstraintException
    * @throws SessionException
    * @throws TypeException
    */
    public function myAccount(ServerRequest $request): ResponseInterface|string
    {
        if (false === $this->user->can(permissionName: 'user:account', request: $request)) {
            Devflow::inst()::$APP->flash->error(
                message: t__(msgid: 'Access denied.', domain: 'devflow')
            );
            return $this->redirect(site_url());
        }

        return $this->view->render(template: 'cms::frontend/my-account', data: ['title' => 'My Account']);
    }
}