Как использовать избирателей для проверки разрешений данных

Как использовать избирателей для проверки разрешений данных

В Symfony вы можете проверить разрешения на доступ к данным, используя модуль СКД, что может быть слишком непомерным для некоторых приложений. Намного более лёгким решением будет работа с пользовательскими избирателями, которые похожи на простые условные утверждения.

Tip

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

Как Symfony использует избирателей

Для того, чтобы использовать избирателей, вам нужно понять, как Symfony работает с ними. Все избиратели вызываются каждый раз, кога вы используете метод isGranted() в проверке авторизации Symfony или вызываете denyAccessUnlessGranted в контроллере (который использует проверку авторизации).

В конечном счёте, Symfony берёт ответы от всех избирателей и принимает окончательное решение (о том, чтобы разрешить или запретить доступ к ресурсу) в соответствии со стратегией, определённой в приложении, которая можетбыть:according to the strategy defined in the application, which can be: утвердительной, консенсусной или единогласной.

Чтобы узнать больше, посмотрите раздел о менеджерах решений доступа.

Интерфейс избирателя

Пользовательский избиратель должен реализовывать VoterInterface или расширять Voter, что делает создание избирателя ещё легче.

1
2
3
4
5
abstract class Voter implements VoterInterface
{
    abstract protected function supports($attribute, $subject);
    abstract protected function voteOnAttribute($attribute, $subject, TokenInterface $token);
}

Установка: Проверка доступа в контроллере

Представьте, что у вас есть объект Post и вам нужно решить, может ли текущий пользователь редактировать или просматривать объект. В вашем контроллере, вы проверите доступ со следующим кодом:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// src/AppBundle/Controller/PostController.php
// ...

class PostController extends Controller
{
    /**
     * @Route("/posts/{id}", name="post_show")
     */
    public function showAction($id)
    {
        // получить объект Post - например, запросить его
        $post = ...;

        // проверить разрешение "просмотра": вызов всех избирателей
        $this->denyAccessUnlessGranted('view', $post);

        // ...
    }

    /**
     * @Route("/posts/{id}/edit", name="post_edit")
     */
    public function editAction($id)
    {
        // получить объект Post - например, запросить его
        $post = ...;

        // проверить разрешение "редактирования": вызов всех избирателей
        $this->denyAccessUnlessGranted('edit', $post);

        // ...
    }
}

Метод denyAccessUnlessGranted() (а также метод isGranted()) делает вызов к системе "избирателей". Сейчас, ни один избиратель не проголосует о том, может ли пользователь "просматривать" или "редактировать" Post. Но вы можете создать вашего собственного избирателя, который решает это, используя любую желаемую вами логику.

Создание пользовательского избирателя

Представьте, что логика для решения, может ли пользователь "просматривать" или "редактировать" объект, достаточно сложная. Например, User может всегда просматривать или редактировать Post, который он создал. А если Post отмечен, как "публичный", то его может просмотреть кто угодно. Избиратель для этой ситуации будет выглядеть так:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// src/AppBundle/Security/PostVoter.php
namespace AppBundle\Security;

use AppBundle\Entity\Post;
use AppBundle\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

class PostVoter extends Voter
{
    // эти строки были просто выдуманы: вы можете использовать что угодно
    const VIEW = 'view';
    const EDIT = 'edit';

    protected function supports($attribute, $subject)
    {
        // если это не один из поддерживаемых атрибутов, возвращается false
        if (!in_array($attribute, array(self::VIEW, self::EDIT))) {
            return false;
        }

        // голосовать только по объектам Post внутри этого избирателя
        if (!$subject instanceof Post) {
            return false;
        }

        return true;
    }

    protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
    {
        $user = $token->getUser();

        if (!$user instanceof User) {
            // пользователь должен быть в системе; если нет - отказать в доступе
            return false;
        }

        // вы знаете, что $subject - это объект Post, благодаря поддержке
        /** @var Post $post */
        $post = $subject;

        switch ($attribute) {
            case self::VIEW:
                return $this->canView($post, $user);
            case self::EDIT:
                return $this->canEdit($post, $user);
        }

        throw new \LogicException('This code should not be reached!');
    }

    private function canView(Post $post, User $user)
    {
        // если они могут просматривать, то они могут редактировать
        if ($this->canEdit($post, $user)) {
            return true;
        }

        // обьект Post может иметь, например, метод isPrivate(),
        // который проверяет булево свойство $private
        return !$post->isPrivate();
    }

    private function canEdit(Post $post, User $user)
    {
        // предполагает, что объект данных имеет метод getOwner(),
        // чтобы получить сущность пользователя, который владеет этим объектом данных
        return $user === $post->getOwner();
    }
}

Вот и всё! Избиратель готов! Далее, сконфигурируйте его.

Чтобы подытожить, вот то, что ожидается от двух абстрактных методов:

Voter::supports($attribute, $subject)
Когда вызывается isGranted() (или denyAccessUnlessGranted()), первый аргумент передаётся как $attribute (например, ROLE_USER, edit), а второй аргумент (если он есть) - как $subject (например, null, объект Post). Ваша задача - определить, должен ли ваш избиратель голосовать по комбинации атрибут/субъект. Если вы вернёте "true", то voteOnAttribute() будет вызван. В обратном случае, ваш избиратель закончил: какой-то другой избиратель должен это обработать. В этом примере, вы возвращаете true, если атрибут - view или edit, и если объект - экземпляр Post.
voteOnAttribute($attribute, $subject, TokenInterface $token)
Если вы возвращаете true из supports(), то вызывается этот метод. Ваша задача проста: вернуть true, чтобы разрешить доступ, и false, чтобы его запретить. $token может быть использован, чтобы найти текущий объект пользователя (если он есть). В этомпримере, вся сложная бизнес-логика включена для того, чтобы определить доступ.

Конфигурация избирателя

Чтобы внедрить избирателя в слой безопасности, вы должны объявить его, как сервис и тегировать его с помощью security.voter. Но если вы используете конфигурацию services.yml по умолчанию, то это делается за вас автоматически! Когда вы вызываете isGranted() с просмотром/редактированием и передаёте объект Post, ваш избиратель будет выполнен и вы сможете контролировать доступ.

Проверка ролей внутри избирателя

Что, если вы хотите вызвать isGranted() изнутри вашего избирателя - например, вы хотите увидеть, имеет ли текущий пользователь ROLE_SUPER_ADMIN. Это возможно с помощью внедрения AccessDecisionManager в вашего избирателя. Вы можете использовать это для того, чтобы, к примеру, всегда разрешать доступ пользователю с ROLE_SUPER_ADMIN:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// src/AppBundle/Security/PostVoter.php

// ...
use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;

class PostVoter extends Voter
{
    // ...

    private $decisionManager;

    public function __construct(AccessDecisionManagerInterface $decisionManager)
    {
        $this->decisionManager = $decisionManager;
    }

    protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
    {
        // ...

        // ROLE_SUPER_ADMIN может сделать что угодно! Вот это сила!
        if ($this->decisionManager->decide($token, array('ROLE_SUPER_ADMIN'))) {
            return true;
        }

        // ... вся логика нормального избирателя
    }
}

Если вы используете конфигурацию services.yml по умолчанию, то вы закончили! Symfony автоматически передаст сервис security.access.decision_manager при инстанциировании вашего избирателя (благодаря автомонтированию).

Вызов decide() в AccessDecisionManager это по сути то же самое, что вызов isGranted() из контроллера или других мест (просто немного ниже по уровню, что необходимо для избирателя).

Note

Если вам нужно проверить доступ к любому сервису не являющемуся избирателем, используйте сервис security.authorization_checker (т.е. типизируйте Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface) вместо сервиса security.access.decision_manager, показанного здесь.

Изменение стратегии решений доступа

Обычно, один избиратель буде голосовать в любое данное время (а остальные будут "воздерживаться", что означает, что они вернут false из supports()). Но в теории, вы можете заставить несколько избирателей голосоватьпо одному действию и объекту. Например, представьте, что у вас есть один избиратель, который проверяет, является ли пользователь членом этого сайта, и второй, который проверяет, чтобы возраст пользователя был старше 18 лет.

Чтобы обработать эти случаи, менеджер решений доступа использует стратегию решений доступа. Вы можете сконфигурировать её под ваши потребности. Существует три доступные стратегии:

affirmative (по умолчанию)
Гарантирует доступ, как только есть один избиратель, гарантирующий доступ;
consensus
Гарантирует доступ, если больше избирателей гарантируют доступ, чем отказывают в нём;
unanimous
Гарантирует доступ только, если все избиратели гарантируют доступ.

В вышеописанном сценарии, оба избирателя должны гарантировать доступ, чтобы гарантировать пользователю доступ к чтению записи. В этом случае, стратегия по умолчанию не валидна, и вместо неё должна быть использована unanimous. Вы можете установить это в конфигурации безопасности:

  • YAML
    1
    2
    3
    4
    # app/config/security.yml
    security:
        access_decision_manager:
            strategy: unanimous
    
  • XML
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    <!-- app/config/security.xml -->
    <?xml version="1.0" encoding="UTF-8" ?>
    <srv:container xmlns="http://symfony.com/schema/dic/security"
        xmlns:srv="http://symfony.com/schema/dic/services"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://symfony.com/schema/dic/services
            http://symfony.com/schema/dic/services/services-1.0.xsd"
    >
    
        <config>
            <access-decision-manager strategy="unanimous" />
        </config>
    </srv:container>
    
  • PHP
    1
    2
    3
    4
    5
    6
    // app/config/security.php
    $container->loadFromExtension('security', array(
        'access_decision_manager' => array(
            'strategy' => 'unanimous',
        ),
    ));
    

Эта документация является переводом официальной документации Symfony и предоставляется по свободной лицензии CC BY-SA 3.0.