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

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!

27
Login or Register to join the conversation
Jakub Avatar
Jakub Avatar Jakub | posted 8 months ago | edited

Hi,
I have an issue with test which checks if cheese listing can be changed via put method by user which isn't its owner. In response I have always 200 status code instead 403. It seems that settings for PUT operation are ignored and I don't know why (by ignoring I mean that I can write everything, e.g. "blablabla"="is_granted('ROLE_USER') and object.getOwner() == user" instead of "security"="is_granted('ROLE_USER') and object.getOwner() == user" and no errors I'll have). I don't know what's going on. Please help me. I'm using API Platform 2.7 and Symfony 4.4
Jakub

Reply
Jakub Avatar
Jakub Avatar Jakub | Jakub | posted 8 months ago | edited

Again clearing cache helped. This time I forgot to set the cache cleaning in the test environment:

php bin/console cache:clear --env=test

Issue closed.

Reply

Hey Jakub,

Awesome, an easy win ;)

Cheers!

Reply
Cerpo Avatar
Cerpo Avatar Cerpo | posted 10 months ago | edited

Hi, i'm strugling with this "roles" update.

On the ressource ( named Pilot )

#[ApiResource(
    security: "is_granted('ROLE_USER')",
        operations: [
        new GetCollection(
            security: "is_granted('ROLE_ADMIN')"
        ),
        new Get(
            security: "object == user or is_granted('ROLE_ADMIN')"
        ),
        new Post(
            security: "is_granted('ROLE_ADMIN')",
            validationContext: ['groups' => ['Default', 'Create']]
        ),
        new Delete(
            security: "is_granted('ROLE_ADMIN')"
        ),
        new Put(
            security: "is_granted('ROLE_ADMIN') or object == user"
        )
    ],

    normalizationContext: ['groups' => 'pilot:read'],
    denormalizationContext: ['groups' => 'pilot:write'],
)]

on the property this .

#[ApiProperty(security: "is_granted('ROLE_ADMIN') or object == user")]
#[Groups(['pilot:read', 'pilot:write'])]
private array $roles = [];

And i dont understand why this test

    public function testPilotCannotChangeItsRole()
    {
        $client = self::createClient();

        $pilotUser = $this->createAndLoginUser($client, 'user@test.fr', 'password');

        $client->request('PUT', $this->PREFIX('/pilots/' . $pilotUser->getId()), [
            'headers' => [
                'accept' => 'application/json+ld',
            ],
            'json' => [
                'roles' => ['ROLE_ADMIN']
            ]
        ]);
        $this->assertResponseStatusCodeSame(401);
    }
    

is failing ( and i should test 403 , not 401 but that does not change my problem :) )

1) App\Test\Functionnal\PilotsTest::testPilotCannotChangeItsRole
Failed asserting that the Response status code is 401.
HTTP/1.1 200 OK
Cache-Control:          max-age=0, must-revalidate, private
Content-Location:       /api/v1/pilots/1ed4f72e-61a2-66f0-9ca4-953b1497bc05
Content-Type:           application/json+ld; charset=utf-8
Date:                   Wed, 19 Oct 2022 05:57:22 GMT
Expires:                Wed, 19 Oct 2022 05:57:22 GMT
Link:                   <http://localhost/api/v1/docs.jsonld>; rel="http://www.w3.org/ns/hydra/core#apiDocumentation"
Vary:                   Accept
X-Content-Type-Options: nosniff
X-Frame-Options:        deny
X-Robots-Tag:           noindex

{"@context":"\/api\/v1\/contexts\/Pilot","@id":"\/api\/v1\/pilots\/1ed4f72e-61a2-66f0-9ca4-953b1497bc05","@type":"Pilot","id":"1ed4f72e-61a2-66f0-9ca4-953b1497bc05","email":"user@test.fr","roles":["ROLE_USER"],"name":"user"}

Strange thing is the context returned is not showing this 200 ( still ROLE_USER )

Reply

Hey Cerpo!

Yea, this stuff is complex! But, hmm. This is, indeed a bit odd. From my perspective, I WOULD expect this situation to be successful. Your Put operation security is is_granted('ROLE_ADMIN') or object == user. So that should pass since the user is editing their own record. You have that same security on the property itself, so I would also expect that to work. Why are you expecting it to fail with 401/403?

But, if I'm correct, then we would expect that the roles WOULD update in the response, which you correctly noted did NOT happen.

So, I'm not sure what's happening. However, I do have one point that might help: If the PUT operation security "passes", but the ApiProperty security "fails", I think (I am not 100% sure - this is a bit of a guess as I haven't used the property-level security yet) that the result would be a 200 status code, except that the roles property would "skip" being set. That feels a lot like what you're experiencing.

Let me know if this helps :)

Cheers!

1 Reply
Cerpo Avatar
Cerpo Avatar Cerpo | weaverryan | posted 10 months ago | edited

Hi Ryan, Thanks for the quick answer.

My goal is

  • Users ( and admin) can read their role to grant user in the font and ( so i'm expecting that roles are not modified -> testPilotCannotChangeItsRole() )
    Only admin change change user's role.

but reading you comment i can see that something's wrong with my decoration .
it should says

  • 'pilot:write' when admin
  • pilot:read when admin for every user
  • pilot:read when object = user

Voters ?
I think it's time the "Re-watch" .. sometimes details suddenly become obvious !

Reply
Cerpo Avatar

Quick update

PHPUnit 9.5.25 #StandWithUkraine

Testing 
............                                                      12 / 12 (100%)

Time: 00:01.204, Memory: 54.50 MB

OK (12 tests, 36 assertions)

I had some answers in the next chapters. ( AdminGroupsContextBuilder and phone / roles example ) . Both on a way to handle this and the explanation on code return 200, property ignored etc..

Thanks

Reply

Woo! Glad you got it worked out - my reply was too slow! Cheers :)

Reply
Ricardo M. Avatar
Ricardo M. Avatar Ricardo M. | posted 1 year ago

Custom error message
In newer versions it seems that `access_control_message` is now `security_message`. (yes, you said that 10 seconds after I mentioned it here)

In my case, it only worked as `security_post_denormalize_message`.

More info at the docs.

Reply

Hey Ricardo M.!

Thanks for posting this - I appreciate it :).I I'll add a note to mention this... I think in the next chapter when we mention security_post_denormalize. Because, basically, if you use security_post_denormalize (which we talk about in the next chapter), then you need to know to also use security_post_denormalize_message.

Cheers!

Reply
Daniel K. Avatar
Daniel K. Avatar Daniel K. | posted 2 years ago | edited

Hi there

i have created API platform 2.6 app and I'm using tests that extends ApiTestCase, so i'm creating some entities before POST call, but that EntityManager in that POST call don't "see" those new entites thus error: Item not found

Reply

Hey Daniel,

What do you do exactly when see that "Item not found" error? Are you sure the entities were stored in the database but entity manager does not see it? Can you go to the DB and make sure they really *are* in the database? Btw, in some cases in tests you need to call $entityManager->refresh() and pass an entity to it to sync changes in the DB with the object, otherwise entity manager won't see the changes.

I hope this helps!

Cheers!

Reply

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.

Reply

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!

Reply

Thank you Victor for your quick response. I hope you get payed well for this job.
I found the solution.
In my entity i <b>now</b> use @ORM\Table(name='mytable').
Before i used @ORM\Table(name=<b>mydatabase</b>.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 !!

Reply

Hey Annemieke,

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

Cheers!

Reply
hous04 Avatar

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 ;)

Reply

Hey hous04!

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!

Reply
hous04 Avatar

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 ;)

Reply
Default user avatar
Default user avatar Paul Molin | posted 3 years ago

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?

Reply

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!

Reply
Default user avatar
Default user avatar Paul Molin | weaverryan | posted 3 years ago | edited

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

Reply
Tobias I. Avatar
Tobias I. Avatar Tobias I. | posted 3 years ago | edited

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....

Reply

Hey Tobias I.

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!

Reply
Tobias I. Avatar
Tobias I. Avatar Tobias I. | MolloKhan | posted 3 years ago | edited

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 <a href="https://puu.sh/EavYl/ab8557ca5e.png&quot;&gt;screenshot&lt;/a&gt;)

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 <a href="https://github.com/api-platform/core/releases/tag/v2.4.6&quot;&gt;release notes</a>. 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

Reply

Hey Tobias I.

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

Cheers!

Reply
Tobias I. Avatar

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

1 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