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 SubscribeVia the access_control
on the PUT
operation, we were able to make sure that only the owner of this CheeseListing
can edit it. If you aren't the owner, access denied! We assert that in our test.
Now... I'm going to trick the security system! We're logged in as user2@example.com
but the CheeseListing
we're trying to update is owned by user1@example.com
... which is why we're getting the 403
status code.
Right now, we've configured the serialization groups to allow for the owner
field to be updated via the PUT request. That might sound odd, but it could be useful for admin users to be able to do this. But... this complicates things beautifully! Let's try changing the owner
field to /api/users/
then $user2->getId()
.
... lines 1 - 9 | |
class CheeseListingResourceTest extends CustomApiTestCase | |
{ | |
... lines 12 - 29 | |
public function testUpdateCheeseListing() | |
{ | |
... lines 32 - 45 | |
$client->request('PUT', '/api/cheeses/'.$cheeseListing->getId(), [ | |
// try to trick security by reassigning to this user | |
'json' => ['title' => 'updated', 'owner' => '/api/users/'.$user2->getId()] | |
]); | |
... lines 50 - 56 | |
} | |
} |
Clearly, this should not be allowed: the user that doesn't own this CheeseListing
is trying to edit it and... make themselves the owner! Naughty!
But... try the test:
php bin/phpunit --filter=testUpdateCheeseListing
It fails! We expected a 403
status code but got 200
! What?
I mentioned earlier that when a request comes in, API Platform goes through three steps in a specific order. First it deserializes the JSON and updates the CheeseListing
object. Second it applies our access_control
security and third it executes our validation rules.
See the problem? By the time API Platform processes our access_control
, this object
has been updated! Its owner has already been changed! I mean, it hasn't been updated in the database yet, but the object in memory has the new owner. This causes access to be granted. Gasp!
There are two solutions to this depending on your API Platform version.
In API Platform 2.4 - that's our version - instead of object
, use previous_object
. Very simply: previous_object
is the CheeseListing
before the JSON is processed and object
is the CheeseListing
after the JSON has been deserialized.
In API Platform 2.5, you'll do something different: use the new security
option instead of access_control
. It's just that simple: security
and access_control
work identically, except that security
runs before the object is updated from the posted data. There's also another option called security_post_denormalize
if you want to run a security check after deserialization. In that case, the object
variable is the updated object.
Tip
If you use security_post_denormalize
, its message can be customized with the
security_post_denormalize_message
option.
Phew! For us on API Platform 2.4, as soon as we change to previous_object
... it should work! Try the test:
... lines 1 - 16 | |
/** | |
* @ApiResource( | |
* itemOperations={ | |
... lines 20 - 22 | |
* "put"={ | |
* "access_control"="is_granted('ROLE_USER') and previous_object.getOwner() == user", | |
... line 25 | |
* }, | |
... line 27 | |
* }, | |
... lines 29 - 39 | |
* ) | |
... lines 41 - 50 | |
*/ | |
class CheeseListing | |
... lines 53 - 211 |
php bin/phpunit --filter=testUpdateCheeseListing
Scroll up... all better!
Now that we've got a rock-solid set of access_control
for CheeseListing
, let's repeat this for User
... because we don't have any access control stuff here now.
Start by saying itemOperations={}
. For the get
operation... let's steal an access_control
from CheeseListing
. Let's see... to be able to fetch a single User, let's say that you need to at least be logged in. So, ROLE_USER
.
... lines 1 - 15 | |
/** | |
* @ApiResource( | |
... lines 18 - 21 | |
* itemOperations={ | |
* "get"={"access_control"="is_granted('ROLE_USER')"}, | |
... lines 24 - 25 | |
* } | |
... lines 27 - 33 | |
*/ | |
class User implements UserInterface | |
... lines 36 - 199 |
For the put
operation, you're probably going to need to be logged in and... you should probably only be able to update your own record. Use is_granted('ROLE_USER') and object == user
.
... lines 1 - 21 | |
* itemOperations={ | |
... line 23 | |
* "put"={"access_control"="is_granted('ROLE_USER') and object == user"}, | |
... line 25 | |
* } | |
... lines 27 - 199 |
In this case, because we're not checking a specific property, we can safely use object
instead of previous_object
: you can send data to change a specific property... but not the entire object.
Finally, for delete
, let's say that you can only delete a User
if you're an admin: access_control
looking for ROLE_ADMIN
.
... lines 1 - 21 | |
* itemOperations={ | |
... lines 23 - 24 | |
* "delete"={"access_control"="is_granted('ROLE_ADMIN')"} | |
* } | |
... lines 27 - 199 |
Cool! Next, collectionOperations
! For get
, let's say that you need to be logged in... and for post
, for creating a User
... hey, that's registration! Put nothing here: this must be available to anonymous users.
... lines 1 - 17 | |
* collectionOperations={ | |
* "get"={"access_control"="is_granted('ROLE_USER')"}, | |
* "post" | |
* }, | |
... lines 22 - 199 |
Very nice! We could create some tests for this, but now that we're getting comfortable... and because these access rules are still fairly simple, I'll skip it and test once manually.
Go refresh the docs to do that. And... syntax error! Wow, I'm super lazy with my commas. Try it again. My web debug toolbar tells me that I am not logged in. So if we try the GET collection operation... 401 status code. Perfect!
Until now, we've been adding the access control rules on an operation-by-operation basis. But you can also add rules at the resource level. Add accessControl
... this time with a capital C - the top-level options are camel case. A few of our operations require ROLE_USER
... so, if we want to, we could say accessControl="is_granted('ROLE_USER')"
.
... lines 1 - 15 | |
/** | |
* @ApiResource( | |
* accessControl="is_granted('ROLE_USER')", | |
... lines 19 - 29 | |
* ) | |
... lines 31 - 34 | |
*/ | |
... lines 36 - 200 |
This becomes the default access control that will be used for all operations unless an operation overrides this with their own access_control
. This means that we don't need to repeat access_control
on the get
collection or get
item operations. But! We do now need to set access_control
on the post
operation to look for IS_AUTHENTICATED_ANONYMOUSLY
. We're overriding the default access control and making sure that anyone can access this operation.
Tip
On Symfony 6 or higher (or with
enable_authenticator_manager: true
insecurity.yaml
in Symfony 5.3/5.4), replaceIS_AUTHENTICATED_ANONYMOUSLY
withPUBLIC_ACCESS
.
... lines 1 - 15 | |
/** | |
* @ApiResource( | |
... line 18 | |
* collectionOperations={ | |
* "get", | |
* "post"={"access_control"="is_granted('IS_AUTHENTICATED_ANONYMOUSLY')"}, | |
* }, | |
* itemOperations={ | |
* "get", | |
... lines 25 - 26 | |
* }, | |
... lines 28 - 29 | |
* ) | |
... lines 31 - 34 | |
*/ | |
... lines 36 - 200 |
Using the resource-level versus operation-level access control is a matter of taste... and resource-level controls fit better on some resources than others.
Let's make sure this works... open the POST
operation, send an empty body and 500 error? Let's see... bah! Another annotation mistake. I like annotations... but I'll admit, they can get a bit big with API Platform... and apparently my comma key is broken today.
Let's execute that operation again and... got it! A 400 error: this value should not be blank.
Next, let's also making it possible for an admin user to be able to edit any CheeseListing
. We could push our access_control
logic further... but it's probably time to talk about voters.
Hey @Mykyta!
You're totally right! Once you switch to the new security system (enable_authenticator_manager: true
in security.yaml
in Symfony 5.3/5.4 and ALWAYS activated in Symfony 6.0 and beyond), IS_AUTHENTICATED_ANONYMOUSLY
is gone and PUBLIC_ACCESS
replaces it. I'll add a note about this!
Cheers!
Hello, in API Platform 2.5, so, which is the best solution between :
'put' => [
'security_post_denormalize' => "is_granted('ROLE_USER') and previous_object.getOwner() == user",
'security_post_denormalize_message' => 'Only the creator can edit a cheese listing'
],
and
'put' => [
'securit' => "is_granted('ROLE_USER') and object.getOwner() == user",
'security_message' => 'Only the creator can edit a cheese listing'
],
?
Hey Kiuega!
Sorry for the slow reply - this message was waiting for me :).
Hmm. I think... these are identical. Though, I would go with the second one: if the object's current owner != the current user, then we already know with complete certainty that this operation should be stopped. Doing it the second way will avoid going through the deserialization process. So, it's a nice way to exit earlier and avoid unnecessary work. But unless I'm not thinking about something, in functional terms, these are identical.
Cheers!
There is something wrong, that doesn't work for me, I can't explain but found working solution. Can anyone explain?
Environment<br />...<br />"php": ">=7.2.5",<br />"api-platform/core": "^2.5",<br />"symfony/framework-bundle": "5.1.*",<br />...<br />
NOT working <b>Entity/CheeseListing.php</b>
`
/**
Working <b>Entity/CheeseListing.php</b>, due to https://api-platform.com/docs/core/security/#executing-access-control-rules-after-denormalization
`
/**
Hey @the_shadow!
Sorry for the slow reply! This is a good topic :).
In our tutorial, which pre-dates the "security" attribute, we used:
"access_control"="is_granted('ROLE_USER') and previous_object.getOwner() == user",
In API Platform 2.5 and higher, as you know, we now have security
and security_post_denormalize
. Basically, access_control
=== security_post_denormalize
. What I mean is, both of these run after denormalization, and so they work exactly the same. That's what you're seeing: you're passing the exact same expression to security_post_denormalize
as I pass to access_control
in this tutorial and it works. For both of these, previous_object
is the object from before the JSON was deserialized and object
is from after.
The difference between security
and security_post_denormalize
is that security
runs before deserialization. In this situation, the object
variable is the original object, before the JSON is deserialized. And there actually is no variable called previous_object
.
I hope this explains what's going on! So, you can use your solution or use security
but change the variable from previous_object
to object
. That second solution is technically a "bit" more correct, because it will deny access even before your JSON is deserialized.
Let me know if that helps!
Cheers!
// composer.json
{
"require": {
"php": "^7.1.3, <8.0",
"ext-ctype": "*",
"ext-iconv": "*",
"api-platform/core": "^2.1", // v2.4.5
"composer/package-versions-deprecated": "^1.11", // 1.11.99
"doctrine/annotations": "^1.0", // 1.13.2
"doctrine/doctrine-bundle": "^1.6", // 1.11.2
"doctrine/doctrine-migrations-bundle": "^2.0", // v2.0.0
"doctrine/orm": "^2.4.5", // v2.7.2
"nelmio/cors-bundle": "^1.5", // 1.5.6
"nesbot/carbon": "^2.17", // 2.21.3
"phpdocumentor/reflection-docblock": "^3.0 || ^4.0", // 4.3.1
"symfony/asset": "4.3.*", // v4.3.2
"symfony/console": "4.3.*", // v4.3.2
"symfony/dotenv": "4.3.*", // v4.3.2
"symfony/expression-language": "4.3.*", // v4.3.2
"symfony/flex": "^1.1", // v1.18.7
"symfony/framework-bundle": "4.3.*", // v4.3.2
"symfony/http-client": "4.3.*", // v4.3.3
"symfony/monolog-bundle": "^3.4", // v3.4.0
"symfony/security-bundle": "4.3.*", // v4.3.2
"symfony/twig-bundle": "4.3.*", // v4.3.2
"symfony/validator": "4.3.*", // v4.3.2
"symfony/webpack-encore-bundle": "^1.6", // v1.6.2
"symfony/yaml": "4.3.*" // v4.3.2
},
"require-dev": {
"hautelook/alice-bundle": "^2.5", // 2.7.3
"symfony/browser-kit": "4.3.*", // v4.3.3
"symfony/css-selector": "4.3.*", // v4.3.3
"symfony/maker-bundle": "^1.11", // v1.12.0
"symfony/phpunit-bridge": "^4.3", // v4.3.3
"symfony/stopwatch": "4.3.*", // v4.3.2
"symfony/web-profiler-bundle": "4.3.*" // v4.3.2
}
}
Hey :)
I faced with the '401 Unauthorized' error while trying to create the User, so I thought it might be useful to write here about the way to fix it..
I find out that the new system (after Symfony v5.3) doesn't "authenticate" user by default with
IS_AUTHENTICATED_ANONYMOUSLY
, because anonymous users no longer exist. So in this case we should useis_granted('PUBLIC_ACCESS')
.Cheers!