Ниже речь пойдет о том, как спроектировать и реализовать довольно гибкую систему авторизации для вашего проекта.
Для начала неплохо было бы зафиксировать понятия. Далее по тексту под авторизацией понимается процесс проверки прав субъекта на доступ к определенному ресурсу, в том числе на совершение определенных действий, например, по модификации ресурса.
Ресурс - любой объект, служба, данные или программный компонент, доступ к которым его владелец хочет ограничить.
Субъект или актор - конкретный пользователь или система, который хочет получить доступ к ресурсу.
После того, как мы определились с ключевыми понятиями, можно приступить к проектированию системы.
Как следует из заголовка статьи авторизация будет проходить на основе политик, а значит будет логично описать интерфейс этой самой политики, но перед этим необходимо понять, что и как политика будет принимать и какой результат возвращать.
Мы уже определили, что у нас есть некий ресурс и актор, который хочет получить к нему доступ. Именно это и логично передавать в политику, если с актором понятно, то как передавать ресурс? Передавать ли объект или его идентификатор, а что если понадобиться передать дополнительную информацию ?
Можно передавать вторым аргументом сформированный вызывающим контекстом массив или иммутабельный объект-контейнер примерно такой.
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]));
Надеюсь материал будет вам полезен или вы найдете его интересным, посмотреть готовую библиотеку можно здесь
