ACL: Only Owners can PUT a CheeseListing

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

Back to security! We need to make sure that you can only make a PUT request to update a CheeseListing if you are the owner of that CheeseListing. As a reminder, each CheeseListing is related to one User via an $owner property. Only that User should be able to update this CheeseListing.

Let's start by writing a test. In the test class, add public function testUpdateCheeseListing() with the normal $client = self::createClient() and $this->createUser() passing cheeseplease@example.com and password foo. Wait, I only want to use createUser() - we'll log in manually a bit later.

... lines 1 - 9
class CheeseListingResourceTest extends CustomApiTestCase
{
... lines 12 - 29
public function testUpdateCheeseListing()
{
$client = self::createClient();
$user = $this->createUser('cheeseplease@example.com', 'foo');
... lines 34 - 48
}
}

Always Start with self::createClient()

Notice that the first line of my test is $client = self::createClient()... even though we haven't needed to use that $client variable yet. It turns out, making this the first line of every test method is important. Yes, this of course creates a $client object that will help us make requests into our API. But it also boots Symfony's container, which is what gives us access to the entity manager and all other services. If we swapped these two lines and put $this->createUser() first... it would totally not work! The container wouldn't be available yet. The moral of the story is: always start with self::createClient().

Testing PUT /api/cheeses/{id}

Ok, let's think about this: in order to test updating a CheeseListing, we first need to make sure there's a CheeseListing in the database to update! Cool! $cheeseListing = new CheeseListing() and we can pass a title right here: "Block of Cheddar". Next say $cheeseListing->setOwner() and make sure this CheeseListing is owned by the user we just created. Now fill in the last required fields: setPrice() to $10 and setDescription().

... lines 1 - 29
public function testUpdateCheeseListing()
{
... lines 32 - 34
$cheeseListing = new CheeseListing('Block of cheddar');
$cheeseListing->setOwner($user);
$cheeseListing->setPrice(1000);
$cheeseListing->setDescription('mmmm');
... lines 39 - 48
}
... lines 50 - 51

To save, we need the entity manager! Go back to CustomApiTestCase... and copy the code we used to get the entity manager. Needing the entity manager is so common, let's create another shortcut for it: protected function getEntityManager() that will return EntityManagerInterface. Inside, return self::$container->get('doctrine')->getManager().

... lines 1 - 9
class CustomApiTestCase extends ApiTestCase
{
... lines 12 - 48
protected function getEntityManager(): EntityManagerInterface
{
return self::$container->get('doctrine')->getManager();
}
}

Let's use that: $em = $this->getEntityManager(), $em->persist($cheeseListing) and $em->batman(). Kidding. But wouldn't that be awesome? $em->flush().

... lines 1 - 9
class CheeseListingResourceTest extends CustomApiTestCase
{
... lines 12 - 29
public function testUpdateCheeseListing()
{
... lines 32 - 39
$em = $this->getEntityManager();
$em->persist($cheeseListing);
$em->flush();
... lines 43 - 48
}
}

Great setup! Now... to the real work. Let's test the "happy" case first: let's test that if we log in with this user and try to make a PUT request to update a cheese listing, we'll get a 200 status code.

Easy peasy: $this->logIn() passing $client, the email and password. Now that we're authenticated, use $client->request() to make a PUT request to /api/cheeses/ and then the id of that CheeseListing: $cheeseListing->getId().

... lines 1 - 29
public function testUpdateCheeseListing()
{
... lines 32 - 43
$this->logIn($client, 'cheeseplease@example.com', 'foo');
$client->request('PUT', '/api/cheeses/'.$cheeseListing->getId(), [
... line 46
]);
... line 48
}
... lines 50 - 51

For the options, most of the time, the only thing you'll need here is the json option set to the data you need to send. Let's just send a title field set to updated. That's enough data for a valid PUT request.

... lines 1 - 29
public function testUpdateCheeseListing()
{
... lines 32 - 44
$client->request('PUT', '/api/cheeses/'.$cheeseListing->getId(), [
'json' => ['title' => 'updated']
]);
... line 48
}
... lines 50 - 51

What status code will we get back on success? You don't have to guess. Down on the docs... it tells us: 200 on success.

Assert that $this->assertResponseStatusCodeSame(200).

... lines 1 - 29
public function testUpdateCheeseListing()
{
... lines 32 - 47
$this->assertResponseStatusCodeSame(200);
}
... lines 50 - 51

Perfect start! Copy the method name so we can execute just this test. At your terminal, run:

php bin/phpunit --filter=testUpdateCheeseListing

And... above those deprecation warnings... yes! It works.

But.. that's no surprise! We haven't really tested the security case we're worried about. What we really want to test is what happens if I login and try to edit a CheeseListing that I do not own. Ooooo.

Rename this $user variable to $user1, change the email to user1@example.com and update the email below on the logIn() call. That'll keep things easier to read... because now I'm going to create a second user: $user2 = $this->createUser() with user2@example.com and the same password.

... lines 1 - 29
public function testUpdateCheeseListing()
{
... line 32
$user1 = $this->createUser('user1@example.com', 'foo');
$user2 = $this->createUser('user2@example.com', 'foo');
... lines 35 - 36
$cheeseListing->setOwner($user1);
... lines 38 - 50
$this->logIn($client, 'user1@example.com', 'foo');
... lines 52 - 55
}
... lines 57 - 58

Now, copy the entire login, request, assert-response-status-code stuff and paste it right above here: before we test the "happy" case where the owner tries to edit their own CheeseListing, let's first see what happens when a non-owner tries this.

Log in this time as user2@example.com. We're going to make the exact same request, but this time we're expecting a 403 status code, which means we are logged in, but we do not have access to perform this operation.

... lines 1 - 29
public function testUpdateCheeseListing()
{
... lines 32 - 44
$this->logIn($client, 'user2@example.com', 'foo');
$client->request('PUT', '/api/cheeses/'.$cheeseListing->getId(), [
'json' => ['title' => 'updated']
]);
$this->assertResponseStatusCodeSame(403, 'only author can updated');
... lines 50 - 55
}
... lines 57 - 58

I love it! With any luck, this should fail: our access_control is not smart enough to prevent this yet. Try the test:

php bin/phpunit --filter=testUpdateCheeseListing

And... yes! We expected a 403 status code but got back 200.

Using object.owner in access_control

Ok, let's fix this!

The access_control option - which will probably be renamed to security in API Platform 2.5 - allows you to write an "expression" inside using Symfony's expression language. This is_granted() thing is a function that's available in that, sort of, Twig-like expression language.

We can make this expression more interesting by saying and to add more logic. API Platform gives us a few variables to work with inside the expression, including one that represents the object we're working with on this operation... in other words, the CheeseListing object. That variable is called... object! Another is user, which is the currently-authenticated User or null if the user is anonymous.

Knowing that, we can say and object.getOwner() == user.

... lines 1 - 16
/**
* @ApiResource(
* itemOperations={
... lines 20 - 22
* "put"={"access_control"="is_granted('ROLE_USER') and object.getOwner() == user"},
... line 24
* },
... lines 26 - 36
* )
... lines 38 - 47
*/
class CheeseListing
... lines 50 - 208

Yea... that's it! Try the test again and...

php bin/phpunit --filter=testUpdateCheeseListing

It passes! I told you the security part of this was going to be easy! Most of the work was the test, but I love that I can prove this works.

access_control_message

While we're here, there's one other related option called access_control_message. Set this to:

only the creator can edit a cheese listing

... and make sure you have a comma after the previous line.

... lines 1 - 16
/**
* @ApiResource(
* itemOperations={
... lines 20 - 22
* "put"={
* "access_control"="is_granted('ROLE_USER') and object.getOwner() == user",
* "access_control_message"="Only the creator can edit a cheese listing"
* },
... line 27
* },
... lines 29 - 39
* )
... lines 41 - 50
*/
class CheeseListing
... lines 53 - 211

If you run the test... this makes no difference. But this option did just change the message the user sees. Check it out: after the 403 status code, var_dump() $client->getResponse()->getContent() and pass that false. Normally, if you call getContent() on an "error" response - a 400 or 500 level response - it throws an exception. This tells it not to, which will let us see that response's content. Try the test:

... lines 1 - 9
class CheeseListingResourceTest extends CustomApiTestCase
{
... lines 12 - 29
public function testUpdateCheeseListing()
{
... lines 32 - 49
var_dump($client->getResponse()->getContent(true));
... lines 51 - 56
}
}
php bin/phpunit --filter=testUpdateCheeseListing

The hydra:title says "An error occurred" but the hydra:description says:

only the creator can edit a cheese listing.

So, the access_control_message is a nice way to improve the error your user sees. By the way, in API Platform 2.5, it'll probably be renamed to security_message.

Remove the var_dump(). Next, there's a bug in our security! Ahh!!! It's subtle. Let's find it and squash it!

Leave a comment!

  • 2020-04-21 Victor Bocharsky

    Hey Annemieke,

    I'm happy you figured out the problem and were able to fix it, well done!

    Cheers!

  • 2020-04-18 Annemieke Buijs

    Thank you Victor for your quick response. I hope you get payed well for this job.
    I found the solution.
    In my entity i now use @ORM\Table(name='mytable').
    Before i used @ORM\Table(name=mydatabase.mytable)

    I am indeed using the test database for test, but because of the orm\table with the database name in it, that won't work.

    Thank you !!

  • 2020-04-17 Victor Bocharsky

    Hey Annemieke,

    "Cannot add or update a child row: a foreign key constraint fails" means that you're trying to input some new data into the DB but it conflicts with the data you already have in your DB. Please, check your DB (I suppose you need to look into test database as you said you run phpunit) and see what data you already have there and what data you're trying to input executing your test. It sounds like you need to empty your DB, i.e. clear the old data before filling it with new data.

    Cheers!

  • 2020-04-17 Annemieke Buijs

    Great tutorial, thank you.
    I am trying all this out in an excisting api. Is there a way to turn foreign_key_checks of while testing? I use ReloadDatabaseTrait, but when i create a new record of an entity (e.g. new Contact), and i run phpunit, i get ' Cannot add or update a child row: a foreign key constraint fails '.

    Thanks in advance.

  • 2020-01-28 Houssem D

    Hi

    "Let me know if that helps - or if you still get the error after adding the getter"

    Yes expert, it's very helpful and good explication, thank you very much ;)

  • 2020-01-27 weaverryan

    Hey Houssem D!

    Short answer: make your properties private. I believe the main reason that you sometimes see public properties used in the API Platform docs is nothing more than... it's easier/shorter to write documentation using public properties than to use private properties and show the getters/setters. Private properties are better just because they're better from an object-oriented perspective.

    > In the begining I have no problem, but now when I tried to protect an object with "object.owner == user" , I get this error
    > "hydra:description": "Cannot access private property App\\Entity\\Book::$owner",

    That's interesting. You *shouldn't* get that error as long as you have a getOwner() method. API Platform is smart enough to access a property directly if it's public or to use a getOwner() method if the property is not public and that method exists. That's exactly what we do in this tutorial: my properties are private, but I have the getter method.

    Let me know if that helps - or if you still get the error after adding the getter.

    Cheers!

  • 2020-01-24 Houssem D

    Hi
    I have noticed that in the current documentation , the entity properties are declared as public , but when I generate entities the properties are declared as private.

    In the begining I have no problem, but now when I tried to protect an object with "object.owner == user" , I get this error

    "hydra:description": "Cannot access private property App\\Entity\\Book::$owner",

    But when I make $owner as public or call getOwner() as you did "object.getOwner() == user", it works good.

    Can you please tell us if declaring properties as public like they did in the documentation is dangerous for security or not ?

    Thanks expert ;)

  • 2019-09-22 Paul Molin

    Thanks weaverryan ! :)
    Keep up the good work, Im' a big fan. :)

  • 2019-09-20 weaverryan

    Hey Paul Molin!

    You're 100% correct - good attention to detail. We change to previous_object and talk about this in the next chapter :).

    Cheers!

  • 2019-09-20 Paul Molin

    Thank you for this great tutorial!
    I have a small feedback: I'd use `previous_object` rather than `object` in the is_granted expression.
    While using object, a user could replace the owner field with their own url/id and "steal" the cheese listing.

    What do you think?

  • 2019-09-01 Tobias Ingold

    ah perfect thank you so much :) works just as expected!

  • 2019-08-29 Diego Aguiar

    Hey Tobias Ingold

    I found the reason of this problem. You can see my answer here: https://symfonycasts.com/sc...

    Cheers!

  • 2019-08-28 Tobias Ingold

    I tried to reproduce on a fresh symfony project. I only created a user entity via the bin/console make:user command and followed this this tutorial on setting up the test environment. This is my test class:

    namespace App\Tests\Functional;

    use App\ApiPlatform\Test\ApiTestCase;

    class UserResourceTest extends ApiTestCase
    {
    public function testPostUser()
    {
    $client = self::createClient();

    $client->request('POST', '/api/users', [
    'headers' => ['Content-Type' => 'application/json'],
    'json' => [],
    ]);
    $this->assertResponseStatusCodeSame(400);
    }
    }

    When I now run bin/phpunit, I again get the same error (here is a screenshot)

    composer show api-platform/core says that I have version 2.4.6, so the 415 status code is expected. Since 2.4.6 you get a 415 instead of 406 when a faulty content type is used as stated in the release notes. I am not sure what broke, but it's probably only in the test environment because the swagger ui gives me the corrent response and status code

  • 2019-08-27 Diego Aguiar

    Hey Tobias Ingold

    That's odd. Is it possible that something is doing an extra request in the middle of your test? Try detecting the request that is failing
    Also, can you show me the piece of code where you perform the request?

    Cheers!

  • 2019-08-27 Tobias Ingold

    For some reason my test fails with error message The content-type "application/x-www-form-urlencoded" is not supported.. In fact all my tests fail with this message. I just ran composer update before testing, could it be that this broke something? Setting 'headers' => ['Content-Type' => 'application/json'] doesn't seem to do anything....