Концепция 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