Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine
This tutorial has a new version, check it out!

ACL & previousObject

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.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Via 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!

Hello previous_object

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!

access_control on User

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!

Top (Resource) Level accessControl

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 in security.yaml in Symfony 5.3/5.4), replace IS_AUTHENTICATED_ANONYMOUSLY with PUBLIC_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.

Leave a comment!

6
Login or Register to join the conversation
Mykyta Avatar

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 use is_granted('PUBLIC_ACCESS').
Cheers!

2 Reply

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!

1 Reply
Kiuega Avatar
Kiuega Avatar Kiuega | posted 2 years ago | edited

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'
            ],

?

Reply

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!

1 Reply
Pavlo S. Avatar
Pavlo S. Avatar Pavlo S. | posted 2 years ago | edited

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>
`
/**

  • @ApiResources(
  • ...
  • itemOperations={
  • ...
  • "put"={"security"=>"is_granted('ROLE_USER') and previous_object.getOwner() == user"}
  • ...}
  • ...
  • )
    */
    `

Working <b>Entity/CheeseListing.php</b>, due to https://api-platform.com/docs/core/security/#executing-access-control-rules-after-denormalization
`
/**

  • @ApiResources(
  • ...
  • itemOperations={
  • ...
  • "put"={"security_post_denormalize"=>"is_granted('ROLE_USER') and previous_object.getOwner() == user"}
  • ...
  • ...}
  • )
    */
    `
Reply

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!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial works great for Symfony 5 and API Platform 2.5/2.6.

What PHP libraries does this tutorial use?

// 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
    }
}
userVoice