Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Lucky you! You found an early release chapter - it will be fully polished and published shortly!

Custom Validator

This Chapter isn't
quite ready...

Rest assured, the gnomes are hard at work
completing this video!

Browse Tutorials

Coming soon...

If you need to control behavior around how a field, for example, is published, is set based on who's logged in, you have two options. First, if you need to prevent certain users from writing to this field entirely, that's what Security is for. The easiest option is to use that API property Security option that we used earlier above the property. Or you can get fancier like we did and add a DynamicAdmin right group with a ContextBuilder. Either way, we're preventing this field from being written entirely. We're determining entirely whether or not this field is allowed to be written. But the second situation is when a field should be, a user should be allowed to write to a field, but the data that's allowed depends on the user. Like maybe the user can set, maybe a user could set isPublished to false, but they're not allowed to set it to true unless they are an admin:write. Let me give you a different example. Currently, when you create a DragonTreasure, we force you to pass an OwnerField. We can actually see this in our testPatchToUpdateTreasure test. We're going to fix that in a minute so that you can leave this off and it's set automatically. But right now, the OwnerField is allowed and it's actually required. But who the API client assigns as the owner, who they are allowed to assign as the owner, depends on who's logged in. For normal users, they can only assign themselves as a user. Here's the goal. As a normal user, they can only assign themselves as an owner. But if for admins, they can assign anyone as an owner. Heck, maybe in the future we even get crazier and there are clans of dragons. Maybe a user, userA, can create a treasure and set the owner to a different user that's in their clan. So the point is, it's not just whether or not you can set this field, it actually depends on... The valid data depends on who you're logged in. I forgot to mention earlier. The best way to solve this type of problem is actually validation. We actually solved this in the past up here on the patch operation with Security. It's actually this securityPostDenormalize part. That's fine, but I want to show you what this looks like with validation. First, I'm going to remind you what this was actually doing. So over here in our test, testPatchToUpdateTreasure(). And let's go ahead and run this. Run just that test. And it currently passes. Now, as a reminder, what we're doing here is we're first logging in as the user that owns the DragonTreasure. And we are updating the user and that was allowed. Then we are trying to log in as a different user and edit the first user's DragonTreasure and that wasn't allowed. This is a proper use of security. Since we do not own this DragonTreasure, we are not at all allowed to edit it. And that's what this first security line is protecting. It's checking to see if the current DragonTreasure object is allowed to be edited by the current user. This last thing here was a little bit trickier. This is actually where we log in as the owner of this DragonTreasure. But then we try to change the owner to someone else. And that's also not allowed. This is the situation we're talking about. We shouldn't be allowed to change the owner to someone else as a normal user. This is what I want to handle in validation. This is currently being fixed by this security postDenormalize() here. Where it checks to see what the DragonTreasure looks like after the new data is put on it to see if we are still the owner. Anyways, long way of saying, I'm going to remove the security postDenormalize(). And to prove what I was just saying is true, when we run our tests. Perfect, it failed on line 132. Which is this one down here. So we're going to rewrite this security check here in validation. And the solution is actually a lot nicer. So first, because this is going to fail via validation when we're done, we're going to change this to assertStatus(422). So basically, we are allowed to patch to this user, but this is invalid data. We can't set this owner to someone other than ourselves. So to handle this, we're going to handle validation. We're going to build our own custom validator. So go to the command line and run make validator. We'll create a new one called IsValidOwnerValidator. Now in Symfony, validators are actually two different classes. So we'll go over and look in src/Validator/IsValidOwner.php. First, you have this very lightweight class, which is going to be used as the attribute. And it just has options on it. And we just have a current message option, which is enough. And actually, while I'm here, I'll just kind of change this default message to something a little more helpful for our situation. Like, you are not allowed to set the owner to this value. The second class is the one that will actually be executed to handle the validation logic. And we'll look at that in a second. First, let's use this. So over in DragonTreasure, down on the owner property, there we go. Here we'll add a new attribute, and we'll say isValidOwner. We're actually referring to that new class that we had right there. And if we wanted to, we could. All right, so now that we have this, when our object is validated, it's going to call isValidOwnerValidator. And it's going to pass us the value, which should be the User object. And then it's going to pass us the constraint, which is actually going to be our isValidOwner. So let's do a little bit of cleanup here. I'm going to take this var out and replace it with an assert.Constraint instance of isValidOwner. And that's basically, once again, just to help my editor. We know Symfony is always going to pass us that value. And then here, notice that it's checking to see if the value is null or blank. And if it does, it does nothing. That's because if the owner property is empty, that's kind of the job of a different validation constraint to catch that. So basically what I mean is what I include on here is a notNull constraint. So if they forget to send the owner, this will handle that validation error. And then inside of our code, we don't have to handle that here. We can just return and we know that it's handled elsewhere. And then down here, I'm going to add one more assert. That value is an instance of User. So we're going to be passed whatever value is attached to this property. We know that's always going to be a User. So basically if for some reason the value is not an instance of User, it means we messed something up and we put this constraint above a property that was unexpected. Cool. Then down here for setParameter(), this is a little wild card you can have in the message. We don't need that. And then we're reading constraint error message to get the validation message. So right now we have a functional validator, except it's going to fail in all situations. So we can at least see if it's being called. So let's run our test. And perfect. OK, 422, 200 expected. This is coming from DragonTreasureResourceTest line 110. So basically it's now failing way up here on top because our IsValidOwner constraint is being called and it's always failing. All right, so let's get to work inside of our validator. We're going to need the currently logged in. The goal is basically to make sure that the owner value is equal to the currently logged in user. So to get the currently logged in user, we'll add a construct method, we'll auto wire our favorite security class. I'll put private in front of that. So it turns into a property. And then down here. We'll say user equals this arrow security arrow get user. And I'm at a little if not user here, we're going to throw a new logic exception. And I'll just put a message inside of there. We could just have a valid validation failure if this happens, but really this is a misconfiguration situation. This valid this. We know that you have to be logged in to modify or create users. So if. Great DragonTreasures. So for somehow we're not logged in, something weird is happening here. Then finally, down here, it's pretty simple. If value doesn't equal user. So if the owner is not the user, then we are going to add that validation failure. Nice. All right, let's try the test. And got it. It passes. We're no longer allowed, whether we're creating or editing a DragonTreasure to set the owner, someone that's not us. And if in the future, this got more complex like that clan idea said earlier, we could handle that in here. For example, we even add admin users right now. So we can say if this arrow security arrow is granted role admin. Then we could say return. So just like that, admin users are allowed to assign owners to anyone. So thanks to validation, a normal user can now only set themselves as the owner of a DragonTreasure when creating or editing. But there is still one big security hole that will allow any user to steal any other users DragonTreasures. Not cool. Let's find out what that is next and fix it.

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
    }
}