Today I started upgrading my application from symfony 3 to 4 (and so the related libraries) and I couldn't understand why I couldn't make certain routes work (I had a 401 error but they were supposed to be public routes so no security checks were made there), then I ended up finding this question: @Security annotation on controller class being overridden by action method
A recent comment on the question says that while in a previous version of symfony framework extra bundle, if you put the security annotation on both a class and a method inside that class, the method annotation would override the class annotation, now they stack instead.
This can also be seen (altough it's not very clear since you could already put a @Security annotation on both class and method) on the SensioFramework changelog https://github.com/sensiolabs/SensioFrameworkExtraBundle/blob/master/CHANGELOG.md for version 4.0
allowed using multiple @Security annotations (class and method)
This is a very big change for me since a lot of routes in my application relied on that behavior (which was similar to Symfony 1 where you could set a default security behavior and then a more specific one for each action)
/**
* @Route("my-route")
* @Security("is_granted('IS_AUTHENTICATED_FULLY')")
*/
class MyController extends Controller {
/**
* In Symfony 3.x this would've removed security checks for the route,
* now it checks both the class and the method Security expressions
* @Security(true)
*/
public function myAction(Request $request) {
}
}
Is there some way other than "don't upgrade to symfony 4" or "reorganize your code" (which is my "plan B") to have this behavior back? Something like a configuration option or similar... I can't seem to find anything about this
I had forgot about this question but I did solve this issue by making my own annotation and EventListener. Disclaimers:
I created 2 annotations (@IsGrantedDefault and @SecurityDefault) that work exactly like @IsGranted and @Security (they actually extend the original annotations) except they can be applied only to classes, then i created 2 event listeners, one for each annotation. The event listeners also extend the original event listeners, but they just check if a method already has a Security or IsGranted annotation, in which case they do nothing.
IsGrantedDefault.php
<?php
/*
* @author valepu
*/
namespace App\Project\AppBundle\Annotation;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
/**
* @Annotation
* @Target("CLASS")
*/
class IsGrantedDefault extends IsGranted {
public function getAliasName() {
return 'is_granted_default';
}
public function allowArray() {
return false;
}
}
SecurityDefault.php
<?php
/*
* @author valepu
*/
namespace App\Project\AppBundle\Annotation;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
/**
* @Annotation
* @Target("CLASS")
*/
class SecurityDefault extends Security {
public function getAliasName() {
return 'security_default';
}
public function allowArray() {
return false;
}
}
DefaultListenerTrait.php (Values::DEFAULT_LISTENER_PREFIX is just a string with an underscore "_")
<?php
/*
* @author valepu
*/
namespace App\Project\AppBundle\Event\Traits;
use App\Project\AppBundle\Utils\Values;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpKernel\Event\FilterControllerArgumentsEvent;
Trait DefaultListenerTrait {
/**
* @var string
*/
private $defaultAttribute;
/**
* @var string
*/
private $otherAttributes = [];
/**
* @var string
*/
private $attribute;
/**
* Sets the class attributes
* @param [type] $defaultAnnotation
* @param string|null $modifyAttr
* @return void
*/
protected function setAttributes($defaultAnnotation, ?string $modifyAttr) {
//Get the attirbutes names
$this->attribute = $modifyAttr;
$this->defaultAttribute = Values::DEFAULT_LISTENER_PREFIX . $defaultAnnotation->getAliasName();
$annotations = [new IsGranted([]), new Security([])];
foreach($annotations as $annotation) {
$this->otherAttributes[] = Values::DEFAULT_LISTENER_PREFIX . $annotation->getAliasName();
}
}
/**
* Checks wheter or not the request needs to be handled by the annotation. If it does adds the correct attribute to the request
* @param \Symfony\Component\HttpKernel\Event\FilterControllerArgumentsEvent $event
* @return boolean
*/
protected function updateDefaultListener(FilterControllerArgumentsEvent $event) {
$request = $event->getRequest();
$default = $request->attributes->get($this->defaultAttribute);
//If there's already an "IsGranted" annotation or there's no "IsGrantedDefault" annotation
if (!$default) {
return false;
}
foreach($this->otherAttributes as $attr) {
if ($request->attributes->get($attr) || !$default) {
return false;
}
}
//We set IsGranted from the default and then call the parent eventListener so that it can handle the security
$request->attributes->set($this->attribute, [$default]);
return true;
}
/**
* Calls the event listener for the class if the request is handled by the class
* @param \Symfony\Component\HttpKernel\Event\FilterControllerArgumentsEvent $event
* @return void
*/
protected function callEventListener(FilterControllerArgumentsEvent $event) {
if($this->updateDefaultListener($event)) {
parent::onKernelControllerArguments($event);
}
}
}
IsGrantedDefaultListener.php
<?php
/*
* @author valepu
*/
namespace App\Project\AppBundle\Event;
use App\Project\AppBundle\Annotation\IsGrantedDefault;
use App\Project\AppBundle\Event\Traits\DefaultListenerTrait;
use App\Project\AppBundle\Utils\Values;
use RS\DiExtraBundle\Annotation as DI;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Sensio\Bundle\FrameworkExtraBundle\EventListener\IsGrantedListener;
use Sensio\Bundle\FrameworkExtraBundle\Request\ArgumentNameConverter;
use Symfony\Component\HttpKernel\Event\FilterControllerArgumentsEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* @DI\Service(autowire = true)
* @DI\Tag("kernel.event_subscriber")
*/
class IsGrantedDefaultListener extends IsGrantedListener {
use DefaultListenerTrait;
/**
* @param \Sensio\Bundle\FrameworkExtraBundle\Request\ArgumentNameConverter $argumentNameConverter
* @param \Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface $authChecker
* @DI\InjectParams({
* "argumentNameConverter" = @DI\Inject("framework_extra_bundle.argument_name_convertor"),
* "authChecker" = @DI\Inject("security.authorization_checker")
* })
*/
public function __construct(ArgumentNameConverter $argumentNameConverter, AuthorizationCheckerInterface $authChecker = null) {
parent::__construct($argumentNameConverter, $authChecker);
$modifyAttr = new IsGranted([]);
$this->setAttributes(new IsGrantedDefault([]), Values::DEFAULT_LISTENER_PREFIX . $modifyAttr->getAliasName());
}
/**
* @param \Symfony\Component\HttpKernel\Event\FilterControllerArgumentsEvent $event
* @return void
*/
public function onKernelControllerArguments(FilterControllerArgumentsEvent $event) {
$this->callEventListener($event);
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() {
return [KernelEvents::CONTROLLER_ARGUMENTS => 'onKernelControllerArguments'];
}
}
SecurityDefaultListener.php
<?php
/*
* @author valepu
*/
namespace App\Project\AppBundle\Event;
use App\Project\AppBundle\Annotation\SecurityDefault;
use App\Project\AppBundle\Event\Traits\DefaultListenerTrait;
use App\Project\AppBundle\Utils\Values;
use Psr\Log\LoggerInterface;
use RS\DiExtraBundle\Annotation as DI;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Sensio\Bundle\FrameworkExtraBundle\EventListener\SecurityListener;
use Sensio\Bundle\FrameworkExtraBundle\Request\ArgumentNameConverter;
use Sensio\Bundle\FrameworkExtraBundle\Security\ExpressionLanguage;
use Symfony\Component\HttpKernel\Event\FilterControllerArgumentsEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Security\Core\Role\RoleHierarchyInterface;
/**
* @DI\Service(autowire = true)
* @DI\Tag("kernel.event_subscriber")
*/
class SecurityDefaultListener extends SecurityListener {
use DefaultListenerTrait;
/**
* @param \Sensio\Bundle\FrameworkExtraBundle\Request\ArgumentNameConverter $argumentNameConverter
* @param \Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface $authChecker
* @DI\InjectParams({
* "argumentNameConverter" = @DI\Inject("framework_extra_bundle.argument_name_convertor"),
* "language" = @DI\Inject("sensio_framework_extra.security.expression_language.default"),
* "trustResolver" = @DI\Inject("security.authentication.trust_resolver"),
* "roleHierarchy" = @DI\Inject("security.role_hierarchy"),
* "tokenStorage" = @DI\Inject("security.token_storage"),
* "authChecker" = @DI\Inject("security.authorization_checker"),
* "logger" = @DI\Inject("logger")
* })
*
*/
public function __construct(ArgumentNameConverter $argumentNameConverter, ExpressionLanguage $language = null, AuthenticationTrustResolverInterface $trustResolver = null, RoleHierarchyInterface $roleHierarchy = null, TokenStorageInterface $tokenStorage = null, AuthorizationCheckerInterface $authChecker = null, LoggerInterface $logger = null) {
parent::__construct($argumentNameConverter, $language, $trustResolver, $roleHierarchy, $tokenStorage, $authChecker, $logger);
$modifyAttr = new Security([]);
$this->setAttributes(new SecurityDefault([]), Values::DEFAULT_LISTENER_PREFIX . $modifyAttr->getAliasName());
}
public function onKernelControllerArguments(FilterControllerArgumentsEvent $event) {
$this->callEventListener($event);
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() {
return [KernelEvents::CONTROLLER_ARGUMENTS => 'onKernelControllerArguments'];
}
}
EDIT 2025: I stopped using Symfony for a couple years, but now I am using it once again with Symfony 7, PHP 8 and attributes. So I had to update the annotation.
Some notes:
IsGrantedDefault.php
<?php
/*
* @author valepu
*/
use Attribute;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Http\Attribute\IsGrantedContext;
#[Attribute(Attribute::TARGET_CLASS)]
class IsGrantedDefault {
/**
* @param string|Expression|\Closure(IsGrantedContext, mixed $subject):bool $attribute The attribute that will be checked against a given authentication token and optional subject
* @param array|string|Expression|\Closure(array<string,mixed>, Request):mixed|null $subject An optional subject - e.g. the current object being voted on
* @param string|null $message A custom message when access is not granted
* @param int|null $statusCode If set, will throw HttpKernel's HttpException with the given $statusCode; if null, Security\Core's AccessDeniedException will be used
* @param int|null $exceptionCode If set, will add the exception code to thrown exception
*/
public function __construct(
public string|Expression|\Closure $attribute,
public array|string|Expression|\Closure|null $subject = null,
public ?string $message = null,
public ?int $statusCode = null,
public ?int $exceptionCode = null,
) { }
}
IsGrantedDefaultListener.php
<?php
/*
* @author valepu
*/
use IsGrantedDefault;
use DefaultListenerTrait;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Security\Http\EventListener\IsGrantedAttributeListener;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class IsGrantedDefaultListener extends IsGrantedAttributeListener {
use DefaultListenerTrait;
public function __construct(private readonly AuthorizationCheckerInterface $authChecker,
private ?ExpressionLanguage $expressionLanguage = null,
) {
parent::__construct($authChecker, $expressionLanguage);
$this->setAttributes(IsGrantedDefault::class, [IsGranted::class]);
}
public function onKernelControllerArguments(ControllerArgumentsEvent $event): void {
$this->callEventListener($event);
}
/**
* {@inheritdoc}
* We can't use the attribute because we are inheriting from another class
*/
public static function getSubscribedEvents(): array {
return [KernelEvents::CONTROLLER_ARGUMENTS => ['onKernelControllerArguments', 20]];
}
}
DefaultListenerTrait.php
<?php
/*
* @author valepu
*/
use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent;
Trait DefaultListenerTrait {
/**
* @var class-string
*/
private $defaultAttribute;
/**
* @var array<class-string>
*/
private $attributes = [];
/**
* Sets the class attributes
*/
protected function setAttributes(string $defaultAttribute, array $attributes) {
$this->defaultAttribute = $defaultAttribute;
foreach($attributes as $attribute) {
$this->attributes[] = $attribute;
}
}
/**
* Checks whether or not the request needs to be handled by the annotation. If it does adds the correct attribute to the request
*/
protected function updateDefaultListener(ControllerArgumentsEvent $event): bool {
$attrs = $event->getAttributes();
// $attrs[$this->defaultAttribute] is an array
$default = $attrs[$this->defaultAttribute] ?? null;
//If there's already an "IsGranted" annotation or there's no "IsGrantedDefault" annotation
if (!$default) {
return false;
}
$attribute = null;
foreach($this->attributes as $attr) {
if (isset($attrs[$attr]) || !$default) {
return false;
} else {
$attribute = $attr;
}
}
//We set IsGranted from the default and then call the parent eventListener so that it can handle the security
$this->setEventAttribute($event, $attribute, $default);
return true;
}
/**
* Sets an attribute in the ControllerArgumentsEvent using reflection to access private properties
* TODO: Check for BC issues when updating Symfony
*/
private function setEventAttribute(ControllerArgumentsEvent $event, string $attributeName, array $attributeValue): void {
// Get the controllerEvent property from ControllerArgumentsEvent
$eventReflection = new \ReflectionClass($event);
$controllerEventProperty = $eventReflection->getProperty('controllerEvent');
$controllerEventProperty->setAccessible(true);
$controllerEvent = $controllerEventProperty->getValue($event);
// Get the attributes property from the controllerEvent
$controllerEventReflection = new \ReflectionClass($controllerEvent);
$attributesProperty = $controllerEventReflection->getProperty('attributes');
$attributesProperty->setAccessible(true);
$attributes = $attributesProperty->getValue($controllerEvent);
// Modify the attributes array
$attributes[$attributeName] = $attributeValue;
// Set the modified attributes back
$attributesProperty->setValue($controllerEvent, $attributes);
}
/**
* Calls the event listener for the class if the request is handled by the class
*/
protected function callEventListener(ControllerArgumentsEvent $event): void {
if($this->updateDefaultListener($event)) {
parent::onKernelControllerArguments($event);
}
}
}