# Symfony Voters
###### tags: `symfony`
Son la herramienta más potente para manejar los permisos que ofrece Symfony. Documentación oficial:
[https://symfony.com/doc/current/security/voters.html](https://symfony.com/doc/current/security/voters.html)
## ¿Qué son?
Los voters son la solución propuesta por Symfony para solucionar problemas de seguridad y permisos. En un sistema que puede usarse también en otros escenarios de decisión y están pensados para escenarios complejos. Aportan cierto grado de complejidad por lo que para escenarios sencillos pueden no ser una buena solución.
Están orientados a escenarios complejos y son una solución muy escalable (ya que se orientan muy claramente a SRP y Open-Close).
Pensemos en nuestro escenario típico de un Controlador. Tenemos un escenario sencillo en el que nuestro controlador se ejecuta sólo para un rol (ROLE_ADMITTED). Generalmente usamos una anotación Security, pero si no la usasemos tendría este aspecto:
```
public function __invoke(Request $request): Response
{
// Get user from Service injected service
$user = $this->security->getUser();
if (!in_array('ROLE_ADMITTED', $user->getRoles())) {
// Access denied
return new JsonResponse(['error' => 'Acceso denegado'], 403);
}
}
```
Como se ve, para un escenario no muy complejo no hay mucho problema. La solución de los Voters empieza a ser muy útil cuando el escenario se complica. Imaginemos por ejemplo para este caso que quisiesemos añadir:
* Que además de para el rol ROLE_ADMITTED, tuviesemos un rol ROLE_SUNDAYS que queramos permitir que acceda, pero sólo los domingos
* Además queremos un tercer caso que pueda acceder para los usuarios con ROLE_USER pero sólo si en la respuesta tenemos un campo "owner" con el valor id del usuario
La implementación de este escenario nos llevaría a implementar una solución con un gran número de if-then-else encadenados en un Controlador como una posible solución:
```
public function __invoke(Request $request): Response
{
// Get user from Service injected service
$user = $this->security->getUser();
if (in_array('ROLE_ADMITTED', $user->getRoles())) {
// Access granted for ROLE_ADMITTED
...
return new JsonResponse($response, 200);
}
if (in_array('ROLE_SUNDAYS', $user->getRoles())) {
if ($this->isTodaySunday()) {
// Access granted for ROLE_SUNDAYS because is sunday
...
return new JsonResponse($response, 200);
}
}
if (in_array('ROLE_USER', $user->getRoles())) {
if ($user->id() === $response->getOwner()) {
// Access granted for ROLE_USER for a given response
...
return new JsonResponse($response, 200);
}
}
}
```
Como se ve se puede complicar y es sólo un ejemplo que nos podemos imaginar.
## ¿Cómo se usan?
Aquí es donde llegan los Voters a ayudarnos. Intentando seguir una idea de separación y segregación de problemas en casos pequeños los Voters nos permiten descomponer casos como el ejemplo anterior.
Siguen una implementación de tipo Observer en la cual todos los Voters se registran en el sistema como Voter (esto es algo que hace el contenedor de Symfony por nosotros al detectar que implementan el interfaz VoterInterface que se implementa por la clase abstracta Voter que nos da Symfony para ayuda).
Por contrato deben cumplir con los métodos:
```
abstract class Voter implements VoterInterface
{
abstract protected function supports(string $attribute, $subject);
abstract protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token);
}
```
Esta clase Voter nos hace trabajar con dos parámetros que se han definido así por decisión del equipo implementador:
* $attribute - que es un atributo (string) indicando el "tipo" de operación o cualquier otra cosa que nos imaginemos para definir la operación (por ejemplo: "read" / "write" o en nuestro caso usamos el término "command" para saber que se trata de "dispatchar" un Command)
* $subject - es el sujeto para el que se vota. Se puede pasar cualquier objeto que luego se puede identificar con "instanceof"
Así cada vez que el sistema utiliza el sistema de votación (mediante por ejemplo el servicio AuthorizationChecker) el sistema:
* Itera por todas las clases que implementan el interfaz VoterInterface (a través de la clase Voter)
* En todos ejecuta primero el método supports que devuelve un bool indicando si ese Voter soporta emitir una votación.
* Si el Voter soporta una votación para el contexto actual se ejecuta voteOnAttribute donde el Voter emite un voto positivo o negativo. Con la implementación de la clase abstracta Voter la política es que si un Voter devuelve true se acepta la votación.
### Ejemplo anterior con voters
Con el uso de los Voters vamos a transformar el ejemplo anterior al sistema de votación donde vamos a separar en 3 voters diferentes cada una de las condiciones (para nuestros roles ROLE_ADMITTED, ROLE_SUNDAYS, y ROLE_USER).
- El primer Voter para el ROLE_ADMITTED emitirá un voto positivo cuando el usuario tenga ROLE_ADMITTED
- Un segundo Voter que actúe en nuestro caso de los domingos
- Un tercer Voter que actúe para nuestro ROLE_USER
### ROLE_ADMITTED
En este caso tenemos que decir al Voter que vote positivo si el rol es ROLE_ADMITTED. Este es un caso bastante trivial y que como hemos dicho podemos implementar de una manera mucho más simple utilizando otras fórmulas de Symfony.
```
final class RoleAdmittedVoter extends Voter
{
private Security $security;
// Inject the Security service
public function __construct(
Security $security
)
{
$this->security = $security;
}
protected function supports($attribute, $subject)
{
// We are telling that this voter only votes when Subject is instanceof MyResponseObject
if (!$subject instanceof MyResponseObject) {
return false;
}
return true;
}
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
if ($this->security->isGranted('ROLE_ADMITTED')) {
return true;
}
return false;
}
}
```
### ROLE_SUNDAYS
Para este caso hacemos un Voter que va a votar positivo para el rol ROLE_SUNDAYS y si es domingo.
```
final class RoleSundaysVoter extends Voter
{
private Security $security;
// Inject the Security service
public function __construct(
Security $security
)
{
$this->security = $security;
}
protected function supports($attribute, $subject)
{
// We are telling that this voter only votes when Subject is instanceof MyResponseObject
if (!$subject instanceof MyResponseObject) {
return false;
}
return true;
}
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
if ($this->security->isGranted('ROLE_SUNDAYS')) {
// Return true if today is Sunday
if (date('w') == 0) {
return true;
}
}
return false;
}
}
```
### ROLE_USER
Para nuestro caso de User la condición tiene un poco en cuenta el Subject
```
final class RoleUserAllowedVoter extends Voter
{
private Security $security;
// Inject the Security service
public function __construct(
Security $security
)
{
$this->security = $security;
}
protected function supports($attribute, $subject)
{
// We are telling that this voter only votes when Subject is instanceof MyResponseObject
if (!$subject instanceof MyResponseObject) {
return false;
}
return true;
}
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
if ($this->security->isGranted('ROLE_USER')) {
$user = $token->getUser();
// $subject is of MyResponseObject type thanks to supports function
if ($subject->owner() === $user->id()) {
return true;
}
}
return false;
}
}
```
## ¿Por qué se usan?
Con todo el código que hemos generado para estos 3 Voters ¿por qué usarlos? ¿no sería más simple tenerlo en el Controlador?
Es una decisión de extensibilidad. Al usar los Voters cada parte del código y cada Controlador y cada Voter se vuelve más SRP y más manejable. Hay que fijarse en como queda el código en el controlador que es donde tenemos una ventaja considerable:
```
public function __invoke(Request $request): Response
{
// authorizationChecker is an injected Symfony Service
if (!$this->authorizationChecker->isGranted('read', MyResponseObject)) {
return new JsonResponse(['error' => 'Unauthorized', 403]);
}
}
```
Tengamos en cuenta que los Voters son reutilizables (pueden usarse para varios controladores, por ejemplo para todos los de un objeto o para todos los que realicen una misma operación).
## ¿Cómo funciona?
El funcionamiento de todo el sistema sigue un patrón Observer. Como hemos visto está el método supports que se ejecuta en todos los Voters antes de ir al método voteOnAttribute.
En realidad esto viene de la clase abstracta [Voter](https://github.com/symfony/symfony/blob/5.4/src/Symfony/Component/Security/Core/Authorization/Voter/Voter.php) que nos da Symfony para ayudarnos a hacer este tipo de Voters
```
public function vote(TokenInterface $token, $subject, array $attributes)
{
// abstain vote by default in case none of the attributes are supported
$vote = self::ACCESS_ABSTAIN;
foreach ($attributes as $attribute) {
if (!$this->supports($attribute, $subject)) {
continue;
}
// as soon as at least one attribute is supported, default is to deny access
$vote = self::ACCESS_DENIED;
if ($this->voteOnAttribute($attribute, $subject, $token)) {
// grant access as soon as at least one attribute returns a positive response
return self::ACCESS_GRANTED;
}
}
return $vote;
}
}
```
Esta es una implementación abstracta del interfaz VoterInterface que es el que de verdad le interesa al sistema de votación (el método vote).
Al llamar al [AuthorizacionChecker]([https://](https://github.com/symfony/symfony/blob/5.4/src/Symfony/Component/Security/Core/Authorization/AuthorizationChecker.php)) que es el que ejecuta los checkeos por ejemplo vemos que depende de otro servicio de más bajo nivel de Symfony que es el [accessDecisionManager](https://github.com/symfony/symfony/blob/5.4/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManager.php) que es el que implementa todo esto.
Este servicio de bajo nivel es el que recorre todos los voters y toma las decisiones. En esa implementación también se pueden ver algunas opciones interesantes como que se puede configurar el algoritmo de decisión para ser por consenso o unánime algo que generalmente no se usa pero ahí está.