Chapters
-
Course Code
Subscribe to download the code!Compatible PHP versions: ^7.1.3, <8.0
Subscribe to download the code!Compatible PHP versions: ^7.1.3, <8.0
-
This Video
Subscribe to download the video!
Subscribe to download the video!
-
Subtitles
Subscribe to download the subtitles!
Subscribe to download the subtitles!
-
Course Script
Subscribe to download the script!
Subscribe to download the script!
ApiResource access_control
Scroll down to the script below, click on any sentence (including terminal blocks) to jump to that spot in the video!
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.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeThere 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
.
Show Lines
|
// ... lines 1 - 16 |
/** | |
* @ApiResource( | |
Show Lines
|
// ... lines 19 - 24 |
* collectionOperations={ | |
* "get", | |
* "post" | |
* }, | |
Show Lines
|
// ... lines 29 - 35 |
* ) | |
Show Lines
|
// ... lines 37 - 46 |
*/ | |
class CheeseListing | |
Show Lines
|
// ... 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.
Show Lines
|
// ... lines 1 - 24 |
* collectionOperations={ | |
Show Lines
|
// ... line 26 |
* "post"={"access_control"="is_granted('ROLE_USER')"} | |
* }, | |
Show Lines
|
// ... 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.
Show Lines
|
// ... lines 1 - 18 |
* itemOperations={ | |
Show Lines
|
// ... lines 20 - 22 |
* "put"={"access_control"="is_granted('ROLE_USER')"}, | |
* "delete"={"access_control"="is_granted('ROLE_ADMIN')"} | |
* }, | |
Show Lines
|
// ... 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.
27 Comments
Hey gabrielmustiere
You can customize your API docs so it can fit your business needs. This video may give you some ideas: https://symfonycasts.com/sc...
If you don't want to overcomplicate things and you can keep your API endpoints secure enough, you can stick to only one API docs page and show all endpoints there
Cheers!
Hi,
I have a question; I have an entity secured by:
* "get" = {
* "path"="",
* "security"="is_granted('IS_AUTHENTICATED_FULLY')",
* },
This document has an extension to add an autofilter based on a user Role.
$user = $this->security->getUser();
if ($user->is(Utente::ROLE_ADMIN)) {
/**
* Se l'utente è un contractor può vedere SOLO i documenti del suo contractor:
*/
$criteria->andWhere(....);
}
If I call the URL without authentication instead of the JSON error, "ACCESS DENIED", I get:
Call to a member function is() on null
...
if ($user->is(Utente::ROLE_ADMIN)) {
That is correct cause there is no user BUT ... should API platform stop my request BEFORE the Extension be applied?
If I perform a check on
if ( !is_null($user)
the platform get me 401 Not Authorized error, as expected cause I'm not logged in.
Is this normal?
Hi Gianluca-F!
Ah, apologies for the very slow answer!
Hmm. Yes, what you're expecting makes sense to me! However, it's done this way on purpose: API Platform FIRST loads your data, and THEN applies security. My guess is that this is done so that you have access to the current resource (as object
) from inside your security rule. In the code, both the "loading data" (done by ReadListener
) and "security" (done by DenyAccessListener
) are done by listeners to the KernelRequest
event. But, very purposely, ReadListener
has a priority of 4 and DenyAccessListener
has a priority of 3.
If you want to nerd about a bit more, DeserializeListener
has priority 2 (this is what uses the serializer to deserialize the data onto your object) and then securityPostNormalize
has a priority of 1. You can see, step-by-step, how they're ordering things.
Anyways, that's the explanation - and you already have a workaround. And now you can be confident this is how it's supposed to work - no bad behavior in your app :).
Cheers!
Hi,
I have a problem with securing post operation on cheeses. I've put that code: "post"={"security"="is_granted('ROLE_USER')"}
into collectionOperations
but when I try to post a new cheeselisting item it works. I mean it responses with code 201 instead of 401. I'm confused, maybe because I'm a begginer.
I'm using Symfony 4.4 and API Platform 2.7.
I don't know what should I do to make security working.
I would be grateful if someone could help me.
Jakub
Sorry for that post. I've cleared the cache and security started to work properly (I mean I received 401 code when I've tried to post new cheeselisting). As I said I'm a begginer.
Hey Jakub,
No problem at all! And I'm super happy you figured the problem out yourself, well done! Clearing the cache is the easiest win ;)
Cheers!
Hi!
Going through this tutorial right now, but with the new versions like php 8.1, api platform 2.6 and noticed that I have the same code like you do, but there is no description field, seems like normalization groups are not working properly...
So I checkout the latest version, when we added the groups, serialized name to the text description and I didn't see the description field in the post request, I'm pretty sure that I did...
Hey @Mykyta!
This stuff can be tricky - especially with slight changes in the new versions of API Platform. API Platform 2.6 "should" still work like the tutorial, but sometimes there are subtle changes.
Anyways, I wasn't quite sure: did you get this working in the end? Or is the description field still missing?
Cheers!
Hey @weaverryan!
Thanks for replying. Yes, It was fixed by updating the symfony/serializer package from v6.1.3 to v6.1.4 :)
Cheers!
Wow - great job figuring that out - super happy it's working!
Hi !
I am facing an issue from yesterday, and I really start to desperate...
I want to use a voter on a post operation, so I wrote this :
* @ApiResource(
* attributes={
* "security" = "is_granted('ROLE_ADMIN')",
* },
* collectionOperations={
* "get" = { "security" = "is_granted('ROLE_ADMIN')" },
* "post" = {
* "security_post_denormalize" = "is_granted('USER_CREATE', object)",
* "validation_groups" = { "Default", "create" },
* },
* },
I did understand that I have to use "security_post_denormalize" instead of "security' because the object has to be first denormalize in order to exist, and then to be used in my Voter.
But it just doesn't work...
If I use "security" instead of "security_post_denormalize", it works fine, but then I can't use the object in the Voter anymore.
I even tried to use YAML instead of annotations, thinking that maybe Annotations is not currectly taking into account "security_post_denormalize". But that changed nothing.
Is there any kind of known bug about "security_post_denormalize", or am I doing some stupid mistake somewhere?
Hey Simon L.!
Hmm, let's see what we can figure out :). From checking the core code, I would expect this to work. Here is the flow internally:
A) The DeserializeListener is called, which deserializes your object and puts it on "data" key of $request->attributes - https://github.com/api-platform/core/blob/29cd84b6deed2f0473c631eb003bc870616ef5f8/src/EventListener/DeserializeListener.php#L104-L107
B) The DenyAccessListener::onSecurityPostDenormalize is called - https://github.com/api-platform/core/blob/29cd84b6deed2f0473c631eb003bc870616ef5f8/src/Security/EventListener/DenyAccessListener.php#L67-L73
If you follow the logic, it uses the data
key as the object variable in the expression: https://github.com/api-platform/core/blob/29cd84b6deed2f0473c631eb003bc870616ef5f8/src/Security/EventListener/DenyAccessListener.php#L100
So... I can't see what you're doing wrong. The first question is: is your custom voter being called at all. It seems like you think the answer to this is "no", but let's verify :). The best way to do that is to make an API request, then go to /_profiler in your browser, and find the profiler for the request you just made - e.g. like we do here - https://symfonycasts.com/screencast/api-platform/profiler On the security section on the left, if you scroll down, there is an "Access Decision Log". You SHOULD see that a USER_CREATE "decision" was considered by the voters. If you don't see it, then we'll know that (for some reason) ApiPlatform is not executing your expression. If you DO see it, then we'll know that there is a problem with the voter itself.
I hope this helps you to debug - let me know what you find out!
Cheers!
Hello weaverryan :)
So I did what you said, and the voter is executed.
I dug further, and I found out that it seems there is a problem with the $attribute parameter in the voter.
dd($attribute) with security : "USER_CREATE" (normal)
dd($attribute) with security_post_denormalize : "ROLE_ADMIN" (not normal !!!)
It's like the $aatribute is not reading USER_CREATE in "security_post_denormalize" = "is_granted('USER_CREATE', object)" anymore...
Hey Simon L.!
Good digging! I might have an explanation :).
You have security" = "is_granted('ROLE_ADMIN')"
on the top-level of your annotations, and then you have security_post_denormalize
under your operation-specific configuration.
That config is merged together for your post operation. The result is that you effectively have this for your post operation:
security" = "is_granted('ROLE_ADMIN')",
"security_post_denormalize" = "is_granted('USER_CREATE', object)",
The result is that the security system will be called twice: the first security
attribute will be run first (right before deserialization) and then (assuming you haven't already been denied access), the second security_post_denormalize
will be executed right after deserialization.
So my guess is that you're getting denied access by that ROLE_ADMIN and that you do NOT want that to be executed at all. If so, just override this inside your post operation. Set it to: security" = "is_granted('IS_AUTHENTICATED_ANONYMOUSLY')"
, that should effectively remove that check :).
Let me know if that helps!
Cheers!
Hello weaverryan
You are right, it seems that the secutiry check is first run for ROLE_ADMIN, and then somehow, the attribute is still ROLE_ADMIN, instead of USER_CREATE.
So I simply removed the first security check on top (and I left the security checks for each operation POST, item GET, PUT, PATCH and DELETE). I guess it is secure enough like this.
Maybe "security_post_denormalize" should be improved in the future?
Anyway, I thank you for your sincere help :)
Hey Simon L.!
You are right, it seems that the secutiry check is first run for ROLE_ADMIN, and then somehow, the attribute is still ROLE_ADMIN, instead of USER_CREATE.
Yep! It's really that there are two security checks for each operation: security
and then also security_post_denormalize
. If both are set, then you have two security checks. And the top-level attributes serve as default values for each operation.
So I simply removed the first security check on top (and I left the security checks for each operation POST, item GET, PUT, PATCH and DELETE). I guess it is secure enough like this.
Hopefully ;). If you also need to check ROLE_ADMIN for any of the other operations, you could re-add it under those operations.
Maybe "security_post_denormalize" should be improved in the future?
I'm not sure I see the problem with it. But if you do see a problem, definitely I would like to know :). I was involved in the feedback that caused security and security_post_denormalize to be created.
Cheers!
I see 3 main problems with this:
If someone doesn't know this, he will just think that "security_post_denormalize" doesn't work. And trust me, I've looked everywhere on the Internet during many hours, just to end up with nothing, and then I asked the question here. If people don't give up or don't know you, they may start building a tricky solution by themselves (as I started to do myself with a validator).
Even if you know this particular behaviour, it's annoying that we must set up every security check if we only want a global security and then a special case for POST.
Even we do set a security check for all operations, we feel as there is something wrong. The $attribute shoudn't silently change. Moreover settings on operation level should prevail on global ones.
That being said, I am not expecting you to change that or anyone else, I would love to help Symfony and API Platform by doing it myself if needed, but I still have lot to learn before contributing to the community :)
I think at least that this behaviour should be mentioned here: https://api-platform.com/docs/core/security/#hooking-custom-permission-checks-using-voters
Notice that in the example, security is used on top level and security_post_denormalize on the POST operation level:
* @ApiResource(
* attributes={"security"="is_granted('ROLE_USER')"},
* collectionOperations={
* "get",
* "post" = { "security_post_denormalize" = "is_granted('BOOK_CREATE', object)" }
* },
* itemOperations={
* "get" = { "security" = "is_granted('BOOK_READ', object)" },
* "put" = { "security" = "is_granted('BOOK_EDIT', object)" },
* "delete" = { "security" = "is_granted('BOOK_DELETE', object)" }
* },
* )
Thanks weaverryan !
Have a great day !
Hey Simon L.!
Thanks for sharing this :). At the very least, you are 100% correct that the example should mention this - it's a perfect example to explain what's going on. If you'd like to contribute, that's a pretty good spot - a tiny explanation after both code blocks would help - something explaining the "flow" that security will still be called first, and then (if access wasn't already denied) your security_post_denormalize will call the voter. That document is on GitHub here - https://github.com/api-platform/docs/blob/2.5/core/security.md - if you do find time to make a pull request, definitely let me know and I can review and +1 it.
Also, I'm not sure if it works (you could try it... I think it doesn't), but it would be nice if we could say "security"=false
in the annotations to disable security. This would allow you to keep "security"="is_granted('ROLE_USER')"
on the top-level, but disable it on an operation. I mentioned a way to do that earlier with IS_AUTHENTICATED_ANONYMOUSLY... but =false would be much nicer. If that doesn't currently work, that might also be a nice, small feature to help things.
Cheers!
Sure I did it: https://github.com/api-plat...
About your suggestion, I am now trying to solve an issue with deserialization, and after I'll try it :)
Woohoo! And merged! You rock!
Am I right saying that one <b>must</b> leave security.firewalls.main.anonymous: <b>true</b>
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: <b>false</b>
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?
Hey erop!
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!
Thanks, Ryan! I like your "philosophical reason" a lot! :) Moreover it seems quite reasonable to set operation-level 'access_control' while developing @ApiResource annotation.
This annotation looks awfull 😥
Hey Stefan T.!
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!
"Houston: no signs of life"
Start the conversation!
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.21.6
"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
}
}
Hi Ryan, I have some questions regarding this point, what is the best practice regarding exposing your API documentation? public entry point versus private entry point.
The entrypoints for my frontend and the entrypoints for the outside that will be potentially consumed by unknown clients who do not have to access all my entry points.
I don't know if it's clear.
Ty.