ApiResource access_control

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

There are two big parts to security in any app. First, how does your user authenticate? How do they log in? Honestly, that is the trickiest part... and it has really nothing to do with API Platform. We're authenticating via the json_login authenticator and a session cookie. That's a great solution for many applications. But in the bonus part 2 of the security tutorial, we'll talk about other types of applications and solutions.

Regardless of how your users authenticate, step two of security - authorization - will look the same. Authorization is all about denying access to read or perform different operations... and this is enforced in a way that's independent of how you log in. So even if the way clients of your API authenticate is much different than what we're doing, all this authorization stuff will still be relevant.

Denying access with access_control in security.yaml

When a user logs in - no matter how they authenticate or where your user data is stored - your login mechanism assigns that user a set of roles. In our app, those roles are stored in the database and we'll eventually let admin users modify them via our API. The simplest way to prevent access to an endpoint is by making sure the user has some role. And the easiest way to do that is via access_control in security.yaml.

We could, for example, say that every URL that matches the ^/api/cheeses regular expression - so anything that starts with /api/cheeses - requires ROLE_ADMIN. This is normal, boring Symfony security... and I love it!

Using access_control on your ApiResource

access_control is great for some situations, but most of the time you'll need more flexibility. In a traditional Symfony app, I typically add security to my controllers. But... in API Platform... um... we don't have any controllers! Ok, so instead of thinking about protecting each controller, we'll think about protecting each API operation. Maybe we want this collection GET operation to be accessible anonymously but we want to require a user to be authenticated in order to POST and create a new CheeseListing.

Open up that entity: src/Entity/CheeseListing.php. We already have an itemOperations key, which we used to remove the delete operation and also to customize the normalization groups of get. We can do something similar with a collectionOperations option. Start by setting this to get and post.

... lines 1 - 16
/**
* @ApiResource(
... lines 19 - 24
* collectionOperations={
* "get",
* "post"
* },
... lines 29 - 35
* )
... lines 37 - 46
*/
class CheeseListing
... lines 49 - 207

If we stopped here, this would change nothing. Oh... except that I have a syntax error! Silly comma! Anyways, API Platform adds two collection operations by default - get and post - so we're simply repeating what it was already doing. But now we can customize these operations.

For the post operation - that's how we create new cheese listings - we really need the user to be authenticated to do this. Set post to {} with a new access_control option inside. We're going to set this to a mini-expression: is_granted() passing that, inside single quotes ROLE_USER - that's the role that our app gives to every user.

... lines 1 - 24
* collectionOperations={
... line 26
* "post"={"access_control"="is_granted('ROLE_USER')"}
* },
... lines 29 - 207

Tip

access_control option was replaced by security in api-platform/core v2.5.0. If you're on a newer version - use it instead:

/**
 * ...
 * @ApiResource(
 *     collectionOperations={
 *          "get",
 *          "post"={"security"="is_granted('ROLE_USER')"}
 *     },
 *     ...
 * )
 * ...
 */
class CheeseListing
{
    // ...
}

Let's try that! The web debug toolbar tells me that I'm not logged in right now. Let's make a POST request... set the owner to /api/users/6 - that's my user id... though the value doesn't matter yet... nor do any of the others fields. Hit Execute and... perfect! A 401 error:

Full authentication is required to access this resource.

If we logged in, this would work.

Let's tighten up our itemOperations too. The PUT operations - editing a CheeseListing - is an interesting case... we definitely need the user to be logged in... but we probably also only want the "owner" of a CheeseListing to be able to edit it. Let's just handle the first part now. I'll copy my whole access_control config and paste. While we're here, let's also re-add the delete operation... but maybe only admin users can do this. Check for some ROLE_ADMIN role.

... lines 1 - 18
* itemOperations={
... lines 20 - 22
* "put"={"access_control"="is_granted('ROLE_USER')"},
* "delete"={"access_control"="is_granted('ROLE_ADMIN')"}
* },
... lines 26 - 208

Go refresh the docs... yes! The DELETE operation is back! Notice that the docs are... basically "static" as far as security is concerned: it documents the whole API, including operations that you might not have access to: it doesn't magically read your roles and hide or show different operations. That's done on purpose, but I wanted to point it out.

Try the PUT endpoint... I think I have a CheeseListing with id 1 and... just send the title field. Another 401!

Next, our security setup is about to get smarter and more complex. The goal is to make sure that only the "owner" of a CheeseListing can update that CheeseListing... and maybe also admin users. To really know that things are working, I think it's time to bootstrap a basic system to functionally test our API.

Leave a comment!

  • 2019-10-03 Egor Ushakov

    Thanks, Ryan! I like your "philosophical reason" a lot! :) Moreover it seems quite reasonable to set operation-level 'access_control' while developing @ApiResource annotation.

  • 2019-10-03 weaverryan

    Hey Egor Ushakov!

    Ah, excellent question! So basically, both options are "valid": (A) setting anonymous: true and then protecting access with access_control (either in the ApiResource annotation or also inside security.yaml itself) OR (B) setting anonymous: false. But let me explain a bit further :).

    The preferred approach is always to use anonymous: true, even if you can get both "options" to work. Why? It's more of a philosophical reason: the purpose of the firewall is authentication (i.e. identifying the user) *not* authorization (blocking access). So we typically prefer to only "identity" the user in the firewall (even if we identity them as anonymous) and then block access elsewhere. But, btw, if you DO want to protect *large* areas of your API easily, using access_control in security.yaml is an easy way to do that. You could even do this:


    access_control:
    - { path: ^/api$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
    - { path: ^/api, roles: ROLE_USER }

    That would require login for any URLs starting with /api, but WOULD allow access to exactly /api. You might need to tweak that a bit to be perfect (e.g. there are some "docs" URLs that you might also want public), but I think you get the idea ;).

    But, there is one more part to your question. Even if anonymous: true is the preferred approach, why didn't your attempt to set anonymous: false and then use access_control to *allow* anonymous access to the Swagger UI work? The reason is that, on each request, Symfony matches & activates exactly ONE firewall. If you have anonymous: false on it, the user is immediately denied access. Only *after* the pass through the firewall are access_control checked. So, *if* you wanted to do the anonymous: false, you need to use *two* firewalls:


    firewalls:
    swagger_ui:
    # again, you might need to play with this to get the exact right URLs
    pattern: ^/api$
    anonymous: true
    main:
    # no pattern here, so it will match all requests *except* those that match the first firewall.
    anonymous: false

    That's not my recommended approach, but hopefully it helps you understand! With this, requests to the Swagger UI will match the first firewall (and only the first, because only one firewall is ever active per request, and it matches from top to bottom like routing) and anonymous *will* be allowed.

    Cheers!

  • 2019-10-03 Egor Ushakov

    Am I right saying that one must leave security.firewalls.main.anonymous: true in config/packages/security.yaml and protect API endpoints with operation-level 'access_control' to expose Swagger UI and make it available for browsing, etc. ? Wouldn't it be more reasonable to protect whole path (eg. '^/api') with global security.firewalls.main.anonymous: false and have a setting for explicitly exposing Swagger UI ? BTW, I tried to expose Swagger UI with 'access_control' in security.yaml with 'roles': IS_AUTHENTICATED_ANONYMOUSLY, methods: [GET] but it didn't work. Are there any best practices to expose Swagger UI on the same path as API endpoints are?

  • 2019-08-19 weaverryan

    Hey ElGovanni!

    Yea... I kind of agree. I recently opened an issue on API Platform where I recommended adding something that would look more like access_control="ROLE_USER", but I'm not sure if it will go anywhere. The problem is that using the "expression language" (the way it is now) is useful in some cases. But when you don't need it, it's ugly.

    We'll see what happens.

    Cheers!

  • 2019-08-19 ElGovanni

    This annotation looks awfull 😥