Revision: Sat, 21 Dec 2024 21:05:36 GMT

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 the list of associated privileges. The components implements "Flat RBAC" patterns described in NIST RBAC research.

The implementation includes multiple additions such as:

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

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

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

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

Actor

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

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

Note
Use IoC scopes to set Actor during the user request.

Read how to use the authenticated user as an actor here.

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

php
namespace App\Controller;

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

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

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

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

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

php
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): void
    {
        $container->bindSingleton(ActorInterface::class, new Actor(['user']));
    }
}

GuardInterface

To use the RBAC component, we must register available roles and create an association between the role and the permission. Use the same Bootloader and Spiral\Security\PermissionsInterface for this purpose.

php
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): void
    {
        $container->bindSingleton(ActorInterface::class, new Actor(['user']));

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

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

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

php
namespace App\Controller;

use Spiral\Security\GuardInterface;

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

        return 'can read';
    }
}

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

Use guard prototype property to develop faster.

php
namespace App\Controller;

use Spiral\Prototype\Traits\PrototypeTrait;

class HomeController
{
    use PrototypeTrait;

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

        return 'can read';
    }
}

Note
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 the current Actor is trying to access or edit.

php
namespace App\Controller;

use Spiral\Security\GuardInterface;

class HomeController
{
    public function index(GuardInterface $guard): string
    {
        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 implementation with a dynamic role and permission configuration, by default, it's intended to configure mapping in the Bootloader.

Create Role

Every application must register available user roles in the RBAC component. Use the method addRole:

php
namespace App\Bootloader;

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

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

Permission

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

php
namespace App\Bootloader;

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

class SecurityBootloader extends Bootloader
{
    public function boot(PermissionsInterface $rbac): void
    {
        $rbac->addRole('guest');
        
        $rbac->associate('guest', 'home.read');
    }
}

Wildcard Association

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

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

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

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

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

php
$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 the 3rd argument, which can specify the Rule. To forbid role access, use Spiral\Security\Rule\ForbidRule:

php
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): void
    {
        $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:

php
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): void
    {
        $rbac->addRole('guest');

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

Note
The Guard will check all of the actor roles, at least one of them must grant the permission.

Rules

As mentioned above, all of the role-to-permission associations are controlled using a set of rules. Each Rule must implement Spiral\Security\RuleInterface.

php
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:

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

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

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

Custom Rules

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

php
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:

php
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): void
    {
        $rbac->addRole('guest');

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

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

php
namespace App\Controller;

use Spiral\Prototype\Traits\PrototypeTrait;

class HomeController
{
    use PrototypeTrait;

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

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

Note
Use the actor interface to create more complex rules.

Pass domain entities as context to check the authority:

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

And the Rule to check if the user is an author:

php
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 Spiral\Security\Rule, which enables the method injection with the method check.

php
namespace App\Security;

use Spiral\Security\Rule;

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

Note
You can access to any application services via method injection.

We recommend declaring rules as singletons to improve the performance of the application.

php
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 Attribute

You can use Guarded attribute to automatically check the access to the controller methods using the Interceptors.

php
namespace App\Controller;

use Spiral\Domain\Annotation\Guarded;

class HomeController
{
    #[Guarded(permission: 'home.index', else: 'notFound')]
    public function index(): string
    {
        return 'OK';
    }
}