HardТеория5 min

Авторизация

Роли, правила доступа, #[IsGranted], иерархия ролей, security expressions

Роли

Роли -- строки вида ROLE_*, определяющие права пользователя. Каждый пользователь имеет минимум ROLE_USER.

<?php

declare(strict_types=1);

namespace App\Entity;

use Symfony\Component\Security\Core\User\UserInterface;

final class User implements UserInterface
{
    /** @var list<string> */
    private array $roles = [];

    /** @return list<string> */
    public function getRoles(): array
    {
        $roles = $this->roles;
        $roles[] = 'ROLE_USER'; // Always has ROLE_USER

        return array_unique($roles);
    }

    public function setRoles(array $roles): void
    {
        $this->roles = $roles;
    }
}

Стандартные роли

Роль Описание
ROLE_USER Базовая роль каждого аутентифицированного пользователя
ROLE_ADMIN Администратор
ROLE_SUPER_ADMIN Суперадминистратор (обычно наследует все роли)
IS_AUTHENTICATED_FULLY Пользователь аутентифицирован (не через remember me)
IS_AUTHENTICATED_REMEMBERED Аутентифицирован, в том числе через remember me
IS_AUTHENTICATED Любой способ аутентификации
PUBLIC_ACCESS Доступно всем, включая анонимных

Иерархия ролей

Иерархия позволяет наследовать роли. Пользователь с ROLE_ADMIN автоматически получает ROLE_USER.

# config/packages/security.yaml
security:
    role_hierarchy:
        ROLE_EDITOR: ROLE_USER
        ROLE_MODERATOR: [ROLE_EDITOR, ROLE_COMMENT_MANAGER]
        ROLE_ADMIN: ROLE_MODERATOR
        ROLE_SUPER_ADMIN: [ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]
ROLE_SUPER_ADMIN
    └── ROLE_ADMIN
        └── ROLE_MODERATOR
            ├── ROLE_EDITOR
            │   └── ROLE_USER
            └── ROLE_COMMENT_MANAGER
                └── ROLE_USER
    └── ROLE_ALLOWED_TO_SWITCH

Подвох экзамена: Иерархия ролей транзитивна. Если ROLE_ADMIN наследует ROLE_MODERATOR, а ROLE_MODERATOR наследует ROLE_USER, то ROLE_ADMIN автоматически имеет ROLE_USER. Не нужно указывать явно.

Access Control Rules

Правила доступа определяют, кто может обращаться к определённым URL.

# config/packages/security.yaml
security:
    access_control:
        # Order matters! First match wins
        - { path: ^/admin/users, roles: ROLE_SUPER_ADMIN }
        - { path: ^/admin, roles: ROLE_ADMIN }
        - { path: ^/profile, roles: ROLE_USER }
        - { path: ^/api, roles: IS_AUTHENTICATED }
        - { path: ^/login$, roles: PUBLIC_ACCESS }

        # With IP restriction
        - { path: ^/internal, roles: ROLE_ADMIN, ips: [127.0.0.1, ::1] }

        # With host restriction
        - { path: ^/, roles: ROLE_ADMIN, host: admin\.example\.com }

        # With HTTP method
        - { path: ^/api/posts, roles: ROLE_EDITOR, methods: [POST, PUT, DELETE] }
        - { path: ^/api/posts, roles: PUBLIC_ACCESS, methods: [GET] }

Подвох экзамена: Порядок правил в access_control критически важен. Проверяется первое совпавшее правило, остальные игнорируются. Более специфичные пути должны идти ПЕРВЫМИ.

Атрибут #[IsGranted]

Атрибут для проверки доступа на уровне контроллера или метода.

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

// Applied to entire controller
#[IsGranted('ROLE_ADMIN')]
#[Route('/admin')]
final class AdminController extends AbstractController
{
    #[Route('/dashboard')]
    public function dashboard(): Response
    {
        // Only ROLE_ADMIN can access
        return $this->render('admin/dashboard.html.twig');
    }

    // Override at method level -- more restrictive
    #[IsGranted('ROLE_SUPER_ADMIN')]
    #[Route('/users')]
    public function users(): Response
    {
        return $this->render('admin/users.html.twig');
    }
}

#[IsGranted] с объектом (subject)

<?php

declare(strict_types=1);

namespace App\Controller;

use App\Entity\Post;
use Symfony\Component\Security\Http\Attribute\IsGranted;

final class PostController extends AbstractController
{
    // Check via Voter: can the user edit THIS specific post?
    #[IsGranted('EDIT', subject: 'post')]
    #[Route('/post/{id}/edit')]
    public function edit(Post $post): Response
    {
        // Voter checks if current user can EDIT this post
        return $this->render('post/edit.html.twig', ['post' => $post]);
    }

    // Multiple subjects
    #[IsGranted('TRANSFER', subject: ['source', 'target'])]
    #[Route('/transfer/{sourceId}/{targetId}')]
    public function transfer(Account $source, Account $target): Response
    {
        // Voter receives both accounts
        return new Response('Transfer page');
    }
}

#[IsGranted] с expression

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\Security\Http\Attribute\IsGranted;

final class ArticleController extends AbstractController
{
    #[IsGranted(new Expression(
        'is_granted("ROLE_ADMIN") or (is_granted("ROLE_EDITOR") and subject.getAuthor() == user)'
    ), subject: 'article')]
    #[Route('/article/{id}/publish')]
    public function publish(Article $article): Response
    {
        // Admin can publish any article
        // Editor can publish only own articles
        return new Response('Published');
    }
}

Проверка доступа в коде

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

<?php

declare(strict_types=1);

namespace App\Controller;

final class DocumentController extends AbstractController
{
    public function view(Document $document): Response
    {
        // Method 1: denyAccessUnlessGranted (throws exception)
        $this->denyAccessUnlessGranted('VIEW', $document);

        // Method 2: isGranted (returns bool)
        if ($this->isGranted('EDIT', $document)) {
            // Show edit button
        }

        // Method 3: Get current user
        $user = $this->getUser();
        if (null === $user) {
            throw $this->createAccessDeniedException();
        }

        return $this->render('document/view.html.twig', [
            'document' => $document,
            'canEdit' => $this->isGranted('EDIT', $document),
        ]);
    }
}

В сервисе

<?php

declare(strict_types=1);

namespace App\Service;

use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;

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

    public function delete(Post $post): void
    {
        // Check permission
        if (!$this->security->isGranted('DELETE', $post)) {
            throw new AccessDeniedException('Cannot delete this post.');
        }

        // Get current user
        $user = $this->security->getUser();

        // Proceed with deletion
    }
}

В Twig

{# Check role #}
{% if is_granted('ROLE_ADMIN') %}
    <a href="{{ path('admin_dashboard') }}">Admin Panel</a>
{% endif %}

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

{# Check if authenticated #}
{% if is_granted('IS_AUTHENTICATED') %}
    <p>Welcome, {{ app.user.userIdentifier }}</p>
{% endif %}

Security Expressions

Security Expressions позволяют описывать сложные правила доступа.

security:
    access_control:
        - { path: ^/api, roles: "is_granted('ROLE_API') and is_authenticated_fully()" }

Доступные функции и переменные

Выражение Описание
is_granted('ROLE') Проверка роли
is_granted('ATTR', object) Проверка через voter
is_authenticated_fully() Полная аутентификация
is_authenticated_remembered() Через remember me
is_authenticated() Любая аутентификация
user Текущий UserInterface
subject Объект, переданный для проверки
token Токен аутентификации
request Текущий Request (в access_control)
<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\Security\Http\Attribute\IsGranted;

final class TeamController extends AbstractController
{
    #[IsGranted(new Expression(
        'user === subject.getOwner() or is_granted("ROLE_ADMIN")'
    ), subject: 'team')]
    public function settings(Team $team): Response
    {
        return $this->render('team/settings.html.twig');
    }
}

Impersonation (Switch User)

# config/packages/security.yaml
security:
    firewalls:
        main:
            switch_user: true  # Enable ?_switch_user=username
<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter;

final class AdminController extends AbstractController
{
    public function impersonate(): Response
    {
        // Only users with ROLE_ALLOWED_TO_SWITCH can use this
        // Check if currently impersonating
        if ($this->isGranted('IS_IMPERSONATOR')) {
            // Currently switched to another user
        }

        return $this->render('admin/impersonate.html.twig');
    }
}
{# Switch to user #}
<a href="{{ path('homepage', {_switch_user: '[email protected]'}) }}">
    Login as user
</a>

{# Exit impersonation #}
{% if is_granted('IS_IMPERSONATOR') %}
    <a href="{{ path('homepage', {_switch_user: '_exit'}) }}">
        Exit impersonation
    </a>
{% endif %}

Итоги

  • Роли -- строки ROLE_*, минимальная роль ROLE_USER
  • Иерархия ролей транзитивна: ROLE_ADMIN > ROLE_MOD > ROLE_USER
  • access_control -- порядок правил важен, первое совпадение побеждает
  • #[IsGranted] -- атрибут для проверки доступа на контроллерах
  • Security::isGranted() -- проверка в сервисах
  • Security expressions позволяют сложные логические правила
  • is_granted('ATTR', $subject) вызывает систему Voters