Lucky you! You found an early release chapter - it will be fully polished and published shortly!
Rest assured, the gnomes are hard at work
completing this video!
New security quest: I want to allow only the owner of a treasure to edit it. Right
now, you're allowed to edit a treasure as long as you have this role. But that means
you can edit anyone's treasure. Someone keep changing my Velvis painting's
coolFactor
to 0. That's super uncool.
Let's write a test for this. At the bottom say
public function testPatchToUpdateTreasure
. And we'll start like normal:
$user = UserFactory::createOne()
then $this->browser->actingAs($user)
.
Since we're editing a treasure, let's ->patch()
to /api/treasures/
... and then
we need a treasure to edit! Create one on top:
$treasure = DragonTreasureFactory::createOne()
. And for this test, we want to make
sure that the owner
is definitely this $user
. Finish the URL with
$treasure->getId()
.
For the data, send some json
to update just the value
field to 12345
,
then assertStatus(200)
and assertJsonMatches('value', 12345)
.
Excellent! This should be allowed because we're the owner
. Copy the
method name, then find your terminal and run it:
symfony php bin/phpunit --filter=testPatchToUpdateTreasure
No surprise, it passes.
Now let's try the other case: let's log in as someone else and try to update this treasure.
Copy the entire $browser
section. We could create another test method, but
this will work fine all in one. Before this, add
$user2 = UserFactory::createOne()
- then log in as that user. This time,
change the value
to 6789
and, since this should not be allowed, assert
that the status code is 403.
When we try the test now:
symfony php bin/phpunit --filter=testPatchToUpdateTreasure
It fails! This is being allowed: the API returned a 200!
So how can we make it so that only the owner of a treasure can edit it? Well, over
in DragonTreasure
, the answer is all about the security
option.
One thing that gets tricky with Put
and Patch
is that both are used
to edit users. So if you're going to have both, you need keep their security
options in sync. I'm actually going to remove Put
so we can focus on Patch
.
The string inside of security
is an expression... and we can get kinda fancy.
We can grant access if you have ROLE_TREASURE_EDIT
and if object.owner == user
.
Inside the security expression, Symfony gives us a few variable. One is user
, which
is the current User
object. Another is object
, which will be the current object
for this operation. So the DragonTreasure
object. So we're saying that access should
be allowed if the DragonTreasure
s owner
is equal to the currently authenticated
user
. That's... exactly what we want!
So, try the test again!
symfony php bin/phpunit --filter=testPatchToUpdateTreasure
And... uh oh! We downgraded to a 500 error! This is where that saved log file comes in handy. I'll click to open that up. If this is hard to read, view the page source. Much better. It says:
Cannot access private property
DragonTreasure::$owner
.
And it's coming from Symfony's ExpressionLanguage
. Ah, I know what's wrong.
The expression language is like Twig... but not exactly the same. We can't
do fancy things like .owner
when owner
is a private property. We need to call
the public method.
Drumroll please:
symfony php bin/phpunit --filter=testPatchToUpdateTreasure
It passes with flying colors!
But you know me, I've gotta make it trickier. Copy part of the test. This time,
log in as the owner and edit our own treasure. So far, this is all good. But now
try to change the owner
to someone else: $user2->getId()
.
Now maybe this is something you want to allow. Maybe you say:
If you can edit a
DragonTreasure
, then you're free to assign it a differentowner
.
But let's pretend that we want to prevent this. So assertStatus(403)
. Do you
think the test will pass? Try it:
symfony php bin/phpunit --filter=testPatchToUpdateTreasure
It fails! It did allow us to change the owner
! Spin back over to DragonTreasure
.
The security
expression is run before the new data is deserialized onto the
object. In other words, object
will be the DragonTreasure
from the database,
before any of the new JSON is applied to it. This means that it's checking that
the current owner
is equal to the currently logged-in user, which is the main
case that we want to protect.
But sometimes you want to run security after the new data has been put onto the
object. In that case, use an option called securityPostDenormalize
. Remember
denormalize is the process of taking the data and putting it onto the object. So
security
will still run first... and make sure we're the original owner. Now we
can also say object.getOwner() == user
.
That looks identical... but this time object
will be the DragonTreasure
with
the new data. So we're checking that the new owner
is also equal to the
currently logged-in user.
By the way, in securityPostDenormalize
, you also have a previous_object
variable,
which is equal to the object before denormalization. So, it's identical to object
up in the security
option. But, we don't need that.
Try the test now:
symfony php bin/phpunit --filter=testPatchToUpdateTreasure
We got it!
This last example highlights two different types of security checks. The
first check determines whether or not the user can perform this operation at
all. Like: is the current user allowed to make a PATCH
request to this treasure?
That depends on the current user and the current DragonTreasure in the database.
But the second check is saying:
Okay, now that I know I'm allowed to make a
PATCH
request, am I allowed to change the data in this exact way?
This depends on the currently logged-in user and the data that's being sent.
I'm bringing up this difference because, for me, the first case - where you're trying to figure out whether an operation is allowed at all - regardless of what data is being sent - that is a job for security. And this is exactly how I would implement it.
However, the second case - where you're trying to figure out whether the
user is allowed to send this exact data - like are they allowed to change the
owner
or not - for me, I think that's better handled by the validation layer.
I'm going to keep this in the security layer right now. But later when we talk about custom validation, we'll move this into that.
Up next: can we flex the security
option enough to also let admin users
edit anyone's treasure? Stay tuned!
"Houston: no signs of life"
Start the conversation!
// 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
}
}