Ниже речь пойдет о том, как спроектировать и реализовать довольно гибкую систему авторизации для вашего проекта.

Для начала неплохо было бы зафиксировать понятия. Далее по тексту под авторизацией понимается процесс проверки прав субъекта на доступ к определенному ресурсу, в том числе на совершение определенных действий, например, по модификации ресурса.

Ресурс - любой объект, служба, данные или программный компонент, доступ к которым его владелец хочет ограничить.

Субъект или актор - конкретный пользователь или система, который хочет получить доступ к ресурсу.

После того, как мы определились с ключевыми понятиями, можно приступить к проектированию системы.

Как следует из заголовка статьи авторизация будет проходить на основе политик, а значит будет логично описать интерфейс этой самой политики, но перед этим необходимо понять, что и как политика будет принимать и какой результат возвращать.

Мы уже определили, что у нас есть некий ресурс и актор, который хочет получить к нему доступ. Именно это и логично передавать в политику, если с актором понятно, то как передавать ресурс? Передавать ли объект или его идентификатор, а что если понадобиться передать дополнительную информацию ?

Можно передавать вторым аргументом сформированный вызывающим контекстом массив или иммутабельный объект-контейнер примерно такой.

interface ContextInterface
{
    public const string RESOURCE_ATTR = '__resource';
    public const string RESOURCE_ID_ATTR = '__resource_id';
   
    public function getAttribute(string $name, mixed $default = null): mixed ;

    public function withAttribute(string $name, mixed $value): static;

}

Так вызывающий код сам сможешь формировать контекст для авторизации.

После того, как определились с аргументами метода, осталось решить, что политика будет возвращать в качестве результата и какие исключения может выбрасывать.

Логика подсказывает, что политика должна возвращать булев: true в случае успеха и false в случае, если доступ запрещен.

Но я предлагаю сделать несколько иначе. Возвращать true для успешной проверки и объект-значение с описанием причины отказа и именем класса отказавшей политики.

final readonly class DenyReason implements \Stringable
{
    public function __construct(
        public string $value,
        public string $policyClass = '',
    ) {}

    public function __toString(): string
    {
        return $this->value;
    }
}

Для исключений же просто создадим интерфейс маркер и условимся, что исключения не связаны с логикой авторизации в том смысле, что если контекст сформирован правильно, но у актора недостаточно прав на совершение операции, то в этом случае возвращаем DenyReason, а не бросаем исключение.

По итогу контракт для политик получается таким.

interface PolicyInterface
{
    /**
     * @throws PolicyExceptionInterface
     */
    public function enforce(object $actor, ContextInterface $context): true|DenyReason;
}

Но почему актор - объект, а не условный ActorInterface ? Ответ прост - чтобы не ограничивать гибгость политик, так политика принимает любой объект и если актор ей не подходит, то может выбросить исключение, например, что ожидается ActorInterface .

Когда с основным интерфейсом определились остается решить какие интерфейсы и классы нам еще необходимы.

Для минимальной рабочей реализации нужны еще как минимум класс оркестратор и провайдер политик, который будет подбирать подходящие политики для ресурса по его идентификатору.

interface PolicyProviderInterface
{
    public function provideFor(string $resourceId): ?PolicyInterface;
}
class PolicyEnforcer {
  public function __construct(
    private PolicyProviderInterface $provider
  )
  
  public function check(
        string $resourceId,
        object $actor,
        ContextInterface $context = [],
    ): true|AccessDenied {
        
        $policy = $this->provider->provideFor($resourceId);

        if ($policy === null) {
            return new AccessDenied(
              $resourceId,
              new DenyReason("No policy defined for resource with id: '$resourceId'"),
              $actor,
              $context,
            );
        }

        $context = $context->withAttribute($context::RESOURCE_ID_ATTR, $resourceId);

        $result = $policy->enforce($actor, $context);

        if ($result === true) {
            return true;
        }

        return AccessDenied::fromReason($result, $resourceId, $actor, $context);
    }
}

final readonly class AccessDenied
{
    public function __construct(
        public string $resourceId,
        public DenyReason $reason,
        public object $actor,
        public ContextInterface $context,
    ) {}
}

PolicyEnforcer получает провайдер политик ищет подходящую политику по идентификатору ресурса, если политики нет, то возвращает отказ. Если политика найдена, то делегирует ей проверку и так же возвращает отказ, если проверка не прошла.

Теперь давайте реализуем первую политику, которая будет решать имеет ли актор доступ к ресурсу на основе имеющихся у него прав. Для этого нам понадобится описать еще три интерфейса.

interface PermissionInterface
{
    public function getName(): string;
}

interface PermissionCollectionInterface extends IteratorAggregate
{
    public function contains(PermissionInterface|PermissionCollectionInterface|string $permission, ContainsMode $mode = ContainsMode::ALL): bool ;
}

interface PermissionAwareInterface
{
    public PermissionCollectionInterface $permissions { get; }
}

enum ContainsMode: string
{
    /** Every listed permission must be granted. */
    case ALL = 'all';

    /** At least one of the listed permissions must be granted. */
    case ANY = 'any';
}

class PermissionPolicy implements PolicyIntrface
{
    public function __construct(
        private readonly PermissionCollectionInterface $permissions,
        private readonly ContainsMode $mode = ContainsMode::ALL,
    ) {
    }

    public function enforce(object $actor, ContextInterface $context): true|DenyReason
    {
        if (!$actor instanceof PermissionAwareInterface) {
          throw InvalidPolicyActorException('$actor must be instance of '. PermissionAwareInterface::class)
        }

        $result = $actor->permissions->containes($this->permissions, $this->mode);

        if ($result) { return true }

        $reason = match ($this->mode) {
          ContainsMode::ALL => sprintf(
            'Доступ запрещён: отсутствуют необходимые разрешения. Требуются все разрешения из набора: %s.',
            $this->permissionsToString($this->permissions),
          ),

          ContainsMode::ANY => sprintf(
            'Доступ запрещён: необходимо хотя бы одно из разрешений: %s.',
            $this->permissionsToString($this->permissions),
          ),
        }

        return new DenyReason($reason, PolicyClass::class);
    }
}

Теперь мы можем авторизовать актора по имеющимся у него разрешениям, но как быть, если мы хотим комбинировать политики, например, пост может редактировать любой пользователь с разрешением на редактирование любых постов и владелец поста?

Сначала добавим вторую политику, которая будет проверять, что текущий актор - владелец поста.

final class PostOwnerPolicy implements PolicyInterface
{
   
    public function enforce(object $actor, ContextInterface $context): true|DenyReason
    {
        //получаем ресурс из контекста

        $post = $context->getAttribute($context::RESOURCE_ATTR);
        if (!$post instanceof Post) {
          // бросаем исключение т.к. контекст невалиден 
        }

        if ($actor->id === $post->author->id) {
          return true;
        }

        return new DenyReason('Редактировать пост может только автор', PostOwnerPolicy::class);
    }
}

Теперь добавим две политики, которые комбинируют прочие политики.

Первая будет логическим и, а вторая соответственно или.

final class AllOf implements PolicyInterface
{
    /** @var PolicyInterface[] */
    private array $policies;

    public function __construct(PolicyInterface ...$policies)
    {
        $this->policies = $policies;
    }

    public function enforce(object $actor, ContextInterface $context): true|DenyReason
    {
        foreach ($this->policies as $policy) {
            $result = $policy->enforce($actor, $context);

            if ($result !== true) {
                return $result;
            }
        }

        return true;
    }
}

final class OneOf extends AbstractPolicy
{
    /** @var PolicyInterface[] */
    private array $policies;

    public function __construct(PolicyInterface ...$policies)
    {
        $this->policies = $policies;
    }

    public function enforce(object $actor, ContextInterface $context): true|DenyReason
    {
        $lastDenied = null;

        foreach ($this->policies as $policy) {
            $result = $policy->enforce($actor, $context);

            if ($result === true) {
                return true;
            }

            $lastDenied = $result;
        }

        return $lastDenied ?? $this->deny('None of the policies allowed access');
    }
}

Теперь вызывающий код может связать ресурс с политикой посредством провайдера политик, сформировать контекст и взывать PolicyEnforcer::check

final class PolicyProvider implements PolicyProviderInterface {
  public function provideFor(string $resourceId):? PolicyInterface {
    return match($resourceId) {
      Post::class => new OneOf(
        new PermissionPolicy(new PermissionCollection(PostPermission::EDIT_ANY)),
        new PostOwnerPolicy
      ),
      default => null
    }
  }
}

$enforcer = new PolicyEnforcer(new PolicyProvider);
$result = $enforcer->check($actor, new Context([Context::RESOURCE_ATTR => $post]));

Надеюсь материал будет вам полезен или вы найдете его интересным, посмотреть готовую библиотеку можно здесь