My problem is the security implementation of the entities when consumed through GraphQL, the queries are perfect, they return the necessary data, but the problem occurs when a query is generated and the many-to-one relationship is limited to having a role "ROLE_ADMIN", the query returns the data even when the user has the role "IS_AUTHENTICATED_ANONYMOUSLY"
How to add the security layer so that data from protected relationships cannot be obtained?
The product query must be seen without any role.
Additional Information
GraphQL Query User Admin
query {
userAdmin(id: "api/user_admins/1") {
id
name
}
}
GraphQL Query User Admin Result OK
{
"errors": [
{
"message": "Sorry, but you don't have access",
"extensions": {
"category": "graphql"
}
]
}
GraphQL Query Product
query {
products {
edges {
node {
name
price
user {
name
}
}
}
}
}
GraphQL Query Product Result FAILED
{
"data": {
"products": {
"edges": [
{
"node": {
"name": "GERLACH-HAAG",
"price": "175",
"user": {
"name": "Sidney Deane" /** this information should not be seen **/
}
}
}
]
}
}
}
Entity Product Configuration
<?php
/**
* #ApiResource(
* graphql={
* "item_query",
* "collection_query",
* "delete"={ "security" = "is_granted('ROLE_ADMIN')" },
* "create"={ "security" = "is_granted('ROLE_ADMIN')" },
* "update"={ "security" = "is_granted('ROLE_ADMIN')" }
* }
* )
* #ORM\Table(name="TBL_PRODUCTS")
* #ORM\Entity(repositoryClass=ProductRepository::class)
* #ORM\HasLifecycleCallbacks()
*/
class Product
{
/**
* #ORM\Id()
* #ORM\GeneratedValue()
* #ORM\Column(type="bigint", name="ID")
*/
private $id;
/**
* #ORM\Column(type="string", length=180, name="NAME")
*/
private $name;
/**
* #ORM\ManyToOne(targetEntity="App\Entity\UserAdmin")
* #ORM\JoinColumn(name="USER", referencedColumnName="ID")
*/
private $user;
Entity User Admin Configuration
<?php
/**
* #ApiResource(
* graphql={
* "item_query"={ "security" = "is_granted('ROLE_ADMIN')" },
* "collection_query"={ "security" = "is_granted('ROLE_ADMIN')" },
* "delete"={ "security" = "is_granted('ROLE_ADMIN')" },
* "create"={ "security" = "is_granted('ROLE_ADMIN')" },
* "update"={ "security" = "is_granted('ROLE_ADMIN')" }
* }
* )
* #ORM\Table(name="TBL_USERS_ADMIN")
* #ORM\Entity(repositoryClass=UserAdminRepository::class)
* #ORM\HasLifecycleCallbacks()
*/
class UserAdmin implements UserInterface
{
/**
* #ORM\Id()
* #ORM\GeneratedValue()
* #ORM\Column(type="bigint", name="ID")
*/
private $id;
/**
* #ORM\Column(type="string", length=180, name="USERNAME")
*/
private $username;
/**
* #ORM\Column(type="string", length=180, name="PASSWORD")
*/
private $password;
/**
* #ORM\Column(type="string", length=180, name="NAME")
*/
private $name;
Please help !!!!
The security attribute allows to define roles or permissions necessary to execute a given query. However, it doesn't define which relation available trough this query the user can access. To do so, you can use dynamic serialization groups.
Basically, mark the properties requiring a special role to be returned in the response with a specific serialization group, such as #Groups("admin"), then create a dynamic serialization context builder to add the special group if the connected user has this special role:
<?php
namespace App\Serializer;
use ApiPlatform\Core\GraphQl\Serializer\SerializerContextBuilderInterface;
use App\Entity\Book;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
final class BookContextBuilder implements SerializerContextBuilderInterface
{
private $decorated;
private $authorizationChecker;
public function __construct(SerializerContextBuilderInterface $decorated, AuthorizationCheckerInterface $authorizationChecker)
{
$this->decorated = $decorated;
$this->authorizationChecker = $authorizationChecker;
}
public function create(?string $resourceClass, string $operationName, array $resolverContext, bool $normalization): array
{
$context = $this->decorated->create($resourceClass, $operationName, $resolverContext, $normalization);
$resourceClass = $context['resource_class'] ?? null;
if ($resourceClass === Book::class && isset($context['groups']) && $this->authorizationChecker->isGranted('ROLE_ADMIN') && false === $normalization) {
$context['groups'][] = 'admin';
}
return $context;
}
}
In the master version (API Platform 2.6), the security attribute is now also available for properties. Unfortunately, this feature is currently available only in the REST subsystem, but we will be very pleased to merge a Pull Request adding support for this attribute to the GraphQL subsystem.
Try using exclusion policies on your entities. You will need to use the JMS Serializer Bundle
Example:
use JMS\Serializer\Annotation as Serializer;
/**
* #ORM\Table(name="TBL_PRODUCTS")
* #ORM\Entity(repositoryClass=ProductRepository::class)
* #ORM\HasLifecycleCallbacks()
* #Serializer\ExclusionPolicy("all")
*/
class Product
{
/**
* #ORM\ManyToOne(targetEntity="App\Entity\UserAdmin")
* #ORM\JoinColumn(name="USER", referencedColumnName="ID")
* #Serializer\Groups({"ROLE_ADMIN"}) // only users with ROLE_ADMIN will see this
* #Serializer\Expose()
*/
private $user;
/**
* ...
* #Serializer\Groups({"ROLE_SUPER_ADMIN"}) // only users with ROLE_SUPER_ADMIN will see this
* #Serializer\Expose()
*/
private $superSecretData;
...
}
This wil expose the $user property only when the user has the ROLE_ADMIN.
In your controller/service where you expose your data, you should add the users role to the serialization context:
use JMS\Serializer\SerializerInterface;
...
$entities = $repository->findAll();
$responseData = $this->serializer->serialize(
$entities,
'json',
SerializationContext::create()
->setGroups(['ROLE_USER']) // Add all roles you want
);
$response = new Response(
$responseData,
Response::HTTP_OK
);
$response->headers->set('Content-Type', 'application/json');
return $response;
This way you should be able to exclude or expose some properties based on the users' role.
Related
I have a big problem with Symfony, Doctrine and partially incorrect data.
First of all
i use uuids for primary keys
i use jwts for authentication
i use symfony 5.4.x and doctrine-bundle 2.7.x
i use class inheriance for getting my pk on each entity
i donĀ“t use at all relations, because i want to have some flexibility to slice components into their origin e.g. security component as a microservice (but i think this is not really neccesarry on my question)
Sample of JWT content
{
"iat": xxx,
"exp": xxx,
"roles": [
"MY_ROLE"
],
"id": "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE",
"tenant": {
"id": "11111111-2222-3333-4444-555555555555",
"name": "MyTenant"
},
"user": {
"id": "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE",
"username": "MyUser",
"firstname": "My",
"name": "User"
}
}
Sample of Entity: AbstractEntity.php
<?php
namespace App\Entity;
use JsonSerializable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
use Symfony\Bridge\Doctrine\IdGenerator\UuidGenerator;
/**
* Class AbstractEntity
* #package App\Entity
*
* #ORM\MappedSuperclass()
*/
abstract class AbstractEntity implements EntityInterface, JsonSerializable
{
/**
* #ORM\Id
* #ORM\Column(type="uuid", unique=true)
* #ORM\GeneratedValue(strategy="CUSTOM")
* #ORM\CustomIdGenerator(class=UuidGenerator::class)
*/
private ?Uuid $id = null;
/**
* #return Uuid|null
*/
public function getId(): ?Uuid
{
return $this->id;
}
/**
* #return bool
*/
public function isNew(): bool
{
return !isset($this->id);
}
/**
* #return array
*/
public function jsonSerialize(): array
{
return [
'id' => $this->getId()->toRfc4122()
];
}
/**
* #return Uuid
*/
public function __toString(): string
{
return $this->getId()->toRfc4122();
}
}
Sample of Entity: Settings.php
<?php
namespace App\Entity\Configuration;
use DateTime;
use App\Entity\AbstractEntity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* #ORM\Entity(repositoryClass="App\Repository\Configuration\SettingsRepository")
* #ORM\Table(name="configuration_settings", indexes={#ORM\Index(columns={"tenant"})})
*/
class Settings extends AbstractEntity
{
/**
* #var Uuid $tenant
*
* #ORM\Column(type="uuid")
*/
private Uuid $tenant;
/**
* #var string|null $payload
*
* #ORM\Column(type="json", nullable=true)
*/
private ?string $payload = "";
/**
* #return Uuid
*/
public function getTenant(): Uuid
{
return $this->tenant;
}
/**
* #param Uuid $tenant
*/
public function setTenant(Uuid $tenant): void
{
$this->tenant = $tenant;
}
/**
* #return string|null
*/
public function getPayload(): ?string
{
return $this->payload;
}
/**
* #param string|null $payload
*/
public function setPayload(?string $payload): void
{
$this->payload = $payload;
}
}
Sample of Controller: SettingsController.php
<?php
namespace App\Controller\Configuration\API;
use App\Entity\Configuration\Settings;
use App\Entity\Security\Role;
use App\Repository\Configuration\SettingsRepository;
use App\Service\Security\UserService;
use Doctrine\ORM\EntityManagerInterface;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Uid\Uuid;
/**
* Settings API controller.
*
* #Route("/api/configuration/settings", name="api_configuration_settings_")
*/
class SettingsController extends AbstractController
{
/**
* #var SettingsRepository
*/
private SettingsRepository $repository;
/**
* SettingsService constructor.
* #param EntityManagerInterface $entityManager
*/
public function __construct(EntityManagerInterface $entityManager)
{
$this->repository = $entityManager->getRepository(Settings::class);
}
/**
* #Route("/obtain", name="obtain", methods={"GET"})
*/
public function obtain(UserService $userService, TokenStorageInterface $tokenStorageInterface, JWTTokenManagerInterface $jwtManager): JsonResponse
{
$userService->isGranted([
Role::ROLE_TENANT_USER
]);
$token = $jwtManager->decode($tokenStorageInterface->getToken());
$tenantAsRFC4122 = $token['tenant']['id']; // Here we have: 11111111-2222-3333-4444-555555555555
$tenant = Uuid::fromString($tenantAsRFC4122); // Here we have a class of Symfony\Component\Uid\UuidV6;
$settings = $this->repository->findOneBy([
'tenant' => $tenant
]);
return new JsonResponse(
$settings
);
}
}
If i now call the API-URL with the Authorization Header including the Bearer as JWT, i will get the Settings for my tenant 11111111-2222-3333-4444-555555555555. But this works not always ...
I can't describe it exactly, because I can't reproduce it, but on one of the systems with a lot of traffic it happens from time to time that despite the correct bearer, i.e. correct tenant, simply wrong settings for another tenant come back.
Is there a doctrine cache somewhere that might cache queries and assign them to the wrong repsonses at the end of the day?
SELECT payload FROM configuration_settings WHERE tenant = '0x1234567890123456789' // here doctrine automaticly strips it to hex
I am so perplexed, because the SQL behind it looks correct and right, here times as an example shortened.
Hope someone can help me.
Thanks,
S.
I'm looking for the correct way to serialize a child of my object.
I have the following classes:
class company {
/**
* #ORM\Id
* #ORM\GeneratedValue
* #ORM\Column(type="integer")
* #Groups ({"get"})
*/
private $id;
}
class User {
/**
* #ORM\Column(type="string", length=180, unique=true)
* #Groups ({"get"})
*/
private $email;
/**
* #ORM\ManyToOne(targetEntity=Company::class, inversedBy="users")
* #ORM\JoinColumn(nullable=false)
* #Groups ({"get"})
*/
private $company;
}
As soon as I use the serializer on a User object, I receive the following response.
{
"id": 1,
"email": "email#mydomain.com",
"company": {
"id": 1
}
}
But I prefer the following response, how can I get these?
{
"id": 1,
"email": "email#mydomain.com",
"company": 1
}
You can create a custom normalizer, and summarise the company within the User - as it is being converted from the original object (with sub-entities) to an array, before being json-encoded.
symfony console make:serializer:normalizer [optional name, eg: 'UserNormalizer']
This creates a new class, with in part, the contents:
public function normalize($object, $format = null, array $context = []): array
{
// $object is a User entity at this point
$data = $this->normalizer->normalize($object, $format, $context);
// Here: add, edit, or delete some data
// and we summarise the company entity to just the ID.
$data['company'] = $object->getCompany()->getId();
return $data;
}
When I did this with a slightly more complex entity that referred back to the original one (if company had a reference back to a user), it made 'A circular reference', so I added an annotation to #Ignore the field in the User entity, for serialization purposes. It was still given to the normalizer, to use from the object passed into normalize().
You could also serialize the result of a method instead:
class User {
/**
* #ORM\Column(type="string", length=180, unique=true)
* #Groups ({"get"})
*/
private $email;
/**
* #ORM\ManyToOne(targetEntity=Company::class, inversedBy="users")
* #ORM\JoinColumn(nullable=false)
*/
private $company;
/**
* #Groups ({"get"})
*/
public function getCompanyId()
{
return $this->company->getId();
}
}
I have an User entity that can add his/hers contacts from Google using GooglePeopleApi.
The API provides an array of contacts to the front-end as it is (Nextjs). The question is how to insert all those contacts in one single mutation.
I could, of course, have the front-end loop through the array and post the contacts one by one, but that is a bit silly.
It should be possible to create a type array with the Contact input and then, with that, set up a customArgsMutation. ( I've seen some examples of that from Hasura ).
Entity wise, it looks like this for now ( only relevant code ):
User.php
....
/**
* #ORM\OneToMany(targetEntity="App\Entity\Contact", mappedBy="user")
* #Groups({"put-contacts", "get-admin", "get-owner"})
*/
private $contacts;
Contact.php
/**
* #ApiResource(
* attributes={"pagination_enabled"=false},
* graphql={
* "item_query"={
* "normalization_context"={"groups"={"get-admin", "get-owner"}},
* },
* "collection_query"={
* "normalization_context"={"groups"={"get-admin", "get-owner"}},
* },
* "delete"={"security"="is_granted('IS_AUTHENTICATED_FULLY') and object.getUser() == user"},
* "create"={
* "security"="is_granted('IS_AUTHENTICATED_FULLY')",
* "denormalization_context"={"groups"={"post", "put"}},
* "normalization_context"={"groups"={"get-owner", "get-admin"}},
* },
* }
* )
* #ORM\Entity(repositoryClass="App\Repository\ContactRepository")
*/
class Contact
{
/**
* #ORM\Id()
* #ORM\GeneratedValue()
* #ORM\Column(type="integer")
*/
private $id;
/**
* #ORM\ManyToOne(targetEntity="App\Entity\User", inversedBy="contacts")
* #Groups({"post", "get-admin", "get-owner"})
*/
private $user;
/**
* #ORM\Column(type="string", length=180)
* #Groups({"post", "put", "get-admin", "get-owner"})
*/
private $email;
/**
* #ORM\Column(type="string", length=180, nullable=true)
* #Groups({"post", "put", "get-admin", "get-owner"})
*/
private $familyName;
/**
* #ORM\Column(type="string", length=180, nullable=true)
* #Groups({"post", "put", "get-admin", "get-owner"})
*/
private $givenName;
/**
* #ORM\Column(type="string", length=180, nullable=true)
* #Groups({"post", "put", "get-admin", "get-owner"})
*/
private $displayName;
From graphiql, the createContact input looks like this:
user: String
email: String!
familyName: String
givenName: String
displayName: String
clientMutationId: String
You have a couple of options here, depending on the amount of concurrency you want:
1. These should be performed in Serial
The client can make a single HTTP request with multiple mutations as aliases:
mutation CreateUsers {
user1: createUser({ //userInput1 }) { ...userFields }
user2: createUser({ //userInput2 }) { ...userFields }
user3: createUser({ //userInput3 }) { ...userFields }
}
fragment userFields on CreateUserPayload {
firstName
// etc
}
The response will look like this:
{
"data": {
"user1": {...},
"user2": {...},
"user3": {...},
}
}
Pros:
If any single mutation fails, just that one will error out without special handling
Order is maintained. Because the API consumer is specifically labelling the mutations, they know which one has which results.
Cons:
By design, multiple mutations run in Serial, where the first must complete fully before the next one is started. Because of this it's going to be a bit slower.
The client has to add the fields themselves or use a fragment for each mutation (what I showed above)
2. These should be performed in Parallel
It is a common practice (as common as it can be, I guess) to create a "bulk mutation" that allows you to create multiple users.
mutation CreateUsers {
createUsers([
{ //userInput1 },
{ //userInput2 },
{ //userInput3 }
]) {
firstName
// etc
}
}
Pros:
Runs in parallel, so it's faster
The client doesn't have to do string interpolation to build the query. They just need the array of objects to pass in.
Cons:
You have to build the logic yourself to be able to return nulls vs errors on your own.
You have to build the logic to maintain order
The client has to build logic to find their results in the response array if they care about order.
This is how I did it:
Create a custom type:
class CustomType extends InputObjectType implements TypeInterface
{
public function __construct()
{
$config = [
'name' => 'CustomType',
'fields' => [
'id_1' => Type::nonNull(Type::id()),
'id_2' => Type::nonNull(Type::id()),
'value' => Type::string(),
],
];
parent::__construct($config);
}
public function getName(): string
{
return 'CustomType';
}
}
Register CustomType in src/config/services.yaml
(...)
App\Type\Definition\CustomType:
tags:
- { name: api_platform.graphql.type }
Add custom mutation to entity, using CustomType argument as array:
/**
* #ApiResource(
* (...)
* graphql={
* "customMutation"={
* "args"={
* (...)
* "customArgumentName"={"type"=[CustomType!]"}
* }
* }
*)
Then the new customMutation can be called with an array of arguments like this:
mutation myMutation($input: customMutationEntitynameInput!) {
customMutationEntityname(input: $input) {
otherEntity {
id
}
}
}
And graphql variables:
{
"input": {
"customArgumentName": [
{
"id_1": 3,
"id_2": 4
},{
"id_1": 4,
"id_2": 4
}
]
}
}
I am afraid I might have ran into some sort of XY problem...
I have an entity "Asset" with related "AssetType" entity (One AssetType can have many Asset entities)
When creating new entity with POST method, the request fails with "SQLSTATE[23000]: Integrity constraint violation: 1048 Column 'type_id' cannot be null"
Data posted from react-admin (POST to /api/assets route):
{
"data":{
"type":"assets",
"attributes":{
"name":"asdf",
"description":"LoraWAN enabled sensor"
},
"relationships":{
"asset_type":{
"data":{
"id":"/api/asset_types/a71b47b8-b9fb-11ea-b4d5-e6b986f12daf",
"type":"asset_types"
}
}
}
}
}
I understand that there is data lost somewhere doing deserialization of object, but cannot figure out where. Also I have identical set of entities (Gateway and Location where each Location can have multiple Gateways) and the creation of new entities work as expected...
New to Symfony & api-platform, any help appreciated.
Asset entity is set tup to be visible in api-platform:
/**
* #ApiResource(
* collectionOperations={"get", "post"},
* itemOperations={"get", "put", "delete"},
* normalizationContext={"groups"={"read"}},
* denormalizationContext={"groups"={"write"}}
* )
* #ORM\Entity(repositoryClass="App\Repository\AssetRepository")
*/
class Asset
{
/**
* #ORM\Id
* #ORM\Column(type="uuid_binary_ordered_time", nullable=false, unique=true)
* #ORM\GeneratedValue(strategy="CUSTOM")
* #ORM\CustomIdGenerator(class="Ramsey\Uuid\Doctrine\UuidOrderedTimeGenerator")
* #Groups({"read"})
*/
private $uuid;
/**
* #ORM\ManyToOne(targetEntity="App\Entity\AssetType", inversedBy="assets", cascade={"persist"})
* #ORM\JoinColumn(name="type_id", referencedColumnName="uuid", nullable=false)
*
* #Groups({"read", "write"})
*/
private $assetType;
}
AssetType entity:
/**
* #ApiResource(
* normalizationContext={"groups"={"read"}},
* denormalizationContext={"groups"={"write"}}
* )
* #ORM\Entity(repositoryClass="App\Repository\AssetTypeRepository")
*/
class AssetType
{
/**
* #ORM\Id()
* #ORM\Column(name="uuid", type="uuid_binary_ordered_time", nullable=false, unique=true)
* #ORM\GeneratedValue(strategy="CUSTOM")
* #ORM\CustomIdGenerator(class="Ramsey\Uuid\Doctrine\UuidOrderedTimeGenerator")
* #Groups({"read", "write"})
*/
private $uuid;
/**
* #ORM\Column(type="string", length=255, nullable=true)
* #Groups({"read", "write"})
*/
private $name;
/**
* #ORM\OneToMany(targetEntity="App\Entity\Asset", mappedBy="assetType")
*/
private $assets;
public function __construct()
{
$this->assets = new ArrayCollection();
}
public function getUuid()
{
return $this->uuid;
}
public function setUuid($uuid): self
{
$this->uuid = $uuid;
return $this;
}
public function getAssets(): Collection
{
return $this->assets;
}
public function addAsset(Asset $asset): self
{
...
}
public function removeAsset(Asset $asset): self
{
...
}
In case anyone sumbles across similar problem - the reason for missing values was property naming and its normalization.
Relationship data posted contains key "asset_type" which needs to be converted to camelCase "assetType" in react-admin's dataProvider (that's the approach I took).
I am using EasyAdmin in my SF 3.3 project but I need to achieve something different from how EasyAdmin has been built for. Take a look at the following picture:
As you might notice a user can be in more than one GroupingRole. Having that information the challenge is:
Check if the user has been assigned to any other GroupingRole
If the criteria meets the condition then show a warning message saying "The user A is already assigned to GroupingRole A" and prevent the record to be created. (this message could be in a popup, a javascript alert or an alert from Bootstrap - since EA already uses it)
When the admin click once again on "Save changes" the record should be created.
What I want to achieve with this approach is to alert the admin that the user is already to any other group but not stop him for create the record.
I have achieve some part of it already by override the prePersist method for just that entity (see below):
class AdminController extends BaseAdminController
{
/**
* Check if the users has been assigned to any group
*/
protected function prePersistGroupingRoleEntity($entity)
{
$usersToGroupRoleEntities = $this->em->getRepository('CommonBundle:UsersToGroupRole')->findAll();
$usersToGroupRole = [];
/** #var UsersToGroupRole $groupRole */
foreach ($usersToGroupRoleEntities as $groupRole) {
$usersToGroupRole[$groupRole->getGroupingRoleId()][] = $groupRole->getUsersId();
}
$usersInGroup = [];
/** #var Users $userEntity */
foreach ($entity->getUsersInGroup() as $userEntity) {
foreach ($usersToGroupRole as $group => $users) {
if (\in_array($userEntity->getId(), $users, true)) {
$usersInGroup[$group][] = $userEntity->getId();
}
}
}
$groupingRoleEnt = $this->em->getRepository('CommonBundle:GroupingRole');
$usersEnt = $this->em->getRepository('CommonBundle:Users');
$message = [];
foreach ($usersInGroup as $group => $user) {
foreach($user as $usr) {
$message[] = sprintf(
'The user %s already exists in %s group!',
$usersEnt->find($usr)->getEmail(),
$groupingRoleEnt->find($group)->getName()
);
}
}
}
}
What I don't know is how to stop the record to be created and instead show the warning just the first time the button is clicked because the second time and having the warning in place I should allow to create the record.
Can any give me some ideas and/or suggestions?
UPDATE: adding entities information
In addition to the code displayed above here is the entities involved in such process:
/**
* #ORM\Entity
* #ORM\Table(name="grouping_role")
*/
class GroupingRole
{
/**
* #ORM\Id
* #ORM\Column(name="id", type="integer",unique=true,nullable=false)
* #ORM\GeneratedValue
*/
private $id;
/**
* #var string
*
* #ORM\Column(name="role_name", type="string", nullable=false)
*/
private $name;
/**
* #var string
*
* #ORM\Column(name="role_description", type="string", nullable=false)
*/
private $description;
/**
* #var ArrayCollection
*
* #ORM\ManyToMany(targetEntity="Schneider\QuoteBundle\Entity\Distributor", inversedBy="groupingRole")
* #ORM\JoinTable(name="grouping_to_role",
* joinColumns={
* #ORM\JoinColumn(name="grouping_role_id", referencedColumnName="id")
* },
* inverseJoinColumns={
* #ORM\JoinColumn(name="DistributorID", referencedColumnName="DistributorID", nullable=false)
* }
* )
*
* #Assert\Count(
* min = 1,
* minMessage = "You must select at least one Distributor"
* )
*/
private $distributorGroup;
/**
* #var ArrayCollection
*
* #ORM\ManyToMany(targetEntity="CommonBundle\Entity\Users", inversedBy="usersGroup")
* #ORM\JoinTable(name="users_to_group_role",
* joinColumns={
* #ORM\JoinColumn(name="grouping_role_id", referencedColumnName="id")
* },
* inverseJoinColumns={
* #ORM\JoinColumn(name="users_id", referencedColumnName="users_id", nullable=false)
* }
* )
*
* #Assert\Count(
* min = 1,
* minMessage = "You must select at least one user"
* )
*/
private $usersInGroup;
/**
* Constructor
*/
public function __construct()
{
$this->distributorGroup = new ArrayCollection();
$this->usersInGroup = new ArrayCollection();
}
}
/**
* #ORM\Entity()
* #ORM\Table(name="users_to_group_role")
*/
class UsersToGroupRole
{
/**
* #var int
*
* #ORM\Id()
* #ORM\Column(type="integer",nullable=false)
* #Assert\Type(type="integer")
* #Assert\NotNull()
*/
protected $usersId;
/**
* #var int
*
* #ORM\Id()
* #ORM\Column(type="integer", nullable=false)
* #Assert\Type(type="integer")
* #Assert\NotNull()
*/
protected $groupingRoleId;
}
A little example by using form validation approach in EasyAdminBundle:
class AdminController extends EasyAdminController
{
// ...
protected function create<EntityName>EntityFormBuilder($entity, $view)
{
$builder = parent::createEntityFormBuilder($entity, $view);
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) {
$data = $event->getData();
$flag = false;
if (isset($data['flag'])) {
$flag = $data['flag'];
unset($data['flag']);
}
$key = md5(json_encode($data));
if ($flag !== $key) {
$event->getForm()->add('flag', HiddenType::class, ['mapped' => false]);
$data['flag'] = $key;
$event->setData($data);
}
});
return $builder;
}
protected function get<EntityName>EntityFormOptions($entity, $view)
{
$options = parent::getEntityFormOptions($entity, $view);
$options['validation_groups'] = function (FormInterface $form) {
if ($form->has('flag')) {
return ['Default', 'CheckUserGroup'];
}
return ['Default'];
};
$options['constraints'] = new Callback([
'callback' => function($entity, ExecutionContextInterface $context) {
// validate here and adds the violation if applicable.
$context->buildViolation('Warning!')
->atPath('<field>')
->addViolation();
},
'groups' => 'CheckUserGroup',
]);
return $options;
}
}
Note that PRE_SUBMIT event is triggered before the validation process happen.
The flag field is added (dynamically) the first time upon submitted the form, so the validation group CheckUserGroup is added and the callback constraint do its job. Later, the second time the submitted data contains the flag hash (if the data does not changes) the flag field is not added, so the validation group is not added either and the entity is saved (same if the callback constraint does not add the violation the first time).
Also (if you prefer) you can do all this inside a custom form type for the target entity.