Campos condicionales por usuario: ApiProperty
Keep on Learning!
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 SubscribeControlamos qué campos son legibles y escribibles mediante grupos de serialización. Pero, ¿y si tienes un campo que debe incluirse en la API... pero sólo para determinados usuarios? Lamentablemente, los grupos no pueden hacer ese tipo de magia por sí solos.
Por ejemplo, busca el campo $isPublished y hagamos que forme parte de nuestra API añadiendo los grupos treasure:read y treasure:write:
| // ... lines 1 - 87 | |
| class DragonTreasure | |
| { | |
| // ... lines 90 - 127 | |
| (['treasure:read', 'treasure:write']) | |
| private bool $isPublished = false; | |
| // ... lines 130 - 248 | |
| } |
Ahora si giramos y probamos las pruebas:
symfony php bin/phpunit
Esto hace que falle una prueba: testGetCollectionOfTreasures ve que se devuelve isPublished... y no lo espera.
Éste es el plan: colaremos el campo en nuestra API, pero sólo para usuarios administradores o propietarios de este DragonTreasure. ¿Cómo podemos conseguirlo?
Hola ApiProperty
Bueno, ¡sorpresa! No solemos necesitarlo, pero podemos añadir un atributo ApiProperty encima de cualquier propiedad para ayudar a configurarla mejor. Tiene un montón de cosas, como una descripción que ayuda con tu documentación y muchos casos extremos. Incluso hay uno llamado readable. Si dijéramos readable: false:
| // ... lines 1 - 88 | |
| class DragonTreasure | |
| { | |
| // ... lines 91 - 129 | |
| (readable: false) | |
| private bool $isPublished = false; | |
| // ... lines 132 - 250 | |
| } |
Entonces los grupos de serialización dirían que esto debería incluirse en la respuesta... pero entonces esto lo anularía. Observa: si probamos las pruebas:
symfony php bin/phpunit
Pasan porque el campo no está.
La opción de la seguridad
Para nuestra misión, podemos aprovechar una opción superguay llamada security. Ponla en is_granted("ROLE_ADMIN"):
| // ... lines 1 - 8 | |
| use ApiPlatform\Metadata\ApiProperty; | |
| // ... lines 10 - 88 | |
| class DragonTreasure | |
| { | |
| // ... lines 91 - 129 | |
| (security: 'is_granted("ROLE_ADMIN")') | |
| private bool $isPublished = false; | |
| // ... lines 132 - 250 | |
| } |
¡Eso es! Si esta expresión devuelve false, isPublished no se incluirá en la API: no se podrá leer ni escribir.
Y cuando ahora ejecutamos las pruebas:
symfony php bin/phpunit
Siguen pasando, lo que significa que no se devuelve isPublished.
Ahora vamos a probar la ruta "feliz" en la que se devuelve este campo. AbreDragonTreasureResourceTest. Aquí está la prueba original: testGetCollectionOfTreasures(). Somos anónimos, así que isPublished no se devuelve.
Ahora desplázate hasta testAdminCanPatchToEditTreasure(). Cuando creemosDragonTreasure, asegurémonos de que siempre empieza por isPublished => false:
| // ... lines 1 - 12 | |
| class DragonTreasureResourceTest extends ApiTestCase | |
| { | |
| // ... lines 15 - 138 | |
| public function testAdminCanPatchToEditTreasure(): void | |
| { | |
| $admin = UserFactory::new()->asAdmin()->create(); | |
| $treasure = DragonTreasureFactory::createOne([ | |
| 'isPublished' => false, | |
| ]); | |
| // ... lines 145 - 156 | |
| } | |
| } |
Luego, aquí abajo, assertJsonMatches('isPublished', false) para comprobar que se devuelve el campo:
| // ... lines 1 - 12 | |
| class DragonTreasureResourceTest extends ApiTestCase | |
| { | |
| // ... lines 15 - 138 | |
| public function testAdminCanPatchToEditTreasure(): void | |
| { | |
| $admin = UserFactory::new()->asAdmin()->create(); | |
| $treasure = DragonTreasureFactory::createOne([ | |
| 'isPublished' => false, | |
| ]); | |
| $this->browser() | |
| // ... lines 147 - 154 | |
| ->assertJsonMatches('isPublished', false) | |
| ; | |
| } | |
| } |
Copia el nombre de la prueba, gira y añade --filter para ejecutar sólo esa prueba:
symfony php bin/phpunit --filter=testAdminCanPatchToEditTreasure
Y... ¡pasa! El campo se devuelve cuando somos administradores.
Devolver también isPublished para el propietario
¿Y si somos el propietario del tesoro? Copia la prueba... cámbiale el nombre a testOwnerCanSeeIsPublishedField()... y vamos a retocar algunas cosas. Cambia el nombre de $admin a $user, simplifícalo a DragonTreasureFactory::createOne()y asegúrate de que owner se establece en nuestro nuevo $user:
| // ... lines 1 - 12 | |
| class DragonTreasureResourceTest extends ApiTestCase | |
| { | |
| // ... lines 15 - 158 | |
| public function testOwnerCanSeeIsPublishedField(): void | |
| { | |
| $user = UserFactory::new()->create(); | |
| $treasure = DragonTreasureFactory::createOne([ | |
| 'isPublished' => false, | |
| 'owner' => $user, | |
| ]); | |
| $this->browser() | |
| ->actingAs($user) | |
| ->patch('/api/treasures/'.$treasure->getId(), [ | |
| 'json' => [ | |
| 'value' => 12345, | |
| ], | |
| ]) | |
| ->assertStatus(200) | |
| ->assertJsonMatches('value', 12345) | |
| ->assertJsonMatches('isPublished', false) | |
| ; | |
| } | |
| } |
Podríamos cambiar esto por una petición GET... pero PATCH está bien. En cualquiera de las dos situaciones, queremos asegurarnos de que se devuelve el campo isPublished.
Como aún no hemos implementado esto... asegurémonos de que falla. Copia el nombre del método y pruébalo:
symfony php bin/phpunit --filter=testOwnerCanSeeIsPublishedField
¡Fallo conseguido! ¡Y ya sabemos cómo solucionarlo! En la opción security, podríamos alinear la lógica con or object.getOwner() === user. Pero recuerda: ¡hemos creado el votante para que no tengamos que hacer locuras como ésa! En lugar de eso, di is_granted(), EDIT y luego object:
| // ... lines 1 - 88 | |
| class DragonTreasure | |
| { | |
| // ... lines 91 - 129 | |
| (security: 'is_granted("EDIT", object)') | |
| private bool $isPublished = false; | |
| // ... lines 132 - 250 | |
| } |
Haz la prueba ahora:
symfony php bin/phpunit --filter=testOwnerCanSeeIsPublishedField
El especial seguridadPostDenormalizar
¡Ya está! Ah, y no la he utilizado mucho, pero también existe la opción securityPostDenormalize. Al igual que con la opción securityPostDenormalize en cada operación, ésta se ejecuta después de que los nuevos datos se deserialicen en el objeto. Lo interesante es que si la expresión devuelve false, en realidad se revierten los datos del objeto.
Por ejemplo, supongamos que la propiedad isPublished comenzó como false y luego el usuario envió algo de JSON para cambiarla a true. Pero entonces, securityPostDenormalize devolviófalse. En ese caso, API Platform revertirá la propiedad isPublished a su valor original: la cambiará de false a true. Ah, y por cierto, securityPostDenormalize no se ejecuta en las peticiones a GET: sólo ocurre cuando se están deserializando los datos. Así que asegúrate de poner tu lógica de seguridad principal en security y sólo utiliza securityPostDenormalize si lo necesitas.
Lo siguiente en nuestra lista de tareas: vamos a nivelar nuestras operaciones de usuario para hacer un hash de la contraseña antes de guardarla en la base de datos. Necesitaremos una nueva propiedad de contraseña simple no persistente para hacerlo.
12 Comments
I tried to change the test testAdminCanPatchToEditTreasure to actually edit the isPublished status.
That was not passing at the current time.
EG. From the script this is OK and passes when the only field changed is value.
But this test does not pass, changing the value of isPublished doesn't seem to work.
Should this work at this point? I think so but maybe I have missed something.
Hey @Lola-Slade!
Sorry for the very slow reply! So the test only passes if you include
'isPublished' => true,? If you don't include it, where is the failure exactly? Do you get a 200 status code, but thevaluereturned is wrong? Or is the failure elsewhere?Let me know and I bet we can unravel this mystery :).
Cheers!
I think I found the explanation for this one after much debugging:
The issue is that the
securityargument of theApiPropertyattribute is evaluated before the denormalization in order to find out which properties are allowed to be written to.But since the treasure object is not denormalized yet the
objectin thesecurityexpression will always benullwhile denormalizing, effectively failing ever time and thus never writing to the property.In this case this could be fixed by letting the
securityexpression pass in caseobjectis not instantiated yet and validate the permissions withsecurityPostDenormalizefor denormalization:I found this behavior of
objectbeingnullin thesecurityexpression while denormalizing very surprising and against my expectation. Especially as the documentation for the same arguments for the operations (Get,Putetc.) attributes mentions a different behavior:And since
securityPostDenormalizeis only evaluated after denormalization of the user provided data into the object this causes huge headaches when a property used for the access decision is writable.Because contrary to the operations
with
ApiPropertythere is noprevious_objectavailable in the expression.Hey @acran!
Fantastic debugging & explanation! Thanks for filling in for me ;)
Thanks a lot for getting back to me Ryan!
It's the opposite. The issue is that the value of isPublished cannot be changed to true.
So any test that does include
isPublishedand then tries to validate the changed value fails.Hey @Lola-Slade!
Glacial reply again, but, does this help? https://symfonycasts.com/screencast/api-platform-security/api-property#comment-33098
Strange Api-platform activity. It looks like ApiProperty Attribute doesn't work with json format. Why? And how to make ApiProperty to work on json format too?
I have simple ApiResource example
If i request jsonld format
http://localhost/api/users/1.jsonld, i get:'doNotShow' is not visible
If i request json format
http://localhost/api/users/1.json, i get:'doNotShow' is visible
PHP 8.2, Symfony 7, "api-platform/core": "3.3.2",
config file: api_platform.yaml
Hey @Mantasz
It seems like an ApiPlatform bug. Try upgrading to the latest version. If the problem remains, you may want to open an issue to the ApiPlatform GitHub repository
Cheers!
it is the latest version: "api-platform/core": "3.3.2".
The only thing I can think of is that it's an ApiPlatform bug. Try asking in their Slack channel
Sorry I cannot help further
is there a securityMessage for ApiProperty like for Operations ?
Hey Gamusta,
Here's the list of possible arguments for ApiProperty: https://github.com/api-platform/core/blob/main/src/Metadata/ApiProperty.php#L45 - there's
security, but nosecurityMessage.Cheers!
"Houston: no signs of life"
Start the conversation!