Buy Access to Course
30.

DTO & Security

|

Share this awesome video!

|

Keep on Learning!

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

Login Subscribe

Our DragonTreasureApi is looking great! Back when this resource was an entity, we added quite a few cool customizations and included tests for those. Past "us" rocks.

The plan now is to put those thing back piece-by-piece and see how we can simplify the implementation inside our new DTO-powered setup.

Be crazy and run all the dragon treasure tests:

symfony php bin/phpunit tests/Functional/DragonTreasureResourceTest.php

Quite a few fail... and one of them says:

Current response status code is 422, but 403 expected.

This testPostToCreateTreasureDeniedWithoutScope is related to security, and that makes sense. DragonTreasureApi is entirely missing security!

Adding Security Back

Start like we did with UserApi: by specifying the operations we want. Start with new Get(), new GetCollection(), and new Post(). In the original system, Post() had a security option set to 'is_granted("ROLE_TREASURE_CREATE").

// ... lines 1 - 12
class DragonTreasureApiToEntityMapper implements MapperInterface
{
public function __construct(
// ... line 16
private Security $security,
)
{
}
// ... lines 22 - 35
public function populate(object $from, object $to, array $context): object
{
// ... lines 38 - 42
if ($dto->owner) {
dd($dto->owner);
} else {
$entity->setOwner($this->security->getUser());
}
$entity->setDescription($dto->description);
$entity->setCoolFactor($dto->coolFactor);
$entity->setValue($dto->value);
// TODO: set published
// ... lines 55 - 58

This is directly related to that test failure, which checks to make sure that our API token has that role. Well... if I spell "create" correctly, at least.

We also had a Patch() operation and that also had a security option. This leveraged a custom voter to check if the current user can EDIT this treasure. More on that in a minute.

66 lines | src/ApiResource/DragonTreasureApi.php
// ... lines 1 - 8
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
// ... lines 13 - 19
#[ApiResource(
// ... line 21
operations: [
new Get(),
new GetCollection(),
new Post(
security: 'is_granted("ROLE_TREASURE_CREATE")',
),
new Patch(
security: 'is_granted("EDIT", object)',
),
// ... lines 31 - 33
],
// ... lines 35 - 38
)]
class DragonTreasureApi
{
// ... lines 42 - 64
}

And finally, we had new Delete(), which we decided only admins could do. Enforce that with is_granted("ROLE_ADMIN").

66 lines | src/ApiResource/DragonTreasureApi.php
// ... lines 1 - 7
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
// ... lines 13 - 19
#[ApiResource(
// ... line 21
operations: [
new Get(),
new GetCollection(),
new Post(
security: 'is_granted("ROLE_TREASURE_CREATE")',
),
new Patch(
security: 'is_granted("EDIT", object)',
),
new Delete(
security: 'is_granted("ROLE_ADMIN")',
)
],
// ... lines 35 - 38
)]
class DragonTreasureApi
{
// ... lines 42 - 64
}

Okay, we had six failures earlier and now:

symfony php bin/phpunit tests/Functional/DragonTreasureResourceTest.php

We're down to five. Progress! Let's zoom in on testPatchToUpdateTreasure and run just that:

symfony php bin/phpunit tests/Functional/DragonTreasureResourceTest.php --filter=testPatchToUpdateTreasure

Back over here... check out what it's doing. Ok, it creates a User, a treasure, logs in as the owner, tries to change the value of that treasure, makes sure we get a 200 status code, and finally, checks that we see the updated value. Right now, we're getting a 403 instead of 200.

Updating the Security Voter for the DTO

A 403 status is a security failure. For some reason, we're not allowed to make a Patch() request to this treasure... even though we're the owner! Rude!

Ok: Patch() is using is_granted("EDIT", object). This "EDIT", object thing is handled by a custom voter called DragonTreasureVoter that we created in a previous tutorial. So, either this voter is not being called or its saying that we shouldn't have access.

To see what's going on under the hood, dump($attribute, $subject). This supports() method is called any time a security decision is made across the entire system, so it should get hit.

When we run the test again:

symfony php bin/phpunit tests/Functional/DragonTreasureResourceTest.php --filter=testPatchToUpdateTreasure

There's the dump! It dumps EDIT, which comes from the Patch() operation. But here's the kicker: the object is now a DragonTreasureApi, which makes sense! But our DragonTreasureVoter was written to work with the entity, not DragonTreasureApi.

No problem! Let's update this voter to work with the DTO. For clarity, rename this to DragonTreasureApiVoter. Then, we'll support if DragonTreasureApi is the $subject. And down here, this $subject should also be DragonTreasureApi. dd($subject)... and below, let's fix the code. This says that if the user doesn't have this role (actually a scope, which relates to the token scopes), return false.

// ... lines 1 - 4
use App\ApiResource\DragonTreasureApi;
// ... lines 6 - 10
class DragonTreasureApiVoter extends Voter
{
// ... lines 13 - 18
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::EDIT])
&& $subject instanceof DragonTreasureApi;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
// ... lines 27 - 36
assert($subject instanceof DragonTreasureApi);
dd($subject);
// ... lines 39 - 53
return false;
}
}

The most important part is this: if the $subject - which is a DragonTreasureApi - has an owner that equals $user - the currently authenticated user - then return true: access granted!

Comment out this dd() real quick. What we need now is $subject->owner.

Well, that's not quite right... and if we put that dd() back, we can see why. Run the test:

symfony php bin/phpunit tests/Functional/DragonTreasureResourceTest.php --filter=testPatchToUpdateTreasure

This dump - the $subject - is, of course, a DragonTreasureApi. But remember, its owner property isn't a User entity: it's a UserApi object. So we can't just compare the UserApi object to the $user entity object.

We also need to be careful because of our mapper. Thanks to the depth, the UserApi isn't populated: it's a shallow object. That's okay - we can compare the id of the objects - just keep this in mind.

So, the tl;dr is: compare the id property to $user->getId(). Oh, and it didn't autocomplete getId()... but we can help our editor by making this instanceof check specifically that this is a User entity, which it always will be in our app.

Now use getId()... and I'll code defensively by adding a ?... in case this DragonTreasureApi doesn't have an owner: like for a treasure we're creating right now.

// ... lines 1 - 4
use App\ApiResource\DragonTreasureApi;
use App\Entity\User;
// ... lines 7 - 10
class DragonTreasureApiVoter extends Voter
// ... lines 13 - 19
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::EDIT])
&& $subject instanceof DragonTreasureApi;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
// ... lines 28 - 29
if (!$user instanceof User) {
return false;
}
// ... lines 33 - 37
assert($subject instanceof DragonTreasureApi);
// ... lines 39 - 40
switch ($attribute) {
case self::EDIT:
// ... lines 43 - 46
if ($subject->owner?->id === $user->getId()) {
return true;
}
// ... lines 50 - 51
}
return false;
}
}

Phew! Head over and try it now!

symfony php bin/phpunit tests/Functional/DragonTreasureResourceTest.php --filter=testPatchToUpdateTreasure

Adding the application/merge-patch+json Header

Progress! The current response status code is now 415. This is thanks to a small detail we talked about a few times:

The content-type application/json is not supported. Supported MIME types are application/merge-patch+json.

When we make a PATCH request, we need to have a headers key with Content-Type set to application/merge-patch+json. The reason we didn't need that before, as I mentioned in a previous tutorial... is due to some funny business with formats which made that, accidentally, unnecessary for this resource. But now we do need it.

Let's quickly add that to all of our patch() requests. There's a bunch of them. Zoom!

// ... lines 1 - 14
class DragonTreasureResourceTest extends ApiTestCase
{
// ... lines 17 - 116
public function testPatchToUpdateTreasure()
{
// ... lines 119 - 121
$this->browser()
// ... line 123
->patch('/api/treasures/'.$treasure->getId(), [
// ... lines 125 - 127
'headers' => ['Content-Type' => 'application/merge-patch+json']
])
// ... lines 130 - 134
$this->browser()
// ... line 136
->patch('/api/treasures/'.$treasure->getId(), [
// ... lines 138 - 142
'headers' => ['Content-Type' => 'application/merge-patch+json']
])
// ... line 145
;
// ... line 147
$this->browser()
// ... line 149
->patch('/api/treasures/'.$treasure->getId(), [
// ... lines 151 - 154
'headers' => ['Content-Type' => 'application/merge-patch+json']
])
// ... line 157
;
}
// ... line 160
public function testPatchUnpublishedWorks()
{
// ... lines 163 - 168
$this->browser()
// ... line 170
->patch('/api/treasures/'.$treasure->getId(), [
// ... lines 172 - 174
'headers' => ['Content-Type' => 'application/merge-patch+json']
])
// ... lines 177 - 178
;
}
// ... lines 181 - 182
public function testAdminCanPatchToEditTreasure(): void
{
// ... lines 185 - 189
$this->browser()
// ... line 191
->patch('/api/treasures/'.$treasure->getId(), [
// ... lines 193 - 195
'headers' => ['Content-Type' => 'application/merge-patch+json']
])
// ... lines 198 - 200
;
}
public function testOwnerCanSeeIsPublishedAndIsMineFields(): void
{
// ... lines 206 - 211
$this->browser()
// ... line 213
->patch('/api/treasures/'.$treasure->getId(), [
// ... lines 215 - 217
'headers' => ['Content-Type' => 'application/merge-patch+json']
])
// ... lines 220 - 223
;
}
// ... line 226
public function testPublishTreasure(): void
{
// ... lines 229 - 234
$this->browser()
// ... line 236
->patch('/api/treasures/'.$treasure->getId(), [
// ... lines 238 - 240
'headers' => ['Content-Type' => 'application/merge-patch+json']
])
// ... lines 243 - 244
;
// ... lines 246 - 247
}
}

Let's see if we have any luck!

symfony php bin/phpunit tests/Functional/DragonTreasureResourceTest.php --filter=testPatchToUpdateTreasure

And... ooh... it dies. It hit our dump! That's coming from DragonTreasureApiToEntityMapper: when the owner is sent in the JSON. Comment this out for a moment so we can see the full picture. Run the test again:

symfony php bin/phpunit tests/Functional/DragonTreasureResourceTest.php --filter=testPatchToUpdateTreasure

Current response status code is 200, but 422 expected.

Coming from down on line 157. So, looking at our test, most of it passes. Line 157 is way down here. This means that we are able to send a patch() request and have that update!

And the full flow here is fascinating! When we make a patch() request to a treasure, API Platform starts by using our data provider to find the DragonTreasure entity. Then we map that to a DragonTreasureApi object. Next, the new value is deserialized onto that DragonTreasureApi. Finally, in our processor, we map the updated DragonTreasureApi back to a DragonTreasure entity, and that is ultimately what saves. The DragonTreasureApi is then serialized and returned as JSON.

So this is working... and I love how all the pieces come together.

Updating the Custom Validator

Where we're failing is all the way down here. This checks to see if we're allowed to change the owner to someone else. We log in as $user and edit our own treasure... but try to change the treasure to another owner! This is like a dragon Santa Claus that sneaks into other dragon's caves for a late-night delivery of treasure. That's super nice... but not something we want to allow.

Previously, we had a custom validator that prevented this. So let's re-add that!

Open DragonTreasureApi and find the $owner property. Add #[IsValidOwner]: a validator we created in an earlier tutorial.

68 lines | src/ApiResource/DragonTreasureApi.php
// ... lines 1 - 15
use App\Validator\IsValidOwner;
// ... lines 17 - 40
class DragonTreasureApi
{
// ... lines 43 - 58
#[IsValidOwner]
public ?UserApi $owner = null;
// ... lines 61 - 66
}

You'll find it in src/Validator/. Previously, this validator expected its constraint to be used above a property that held a User entity. We're putting it on a property that holds a UserApi. So like with the voter, we need to update it for the new reality.

Right here, assert() that $value is an instanceof UserApi.

44 lines | src/Validator/IsValidOwnerValidator.php
// ... lines 1 - 4
use App\ApiResource\UserApi;
// ... lines 6 - 10
class IsValidOwnerValidator extends ConstraintValidator
{
// ... lines 13 - 16
public function validate($value, Constraint $constraint): void
{
// ... lines 19 - 25
assert($value instanceof UserApi);
// ... lines 27 - 41
}
}

Down here, we need to check if the value (meaning the UserApi that's on this property) is not equal to the currently authenticated user. Once again, we'll use the ids to compare this. And... also once again, I'll use assert() to help my editor. Now... it's happy about getId()... but not about my missing semicolon!

44 lines | src/Validator/IsValidOwnerValidator.php
// ... lines 1 - 4
use App\ApiResource\UserApi;
// ... lines 6 - 10
class IsValidOwnerValidator extends ConstraintValidator
{
// ... lines 13 - 16
public function validate($value, Constraint $constraint): void
{
// ... lines 19 - 25
assert($value instanceof UserApi);
// ... lines 27 - 37
if ($value->id !== $user->getId()) {
// ... lines 39 - 40
}
}
}

Moment of truth! Run that test:

symfony php bin/phpunit tests/Functional/DragonTreasureResourceTest.php --filter=testPatchToUpdateTreasure

It passes! Try everything:

symfony php bin/phpunit tests/Functional/DragonTreasureResourceTest.php

And... ah! We're down to just three failures. And they're all related to the same thing: the isPublished property. Our DragonTreasureApi doesn't even have an isPublished property yet. We saved that for last because it's a little different and interesting. Let's tackle it next.