Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Context Builder: Dynamic Fields/Groups

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

Here's the goal: add logic to our new context builder so that, if the currently- authenticated user has ROLE_ADMIN, an extra admin:output group is always added during normalization.

Delete the existing code... though it's pretty close to what we're going to do. Then say $isAdmin =. When we copied this class, in addition to the "decorated" context builder, it came with a second argument: AuthorizationCheckerInterface. This is a service that allows us to check whether or not a user has a role.

But wait... when we needed to do that in our voter, we autowired a different service via the Security type-hint. Well... these are both ways to do the exact same thing: use whichever you like. Yep, we can say $isAdmin = $this->authorizationChecker->isGranted('ROLE_ADMIN'). Then, if $context['groups'] and $isAdmin... we should add the extra group!

... lines 1 - 8
final class AdminGroupsContextBuilder implements SerializerContextBuilderInterface
{
... lines 11 - 19
public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array
{
... lines 22 - 23
$isAdmin = $this->authorizationChecker->isGranted('ROLE_ADMIN');
if (isset($context['groups']) && $isAdmin) {
... line 27
}
... lines 29 - 30
}
}

But... why am I checking if $context['groups']? Well, first, I should probably be checking if isset($context['groups']). And second... it doesn't really matter for us. In theory, if you had a resource with no groups configured, it would mean the serializer should serialize all fields. In that situation, we wouldn't want to add the admin:output group because it would actually cause less fields to be serialized. But because I like to always specify normalization and denormalization groups, this isn't a real situation: $context['groups'] will always have something in it at this point.

Add the new group with $context['groups'][] = 'admin:read'. Right? Well, it's not that simple. This createFromRequest() method is called both when the object is being serialized to JSON - so when it's being "normalized" - and when the JSON is being deserialized to the object - when it's being "denormalized". That's what this normalization flag here is telling us.

Cool! We can say, if the object is being normalized, add admin:read, else, add admin:write.

... lines 1 - 19
public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array
{
... lines 22 - 25
if (isset($context['groups']) && $isAdmin) {
$context['groups'][] = $normalization ? 'admin:read' : 'admin:write';
}
... lines 29 - 30
}
... lines 32 - 33

We're done! I'll even remove this $resourceClass thing. That tells us the class of the object that's being serialized or deserialized... which we don't need because we're adding these groups to every resource.

Side Note: You can Decorate Multiple Times

Side note: we've only created one context builder so far, but it's legal to create as many as you want. In the last chapter, I talked about how our new service "decorates" and "replaces" the core context builder. But you could repeat this 3 times - each time "decorating" that same core service. You can do this because Symfony is smart: it will create 3 layers of decoration. This $decorated property might be the "core" service... or it could just be the next decorated service... which itself would call the next, until the core one is eventually called.

If that didn't make sense, don't sweat it. My point is: if you want to control the context in multiple ways, you could smash all that logic into one context builder or create several. The service config for each will look identical to the one we created.

Let's head over and run our tests:

php bin/phpunit --filter=testGetUser

This time... it passes! That test proves that a normal user will not get the phoneNumber field.... but as soon as we give that user ROLE_ADMIN and make another request, phoneNumber is returned! Mission accomplished!

Making "roles" Writeable by only an Admin

And now we can fix the huge security problem I created a few minutes ago: instead of allowing anyone to set the $roles property on User, only admin users should be able to do this.

Before we make that change, let's tweak our test to check for this. In testUpdateUser, let's also try to pass a roles field set to ROLE_ADMIN. Because we're not logged in as an admin user, once we've finished our work, the roles field should simply be ignored. It won't cause a validation error... it just won't be processed at all.

... lines 1 - 8
class UserResourceTest extends CustomApiTestCase
{
... lines 11 - 28
public function testUpdateUser()
{
... lines 31 - 33
$client->request('PUT', '/api/users/'.$user->getId(), [
'json' => [
... line 36
'roles' => ['ROLE_ADMIN'] // will be ignored
]
]);
... lines 40 - 48
}
... lines 50 - 78
}

To make sure it's ignored, at the bottom, say $em = $this->getEntityManager() and then query for a fresh user from the database: $user = $em->getRepository(User:class)->find($user->getId()). I'll put some PHPDoc above this to tell PhpStorm that this will be a User object. Finish with $this->assertEquals() that we expect ['ROLE_USER'] to be returned from $user->getRoles().

... lines 1 - 28
public function testUpdateUser()
{
... lines 31 - 44
$em = $this->getEntityManager();
/** @var User $user */
$user = $em->getRepository(User::class)->find($user->getId());
$this->assertEquals(['ROLE_USER'], $user->getRoles());
}
... lines 50 - 79

Why ROLE_USER? Because even if the roles property is empty in the database... the getRoles() method always returns at least ROLE_USER.

Let's make sure this fails. Copy testUpdateUser and run that test:

php bin/phpunit --filter=testUpdateUser

It... yes - fails! Our API does let us write to the roles property. How do we fix this? You probably already know... and it's gorgeously simple. Change the group to admin:write.

... lines 1 - 39
class User implements UserInterface
{
... lines 42 - 56
/**
... line 58
* @Groups({"admin:write"})
*/
private $roles = [];
... lines 62 - 239
}

Run the test again:

php bin/phpunit --filter=testUpdateUser

This time... it passes! Context builders are awesome.

Context Builders & Documentation

Though... they do have one downside: the dynamic groups are not reflected anywhere in the documentation. Refresh the docs... then open the GET operation for a single User. The docs say that this will return a User model with email, username and cheeseListings fields. It does not say that phoneNumber will be returned. And even if we logged in as an admin user, this won't change - there would still be no mention of phoneNumber. The docs are static. If an admin user makes this request, that JSON will contain a phoneNumber field, but it won't say that in the docs.

Next, we're going to do a crazy experiment. Because we're following tight naming conventions for our groups - like cheese_listing:output, cheese_listing:input and even operation-specific groups like cheese_listing:item:get, could we use a context builder to automatically set these groups... so we don't need to manually manage them via annotations? The answer is... yes. But things get really interesting if you want your docs to reflect this.

Leave a comment!

22
Login or Register to join the conversation
Jerôme S. Avatar
Jerôme S. Avatar Jerôme S. | posted 2 years ago

Great job!!!

1 Reply

Thank you for your feedback!

Cheers!

Reply
Emanuele P. Avatar
Emanuele P. Avatar Emanuele P. | posted 3 months ago

hallo i would like to know wich is the best way to clean input field using api platform, according to this https://github.com/api-plat... has been told to take care of cleaning the values before sending to the client but for me doesn't look like it is the right approach,
what do you think it is the correct approach

Reply
Default user avatar

Hi!

Thanks for the tutorial, first time I've created a ContextBuilder, nice discovery. :)
I was wondering though... If I'm not mistaking, in the tutorial the PUT method for User has access_control/security property set to is_granted('ROLE_USER') and object == user, so then when we dynamically add "admin:write" to the denormalization context, and thus allowing the admin user to update their roles... it allows only this user to update their own roles, right? What if I would like to be able in my admin board to update the roles of any user, but without being able to change their e-mail or password? (Because if I change my PUT security attribute to is_granted("ROLE_ADMIN") or object == user, then the admin user can update any field of any user, and that I don't want...) Is this the right situation to use the PATCH method (which I've never used yet)?

Thanks!

Reply

Hi Chloé!

Excellent, crazy question - I like it! So, this is something that we'll talk about in the next tutorial - https://symfonycasts.com/sc... - but it will be a few more days or even a week+ before it's out. So let me give you the preview:

A) validation & security are a blurry line. Is what you want to do security... or validation? It's sort of both :p

B) What I do is use security to deny access entirely to operations: like, you do or don't have access to the PUT method for this user. I use validation for everything else: where a user can/cannot *change* a certain field to a certain value.

C) So, in the next tutorial, we will create a custom validator, attach it to the ApiResource (attach it above the class if you need access to the entire object, or attach it just to a specific field if you only need to read that one field), inject the Security service into that validator, then go crazy by doing whatever checks that you want. One key ingredient to this is that you will need to know which fields were *changed* on the request. What I means is, you can't simply look at $user->getRoles() in the validator and say "it looks like the roles changed!" because... maybe they did... or maybe the request didn't touch the roles at all. What I will do in the next tutorial is get the "original data" of my entity (User in your case) from Doctrine and compare it with the new data. For example:


// in the validator, assume there is an entityManager property

// will be an array of the data from the query, before the data was changed
// will be empty for new objects
$originalData = $this->entityManager->getUnitOfWork()->getOriginalEntityData($user);
$originalRoles = $originalData['roles'] ?? [];

if ($originalRoles == $user->getRoles()) {
// the roles did not change
return;
}

// the roles DID change! Do some checks to see if that was allowed

Also:

> so then when we dynamically add "admin:write" to the denormalization context, and thus allowing the admin user to update their roles... it allows only this user to update their own roles, right

Correct. admin:write means only an admin can edit that field... and because the only user that has access to a PUT request at all is "myself", it means that only an admin could modify the roles field... and they could only modify their *own* field :p.

I hope that helps!

Reply
Default user avatar

Hi @weaverryan!
Thanks a lot for your fast and complete answer, as always it's very interesting and really helps. :D with that in mind I'll think a bit more about what I want to achieve/how I want to achieve it, and wait patiently until the release of the next tutorial.
Cheers!

Reply

Hey Chloé

Just to let you know that the ApiPlatform episode 3 course it's been actively released now https://symfonycasts.com/sc...

Cheers!

Reply
Default user avatar
Default user avatar Chloé | MolloKhan | posted 2 years ago | edited

Hey MolloKhan

Thanks, actually I think the chapter that would interest me most regarding my question is not out yet (I'm guessing it would be the 4th or 6th)... but of course I'll take a look at the first three that are out. :)

1 Reply
Default user avatar
Default user avatar couteau | posted 2 years ago

This will successfully prevent an unauthorized user from writing to a field in the "admin:write" group, but attempting to do so will still return success in the http response, and it will still write any other fields that are sent that the user is allowed to write to. Shouldn't it throw a 403, and abort the whole PUT/PATCH/POST? Is there a way to do that?

Reply

Hey @couteau!

Excellent question! So... it depends on what you want :). In theory, by throwing a 403, you are:

A) GOOD: Giving the user more information about why they can't set this field (that's a good thing! Maybe they forgot to authenticate)

B) BAD: You are potentially exposing that this field exists. That's not the biggest deal ever, but if this IS truly an admin-only field, you're exposing the fact that these fields DO exist - e.g. if I send a non-existent field called "bar", it's simply ignored. But if I send a field that DOES exist, but is protected, then I would suddenly get a 403.

So... it depends on what you want :). How *would* you throw a 403 instead? Hmm. A few options come to mind:

1) You could create a custom denormalizer that looks at the input array, sees if the field is present, checks if the admin:write group is present, and if the field IS present, but the group is not, throw an AccessDeniedException (the one from the security system). This is pretty "hands on", you are effectively duplicating your work in the context builder. Basically, you now have a context builder to add the admin:write group... but then you *again* manually check for that field in your custom denormalizer. You could probably also do something similar via an event listener.

2) By default, in general, Symfony's serializer ignores extra fields. However, you can change this behavior and tell it to throw an exception instead - https://symfony.com/doc/cur... - you do this by adding setting an AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES key to false on the $context, which you could do in your context builder. This will cause a ExtraAttributesException to be thrown, which I believe will map to a 500 error. However, you could map it to a 403 via the api_platform config: https://api-platform.com/do...

So yes, this is possible - but it's not the "normal case" I think, so implementing it will take a bit of work :).

Cheers!

Reply
Philippe B. Avatar
Philippe B. Avatar Philippe B. | posted 2 years ago

Hey!

I like this way of adding groups depending on the context!

However, I stumbled upon a problem with admin:* (and in fact, every global groups).
Because this method uses the same group name for every resource, it can easily create conflicts with relations.

For instance, I have an User whose passwordVersion is admin-only.
In my Book, I have a relation to the User, which I'd like to represent as just an IRI.
It's fine, api-platform does that by default... EXCEPT!
Since the admin:read group is present, my beautiful IRI has become a nested object with just passwordVersion (and jsonld stuff).

Note that I may have made a mistake. If so, stop me right here.

The way I fixed it was duplicating already present groups with the admin prefix:


if (isset($context['groups']) && $isAdmin) {
// Adding the admin version of every already registered groups
foreach ($context['groups'] as $group) {
$context['groups'][] = 'admin:' . $group; // i.e. 'admin:user:read'
}
}


This way, nested resources will contain admin-specific data only if they were explicitly asked for, not just by "chance".
Plus, if I want the User embedded in the Book, I can add user:read to the books' normalization groups. admin:user:read will follow.

So far it's working well but I'd like to get some feedback from you guys, in case I'm missing something

Thanks in advance!

Reply

Hey Philippe B.!

Thanks for sharing! This makes sense to me - I actually like your longer admin groups, instead of the generic admin:read. When I was writing the tutorial, I gave a lot of thought to these group names - and also talked to Kevin Dunglas about the general idea - because I also like these auto-groups, butI was indeed afraid of conflicts like this. This is one that I honestly didn't think of.

So, you have no complains from me! I like it!

Cheers!

Reply
Sardar K. Avatar
Sardar K. Avatar Sardar K. | posted 2 years ago

Thank you for your great job.

suppose that i have ManyToOne relation inside User entity.
for example one association will have many users and each association is having their own admin
so current logged admin can see only users from the same association as admin

i hope you understand my question
but simply i want to compare current logged user with all other users inside decorator (contextBuilder)

Reply

Hey Sardar K.!

I don't think I fully understand your situation, but I think I understand enough to try to answer ;). Let me start with the easy answer:

> but simply i want to compare current logged user with all other users inside decorator (contextBuilder)

You *are* able to access the currently-authenticated user by autowiring a new Security $security argument and then using that to get the currently-authenticated user.

But... I'm not sure this was *really* your question. Can you give me a concrete example with real entity names and real API request (e.g. when I login as UserA and make a GET request to /api/products ...)? My guess is that you want to actually *filter* some collection result: make it so that one user sees a certain sub-set of "items" and another sees a different sub-set. If so, I'm not sure that a context builder will be the answer. But let me know the full situation and then we can decide :).

Cheers!

Reply
Sardar K. Avatar

Thank you for your answer

actually the problem solved with chapter 29 custom normalizer

but i have a new question i will try my best to explain correct way this time
i will add code first the i will explain what really i want.

App\Entity\User


/**
* @ORM\Entity(repositoryClass="App\Repository\UserRepository")
* @UniqueEntity("username")
* @UniqueEntity("email")
* @ApiResource(
* itemOperations={
* "get",
* "put" = {
* "access_control"="is_granted('ROLE_ADMIN') or (is_granted('IS_AUTHENTICATED_FULLY') and object == user)",
* "access_control_message"="Only Admin or the owner can edit this field",
* "denormalization_context"={"groups"={"user:update"}}
* },
* },
* collectionOperations={
* "get",
* "post" = {
* "denormalization_context"={"groups"={"user:write"}}
* }
* },
* normalizationContext={
* "groups"={"user:read"}
* }
* )
*/
class User implements UserInterface
{
public const ROLE_ADMIN = 'ROLE_ADMIN';
public const ROLE_MEMBER = 'ROLE_MEMBER';
public const ROLE_COLLECTOR = 'ROLE_COLLECTOR';
public const ROLE_WEBMASTER = 'ROLE_WEBMASTER';

const DEFAULT_ROLES = [self::ROLE_MEMBER];

public const ROLES = [
['ROLE_PRESIDENT'],
['ROLE_ADMIN'],
['ROLE_MEMBER']
];

/**
* @ORM\Id()
* @ORM\GeneratedValue()
* @ORM\Column(type="integer")
*
* @Groups("user:read")
*/
private ?int $id = null;

/**
* @ORM\Column(type="string", length=100)
* @Groups({"user:read" , "user:write"})
* @Assert\NotBlank()
* @Assert\Length(min="4")
*/
private ?string $username = null;

/**
* @ORM\Column(type="string", length=100)
* @Groups({"admin:read", "owner:read", "user:create", "user:update", "admin:write"})
*
* @Assert\NotBlank()
* @Assert\Email()
*/
private ?string $email = null;

/**
* @Groups({"user:write", "user:update"})
* @SerializedName("password")
* @Assert\NotBlank()
* @Assert\Length(min="8")
*/
private ?string $plainPassword = null;

/**
* @ORM\Column(type="simple_array", length=200, nullable=false)
* @Groups("admin:read")
*/
private array $roles = [];

/**
* @ORM\ManyToOne(targetEntity="App\Entity\Association", inversedBy="users", cascade={"persist", "remove"})
* @Groups({"user:read"})
*/
private ?Association $association = null;

// setter and getters...

App\Entity\Association


/**
* @ORM\Entity(repositoryClass="App\Repository\AssociationRepository")
* @UniqueEntity("name")
* @ApiResource(
* itemOperations={
* "get"
* },
* collectionOperations={
* "get"
* }
* )
*/
class Association
{
/**
* @ORM\Id()
* @ORM\GeneratedValue()
* @ORM\Column(type="integer")
*/
private ?int $id = null;

/**
* @ORM\Column(type="string", length=50)
* @Groups("user:read")
*/
private ?string $name = null;

/**
* @ORM\OneToMany(targetEntity="App\Entity\User", mappedBy="association")
* @ApiSubresource()
*/
private $users;

// setters and getters

so as you can see one Association is having many users that mean each Association can have their own Admin
so what i want that admin can modify email or any other thing only user from the same Association as the admin himself
just like FACEBOOK GROUP admin of the group can only delete or block user from his group not from the group someone else.

i tried with DeserializerInterface but i couldn't achieve what i wanted

so i hope it is clear this time
if not i'm too bad. :)

Reply

Hey Sardar K.!

Let me see if I understand correctly :). Let's suppose that

1) UserA is the admin or AssociationA.
2) UserRyan is a member of AssociationA
3) UserSARDAR is a member of AssociationB

In this situation, UserA should be able allowed to edit the email (or other data) of UserRyan. But UserA should *not* be able to edit the email or UserSARDAR. Correct?

If I'm right, then you should protect this with a custom voter put operation on the User. Something like this (instead of what you have currently):


* @ApiResource(
* itemOperations={
* "get",
* "put" = {
* "access_control"="is_granted('EDIT_USER', object)",
* "access_control_message"="Only Admin or the owner can edit this field",
* },
* },
// ...

Then you will create a class like we do in this chapter - https://symfonycasts.com/sc...

This will allow you to do any checks you need:

A) Check to make user the current user is authenticated
B) If the currently-authenticated user === the user that's being edited (someone is editing their own information) allow it
C) If the user that's being edited is related to an association AND the currently-authenticated user === the admin of that association, allow it

There is one small "strange" thing with your data setup. It seems that you are determining if a user is a "member", "president" or "admin" or an association via roles on the User. I would not do it that way, just because there are simpler options. I would do one of the following:

1) Add a "membershipType" string field on User that is set to either "member", "president" or "admin". It doesn't make sense to use roles unless you are ever going to do something like is_granted("ROLE_PRESIDENT") where you are asking "is this user the president of *any* association in general?"... which I don't think you will be doing. And so, that string field is a bit simpler than storing the value in the roles array.

2) If users could actually belong to multiple associations, then you could create a AssociationMembership table that has a ManyToOne to User and Association (instead of relating User and Association directly to each other). If you had this setup, I would then put that membershipType field on this AssociationMembership entity. That would allow a user to be a president of one association, but only a member of a different one.

Cheers!

Reply
Default user avatar

I think the test on normalization will return true is not false === normalization I think it's a fault


if ($resourceClass === User::class && isset($context['groups']) && $isAdmin && false=== $normalization) // true=== $normalization {
$context['groups'][] = $normalization ? 'admin:read' : 'admin:write';
}
Reply

Hey Anas,

Could you explain a bit more, where do you think a fault? Or better, could you link us to specific code block where you think we have an error? I didn't see "false === $normalization" checks in this chapter.

Cheers!

Reply
Bohdan T. Avatar
Bohdan T. Avatar Bohdan T. | posted 3 years ago

Hey,
Looks like on the code example of Entity/User.php (line 59) there has to be "* @Groups({"admin:write"})" instead of "* @Groups({"user:write"})"

Reply

Hey Bohdan,

Agree, in the video we change it to the "admin:write", I updated the code block in https://github.com/SymfonyC...

Thank you for reporting this!

Cheers!

Reply
Default user avatar

Using context decorators to restrict access to certain entity fields is a great approach I can see myself using a lot. I have just implemented this for my User roles like your example. It really is a shame the documentation created from the API doesn't reflect this like you mentioned though!

One issue I did find with this approach is that if you are allowing group filters without a white-list of allowed groups (@ApiFilter(GroupFilter::class)), you can pass the admin:write group with the POST/PUT/PATCH request (with &groups[]=admin:write) and it will allow you to write to any field restricted with the admin:write group - even if the user is not an admin! This is more of a gotcha then anything else but it would be nice if the GroupFilter logic could have different white-lists for normalization and deserialization.

Restricting the ability to update the roles field is great but only half the battle in terms of roles. If you were to go a step further and also only allow a role to be added if the user has access to give it, say they have it themselves, then how would you go about that? E.G User 1 and User 2 both have access to update the roles field of any User. User 1 has the ROLE_ADMIN role so can give User 2 the ROLE_ADMIN role. User 2 can not give the ROLE_ADMIN role to themselves (until assigned it by User 1).

Thanks!

Reply

Hey Jarrod

Probably you will have to add the ROLE_ADMIN manually to the first user, or you can code a Symfony command to update/add roles to a specific user. It depends on your needs but if you don't need anything fancy, I would rather do it manually and save some time.

Cheers!

Reply
Cat in space

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

This tutorial works great for Symfony 5 and API Platform 2.5/2.6.

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.18.7
        "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
    }
}