Home > Back-end >  Make a field @Assert\NotBlank only for admin
Make a field @Assert\NotBlank only for admin

Time:09-24

This is my first time posting, my apologies if I don't follow some rules.

I'm using API Platform in my Symfony 5.3 project. I'm trying to make a field in one of my entities writable with some rules. The entity is called StripeAccount and must be linked to a $company object (see mapping below). Here are the rules

  • If the user is NOT granted ROLE_ADMIN, then the $company is not mandatory as it will be automatically filled
  • If the user is NOT granted ROLE_ADMIN and provide the $company, it MUST match the user's one (or else a violation is added)
  • If the user IS granted ROLE_ADMIN, then the $company IS mandatory but it can be any company

This is my StripeAccount entity :

<?php

namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiProperty;
use ApiPlatform\Core\Annotation\ApiResource;
use App\Repository\StripeAccountRepository;
use App\Validator\IsValidCompany;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\MaxDepth;
use Symfony\Component\Validator\Constraints as Assert;
use Vich\UploaderBundle\Mapping\Annotation as Vich;

/**
 * @Vich\Uploadable
 * @ApiResource(
 *      iri="http://schema.org/StripeAccount",
 *      normalizationContext={"groups"={"read:StripeAccount"}, "enable_max_depth"=true},
 *      denormalizationContext={"groups"={"write:StripeAccount"}},
 *      collectionOperations={
 *          "post"={
 *              "input_formats"={
 *                  "multipart"={"multipart/form-data"}
 *              },
 *          },
 *      },
 *      itemOperations={
 *          "get"
 *      }
 * )
 * @ORM\Entity(repositoryClass=StripeAccountRepository::class)
 */
class StripeAccount
{
    public const ACCOUNT_TYPE = 'custom';

    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     * @Groups({"read:StripeAccount", "write:StripeAccount"})
     */
    private $id;

    /**
     * @ORM\ManyToOne(targetEntity=Company::class, inversedBy="stripeAccounts")
     * @ORM\JoinColumn(nullable=false)
     * @Groups({"read:StripeAccount", "admin:write"})
     * @Assert\NotBlank(groups={"admin:write"})
     * @IsValidCompany
     */
    private $company;

    /**
     * @ORM\OneToMany(targetEntity=Brand::class, mappedBy="stripeAccount")
     * @Groups({"read:StripeAccount", "write:StripeAccount"})
     */
    private $brands;

    // other fields


    public function __construct()
    {
        $this->brands = new ArrayCollection();
    }

    public static function getType(): string
    {
        return self::ACCOUNT_TYPE;
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getCompany(): ?Company
    {
        return $this->company;
    }

    public function setCompany(?Company $company): self
    {
        $this->company = $company;
        return $this;
    }

    // other methods
}

I followed this tutorial : https://symfonycasts.com/screencast/api-platform-security/context-builder#play (chapters 25, and 33 to 36), so I have this validator :

<?php

namespace App\Validator;

use App\Entity\{Company, User};
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;

class IsValidCompanyValidator extends ConstraintValidator
{
    private $security;

    public function __construct(Security $security)
    {
        $this->security = $security;
    }

    public function validate($value, Constraint $constraint)
    {
        /* @var $constraint \App\Validator\IsValidCompany */

        if (null === $value || '' === $value) {
            return;
        }

        $user = $this->security->getUser();
        if (!$user instanceof User) {
            $this->context->buildViolation($constraint->anonymousMessage)->addViolation();
            return;
        }

        if ($this->security->isGranted('ROLE_ADMIN')) {
            return;
        }

        if (!$value instanceof Company) {
            throw new \InvalidArgumentException(
                '@IsValidCompany constraint must be put on a property containing a Company object'
            );
        }

        if ($value->getId() !== $user->getId()) {
            $this->context->buildViolation($constraint->message)
                ->setParameter('%value%', $value)
                ->addViolation();
        }
    }
}

and this ContextBuilder :

<?php

namespace App\Serializer;

use ApiPlatform\Core\Serializer\SerializerContextBuilderInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;

final class AdminGroupsContextBuilder implements SerializerContextBuilderInterface
{
    private $decorated;
    private $authorizationChecker;

    public function __construct(
        SerializerContextBuilderInterface $decorated,
        AuthorizationCheckerInterface $authorizationChecker
    ) {
        $this->decorated = $decorated;
        $this->authorizationChecker = $authorizationChecker;
    }

    public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array
    {
        $context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes);

        $isAdmin = $this->authorizationChecker->isGranted('ROLE_ADMIN');

        if (isset($context['groups']) && $isAdmin) {
            $context['groups'][] = $normalization ? 'admin:read' : 'admin:write';
        }

        return $context;
    }
}

Everything works fine, the group 'admin:write' is added if the user making the request is an admin, and the $company is set if the user is not an admin.

My problem is : My @Assert\NotBlank(groups={"admin:write"}) is completly ignored. I tried a few adjustments with the @Groups annotation and even the denormalizationContext, but no, it's not applied at any moment. What am I missing here?

Btw, I'm using Postman to test my API

Thanks a lot for your help

[EDIT] Based on @TekPike's answer, here is my working code:

StripeAccount.php

<?php

namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiProperty;
use ApiPlatform\Core\Annotation\ApiResource;
use App\Repository\StripeAccountRepository;
use App\Validation\AdminValidationGroupsGenerator;
use App\Validator\IsValidCompany;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\MaxDepth;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * @ApiResource(
 *      iri="http://schema.org/StripeAccount",
 *      attributes={
 *          "validation_groups"=AdminValidationGroupsGenerator::class,
 *      },
 *      normalizationContext={"groups"={"read:StripeAccount"}, "enable_max_depth"=true},
 *      denormalizationContext={"groups"={"write:StripeAccount"}},
 *      collectionOperations={
 *          "post"={
 *              "input_formats"={
 *                  "multipart"={"multipart/form-data"}
 *              },
 *          },
 *      },
 *      itemOperations={
 *          "get",
 *          "delete",
 *      }
 * )
 * @ORM\Entity(repositoryClass=StripeAccountRepository::class)
 */
class StripeAccount
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     * @Groups({"read:StripeAccount", "write:StripeAccount"})
     */
    private $id;

    /**
     * @ORM\ManyToOne(targetEntity=Company::class, inversedBy="stripeAccounts")
     * @ORM\JoinColumn(nullable=false)
     * @Groups({"read:StripeAccount", "admin:write"})
     * @Assert\NotBlank(groups={"admin:write"})
     * @IsValidCompany
     */
    private $company;


    /**
     * @ORM\Column(type="string", length=255)
     * @Groups({"read:StripeAccount", "write:StripeAccount"})
     * @Assert\NotBlank
     */
    private $name;

    // ...
}

And my AdminValidationGroupsGenerator.php :

<?php

namespace App\Validation;

use ApiPlatform\Core\Bridge\Symfony\Validator\ValidationGroupsGeneratorInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;

final class AdminValidationGroupsGenerator implements ValidationGroupsGeneratorInterface
{
    private $authorizationChecker;

    public function __construct(AuthorizationCheckerInterface $authorizationChecker)
    {
        $this->authorizationChecker = $authorizationChecker;
    }

    /**
     * {@inheritdoc}
     */
    public function __invoke($entity): array
    {
        $reflect = new \ReflectionClass($entity);
        $name = "write:" . $reflect->getShortName();
        return $this->authorizationChecker->isGranted('ROLE_ADMIN', $entity) ? [$name, 'admin:write'] : [$name];
    }
}

CodePudding user response:

You confuse serialization groups with validation groups.

Currently you define serialization groups with the annotation denormalizationContext={"groups"={"write:StripeAccount"}} and the class App\SerializerAdminGroupsContextBuilder.

However, the "admin:write" group defined in the constraint @Assert\NotBlank(groups={"admin:write"}) is a validation group.

In your case, since the validation group changes depending on the user, you have to use dynamic validation groups.

  • Related