Перевірено на Symfony 7.4 LTS і 8.0, symfony/security-bundle, PHP 8.4. Приклади на атрибутах маршрутів і Doctrine-сутностях, але сама механіка від ORM не залежить.
Ролі проти voters
Роль живе на користувачі й не знає нічого про дані. ROLE_EMPLOYER мають усі роботодавці порталу, тож перевірка isGranted('ROLE_EMPLOYER') пропустить будь-кого з них до форми редагування будь-якої вакансії. Щоб це виправити, у контролер дописують другу умову:
if (!$this->isGranted('ROLE_EMPLOYER') || $job->getCompany() !== $user->getCompany()) {
throw $this->createAccessDeniedException();
}
Працює. Проблема в тому, що ця умова потрібна ще в шаблоні (показувати кнопку «Редагувати» чи ні), в API-контролері, у консольній команді масового оновлення і в обробнику черги. Через півроку вони розходяться: десь забули про адміна, десь порівняли сутності замість ідентифікаторів.
Voter прибирає дублювання, бо переносить умову в одне місце й дає їй ім'я. Атрибут JOB_EDIT для AccessDecisionManager нічим не відрізняється від ROLE_EMPLOYER: обидва проходять через той самий менеджер, просто перший обробляє ваш клас, а другий - вбудований RoleHierarchyVoter. IS_AUTHENTICATED_FULLY, PUBLIC_ACCESS і IS_IMPERSONATOR теж звичайні воутери (AuthenticatedVoter), жодного особливого механізму за ними не стоїть.
access_control у security.yaml лишається для грубих речей: «весь /admin вимагає ROLE_ADMIN», «/profile тільки для автентифікованих». Він працює з шляхом і не бачить об'єкта, тому все, що стосується конкретного рядка в базі, туди не поміщається.
Voter на прикладі редагування чужої вакансії
namespace App\Security\Voter;
use App\Entity\Job;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* @extends Voter<self::*, Job>
*/
final class JobVoter extends Voter
{
public const string VIEW = 'JOB_VIEW';
public const string EDIT = 'JOB_EDIT';
public const string DELETE = 'JOB_DELETE';
protected function supports(string $attribute, mixed $subject): bool
{
return \in_array($attribute, [self::VIEW, self::EDIT, self::DELETE], true)
&& $subject instanceof Job;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
return match ($attribute) {
self::VIEW => $subject->isPublished() || $this->belongsToUserCompany($user, $subject),
self::EDIT, self::DELETE => $this->belongsToUserCompany($user, $subject),
};
}
private function belongsToUserCompany(User $user, Job $job): bool
{
$company = $user->getCompany();
return $company !== null && $company->getId() === $job->getCompany()->getId();
}
}
Дрібниці, які тут не випадкові. $token->getUser() повертає UserInterface|null, і null там буває реально: анонімний відвідувач теж має токен. Перевірка instanceof User одночасно відсіює анонімів і дає статичному аналізатору конкретний тип. Порівняння йде за ідентифікаторами, а не за об'єктами: === на сутностях дасть правильну відповідь лише доти, доки обидві прийшли з одного identity map, а == на проксі Doctrine здатен тригернути ліниве завантаження й порівняти половину графа.
Реєструвати воутер у конфізі не треба: autoconfigure вішає тег security.voter на все, що реалізує VoterInterface. Перевірити список можна командою php bin/console debug:container --tag=security.voter.
Тепер про кількість викликів. Абстрактний Voter реалізує CacheableVoterInterface, але робить це консервативно: обидва методи за замовчуванням повертають true на будь-який атрибут і будь-який тип. Тобто ваш JobVoter буде інстанційований і викликаний на кожному isGranted('ROLE_ADMIN') у застосунку, просто щоб supports() відповів false. Два методи це виправляють:
public function supportsAttribute(string $attribute): bool
{
return \in_array($attribute, [self::VIEW, self::EDIT, self::DELETE], true);
}
public function supportsType(string $subjectType): bool
{
return is_a($subjectType, Job::class, true);
}
$subjectType приходить як результат get_debug_type($subject), тож для перевірки без об'єкта (скажімо, JOB_CREATE без subject) там буде рядок 'null', і воутер буде коректно пропущено. Якщо у вас є атрибут, який працює саме з null, це треба врахувати в supportsType() явно.
Стратегія прийняття рішення за замовчуванням affirmative: достатньо одного «за», решта може бути проти. Коли ви додаєте другий воутер, який має право накласти вето (наприклад, блокування акаунта компанії), affirmative вас не врятує, потрібен unanimous у security.access_decision_manager.strategy. Змінювати стратегію глобально заради одного випадку зазвичай гірше, ніж зібрати обидві умови в одному воутері.
Воутер тестується як звичайний клас, без ядра:
$voter = new JobVoter();
$token = new UsernamePasswordToken($owner, 'main', $owner->getRoles());
self::assertSame(
VoterInterface::ACCESS_GRANTED,
$voter->vote($token, $job, [JobVoter::EDIT]),
);
IsGranted у контролерах і на рівні обʼєкта
#[IsGranted] з Symfony\Component\Security\Http\Attribute перевіряє доступ до того, як тіло екшену почне виконуватись:
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/jobs/{id}/edit', name: 'job_edit', methods: ['GET', 'POST'])]
#[IsGranted(JobVoter::EDIT, subject: 'job', message: 'Ця вакансія належить іншій компанії.')]
public function edit(Job $job, Request $request): Response
{
// ...
}
subject: 'job' - це ім'я аргументу контролера, а не властивості. Слухач атрибута працює на події kernel.controller_arguments, тобто після резолверів, тож #[MapEntity] уже перетворив {id} на сутність. Помилка в імені аргументу дає виняток під час запиту, а не тихий пропуск перевірки, і це добре.
Коли перевіряти треба не сам аргумент, а щось у його графі, підійде Expression (потрібен symfony/expression-language):
use Symfony\Component\ExpressionLanguage\Expression;
#[IsGranted('COMPANY_MANAGE', subject: new Expression('args["job"].getCompany()'))]
Опція statusCode важить тут більше, ніж здається. Для приватної вакансії 403 сам по собі є витоком: він підтверджує, що об'єкт із таким id існує. У публічних розділах логічніше віддавати 404:
#[IsGranted(JobVoter::VIEW, subject: 'job', statusCode: 404, message: 'Вакансію не знайдено.')]
Атрибут на рівні класу застосовується до всіх екшенів контролера, і він не скасовує методного: перевіряються обидва. Типова комбінація - #[IsGranted('ROLE_EMPLOYER')] на класі як груба відсічка і об'єктні перевірки на методах.
Там, де subject з'являється вже в тілі екшену (наприклад, сутність дістали після валідації форми), використовується $this->denyAccessUnlessGranted(JobVoter::EDIT, $job). Поза контролером те саме робить Symfony\Bundle\SecurityBundle\Security з методами isGranted() і denyAccessUnlessGranted().
У шаблонах усе працює через is_granted():
{% if is_granted('JOB_EDIT', job) %}
<a href="{{ path('job_edit', {id: job.id}) }}">Редагувати</a>
{% endif %}
Тут же ховається головна пастка продуктивності. У циклі по сорока вакансіях воутер викликається сорок разів. Якщо всередині він робить запит до бази (скажімо, тягне членство користувача в компанії), ви отримали N+1 у місці, де його ніхто не шукає. Дані, потрібні для рішення, мають бути або вже в об'єкті, або в користувачеві з токена, або підвантажені один раз у самому воутері.
Починаючи з Symfony 7.3, voteOnAttribute() може приймати необов'язковий об'єкт Vote, куди воутер кладе причину рішення через addReason(). Причину видно в профайлері, і вона доступна з AccessDeniedException, тож замість «403 і здогадуйся» можна показати користувачеві, чому саме відмовлено. Панель Security у профайлері й без цього показує таблицю з голосами кожного воутера за кожен атрибут, і це перше місце, куди варто дивитись, коли доступ поводиться не так, як ви очікували.
Що не повинно жити у voter
Найчастіша помилка - засунути у воутер стан домену. «Вакансію не можна редагувати, бо вона в архіві» виглядає як перевірка доступу, але це правило про об'єкт, а не про того, хто просить. Просте розмежування: якщо відповідь змінюється, коли ви підставляєте іншого користувача, це авторизація; якщо вона змінюється від зміни стану об'єкта при тому самому користувачеві, це бізнес-правило, і його місце в сутності чи в сервісі домену.
Практична ціна змішування двох речей: користувач бачить 403 «доступ заборонено» там, де правильна відповідь - «вакансія в архіві, спочатку поновіть її». Правило, яке живе у воутері, не доступне ні формі, ні API-відповіді, ні консольній команді, бо всі вони працюють не через HTTP-шар безпеки. Коли перевірки перетинаються (адмін редагує навіть архівну вакансію), розділіть їх: воутер вирішує, хто має право торкатися цієї вакансії, а перевірка стану лишається окремим кроком з власним повідомленням.
Далі за списком те, що у воутері викликає проблеми рано чи пізно:
Важкі запити й зовнішні виклики. Воутер виконується частіше, ніж здається на етапі написання. HTTP-запит до біллінгу всередині voteOnAttribute() перетворює рендер списку на лотерею з таймаутами.
Зміна стану. Ніяких flush(), записів в аудит-лог чи лічильників. Той самий воутер може бути викликаний кілька разів за запит, а може бути пропущений через CacheableVoterInterface. Аудит доступу пишеться з обробника події kernel.exception або з самого місця дії.
Залежність від RequestStack. Щойно воутер починає дивитися на маршрут чи параметри запиту, він перестає працювати в консолі й у Messenger-обробнику, де запиту немає взагалі, а токен треба ставити вручну.
Фільтрація колекцій. array_filter($jobs, fn (Job $j) => $security->isGranted(JobVoter::VIEW, $j)) після пагінації дає сторінку, на якій із двадцяти рядків лишилось сім, а лічильник показує «знайдено 340». Умова видимості має бути в запиті репозиторію; воутер перевіряє конкретний об'єкт, а не будує вибірку.
Що лишається у воутері в підсумку: власність, членство, роль у межах організації, делеговані права, стан акаунта того, хто діє. Все це відповідає на питання «хто просить», і всі ці перевірки можна написати без жодного звернення до бази, якщо потрібні зв'язки вже є в об'єкті користувача.