Skip to content

feat: add security_post_denormalize in ApiProperty #4184

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged

Conversation

DavidBennettUK
Copy link
Contributor

Q A
Branch? main
Bug fix? yes
New feature? yes
Deprecations? no
Tickets N/A
License MIT
Doc PR N/A

While implementing #4143 I noticed that it's not possible to use object in the security expression of ApiProperty - not only for GraphQL, but for all.

It was already possible to use object for read operations - but doing so would cause unexpected exceptions to be thrown on updates/creates, as the object is actually a class string. For example, the security expression object.getOwner() == user would throw an exception when performing an update as object would be a class string - which the developer wouldn't expect. This exception would happen even if you weren't updating that specific field, as the Symfony AbstractObjectNormalizer::denormalize() calls getAllowedAttributes - which runs security on every single field, due to canAccessAttribute being overridden in AbstractItemNormalizer.

There didn't seem to be a way to work around this in the expressions, as there isn't an obvious way to check if a variable is an object or string in the expression language.

This PR updates this so that object will always be the object or null - which can easily be checked for in the expression.

Also added is a security_post_denormalize which works similarly to those on ApiResource. This lets you be guaranteed that object isn't null and will also give you access to previous_object for more advanced checks.

This should hopefully not be a BC break unless developer workarounds were put in place possibly. For safety, and because it depends on #4143, it is targeting main.

@alanpoulain
Copy link
Member

alanpoulain commented Apr 1, 2021

Related: #3966
@Toflar, since you are the one who has created the PR, could you see if it's OK for you?

@DavidBennettUK I think @Toflar is able to distinguish between an object and a class string by using a custom expression language function (user_has_role_for_organisation).


// Filter out input attributes that aren't allowed for updates
if ($isUpdate) {
$data = array_filter($data, function (string $attribute) use ($context, $objectToPopulate) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why filtering the input? Was it possible to modify a property of a resource without the right to do so before?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right that there was already a security pass - AbstractObjectNormalizer::denormalize() calls AbstractItemNormalizer::getAllowedAttributes() but only with the class string, so object can't be used. This addition does another pass over, calling the security expression with a populated object variable. Without this, security will always be called with a null object for updates, which may be unexpected. We could take this out and force use of security_post_denormalize if the developer wants an object, but I think an object would be expected for updates as it already exists.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd be in favor of reducing the complexity in our code, enforcing the use of security_post_denormalize

@Toflar
Copy link
Contributor

Toflar commented Apr 1, 2021

In my case, I cannot check on the roles of the user directly because the same user has different roles for different organisations. So what I did is this:

    /**
     * @ApiProperty(security="user_has_role_for_organisation('ROLE_OWNER', object)")
     */
    private BillingInfo $billingInfo;

And then a simple

class AccessExpressionProvider implements ExpressionFunctionProviderInterface
{
    public function getFunctions(): array
    {
        return [
            new ExpressionFunction('user_has_role_for_organisation', function (string $role, Organisation $organisation) {
                return sprintf('$user->hasRoleForOrganisation(%s, %s)', $role, $organisation);
            }, function (array $variables, string $role, Organisation $organisation) {
                return $variables['user']->hasRoleForOrganisation($role, $organisation);
            }),
        ];
    }
}

This should still work as far as I can see.

@alanpoulain
Copy link
Member

That's weird. If I understand what @DavidBennettUK is saying, your function should fail when doing an update / create, because $organisation is then a string and not an Organisation object.

@Toflar
Copy link
Contributor

Toflar commented Apr 1, 2021

Everywhere I use this, it's only GET ;)

@alanpoulain
Copy link
Member

It makes sense then! It should fail if you try an update.

@Toflar
Copy link
Contributor

Toflar commented Apr 1, 2021

Might very well be but as this is not a requirement for me, I have no tests for that :)

@alanpoulain
Copy link
Member

No problem 🙂


// Filter out input attributes that aren't allowed for updates
if ($isUpdate) {
$data = array_filter($data, function (string $attribute) use ($context, $objectToPopulate) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd be in favor of reducing the complexity in our code, enforcing the use of security_post_denormalize

@DavidBennettUK
Copy link
Contributor Author

@soyuka thanks for the feedback, I've made the suggested changes 👍

$object = parent::denormalize($data, $resourceClass, $format, $context);

// Revert attributes that aren't allowed to be changed after a post-denormalize check
foreach (array_keys($data) as $attribute) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was it possible to modify a property of a resource without the right to do so before?
If so, isn't it a bugfix for 2.6?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not that I'm aware of. This is only reverting the properties that fail the new security_post_denormalize check. Properties that fail the security check are still filtered from denormalization via AbstractItemNormalizer::getAllowedAttributes()

*
* @ODM\Field
* @ApiProperty(
* security="object == null or object.getOwner() == user",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it still useful?

*
* @ORM\Column
* @ApiProperty(
* security="object == null or object.getOwner() == user",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it still useful?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As the object isn't available in creates/updates, in this scenario you'd need to pass the security check if the objectis null, as otherwise it would fail this check (object.getOwner() would throw a null pointer exception) and never run security_post_denormalize.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But if there is already a securityPostDenormalize, why a security is needed?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found the answer myself: it's because it is used for the get operation too.

@alanpoulain
Copy link
Member

LGTM. Don't forget the documentation entry.

@DavidBennettUK
Copy link
Contributor Author

Documentation PR api-platform/docs#1349

…ct` passed to the expression during denormalization when trying to update or create a resource. This meant that only simple role checks etc. could be used. Now the object can be used to grant access directly, passed to voters, etc. If the object isn't available, then null is passed as the `object` instead, making it easier to check for this condition in the expression. Also implements a `security_post_denormalization` attribute on `ApiProperty` to perform another check after denormalisation (for create/update) and be guaranteed that there is an `object` (as well as `previous_object` for updates).
… reduce code complexity. `security_post_normalize` should be used if an object is required.
@alanpoulain alanpoulain force-pushed the api-property-security-objects branch from 4e5ded5 to c178a85 Compare May 6, 2021 15:49
@alanpoulain alanpoulain changed the title Fix ApiProperty security expressions not supporting objects in updates/creates feat: add security_post_denormalize in ApiProperty May 6, 2021
@alanpoulain alanpoulain merged commit de5988d into api-platform:main May 6, 2021
@alanpoulain
Copy link
Member

Thank you very much @DavidBennettUK!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants