Security - Role Based Access Control

The framework includes the component spiral/security which provides the ability to authorize user/actor access to the specific resources or actions based on list of associated privileges. The components implements "Flat RBAC" patterns described in NIST RBAC research.

Implementation includes multiple additions such as:

  • an additional layer of rules to control the privilege/permission context
  • the ability to assign role to multiple privileges using wildcard pattern
  • the ability to overwrite role-to-permission assignment using higher priority rule

Such additions make possible to use the component as the framework for ACL, DAC and ABAC security models.

Make sure to enable the Spiral\Bootloader\Security\GuardBootloader to activate the component, no configuration is required.

The component is enabled in Web and GRPC bundles by default.

Actor

All the privileges within the application will be granted based on roles associated with current Spiral\Security\ActorInterface:

interface ActorInterface
{
    public function getRoles(): array;
}

Use IoC scopes to set Actor during the user request.

Read how to use authenticated user as an actor here.

By default, the application use Spiral\Security\Actor\Guest as default actor. You can set the actor globally or inside IoC scope using the container binding.

namespace App\Controller;

use Spiral\Core\ScopeInterface;
use Spiral\Security\Actor\Actor;
use Spiral\Security\ActorInterface;

class HomeController
{
    public function index(ScopeInterface $scope)
    {
        return $scope->runScope([ActorInterface::class => new Actor(['admin'])], function () {

            // the actor has role `admin` in this scope
            return 'ok';
        });
    }
}

You can set the active actor using domain core interceptors, GRPC interceptors, HTTP middleware, custom IoC scopes and etc.

For the simplicity of this guide we will see the default actor globally, via custom bootloader:

namespace App\Bootloader;

use Spiral\Boot\Bootloader\Bootloader;
use Spiral\Core\Container;
use Spiral\Security\Actor\Actor;
use Spiral\Security\ActorInterface;

class SecurityBootloader extends Bootloader
{
    public function boot(Container $container)
    {
        $container->bindSingleton(ActorInterface::class, new Actor(['user']));
    }
}

GuardInterface

In order to use RBAC component we must register available roles and create association between the role and the permission, use the same bootloader and Spiral\Security\PermissionsInterface for this purpose.

namespace App\Bootloader;

use Spiral\Boot\Bootloader\Bootloader;
use Spiral\Core\Container;
use Spiral\Security\Actor\Actor;
use Spiral\Security\ActorInterface;
use Spiral\Security\PermissionsInterface;

class ActorBootloader extends Bootloader
{
    public function boot(Container $container, PermissionsInterface $rbac)
    {
        $container->bindSingleton(ActorInterface::class, new Actor(['user']));

        $rbac->addRole('user');
        $rbac->associate('user', 'home.read');
    }
}

The role-rule-permission association will be explained in details down below.

Once the bootloader is activated you can use the Spiral\Core\GuardInterface to check the access to the specific permissions, the guard will resolve active actor automatically via active scope using Spiral\Security\GuardScope. The interface provides the method allows which we can use to check if the actor has the access to the specific permission:

namespace App\Controller;

use Spiral\Security\GuardInterface;

class HomeController
{
    public function index(GuardInterface $guard)
    {
        if (!$guard->allows('home.read')) {
            return 'can not read';
        }

        return 'can read';
    }
}

Change the default actor roles to see how it affects the result.

Use the guard prototype property to develop faster.

namespace App\Controller;

use Spiral\Prototype\Traits\PrototypeTrait;

class HomeController
{
    use PrototypeTrait;

    public function index()
    {
        if (!$this->guard->allows('home.read')) {
            return 'can not read';
        }

        return 'can read';
    }
}

You can use GuardInterface in controllers, services and views.

Permission Context

The allows method of the guard object supports the second argument which defines the permission context, usually it must contain the instance of the target entity which current actor is trying to access or edit.

namespace App\Controller;

use Spiral\Security\GuardInterface;

class HomeController
{
    public function index(GuardInterface $guard)
    {
        if (!$guard->allows('home.read', ['key' => 'value'])) {
            return 'can not read';
        }

        return 'can read';
    }
}

Down below we will explain how to use the context to create more complex role-permission associations.

Permissions Management

The core part of the RBAC component is Spiral\Security\PermissionInterface. While you can use your own implementation with dynamic role and permission configuration, by default, it's intended to configure mapping in the bootloader.

Create Role

Every application user role must first be register in the RBAC component, use method addRole:

namespace App\Bootloader;

use Spiral\Boot\Bootloader\Bootloader;
use Spiral\Security\PermissionsInterface;

class SecurityBootloader extends Bootloader
{
    public function boot(PermissionsInterface $rbac)
    {
        $rbac->addRole('guest');
    }
}

Permission

Once the role or roles are created you can associate them to the permission using method associate:

namespace App\Bootloader;

use Spiral\Boot\Bootloader\Bootloader;
use Spiral\Security\PermissionsInterface;

class SecurityBootloader extends Bootloader
{
    public function boot(PermissionsInterface $rbac)
    {
        $rbac->addRole('guest');

        $rbac->associate('guest', 'home.read');
    }
}

Wildcard Association

You can associate the role to more than one permission at once:

$rbac->associate('guest', 'home.(read|write)');

You can also use the * pattern to create the association with the whole namespace of permissions:

$rbac->associate('guest', 'home.*');

Note, only namespace level can be associated at one, if you use deep namespaces with . separator:

$rbac->associate('guest', 'home.*');
$rbac->associate('guest', 'home.*.*');
// ...

Exclude Permission

In some cases you need to grant access to all of the namespace permissions excluding specific ones. Overwrite the permissions using 3rd argument which can specify the rule. To forbid access use Spiral\Security\Rule\ForbidRule:

namespace App\Bootloader;

use Spiral\Boot\Bootloader\Bootloader;
use Spiral\Security\PermissionsInterface;
use Spiral\Security\Rule\ForbidRule;

class SecurityBootloader extends Bootloader
{
    public function boot(PermissionsInterface $rbac)
    {
        $rbac->addRole('guest');

        $rbac->associate('guest', 'home.*');
        $rbac->associate('guest', 'home.read', ForbidRule::class);
    }
}

The default rule used to create an association is AllowRule, the previous example can be explained better using:

namespace App\Bootloader;

use Spiral\Boot\Bootloader\Bootloader;
use Spiral\Security\PermissionsInterface;
use Spiral\Security\Rule;

class SecurityBootloader extends Bootloader
{
    public function boot(PermissionsInterface $rbac)
    {
        $rbac->addRole('guest');

        $rbac->associate('guest', 'home.*', Rule\AllowRule::class);
        $rbac->associate('guest', 'home.read', Rule\ForbidRule::class);
    }
}

The Guard will check the all of the actor roles, at least one of them must allow the access in order to grant the permission.

Rules

As mentioned above all of the role-to-permission associated are controlled using set of rules. Each rule must implement Spiral\Security\RuleInterface.

interface RuleInterface
{
    public function allows(ActorInterface $actor, string $permission, array $context): bool;
}

While checking the role access to the permission, the rule will receive the current context provided by GuardInterface->allows method:

if (!$guard->allows('home.read', ['key' => 'value'])) {
    return 'can not read';
}

Such approach allows you to create complex rule association between role and the set of permissions.

The default rules AllowRule and ForbidRule are always returning true and fast accordingly.

Custom Rules

To create custom rule simply implement the Spiral\Security\RuleInterface interface, for example we can create the rule which will only allow the access when the context has key equals value:

namespace App\Security;

use Spiral\Security\ActorInterface;
use Spiral\Security\RuleInterface;

class SampleRule implements RuleInterface
{
    public function allows(ActorInterface $actor, string $permission, array $context): bool
    {
        return $context['key'] === 'value';
    }
}

You can assign this rule to the role-to-permission association using class name:

namespace App\Bootloader;

use App\Security\SampleRule;
use Spiral\Boot\Bootloader\Bootloader;
use Spiral\Security\PermissionsInterface;

class SecurityBootloader extends Bootloader
{
    public function boot(PermissionsInterface $rbac)
    {
        $rbac->addRole('guest');

        $rbac->associate('guest', 'home.*', SampleRule::class);
    }
}

Now, changing the context of allows method will cause either approval or denial:

namespace App\Controller;

use Spiral\Prototype\Traits\PrototypeTrait;

class HomeController
{
    use PrototypeTrait;

    public function index()
    {
        if ($this->guard->allows('home.read', ['key' => 'value'])) {
            echo 'yay';
        }

        if ($this->guard->allows('home.read', ['key' => 'else'])) {
            echo 'nope';
        }
    }
}

Use the actor interface to create more complex rules.

Pass domain entities as context to check the authority:

if ($this->guard->allows('post.edit', ['post' => $post])) {
    echo 'yay';
}

And the rule to check if user if author:

class SampleRule implements RuleInterface
{
    public function allows(ActorInterface $actor, string $permission, array $context): bool
    {
        return $context['post']->user === $actor;
    }
}

Abstract Rule

To simplify the rule creation use the Spiral\Security\Rule which enables the method injection with on method check.

namespace App\Security;

use Spiral\Security\Rule;

class SampleRule extends Rule
{
    public function check(string $key): bool
    {
        return $key === 'value';
    }
}

You can access to any application services via method injection.

It is recommended to declare rules as singletons to improve the performance of the application.

namespace App\Security;

use Spiral\Core\Container\SingletonInterface;
use Spiral\Security\Rule;

class SampleRule extends Rule implements SingletonInterface
{
    public function check(string $key): bool
    {
        return $key === 'value';
    }
}

@Guarded Annotation

You can use Guarded annotation to automatically check the access to the controller methods using the domain cores.

namespace App\Controller;

use Spiral\Domain\Annotation\Guarded;

class HomeController
{
    /**
     * @Guarded("home.index", else="notFound")
     */
    public function index()
    {
        return 'OK';
    }
}
Edit this page