HardПрактика6 min

Voters

VoterInterface, кастомные voters, стратегии голосования

Концепция Voters

Voter -- класс, который решает, имеет ли пользователь доступ к конкретному ресурсу. Voters работают через метод isGranted() и атрибут #[IsGranted].

isGranted('EDIT', $post)
    |
    v
AccessDecisionManager
    |
    v
[PostVoter] [RoleVoter] [AuthenticatedVoter]
    |            |              |
  GRANT        ABSTAIN        ABSTAIN
    |
    v
Strategy: AFFIRMATIVE --> ACCESS GRANTED

VoterInterface

<?php

declare(strict_types=1);

namespace Symfony\Component\Security\Core\Authorization\Voter;

interface VoterInterface
{
    public const ACCESS_GRANTED = 1;
    public const ACCESS_ABSTAIN = 0;
    public const ACCESS_DENIED = -1;

    /**
     * @param list<string> $attributes
     */
    public function vote(
        TokenInterface $token,
        mixed $subject,
        array $attributes,
    ): int;
}

Три возможных ответа Voter

Ответ Константа Описание
Grant ACCESS_GRANTED Разрешить доступ
Deny ACCESS_DENIED Запретить доступ
Abstain ACCESS_ABSTAIN Voter не принимает решение по этому вопросу

Создание Voter через Voter (abstract)

Абстрактный класс Voter упрощает создание кастомных voters. Достаточно реализовать два метода: supports() и voteOnAttribute().

<?php

declare(strict_types=1);

namespace App\Security\Voter;

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

final class PostVoter extends Voter
{
    public const VIEW = 'VIEW';
    public const EDIT = 'EDIT';
    public const DELETE = 'DELETE';
    public const PUBLISH = 'PUBLISH';

    /**
     * Does this voter support the given attribute and subject?
     */
    protected function supports(string $attribute, mixed $subject): bool
    {
        // Only vote on Post objects with known attributes
        return $subject instanceof Post
            && in_array($attribute, [self::VIEW, self::EDIT, self::DELETE, self::PUBLISH], true);
    }

    /**
     * Perform the actual authorization check
     */
    protected function voteOnAttribute(
        string $attribute,
        mixed $subject,
        TokenInterface $token,
    ): bool {
        $user = $token->getUser();

        // Must be logged in
        if (!$user instanceof User) {
            return false;
        }

        /** @var Post $post */
        $post = $subject;

        return match ($attribute) {
            self::VIEW => $this->canView($post, $user),
            self::EDIT => $this->canEdit($post, $user),
            self::DELETE => $this->canDelete($post, $user),
            self::PUBLISH => $this->canPublish($post, $user),
            default => false,
        };
    }

    private function canView(Post $post, User $user): bool
    {
        // Published posts are visible to everyone
        if ($post->isPublished()) {
            return true;
        }

        // Draft posts visible only to author
        return $post->getAuthor() === $user;
    }

    private function canEdit(Post $post, User $user): bool
    {
        // Author can edit own posts
        return $post->getAuthor() === $user;
    }

    private function canDelete(Post $post, User $user): bool
    {
        // Only author can delete
        return $post->getAuthor() === $user;
    }

    private function canPublish(Post $post, User $user): bool
    {
        // Only author can publish drafts
        return !$post->isPublished()
            && $post->getAuthor() === $user;
    }
}

Voter с проверкой ролей

Часто voter должен учитывать роли пользователя. Используйте Security для проверки.

<?php

declare(strict_types=1);

namespace App\Security\Voter;

use App\Entity\Project;
use App\Entity\User;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

final class ProjectVoter extends Voter
{
    public const VIEW = 'PROJECT_VIEW';
    public const EDIT = 'PROJECT_EDIT';
    public const DELETE = 'PROJECT_DELETE';
    public const MANAGE_MEMBERS = 'PROJECT_MANAGE_MEMBERS';

    public function __construct(
        private readonly Security $security,
    ) {
    }

    protected function supports(string $attribute, mixed $subject): bool
    {
        return $subject instanceof Project
            && in_array($attribute, [
                self::VIEW,
                self::EDIT,
                self::DELETE,
                self::MANAGE_MEMBERS,
            ], true);
    }

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

        if (!$user instanceof User) {
            return false;
        }

        // Admin can do everything
        if ($this->security->isGranted('ROLE_ADMIN')) {
            return true;
        }

        /** @var Project $project */
        $project = $subject;

        return match ($attribute) {
            self::VIEW => $this->canView($project, $user),
            self::EDIT => $this->canEdit($project, $user),
            self::DELETE => $this->canDelete($project, $user),
            self::MANAGE_MEMBERS => $this->canManageMembers($project, $user),
            default => false,
        };
    }

    private function canView(Project $project, User $user): bool
    {
        // Public projects or member of project
        return $project->isPublic()
            || $project->isMember($user);
    }

    private function canEdit(Project $project, User $user): bool
    {
        // Owner or manager
        return $project->getOwner() === $user
            || $project->isManager($user);
    }

    private function canDelete(Project $project, User $user): bool
    {
        // Only owner
        return $project->getOwner() === $user;
    }

    private function canManageMembers(Project $project, User $user): bool
    {
        // Owner or manager
        return $project->getOwner() === $user
            || $project->isManager($user);
    }
}

Использование Voter

В контроллере

<?php

declare(strict_types=1);

namespace App\Controller;

use App\Entity\Post;
use App\Security\Voter\PostVoter;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Http\Attribute\IsGranted;

final class PostController extends AbstractController
{
    // Via attribute
    #[IsGranted(PostVoter::EDIT, subject: 'post')]
    public function edit(Post $post): Response
    {
        return $this->render('post/edit.html.twig', ['post' => $post]);
    }

    // Via method call
    public function show(Post $post): Response
    {
        $this->denyAccessUnlessGranted(PostVoter::VIEW, $post);

        $canEdit = $this->isGranted(PostVoter::EDIT, $post);

        return $this->render('post/show.html.twig', [
            'post' => $post,
            'canEdit' => $canEdit,
        ]);
    }
}

В Twig

{% if is_granted('EDIT', post) %}
    <a href="{{ path('post_edit', {id: post.id}) }}">Edit</a>
{% endif %}

{% if is_granted('DELETE', post) %}
    <form method="post" action="{{ path('post_delete', {id: post.id}) }}">
        <input type="hidden" name="_token" value="{{ csrf_token('delete' ~ post.id) }}">
        <button type="submit">Delete</button>
    </form>
{% endif %}

В сервисе

<?php

declare(strict_types=1);

namespace App\Service;

use Symfony\Bundle\SecurityBundle\Security;

final class PostService
{
    public function __construct(
        private readonly Security $security,
        private readonly EntityManagerInterface $em,
    ) {
    }

    public function publish(Post $post): void
    {
        if (!$this->security->isGranted('PUBLISH', $post)) {
            throw new AccessDeniedException('Cannot publish this post.');
        }

        $post->publish();
        $this->em->flush();
    }
}

Стратегии голосования

AccessDecisionManager определяет итоговое решение на основе ответов всех voters.

security:
    access_decision_manager:
        strategy: affirmative  # Default

Affirmative (по умолчанию)

Доступ разрешён, если хотя бы один voter вернул GRANT.

Voter 1: GRANT  --> Result: GRANTED
Voter 2: DENY       (one GRANT is enough)
Voter 3: ABSTAIN

Consensus

Доступ разрешён, если большинство voters вернули GRANT.

Voter 1: GRANT  --> Result: DENIED
Voter 2: DENY       (2 deny vs 1 grant)
Voter 3: DENY

Unanimous

Доступ разрешён, если все voters вернули GRANT (или ABSTAIN).

Voter 1: GRANT  --> Result: DENIED
Voter 2: GRANT      (one DENY blocks)
Voter 3: DENY

Priority

Используется решение первого voter, который НЕ абстаинится.

Voter 1: ABSTAIN  --> Result: DENIED
Voter 2: DENY         (first non-abstain)
Voter 3: GRANT
Стратегия Логика Когда использовать
affirmative Хоть один GRANT По умолчанию, подходит для большинства
consensus Большинство GRANT Множественные проверки, "демократия"
unanimous Все GRANT Максимальная безопасность
priority Первый ответ Приоритетные voters

Подвох экзамена: Стратегия по умолчанию -- affirmative. Если хотя бы один voter вернул GRANT, доступ разрешён, даже если другие вернули DENY. Это важно помнить при работе с несколькими voters.

Встроенные Voters

Voter Что проверяет
RoleVoter Роли вида ROLE_*
RoleHierarchyVoter Роли с учётом иерархии
AuthenticatedVoter IS_AUTHENTICATED_*

Встроенные voters работают автоматически. Кастомные voters добавляются через autoconfigure.

Полный пример: Document Voter

<?php

declare(strict_types=1);

namespace App\Security\Voter;

use App\Entity\Document;
use App\Entity\User;
use App\Enum\DocumentStatus;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

final class DocumentVoter extends Voter
{
    public const VIEW = 'DOCUMENT_VIEW';
    public const EDIT = 'DOCUMENT_EDIT';
    public const DELETE = 'DOCUMENT_DELETE';
    public const APPROVE = 'DOCUMENT_APPROVE';
    public const ARCHIVE = 'DOCUMENT_ARCHIVE';

    public function __construct(
        private readonly Security $security,
    ) {
    }

    protected function supports(string $attribute, mixed $subject): bool
    {
        return $subject instanceof Document
            && in_array($attribute, [
                self::VIEW, self::EDIT, self::DELETE,
                self::APPROVE, self::ARCHIVE,
            ], true);
    }

    protected function voteOnAttribute(
        string $attribute,
        mixed $subject,
        TokenInterface $token,
    ): bool {
        $user = $token->getUser();
        if (!$user instanceof User) {
            return self::VIEW === $attribute && $subject->isPublic();
        }

        // Super admin bypasses all checks
        if ($this->security->isGranted('ROLE_SUPER_ADMIN')) {
            return true;
        }

        /** @var Document $document */
        $document = $subject;

        return match ($attribute) {
            self::VIEW => $this->canView($document, $user),
            self::EDIT => $this->canEdit($document, $user),
            self::DELETE => $this->canDelete($document, $user),
            self::APPROVE => $this->canApprove($document, $user),
            self::ARCHIVE => $this->canArchive($document, $user),
            default => false,
        };
    }

    private function canView(Document $document, User $user): bool
    {
        return $document->isPublic()
            || $document->getAuthor() === $user
            || $document->getDepartment() === $user->getDepartment();
    }

    private function canEdit(Document $document, User $user): bool
    {
        // Can only edit drafts
        if ($document->getStatus() !== DocumentStatus::Draft) {
            return false;
        }

        return $document->getAuthor() === $user;
    }

    private function canDelete(Document $document, User $user): bool
    {
        // Only drafts can be deleted
        if ($document->getStatus() !== DocumentStatus::Draft) {
            return false;
        }

        return $document->getAuthor() === $user
            || $this->security->isGranted('ROLE_ADMIN');
    }

    private function canApprove(Document $document, User $user): bool
    {
        // Only pending documents, only managers
        return $document->getStatus() === DocumentStatus::Pending
            && $this->security->isGranted('ROLE_MANAGER');
    }

    private function canArchive(Document $document, User $user): bool
    {
        // Only approved documents
        return $document->getStatus() === DocumentStatus::Approved
            && ($document->getAuthor() === $user
                || $this->security->isGranted('ROLE_ADMIN'));
    }
}

Итоги

  • Voter решает: GRANT, DENY или ABSTAIN для конкретного attribute + subject
  • Абстрактный Voter упрощает создание: supports() + voteOnAttribute()
  • Security внедряется для проверки ролей внутри voter
  • Стратегия affirmative -- по умолчанию (один GRANT достаточно)
  • Voters регистрируются автоматически через autoconfigure
  • #[IsGranted], denyAccessUnlessGranted(), is_granted() -- все используют voters