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!

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

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/api-pack": "^1.2", // v1.2.0
        "doctrine/doctrine-migrations-bundle": "^2.0", // v2.0.0
        "nesbot/carbon": "^2.17", // 2.21.3
        "symfony/console": "4.3.*", // v4.3.2
        "symfony/dotenv": "4.3.*", // v4.3.2
        "symfony/flex": "^1.1", // v1.4.5
        "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/webpack-encore-bundle": "^1.6", // v1.6.2
        "symfony/yaml": "4.3.*" // v4.3.2
    },
    "require-dev": {
        "hautelook/alice-bundle": "^2.5", // v2.5.1
        "symfony/maker-bundle": "^1.11", // v1.12.0
        "symfony/profiler-pack": "^1.0", // v1.0.4
        "symfony/test-pack": "^1.0" // v1.0.6
    }
}