<? phpukraine СПІВБЕСІДИ
Пошук по платформі
SYMFONY · MIDDLE ЧАСТО ПИТАЮТЬ

Як влаштована авторизація в Symfony: Voters, атрибути, ієрархія ролей?

Будь-яка перевірка проходить через `isGranted($attribute, $subject)`: AccessDecisionManager опитує всі voter'и й зводить їхні голоси стратегією (за замовчуванням affirmative). Ролі перевіряє RoleHierarchyVoter, який розгортає `role_hierarchy`, а права на конкретний обʼєкт — власний Voter, підключений атрибутом `#[IsGranted('EDIT', subject: 'post')]`.

Чому `#[IsGranted('ROLE_ADMIN')]` пропускає користувача, у якого в базі записано лише `ROLE_SUPER_ADMIN`, а перевірка `in_array('ROLE_ADMIN', $token->getRoleNames())` у власному voter'і — ні?
Де перевіряти право «редагувати цей пост»: у контролері, в entity чи у voter'і?
Що відбувається, коли всі voter'и повернули ABSTAIN?
Чим `access_control` у security.yaml відрізняється від `#[IsGranted]` на контролері?
Security Voter IsGranted role_hierarchy AccessDecisionManager

Уся авторизація в Symfony зводиться до одного виклику: isGranted($attribute, $subject). Ні контролер, ні Twig не знають, що таке «роль» чи «власник поста» — вони лише передають рядок-атрибут і необовʼязковий обʼєкт у AccessDecisionManager, а той опитує кожен сервіс, тегований security.voter (autoconfigure навішує тег автоматично за VoterInterface). Кожен voter повертає GRANTED, DENIED або ABSTAIN, і менеджер зводить голоси стратегією: за замовчуванням affirmative — достатньо одного GRANTED. Якщо всі утрималися, працює allow_if_all_abstain: false, тобто доступу немає. Через це друкарка в назві атрибута ніколи не відкриває доступ, але й не кидає помилки — саме тому атрибути тримають константами voter'а.

Ролі — не окремий механізм, а такі самі атрибути. Рядок, що починається з ROLE_, підбирає RoleHierarchyVoter: він бере ролі токена, розгортає їх через RoleHierarchy::getReachableRoleNames() за конфігом role_hierarchy і порівнює з потрібною. Ключова деталь, на якій валяться: розгортання живе всередині цього voter'а, а не в токені. $token->getRoleNames() і $user->getRoles() повертають сирі ролі, тому перевірка in_array('ROLE_ADMIN', $token->getRoleNames(), true) у власному voter'і не побачить ROLE_SUPER_ADMIN, який успадковує ROLE_ADMIN. Правильно — інʼєктувати Symfony\Bundle\SecurityBundle\Security і викликати isGranted('ROLE_ADMIN'). Поруч із ролями є службові атрибути: IS_AUTHENTICATED_FULLY, IS_AUTHENTICATED_REMEMBERED, IS_IMPERSONATOR, PUBLIC_ACCESS (замість прибраного в Symfony 6 IS_AUTHENTICATED_ANONYMOUSLY).

Точок входу в перевірку дві, і вони працюють на різних етапах запиту. access_control у security.yaml — це AccessListener на firewall: він бачить лише URL, IP, метод і хост, спрацьовує до контролера і добре закриває цілі розділи (/admin). Атрибут #[IsGranted] (у ядрі з Symfony 6.2) обробляється слухачем на події kernel.controller_arguments, тобто вже після того, як ParamConverter або MapEntity завантажили Post — тому subject: 'post' посилається на аргумент контролера й дає перевірку на конкретному обʼєкті. Той самий voter викликається з Twig через is_granted('POST_EDIT', post), коли треба сховати кнопку, і з сервісу через denyAccessUnlessGranted(). Це і є головна цінність voter'а: правило описане один раз, а не тричі.

Продуктивність упирається в те, що voter — звичайний сервіс, який викликається на кожен isGranted() і нічого не memoize. Абстрактний Voter у Symfony 6/7 реалізує CacheableVoterInterface, і його supportsAttribute()/supportsType() дозволяють менеджеру раз і назавжди зрозуміти, що цей voter не цікавиться атрибутом ROLE_USER чи типом Comment, і більше його не смикати. Але всередині свого voter'а за кеш відповідаєте ви: якщо voteOnAttribute() ходить у базу по правах, то список із 200 постів дасть 200 запитів. Лікується це або завантаженням прав одним запитом у приватну властивість voter'а, або перевіркою на рівні запиту (фільтрувати вибірку по власнику), а не поштучним isGranted() у шаблоні.

Межі підходу варто назвати самому. #[IsGranted] захищає дію контролера, а не сутність: виклик того самого сервісу з консольної команди чи з Messenger-хендлера пройде повз перевірку, тому критичні правила дублюють у домені. Voter'и погано підходять для «показати лише свої записи» — це завдання запиту, а не перевірки постфактум. І якщо прав стає багато й вони налаштовуються адміністратором у рантаймі, конфіг role_hierarchy перестає бути відповіддю: тоді ролі перетворюють на permissions у базі, а voter стає тонким шаром, який їх читає, лишаючи isGranted() єдиним публічним API авторизації.

// src/Security/Voter/PostVoter.php
final class PostVoter extends Voter   // абстрактний Voter реалізує CacheableVoterInterface
{
    public const EDIT = 'POST_EDIT';
    public const PUBLISH = 'POST_PUBLISH';

    public function __construct(private Security $security) {}

    protected function supports(string $attribute, mixed $subject): bool
    {
        // false тут = ABSTAIN, а не DENY: інші voter'и голосують далі
        return in_array($attribute, [self::EDIT, self::PUBLISH], true)
            && $subject instanceof Post;
    }

    /** @param Post $subject */
    protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
    {
        $user = $token->getUser();
        if (!$user instanceof User) {
            return false;                     // не залогінений
        }

        // ROLE_ADMIN перевіряємо через Security, щоб спрацювала role_hierarchy;
        // $token->getRoleNames() поверне сирі ролі без успадкування
        if ($this->security->isGranted('ROLE_ADMIN')) {
            return true;
        }

        return match ($attribute) {
            self::EDIT => $subject->getAuthor() === $user && !$subject->isLocked(),
            self::PUBLISH => $subject->getAuthor() === $user
                && $this->security->isGranted('ROLE_EDITOR'),
        };
    }
}

// src/Controller/PostController.php — subject бере значення аргументу $post
#[Route('/posts/{id}/edit', name: 'post_edit')]
#[IsGranted(PostVoter::EDIT, subject: 'post')]
public function edit(Post $post): Response { /* ... */ }

// config/packages/security.yaml
// role_hierarchy:
//     ROLE_EDITOR: [ROLE_USER]
//     ROLE_ADMIN:  [ROLE_EDITOR]
//     ROLE_SUPER_ADMIN: [ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]
Що `isGranted()` не знає нічого про ролі: він передає атрибут і subject у AccessDecisionManager, а той опитує всі теговані `security.voter` сервіси.
Що стратегія за замовчуванням affirmative — достатньо одного GRANTED; при повному ABSTAIN доступ забороняється, бо `allow_if_all_abstain: false`.
Що `role_hierarchy` розгортає RoleHierarchyVoter, тому в кастомному voter'і не можна порівнювати `$token->getRoleNames()` напряму — треба `Security::isGranted('ROLE_X')`.
Що абстрактний `Voter` у Symfony 6/7 реалізує `CacheableVoterInterface`: `supportsAttribute()` і `supportsType()` дозволяють менеджеру взагалі не викликати voter для чужих атрибутів.
Що `access_control` спрацьовує на firewall до контролера і працює з URL, а `#[IsGranted]` — на `kernel.controller_arguments`, тому має доступ до вже завантаженого обʼєкта.
Перевіряти роль усередині voter'а через `in_array('ROLE_ADMIN', $token->getRoleNames(), true)`: токен містить сирі ролі, ієрархія там не розгорнута, і `ROLE_SUPER_ADMIN` не спрацює.
Писати в voter'і `if (!$user instanceof User) { return false; }` замість `return false` у `supports()` — voter повертає DENY замість ABSTAIN і глушить решту voter'ів при стратегії unanimous.
Ставити `IS_AUTHENTICATED_ANONYMOUSLY` у security.yaml: атрибут прибрано в Symfony 6, для відкритих маршрутів є `PUBLIC_ACCESS`.
Класти бізнес-логіку доступу в контролер (`if ($post->getAuthor() === $this->getUser())`) і дублювати ту саму умову в Twig — правило розповзається у трьох місцях замість одного voter'а.
Викликати `isGranted()` у циклі по 200 сутностях: voter не кешує результат між викликами, і кожен його запит до БД перетворюється на N+1.
Вважати, що `#[IsGranted]` захищає entity: він захищає лише дію контролера, прямий виклик сервісу з іншого місця пройде без перевірки.
ПОРАДА

Порада: скажіть, що voter — це єдине місце правди для правила доступу, бо той самий `isGranted('EDIT', $post)` викликається з контролера, з Twig (`is_granted`) і з сервісу. І одразу назвіть пастку з `role_hierarchy`: у voter'і ролі перевіряють через `Security`, а не через `$token->getRoleNames()`.

оновлено 4 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Стратегія за замовчуванням affirmative: один GRANTED відкриває доступ. Якщо всі утрималися, спрацьовує allow_if_all_abstain: false і доступ закривається.