<? phpukraine СТАТТІ
Пошук по платформі
SYMFONY 14 вересня 2026 · 8 хв читання

Авторизація в Symfony: voters, атрибути IsGranted і межа з бізнес-правилами

Роль відповідає на питання «хто ти», voter - на питання «чи можна тобі зробити це саме з цим об'єктом». Розбираємо voter на прикладі редагування чужої вакансії, `CacheableVoterInterface` і чому без нього воутер викликається завжди, `#[IsGranted]` із subject та Expression, статус 404 замість 403, і де проходить межа, за якою перевірка перестає бути авторизацією і стає бізнес-правилом.

РP
Редакція phpukraine
Редакція платформи

Перевірено на 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». Умова видимості має бути в запиті репозиторію; воутер перевіряє конкретний об'єкт, а не будує вибірку.

Що лишається у воутері в підсумку: власність, членство, роль у межах організації, делеговані права, стан акаунта того, хто діє. Все це відповідає на питання «хто просить», і всі ці перевірки можна написати без жодного звернення до бази, якщо потрібні зв'язки вже є в об'єкті користувача.

ПИШЕТЕ ПРО PHP?Опублікуйте розбір або історію з проєкту на платформіРедактор із чеклістом, редактура, авторська сторінка. Републікація з блогу отримує canonical на оригінал. Відкрити редактор →
РP
Редакція phpukraine
Редакція платформи
Матеріали, які готує команда платформи на основі власних даних: каталогу вакансій, зарплатного звіту й банку питань. Кожна цифра в них рахується з бази, а не береться з голови.
оновлено 15 вересня 2026 · ліцензія CC-BY-SA-4.0
ДАЛІ ПО ТЕМІ
ЧИТАТИ ДАЛІ
← Усі статті