Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Allow Admin Users to Edit any Treasure

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

We've got things set up so that only the owner of a treasure can edit it. Now, a new requirement has come down from on-high: admin users should be able to edit any treasure. That means a user that has ROLE_ADMIN.

To the test-mobile! Add a public function testAdminCanPatchToEditTreasure(). Then create an admin user with UserFactory::createOne() passing roles set to ROLE_ADMIN:

... lines 1 - 12
class DragonTreasureResourceTest extends ApiTestCase
{
... lines 15 - 138
public function testAdminCanPatchToEditTreasure(): void
{
$admin = UserFactory::createOne(['roles' => ['ROLE_ADMIN']]);
}
}

Foundry State Methods

That'll work fine. But if we need to create a lot of admin users in our tests, we can add a shortcut to Foundry. Open UserFactory. We're going to create something called a "state" method. Anywhere inside, add a public function called, how about withRoles() that has an array $roles argument and returns self, which will make this more convenient when we use it. Then return $this->addState(['roles' => $roles]):

... lines 1 - 30
final class UserFactory extends ModelFactory
{
... lines 33 - 54
public function withRoles(array $roles): self
{
return $this->addState(['roles' => $roles]);
}
... lines 59 - 92
}

Whatever we pass to addState() becomes part of the data that will be used to make this user.

To use the state method, the code changes to UserFactory::new(). Instead of creating a User object, this instantiates a new UserFactory... and then we can call withRoles() and pass ROLE_ADMIN:

So, we're "crafting" what we want the user to look like. When we're done, call create():

... lines 1 - 12
class DragonTreasureResourceTest extends ApiTestCase
{
... lines 15 - 138
public function testAdminCanPatchToEditTreasure(): void
{
$admin = UserFactory::new()->withRoles(['ROLE_ADMIN'])->create();
}
}

createOne() is a static shortcut method. But since we have an instance of the factory, use create().

But we can go even further. Back in UserFactory, add another state method called asAdmin() that returns self. Inside return $this->withRoles(['ROLE_ADMIN']):

... lines 1 - 30
final class UserFactory extends ModelFactory
{
... lines 33 - 59
public function asAdmin(): self
{
return $this->withRoles(['ROLE_ADMIN']);
}
... lines 64 - 97
}

Thanks to that, we can simplify to UserFactory::new()->asAdmin()->create():

... lines 1 - 12
class DragonTreasureResourceTest extends ApiTestCase
{
... lines 15 - 138
public function testAdminCanPatchToEditTreasure(): void
{
$admin = UserFactory::new()->asAdmin()->create();
}
}

Nice!

Writing the Test

Now let's get this test going. Create a new $treasure set to DragonTreasureFactory::createOne():

... lines 1 - 12
class DragonTreasureResourceTest extends ApiTestCase
{
... lines 15 - 138
public function testAdminCanPatchToEditTreasure(): void
{
$admin = UserFactory::new()->asAdmin()->create();
$treasure = DragonTreasureFactory::createOne();
... lines 143 - 153
}
}

Because we're not passing an owner, this will create a new User in the background and use that as the owner. This means that our admin user will not be the owner.

Now, $this->browser()->actingAs($adminUser) then ->patch() to /api/treasures/, $treasure->getId(), sending json to update value to the same 12345. ->assertStatus(200) and assertJsonMatches(), value, 12345:

... lines 1 - 12
class DragonTreasureResourceTest extends ApiTestCase
{
... lines 15 - 138
public function testAdminCanPatchToEditTreasure(): void
{
$admin = UserFactory::new()->asAdmin()->create();
$treasure = DragonTreasureFactory::createOne();
$this->browser()
->actingAs($admin)
->patch('/api/treasures/'.$treasure->getId(), [
'json' => [
'value' => 12345,
],
])
->assertStatus(200)
->assertJsonMatches('value', 12345)
;
}
}

Cool! Copy the method name. Let's try it:

symfony php bin/phpunit --filter=testAdminCanPatchToEditTreasure

And... okay! We haven't implemented this yet, so it fails.

Allowing Admins to Edit Anything

So, how do we allow admins to edit any treasure? Well, at first, it's relatively easy because we have total control via the security expression. So we can add something like if is_granted("ROLE_ADMIN") OR and then put parentheses around the other use-case:

... lines 1 - 27
#[ApiResource(
... lines 29 - 30
operations: [
... lines 32 - 40
new Patch(
security: 'is_granted("ROLE_ADMIN") or (is_granted("ROLE_TREASURE_EDIT") and object.getOwner() == user)',
... line 43
),
... lines 45 - 47
],
... lines 49 - 65
)]
... lines 67 - 87
class DragonTreasure
{
... lines 90 - 247
}

Let's make sure it works!

symfony php bin/phpunit --filter=testAdminCanPatchToEditTreasure

A 500 error! Let's see what's going on. Click to open this.

Unexpected token "name" around position 26.

So... that was an accident. Change OR to or. And... also move this new logic into securityPostDenormalize:

... lines 1 - 27
#[ApiResource(
... lines 29 - 30
operations: [
... lines 32 - 40
new Patch(
security: 'is_granted("ROLE_ADMIN") or (is_granted("ROLE_TREASURE_EDIT") and object.getOwner() == user)',
securityPostDenormalize: 'is_granted("ROLE_ADMIN") or object.getOwner() == user',
),
... lines 45 - 47
],
... lines 49 - 65
)]
... lines 67 - 87
class DragonTreasure
{
... lines 90 - 247
}

Then try the test again:

symfony php bin/phpunit --filter=testAdminCanPatchToEditTreasure

Got it! But my screw-up brings up a great point: the security expression is getting too complex. It's about as readable as a single-line PERL script... and we do not want to make mistakes when it comes to security.

So next, let's centralize this logic with a voter.

Leave a comment!

0
Login or Register to join the conversation
Cat in space

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

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^3.0", // v3.1.2
        "doctrine/annotations": "^2.0", // 2.0.1
        "doctrine/doctrine-bundle": "^2.8", // 2.8.3
        "doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
        "doctrine/orm": "^2.14", // 2.14.1
        "nelmio/cors-bundle": "^2.2", // 2.2.0
        "nesbot/carbon": "^2.64", // 2.66.0
        "phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
        "phpstan/phpdoc-parser": "^1.15", // 1.16.1
        "symfony/asset": "6.2.*", // v6.2.5
        "symfony/console": "6.2.*", // v6.2.5
        "symfony/dotenv": "6.2.*", // v6.2.5
        "symfony/expression-language": "6.2.*", // v6.2.5
        "symfony/flex": "^2", // v2.2.4
        "symfony/framework-bundle": "6.2.*", // v6.2.5
        "symfony/property-access": "6.2.*", // v6.2.5
        "symfony/property-info": "6.2.*", // v6.2.5
        "symfony/runtime": "6.2.*", // v6.2.5
        "symfony/security-bundle": "6.2.*", // v6.2.6
        "symfony/serializer": "6.2.*", // v6.2.5
        "symfony/twig-bundle": "6.2.*", // v6.2.5
        "symfony/ux-react": "^2.6", // v2.7.1
        "symfony/ux-vue": "^2.7", // v2.7.1
        "symfony/validator": "6.2.*", // v6.2.5
        "symfony/webpack-encore-bundle": "^1.16", // v1.16.1
        "symfony/yaml": "6.2.*" // v6.2.5
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
        "mtdowling/jmespath.php": "^2.6", // 2.6.1
        "phpunit/phpunit": "^9.5", // 9.6.3
        "symfony/browser-kit": "6.2.*", // v6.2.5
        "symfony/css-selector": "6.2.*", // v6.2.5
        "symfony/debug-bundle": "6.2.*", // v6.2.5
        "symfony/maker-bundle": "^1.48", // v1.48.0
        "symfony/monolog-bundle": "^3.0", // v3.8.0
        "symfony/phpunit-bridge": "^6.2", // v6.2.5
        "symfony/stopwatch": "6.2.*", // v6.2.5
        "symfony/web-profiler-bundle": "6.2.*", // v6.2.5
        "zenstruck/browser": "^1.2", // v1.2.0
        "zenstruck/foundry": "^1.26" // v1.28.0
    }
}
userVoice