Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Deny Access with The "security" Option

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 just talked a lot about authentication: that's the way we tell the API who we are. Now we turn to authorization, which is all about denying access to certain operations and other things based on who you are.

Using access_control

There are multiple ways to control access to something. The simplest is in config/packages/security.yaml. Just like normal Symfony security, down here, we have an access_control section:

security:
... lines 2 - 37
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
# - { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
... lines 43 - 56

If you want to lock down a specific URL pattern by a specific role, use access_control. You could use this, for example, to require that the user has a role to use anything in your API by targeting URLs starting with /api.

Hello "security" Option

In a traditional web app, I do use access_control for several things. But most of the time I put my authorization rules inside controllers. But... of course, with API Platform, we don't have controllers. All we have are API resource classes, like DragonTreasure. So instead of putting security rules in controllers, we'll attach them to our operations.

For example, let's make the POST request to create a new DragonTreasure require the user to be authenticated. Do that by adding a very handy security option. Set that to a string and inside, say is_granted(), double quotes then ROLE_TREASURE_CREATE:

... lines 1 - 26
#[ApiResource(
... lines 28 - 29
operations: [
... lines 31 - 36
new Post(
security: 'is_granted("ROLE_TREASURE_CREATE")',
),
... lines 40 - 41
],
... lines 43 - 56
)]
... lines 58 - 75
class DragonTreasure
{
... lines 78 - 235
}

We could simply use ROLE_USER here if we just wanted to make sure that the user is logged in. But we have a cool system where, if you use an API token for authentication, that token will have specific scopes. One possible scope is called SCOPE_TREASURE_CREATE... which maps to ROLE_TREASURE_CREATE. So we look for that. Also, in security.yaml, via role_hierarchy, if you log in via the login form, you get ROLE_FULL_USER... and then you automatically also get ROLE_TREASURE_CREATE.

In other words, by using ROLE_TREASURE_CREATE, access will be granted either because you logged in via the login form or you authenticated using an API token that has that scope.

Let's try it. Make sure you're logged out. I'll refresh. Yup, you can see on the web debug toolbar that I'm not logged in... and Swagger does not currently have an API token.

Let's test the POST endpoint. Try it out.. and... just Execute with the example data. And... yes! A 401 status code with type hydra:error!

More about the "security" Attribute

The security option actually holds an expression using Symfony's expression language. And you can get pretty fancy with it. Though, we're going to try to keep things simple. And later, we'll learn how to offload complex rules to voters.

Let's add a few more rules. Put and Patch are both edits. These are especially interesting because, to use these, not only do we need to be logged in, we probably need to be the owner of this DragonTreasure. We don't want other people to edit our goodies.

We're going to worry about the ownership part later. But for now, let's at least add security with is_granted() then ROLE_TREASURE_EDIT:

... lines 1 - 27
#[ApiResource(
... lines 29 - 30
operations: [
... lines 32 - 40
new Put(
security: 'is_granted("ROLE_TREASURE_EDIT")',
),
... lines 44 - 49
],
... lines 51 - 64
)]
... lines 66 - 83
class DragonTreasure
{
... lines 86 - 243
}

Once again, I'm using the scope role. Copy that, and duplicate it down here for Patch:

... lines 1 - 27
#[ApiResource(
... lines 29 - 30
operations: [
... lines 32 - 43
new Patch(
security: 'is_granted("ROLE_TREASURE_EDIT")',
),
... lines 47 - 49
],
... lines 51 - 64
)]
... lines 66 - 83
class DragonTreasure
{
... lines 86 - 243
}

Oh, and earlier, we removed the Delete operation. Let's add that back with security set to look for ROLE_ADMIN:

... lines 1 - 27
#[ApiResource(
... lines 29 - 30
operations: [
... lines 32 - 46
new Delete(
security: 'is_granted("ROLE_ADMIN")',
),
],
... lines 51 - 64
)]
... lines 66 - 83
class DragonTreasure
{
... lines 86 - 243
}

If we decided later to add a scope that allowed API tokens to delete treasures, we could add that and change this to ROLE_TRESURE_DELETE.

Let's make sure this works! Use the GET collection endpoint. Try that out. This operation does not require authentication... so it works just fine. And we have a treasure with ID 1. Close this up, open the PUT operation, hit "Try it out", 1, "Execute" and... alright! We get a 401 here too!

Adding "security" to an Entire Clas

So adding the security option to the individual operations is probably the most common thing to do. But you can also add it to the ApiResource itself to apply to the entire class. For example, on User, we probably want every operation to require authentication... except for the Post to create, because that's how you would register a new user.

So up here, add security and look for ROLE_USER... just to check that we're logged in:

... lines 1 - 20
#[ApiResource(
... lines 22 - 23
security: 'is_granted("ROLE_USER")',
)]
... lines 26 - 40
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 43 - 250
}

And because this class has a sub resource... and this also allows us to fetch a user, be sure to add security here too:

... lines 1 - 25
#[ApiResource(
... lines 27 - 35
security: 'is_granted("ROLE_USER")',
)]
... lines 38 - 40
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 43 - 250
}

Keep close track of security if you're using subresources.

Ok, so now every operation on User requires you to be logged in. But... we don't want that for the Post operation. To add flexibility, go up to the first ApiResource, add the operations option, and, real quick, list all the normal operations, new Get(), new GetCollection(), new Post(), new Put(), new Patch(), and new Delete():

... lines 1 - 25
#[ApiResource(
// Now add `operations` set to the 6 normal operations
operations: [
new Get(),
new GetCollection(),
new Post(
... line 32
),
new Put(
... line 35
),
new Patch(
... line 38
),
new Delete(),
],
... lines 42 - 44
)]
... lines 46 - 60
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 63 - 270
}

Now that we have those, we can customize them. For Post, since we want this to not require authentication, say security: 'is_granted() passing a special fake role called PUBLIC_ACCESS:

... lines 1 - 25
#[ApiResource(
// Now add `operations` set to the 6 normal operations
operations: [
... lines 29 - 30
new Post(
security: 'is_granted("PUBLIC_ACCESS")',
),
... lines 34 - 40
],
... lines 42 - 44
)]
... lines 46 - 60
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 63 - 270
}

This will override the security rule that we're passing on the resource level. Oh, and while we're here, for Put, set security to look for ROLE_USER_EDIT since we have a scope role for editing users. Repeat that down here for Patch:

... lines 1 - 25
#[ApiResource(
// Now add `operations` set to the 6 normal operations
operations: [
... lines 29 - 33
new Put(
security: 'is_granted("ROLE_USER_EDIT")'
),
new Patch(
security: 'is_granted("ROLE_USER_EDIT")'
),
... line 40
],
... lines 42 - 44
)]
... lines 46 - 60
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 63 - 270
}

I love it! Refresh the whole page. We're most interested in the POST users endpoint. We are not authenticated, so hit "Try it out" and I'll leave the default data. "Execute" and... we nailed it! A 201 status. That did allow anonymous access.

Checking the Security Decisions

Oh, and super fun: if you ever want to see the security decisions that were made during a request, open the profiler for that request, go down to the "Security" section then "Access Decision". For this request, only one decision made by the security system: it was for PUBLIC_ACCESS, and that was allowed.

Next: our API is getting complex... and it's only going to get more complex. It's time to stop testing our endpoints manually via Swagger and start testing them with automated tests.

Leave a comment!

5
Login or Register to join the conversation

Hey SymfonyCasts friends,

There is a problem with this chapter's video. For some reason I get:

The media could not be loaded, either because the server or network failed or because the format is not supported.

But I was able to download the video from the download menu.

Cheers

Reply

A little more info, it might happen when I open back symfonycasts to catch up where I left off. I'm pretty sure I was able to play video 20 yesterday, but I opened back your site and now I have the same problem with that chapter. Chapter 21 plays just fine. And I still have a problem with this chapter (13).

Reply

Hey @julien_bonnier!

Hmm, sorry about that trouble :/. We had a few reports over the weekend like this - I'm wondering if Vimeo (our video host) had (or is having) some issues.

A little more info, it might happen when I open back symfonycasts to catch up where I left off

Do you mean that you, for example, have the browser open, close your computer for the day, come back tomorrow, and hit play again? It is possible there is some video problem if you're "resuming" in some way - so I'd love to know more.

Cheers!

Reply

Yep that's what I mean but it might also be a coincidence. ¯\_(⁠ツ⁠)_/⁠¯

Reply

Haha, right? Like many computer mysteries... coincidence? Or significant? lol

Reply
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