If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeNuestra seguridad se está convirtiendo en una casa de locos, lo que no me gusta. Quiero que mi lógica de seguridad sea simple y centralizada. La forma de hacerlo en Symfony es con un votador. Vamos a crear uno.
En la línea de comandos, ejecuta:
php ./bin/console make:voter
Llámalo DragonTreasureVoter
. Es bastante común tener un votante por entidad para la que necesites lógica de seguridad. Así que este votante tomará todas las decisiones relacionadas con DragonTreasure
: puede el usuario actual editar una, borrar una, ver una: lo que eventualmente necesitemos.
Ve a abrirlo: src/Security/Voter/DragonTreasureVoter.php
:
... lines 1 - 2 | |
namespace App\Security\Voter; | |
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; | |
use Symfony\Component\Security\Core\Authorization\Voter\Voter; | |
use Symfony\Component\Security\Core\User\UserInterface; | |
class DragonTreasureVoter extends Voter | |
{ | |
public const EDIT = 'POST_EDIT'; | |
public const VIEW = 'POST_VIEW'; | |
protected function supports(string $attribute, mixed $subject): bool | |
{ | |
// replace with your own logic | |
// https://symfony.com/doc/current/security/voters.html | |
return in_array($attribute, [self::EDIT, self::VIEW]) | |
&& $subject instanceof \App\Entity\DragonTreasure; | |
} | |
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool | |
{ | |
$user = $token->getUser(); | |
// if the user is anonymous, do not grant access | |
if (!$user instanceof UserInterface) { | |
return false; | |
} | |
// ... (check conditions and return true to grant permission) ... | |
switch ($attribute) { | |
case self::EDIT: | |
// logic to determine if the user can EDIT | |
// return true or false | |
break; | |
case self::VIEW: | |
// logic to determine if the user can VIEW | |
// return true or false | |
break; | |
} | |
return false; | |
} | |
} |
Antes de hablar de esta clase, déjame mostrarte cómo la utilizaremos. EnDragonTreasure
, vamos a seguir utilizando la función is_granted()
. Pero para el primer argumento, pasa EDIT
... que es sólo una cadena que me estoy inventando: ya verás cómo se utiliza en el votante. Luego pasa object
:
... lines 1 - 27 | |
( | |
... lines 29 - 30 | |
operations: [ | |
... lines 32 - 40 | |
new Patch( | |
security: 'is_granted("EDIT", object)', | |
... line 43 | |
), | |
... lines 45 - 47 | |
], | |
... lines 49 - 65 | |
) | |
... lines 67 - 87 | |
class DragonTreasure | |
{ | |
... lines 90 - 247 | |
} |
Normalmente pasamos a is_granted()
un único argumento: ¡un papel! Pero también puedes pasarle cualquier cadena aleatoria como EDIT
... siempre que tengas un votante configurado para manejar eso. Si tu votante necesita información adicional para tomar su decisión, puedes pasársela como segundo argumento.
A grandes rasgos, estamos preguntando al sistema de seguridad si el usuario actual tiene permiso o no para EDIT
este objeto DragonTreasure
. DragonTreasureVoter
tomará esa decisión.
Copia esto y pégalo abajo para securityPostDenormalize
:
... lines 1 - 27 | |
( | |
... lines 29 - 30 | |
operations: [ | |
... lines 32 - 40 | |
new Patch( | |
security: 'is_granted("EDIT", object)', | |
securityPostDenormalize: 'is_granted("EDIT", object)', | |
), | |
... lines 45 - 47 | |
], | |
... lines 49 - 65 | |
) | |
... lines 67 - 87 | |
class DragonTreasure | |
{ | |
... lines 90 - 247 | |
} |
Así que el asunto es el siguiente: cada vez que se llama a is_granted()
-desde cualquier lugar, no sólo desde API Platform- Symfony recorre una lista de clases "votantes" e intenta averiguar cuál de ellas sabe cómo tomar esa decisión. Cuando comprobamos un rol, hay un votante existente que sabe cómo manejarlo. En el caso de EDIT
, no hay ningún votante principal que sepa cómo manejarlo. Así que haremos que DragonTreasureVoter
pueda manejarlo.
Para determinar quién puede manejar una llamada a isGranted
, Symfony llama a supports()
en cada votante pasándole los mismos dos argumentos. En nuestro caso, $attribute
seráEDIT
y $subject
será el objeto DragonTreasure
:
... lines 1 - 8 | |
class DragonTreasureVoter extends Voter | |
{ | |
... lines 11 - 13 | |
protected function supports(string $attribute, mixed $subject): bool | |
{ | |
... lines 16 - 19 | |
} | |
... lines 21 - 43 | |
} |
MakeBundle generó un votante que se encarga de comprobar si podemos "editar" o "ver" un DragonTreasure
. Ahora mismo no necesitamos esa "vista", así que la borraré. A continuación, cambiaré esto por una instancia de DragonTreasure
y volveré a escribir el final y le daré al tabulador para añadir la declaración use
... sólo para limpiar las cosas:
... lines 1 - 9 | |
class DragonTreasureVoter extends Voter | |
{ | |
public const EDIT = 'EDIT'; | |
protected function supports(string $attribute, mixed $subject): bool | |
{ | |
return in_array($attribute, [self::EDIT]) | |
&& $subject instanceof DragonTreasure; | |
} | |
... lines 19 - 38 | |
} |
Así, si alguien llama a isGranted()
y le pasa la cadena EDIT
y un objeto DragonTreasure
, sabremos cómo tomar esa decisión.
Ah, y tengo que cambiar el valor de la constante a EDIT
para que coincida con la cadena EDIT
que pasamos a is_granted()
.
Si devolvemos true
desde supports()
, Symfony llamará entonces a voteOnAttribute()
. Muy sencillo: devolvemos true
si el usuario debe tener acceso, false
en caso contrario.
Para empezar, basta con return false
:
... lines 1 - 9 | |
class DragonTreasureVoter extends Voter | |
{ | |
... lines 12 - 19 | |
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool | |
{ | |
return false; | |
... lines 23 - 37 | |
} | |
} |
Si hemos jugado bien nuestras cartas, nuestro votante se abalanzará como un superhéroe hiperactivo cada vez que hagamos una petición PATCH y cerrará de golpe la puerta de acceso. Antes de probar esa teoría, elimina el caso "vista" de aquí abajo:
... lines 1 - 9 | |
class DragonTreasureVoter extends Voter | |
{ | |
... lines 12 - 19 | |
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool | |
{ | |
return false; | |
$user = $token->getUser(); | |
// if the user is anonymous, do not grant access | |
if (!$user instanceof UserInterface) { | |
return false; | |
} | |
// ... (check conditions and return true to grant permission) ... | |
switch ($attribute) { | |
case self::EDIT: | |
// logic to determine if the user can EDIT | |
// return true or false | |
break; | |
} | |
return false; | |
} | |
} |
Bien, ¡asegurémonos de que nuestras pruebas fallan! Ejecuta:
symfony php bin/phpunit
Y... ¡sí! Fallan dos pruebas: ambas porque se deniega el acceso. Nuestro votante está siendo llamado.
De vuelta a la clase, a voteOnAttribute()
se le pasa el atributo - EDIT
- el$subject
- un objeto DragonTreasure
y un $token
, que es una envoltura alrededor del objeto User
actual. Así que primero comprobamos que el usuario está autenticado.
Después, assert()
que $subject
es una instancia de DragonTreasure
porque este método sólo debería llamarse cuando supports()
devuelve true
:
... lines 1 - 9 | |
class DragonTreasureVoter extends Voter | |
{ | |
... lines 12 - 19 | |
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool | |
{ | |
$user = $token->getUser(); | |
// if the user is anonymous, do not grant access | |
if (!$user instanceof UserInterface) { | |
return false; | |
} | |
assert($subject instanceof DragonTreasure); | |
// ... (check conditions and return true to grant permission) ... | |
... lines 31 - 40 | |
} | |
} |
Principalmente escribo esto para que mi editor sepa que $subject
es una DragonTreasure
:assert()
es una forma práctica de hacerlo.
La declaración switch
sólo tiene un case
en este momento. Y aquí es donde vivirá nuestra lógica. Muy sencillo: si $subject
- que es el DragonTreasure
- ->getOwner()
es igual a $user
, entonces devuelve true
. En caso contrario, será igual a break
y devolveráfalse
:
... lines 1 - 9 | |
class DragonTreasureVoter extends Voter | |
{ | |
... lines 12 - 19 | |
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool | |
{ | |
... lines 22 - 29 | |
// ... (check conditions and return true to grant permission) ... | |
switch ($attribute) { | |
case self::EDIT: | |
if ($subject->getOwner() === $user) { | |
return true; | |
} | |
break; | |
} | |
return false; | |
} | |
} |
Ésta no es toda la lógica que necesitamos, ¡pero es un buen comienzo!
Prueba ahora las pruebas:
symfony php bin/phpunit
¡Un fallo menos!
¿Qué es lo siguiente? Bueno, no tenemos una prueba para ello, pero si nos autenticamos con un token de la API, para editar un tesoro, necesitas ROLE_TREASURE_EDIT
, que puedes obtener a través del ámbito del token.
Así que, en el votante, tenemos que comprobar si el usuario tiene ese rol. Añade un método __construct()
y autoconecta Security
- el del SecurityBundle - $security
:
... lines 1 - 5 | |
use Symfony\Bundle\SecurityBundle\Security; | |
... lines 7 - 10 | |
class DragonTreasureVoter extends Voter | |
{ | |
... lines 13 - 14 | |
public function __construct(private Security $security) | |
{ | |
} | |
... lines 18 - 50 | |
} |
Entonces, a continuación, antes de comprobar el propietario, si no$this->security->isGranted('ROLE_TREASURE_EDIT')
, entonces devuelve definitivamentefalse
:
... lines 1 - 10 | |
class DragonTreasureVoter extends Voter | |
{ | |
... lines 13 - 24 | |
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool | |
{ | |
... lines 27 - 35 | |
switch ($attribute) { | |
case self::EDIT: | |
if (!$this->security->isGranted('ROLE_TREASURE_EDIT')) { | |
return false; | |
} | |
if ($subject->getOwner() === $user) { | |
return true; | |
} | |
break; | |
} | |
... lines 48 - 49 | |
} | |
} |
La última prueba que falla es comprobar que un administrador puede parchear para editar cualquier tesoro. Como ya hemos inyectado el servicio Security
, esto es fácil.
Hagamos como si los usuarios administradores pudieran hacer cualquier cosa. Así que por encima de switch
, si $this->security->isGranted('ROLE_ADMIN')
, entonces devuelve true
:
... lines 1 - 10 | |
class DragonTreasureVoter extends Voter | |
{ | |
... lines 13 - 24 | |
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool | |
{ | |
... lines 27 - 32 | |
if ($this->security->isGranted('ROLE_ADMIN')) { | |
return true; | |
} | |
assert($subject instanceof DragonTreasure); | |
... lines 38 - 53 | |
} | |
} |
Momento de la verdad:
symfony php bin/phpunit
¡Voilà! Nuestra lógica ha encontrado un hogar acogedor dentro del votante, la expresión security
es ahora tan sencilla que casi da miedo, y hemos conseguido escribir nuestra lógica en PHP.
A continuación: vamos a explorar la posibilidad de ocultar determinados campos en la respuesta en función del usuario.
Hey @Dejan94!
You're absolutely correct! And that's a really great point to bring up. Suppose you add a security
attribute like this to your GetCollection
:
new GetCollection(
security: 'is_granted("EDIT", object)',
),
In this situation, the object
is actually a "collection" - it's an array
or maybe a Doctrine Collection
object, I can't actually remember. The point is: it's not a single object. And that makes sene: if your endpoint returns 10 treasures, you might have access to EDIT
5 of them, but not the other 5.
So, unfortunately, for the GetCollection
operation, if you want to use security to "filter" the collection automatically so that only some are shown, that's not done directly via security
. Instead, in chapter 35 (will be released this week - but you can peek at it here https://symfonycasts.com/screencast/api-platform-security/query-extension or download the course code and look at the final directory to see what we did), we create a "query extension" that uses our security logic to modify the query itself to only returns the ones we have access to.
Let me know if that helps... or if it didn't ;).
Cheers!
// composer.json
{
"require": {
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"api-platform/core": "^3.0", // v3.1.2
"doctrine/annotations": "^2.0", // 2.0.1
"doctrine/doctrine-bundle": "^2.8", // 2.8.3
"doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
"doctrine/orm": "^2.14", // 2.14.1
"nelmio/cors-bundle": "^2.2", // 2.2.0
"nesbot/carbon": "^2.64", // 2.66.0
"phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
"phpstan/phpdoc-parser": "^1.15", // 1.16.1
"symfony/asset": "6.2.*", // v6.2.5
"symfony/console": "6.2.*", // v6.2.5
"symfony/dotenv": "6.2.*", // v6.2.5
"symfony/expression-language": "6.2.*", // v6.2.5
"symfony/flex": "^2", // v2.2.4
"symfony/framework-bundle": "6.2.*", // v6.2.5
"symfony/property-access": "6.2.*", // v6.2.5
"symfony/property-info": "6.2.*", // v6.2.5
"symfony/runtime": "6.2.*", // v6.2.5
"symfony/security-bundle": "6.2.*", // v6.2.6
"symfony/serializer": "6.2.*", // v6.2.5
"symfony/twig-bundle": "6.2.*", // v6.2.5
"symfony/ux-react": "^2.6", // v2.7.1
"symfony/ux-vue": "^2.7", // v2.7.1
"symfony/validator": "6.2.*", // v6.2.5
"symfony/webpack-encore-bundle": "^1.16", // v1.16.1
"symfony/yaml": "6.2.*" // v6.2.5
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
"mtdowling/jmespath.php": "^2.6", // 2.6.1
"phpunit/phpunit": "^9.5", // 9.6.3
"symfony/browser-kit": "6.2.*", // v6.2.5
"symfony/css-selector": "6.2.*", // v6.2.5
"symfony/debug-bundle": "6.2.*", // v6.2.5
"symfony/maker-bundle": "^1.48", // v1.48.0
"symfony/monolog-bundle": "^3.0", // v3.8.0
"symfony/phpunit-bridge": "^6.2", // v6.2.5
"symfony/stopwatch": "6.2.*", // v6.2.5
"symfony/web-profiler-bundle": "6.2.*", // v6.2.5
"zenstruck/browser": "^1.2", // v1.2.0
"zenstruck/foundry": "^1.26" // v1.28.0
}
}
Voter dont work on Get Collection.
403
Error: Forbidden
For single resources it works.